Compare commits
98 Commits
fix-subtas
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
076cd214fe | |
|
|
8a4a1c1af7 | |
|
|
655f553bdb | |
|
|
911c9dd3d0 | |
|
|
3aab4ab51a | |
|
|
5bd55e0322 | |
|
|
a74bb408ee | |
|
|
5d368b849a | |
|
|
9657cff19a | |
|
|
e1bff274c7 | |
|
|
756ecb3ec0 | |
|
|
0d1f44cb2a | |
|
|
03a9056d8f | |
|
|
73f68f61c1 | |
|
|
9f9711cdfe | |
|
|
8f68b3f396 | |
|
|
988dfa0b3a | |
|
|
01a851ca72 | |
|
|
65a498dd50 | |
|
|
d9804c3e00 | |
|
|
83f353aee9 | |
|
|
bb0055293b | |
|
|
b947e892d0 | |
|
|
d2fcd2efa5 | |
|
|
8ae1ee0645 | |
|
|
bfead87452 | |
|
|
8f429ac643 | |
|
|
90d57f4b38 | |
|
|
07c872eb2b | |
|
|
e1afa039cb | |
|
|
ee8c759f0b | |
|
|
b866ba3f58 | |
|
|
b0bbfa677a | |
|
|
82f03d94b6 | |
|
|
59ef240a4d | |
|
|
421c45e60b | |
|
|
837339b894 | |
|
|
7f687236d4 | |
|
|
fa0c9a8584 | |
|
|
71639a3dc5 | |
|
|
8d10e053d4 | |
|
|
a728e50796 | |
|
|
9015bad65c | |
|
|
3459158b99 | |
|
|
3abe8d650a | |
|
|
a2063a27a8 | |
|
|
cf1273c1d9 | |
|
|
8d0814e460 | |
|
|
2690b7153e | |
|
|
c55ee0b742 | |
|
|
c72cfdf50d | |
|
|
c6b3c7cddc | |
|
|
12952516cf | |
|
|
9946ca9031 | |
|
|
a73761f4c5 | |
|
|
ac9811826e | |
|
|
0369b61001 | |
|
|
59da1d9514 | |
|
|
d374c8e6f9 | |
|
|
aa8c5974ae | |
|
|
0dba563a03 | |
|
|
dab2ac473f | |
|
|
57b6d530f3 | |
|
|
ba5c09f962 | |
|
|
eed762097a | |
|
|
f6baa7d472 | |
|
|
07d39b4290 | |
|
|
0efae572cd | |
|
|
9e880e98a5 | |
|
|
e25ca7ab9a | |
|
|
18ee92f227 | |
|
|
96452f0b71 | |
|
|
3f8ce93636 | |
|
|
626e1e267e | |
|
|
f18813f3ff | |
|
|
f7ac69d01a | |
|
|
98b3613247 | |
|
|
18a0df505b | |
|
|
7b5b8ecad2 | |
|
|
4b18d08993 | |
|
|
7c9b9e3352 | |
|
|
08890895de | |
|
|
330b94c3c4 | |
|
|
7691f282cf | |
|
|
6cee626383 | |
|
|
0d043e80e4 | |
|
|
9390199ce0 | |
|
|
f8eacca7c8 | |
|
|
7a182817ee | |
|
|
aaa2428f6c | |
|
|
0f3a8a7e39 | |
|
|
f4bbe80144 | |
|
|
02d46944ac | |
|
|
0e17556a16 | |
|
|
84dc57c562 | |
|
|
82dae774f1 | |
|
|
63b7f32379 | |
|
|
81791fd346 |
3
.envrc
3
.envrc
|
|
@ -1,3 +0,0 @@
|
|||
source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0="
|
||||
|
||||
use devenv
|
||||
|
|
@ -79,7 +79,7 @@ runs:
|
|||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download Mage binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ runs:
|
|||
|
||||
- name: Download frontend dist (vikunja only)
|
||||
if: inputs.project == 'vikunja'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: frontend/dist
|
||||
|
|
@ -110,7 +110,7 @@ runs:
|
|||
sudo mv upx-5.0.0-amd64_linux/upx /usr/local/bin
|
||||
|
||||
- name: Setup xgo cache
|
||||
uses: useblacksmith/cache@71c7c918062ba3861252d84b07fe5ab2a6b467a6 # v5
|
||||
uses: useblacksmith/cache@c5fe29eb0efdf1cf4186b9f7fcbbcbc0cf025662 # v5.1.0
|
||||
with:
|
||||
path: /home/runner/.xgo-cache
|
||||
key: xgo-${{ inputs.project }}-${{ hashFiles('**/go.sum') }}
|
||||
|
|
@ -133,7 +133,7 @@ runs:
|
|||
cd build && mage release:build "$PROJECT"
|
||||
|
||||
- name: GPG setup
|
||||
uses: kolaente/action-gpg@main
|
||||
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
|
||||
with:
|
||||
gpg-passphrase: ${{ inputs.gpg-passphrase }}
|
||||
gpg-sign-key: ${{ inputs.gpg-sign-key }}
|
||||
|
|
@ -164,7 +164,7 @@ runs:
|
|||
done
|
||||
|
||||
- name: Upload zips to S3
|
||||
uses: kolaente/s3-action@main
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
with:
|
||||
s3-access-key-id: ${{ inputs.s3-access-key-id }}
|
||||
s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
|
||||
|
|
@ -176,14 +176,14 @@ runs:
|
|||
strip-path-prefix: ${{ env.DIST_PREFIX }}/zip/
|
||||
|
||||
- name: Store binaries
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_BINARIES_NAME }}
|
||||
path: ./${{ env.DIST_PREFIX }}/binaries/*
|
||||
|
||||
- name: Store binary packages
|
||||
if: github.ref_type == 'tag'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_ZIPS_NAME }}
|
||||
path: ./${{ env.DIST_PREFIX }}/zip/*
|
||||
|
|
|
|||
|
|
@ -91,12 +91,12 @@ runs:
|
|||
echo "S3_TARGET_PATH=/${PROJECT}/${VERSION_OR_UNSTABLE}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download project binaries
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: ${{ env.BINARIES_ARTIFACT_NAME }}
|
||||
path: ${{ env.BINARIES_DOWNLOAD_PATH }}
|
||||
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
|
|
@ -123,7 +123,7 @@ runs:
|
|||
|
||||
- name: GPG setup for archlinux signing
|
||||
if: inputs.packager == 'archlinux'
|
||||
uses: kolaente/action-gpg@main
|
||||
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
|
||||
with:
|
||||
gpg-passphrase: ${{ inputs.gpg-passphrase }}
|
||||
gpg-sign-key: ${{ inputs.gpg-sign-key }}
|
||||
|
|
@ -163,7 +163,7 @@ runs:
|
|||
run: mkdir -p "$PACKAGE_OUTPUT_DIR"
|
||||
|
||||
- name: Create package
|
||||
uses: kolaente/action-gh-nfpm@master
|
||||
uses: kolaente/action-gh-nfpm@08460c16ce3baaa48eaf94d51eea0e653b15d955 # master
|
||||
with:
|
||||
packager: ${{ inputs.packager }}
|
||||
target: ${{ env.PACKAGE_OUTPUT_DIR }}/${{ env.PACKAGE_FILENAME }}
|
||||
|
|
@ -186,7 +186,7 @@ runs:
|
|||
"$PACKAGE_OUTPUT_DIR/$PACKAGE_FILENAME"
|
||||
|
||||
- name: Upload to S3
|
||||
uses: kolaente/s3-action@main
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
with:
|
||||
s3-access-key-id: ${{ inputs.s3-access-key-id }}
|
||||
s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
|
||||
|
|
@ -198,7 +198,7 @@ runs:
|
|||
strip-path-prefix: ${{ env.PACKAGE_OUTPUT_DIR }}/
|
||||
|
||||
- name: Store OS package
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: ${{ env.PACKAGE_OUTPUT_DIR }}/*
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ runs:
|
|||
echo "CYPRESS_INSTALL_BINARY=0" >> $GITHUB_ENV
|
||||
echo "PUPPETEER_SKIP_DOWNLOAD=true" >> $GITHUB_ENV
|
||||
echo "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1" >> $GITHUB_ENV
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
run_install: false
|
||||
package_json_file: frontend/package.json
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
cache: 'pnpm'
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout (for prompt template)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/workflows/auto-label.prompt.md
|
||||
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
|
||||
- name: Render system prompt from live labels
|
||||
id: render
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
PROMPT_TEMPLATE_PATH: .github/workflows/auto-label.prompt.md
|
||||
with:
|
||||
|
|
@ -122,7 +122,7 @@ jobs:
|
|||
|
||||
- name: Classify with AI
|
||||
id: classify
|
||||
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
with:
|
||||
model: openai/gpt-4.1-mini
|
||||
# GPT-5 is a reasoning model: output tokens include reasoning, so budget generously.
|
||||
|
|
@ -132,7 +132,7 @@ jobs:
|
|||
prompt-file: ${{ steps.prep.outputs.prompt_path }}
|
||||
|
||||
- name: Apply labels
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
AI_RESPONSE: ${{ steps.classify.outputs.response }}
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -9,19 +9,19 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
persist-credentials: true
|
||||
- name: push source files
|
||||
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
with:
|
||||
command: 'push'
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
- name: pull translations
|
||||
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
with:
|
||||
command: 'download'
|
||||
command_args: '--export-only-approved --skip-untranslated-strings'
|
||||
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- name: Ensure file permissions
|
||||
|
|
@ -55,7 +55,7 @@ jobs:
|
|||
git commit -m "chore(i18n): update translations via Crowdin"
|
||||
- name: Push changes
|
||||
if: steps.check_changes.outputs.changes_exist != '0'
|
||||
uses: ad-m/github-push-action@master
|
||||
uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # master
|
||||
with:
|
||||
ssh: true
|
||||
branch: ${{ github.ref }}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ jobs:
|
|||
directory: [frontend, desktop]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Create Diff
|
||||
uses: e18e/action-dependency-diff@v1
|
||||
uses: e18e/action-dependency-diff@8e9b8c1957ab066d36235a43f4c1ff1522e1bdbc # v1.6.1
|
||||
with:
|
||||
working-directory: ${{ matrix.directory }}
|
||||
|
||||
|
|
@ -33,11 +33,11 @@ jobs:
|
|||
directory: [frontend, desktop]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check provenance downgrades
|
||||
uses: danielroe/provenance-action@main
|
||||
uses: danielroe/provenance-action@81568f71211c1839d6d3583c6a93037f5348c816 # main
|
||||
with:
|
||||
workspace-path: ${{ matrix.directory }}
|
||||
fail-on-provenance-change: true
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ jobs:
|
|||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
|
||||
with:
|
||||
app-id: ${{ secrets.BOT_APP_ID }}
|
||||
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Find closing PR or commit
|
||||
id: find-closer
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
|
|
@ -82,7 +82,7 @@ jobs:
|
|||
|
||||
- name: Comment on issue
|
||||
if: steps.find-closer.outputs.closed_by_code == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
docker-images: false
|
||||
swap-storage: false
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# For pull_request_target, we need to explicitly fetch the PR ref from forks
|
||||
# since the PR's commit SHA is not reachable in the base repository.
|
||||
|
|
@ -34,27 +34,27 @@ jobs:
|
|||
ref: refs/pull/${{ github.event.pull_request.number }}/head
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
with:
|
||||
version: latest
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=sha,format=long
|
||||
- name: Build and push PR image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
|
|
@ -66,7 +66,7 @@ jobs:
|
|||
build-args: |
|
||||
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
|
||||
- name: Comment on PR
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
DOCKER_META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
name: prepare-build-mage
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Cache build mage
|
||||
id: cache-build-mage
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
key: ${{ runner.os }}-build-mage-build-${{ hashFiles('build/magefile.go') }}
|
||||
path: |
|
||||
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
export PATH=$PATH:$GOPATH/bin
|
||||
mage -compile ./build-mage-static
|
||||
- name: Store build mage binary
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: build_mage_bin
|
||||
path: ./build/build-mage-static
|
||||
|
|
@ -43,14 +43,14 @@ jobs:
|
|||
steps:
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
|
@ -58,7 +58,7 @@ jobs:
|
|||
- name: Docker meta version
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
vikunja/vikunja
|
||||
|
|
@ -70,7 +70,7 @@ jobs:
|
|||
type=raw,value=latest
|
||||
- name: Build and push unstable
|
||||
if: ${{ github.ref_type != 'tag' }}
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
push: true
|
||||
|
|
@ -81,7 +81,7 @@ jobs:
|
|||
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
|
||||
- name: Build and push version
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
push: true
|
||||
|
|
@ -93,10 +93,10 @@ jobs:
|
|||
binaries:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- uses: ./.github/actions/release-binaries
|
||||
with:
|
||||
project: vikunja
|
||||
|
|
@ -112,10 +112,10 @@ jobs:
|
|||
veans-binaries:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- uses: ./.github/actions/release-binaries
|
||||
with:
|
||||
project: veans
|
||||
|
|
@ -147,10 +147,10 @@ jobs:
|
|||
pkg: armv7
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- uses: ./.github/actions/release-os-package
|
||||
with:
|
||||
project: vikunja
|
||||
|
|
@ -186,10 +186,10 @@ jobs:
|
|||
pkg: armv7
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- uses: ./.github/actions/release-os-package
|
||||
with:
|
||||
project: veans
|
||||
|
|
@ -235,19 +235,19 @@ jobs:
|
|||
REPO_SUITE: ${{ github.ref_type == 'tag' && 'stable' || 'unstable' }}
|
||||
RELEASE_VERSION: unstable
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Download build mage binary
|
||||
# Statically compiled in test.yml's build-mage job so it runs inside
|
||||
# ubuntu/fedora/archlinux containers without a Go toolchain.
|
||||
if: matrix.format != 'apk'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: build_mage_bin
|
||||
path: build
|
||||
|
||||
- name: Download all server OS packages
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: vikunja_os_package_*
|
||||
merge-multiple: true
|
||||
|
|
@ -257,14 +257,14 @@ jobs:
|
|||
# Merged into the same incoming dir so reprepro / createrepo_c /
|
||||
# repo-add / the apk loop pick them up alongside vikunja's packages
|
||||
# — same suite, same arch fan-out, no extra source entry for users.
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: veans_os_package_*
|
||||
merge-multiple: true
|
||||
path: dist/repo-work/incoming
|
||||
|
||||
- name: Download desktop packages (Linux)
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_desktop_packages_ubuntu-latest
|
||||
path: dist/repo-work/incoming-desktop
|
||||
|
|
@ -309,7 +309,7 @@ jobs:
|
|||
|
||||
- name: GPG setup
|
||||
if: matrix.format != 'apk'
|
||||
uses: kolaente/action-gpg@main
|
||||
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
|
||||
with:
|
||||
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
|
||||
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
|
||||
|
|
@ -384,7 +384,7 @@ jobs:
|
|||
find dist/repo-output -type d -empty -delete 2>/dev/null || true
|
||||
|
||||
- name: Upload to R2
|
||||
uses: kolaente/s3-action@main
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
with:
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
|
|
@ -398,12 +398,12 @@ jobs:
|
|||
config-yaml:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: generate
|
||||
|
|
@ -411,7 +411,7 @@ jobs:
|
|||
chmod +x ./mage-static
|
||||
./mage-static generate:config-yaml 1
|
||||
- name: Upload to S3
|
||||
uses: kolaente/s3-action@main
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
with:
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
|
|
@ -431,16 +431,16 @@ jobs:
|
|||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
package_json_file: desktop/package.json
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
cache: pnpm
|
||||
|
|
@ -451,7 +451,7 @@ jobs:
|
|||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y libopenjp2-tools rpm libarchive-tools
|
||||
- name: get frontend
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: frontend/dist
|
||||
|
|
@ -461,7 +461,7 @@ jobs:
|
|||
pnpm install --frozen-lockfile --prefer-offline --fetch-timeout 100000
|
||||
node build.js "${{ steps.ghd.outputs.describe }}" ${{ github.ref_type == 'tag' }}
|
||||
- name: Upload to S3
|
||||
uses: kolaente/s3-action@main
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
with:
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
|
|
@ -473,7 +473,7 @@ jobs:
|
|||
strip-path-prefix: desktop/dist/
|
||||
exclude: "desktop/dist/*.blockmap"
|
||||
- name: Store Desktop Package
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: vikunja_desktop_packages_${{ matrix.os }}
|
||||
path: |
|
||||
|
|
@ -486,16 +486,16 @@ jobs:
|
|||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
persist-credentials: true
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: generate
|
||||
|
|
@ -520,7 +520,7 @@ jobs:
|
|||
git commit -am "[skip ci] Updated swagger docs"
|
||||
- name: Push changes
|
||||
if: steps.check_changes.outputs.changes_exist != '0'
|
||||
uses: ad-m/github-push-action@master
|
||||
uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # master
|
||||
with:
|
||||
ssh: true
|
||||
branch: ${{ github.ref }}
|
||||
|
|
@ -539,44 +539,44 @@ jobs:
|
|||
contents: write
|
||||
steps:
|
||||
- name: Download Binaries
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_bin_packages
|
||||
|
||||
- name: Download OS Packages
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: vikunja_os_package_*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Veans Binaries
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: veans_bin_packages
|
||||
|
||||
- name: Download Veans OS Packages
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: veans_os_package_*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Desktop Package Linux
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_desktop_packages_ubuntu-latest
|
||||
|
||||
- name: Download Desktop Package MacOS
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_desktop_packages_macos-latest
|
||||
|
||||
- name: Download Desktop Package Windows
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_desktop_packages_windows-latest
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||
if: github.ref_type == 'tag'
|
||||
with:
|
||||
draft: true
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
with:
|
||||
only-labels: 'waiting for reply'
|
||||
days-before-issue-stale: 30
|
||||
|
|
@ -24,6 +24,7 @@ jobs:
|
|||
questions. If you're still seeing this on a recent version, just
|
||||
drop a comment with the requested info and we'll reopen. Thanks
|
||||
for the report!
|
||||
days-before-pr-stale: -1
|
||||
stale-pr-label: 'waiting for reply'
|
||||
days-before-pr-stale: 30
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 100
|
||||
|
|
|
|||
|
|
@ -8,26 +8,26 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
name: prepare-mage
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Cache Mage
|
||||
id: cache-mage
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
key: ${{ runner.os }}-build-mage-${{ hashFiles('magefile.go') }}
|
||||
path: |
|
||||
./mage-static
|
||||
- name: Compile Mage
|
||||
if: ${{ steps.cache-mage.outputs.cache-hit != 'true' }}
|
||||
uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3
|
||||
uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3.1.0
|
||||
with:
|
||||
version: latest
|
||||
args: -compile ./mage-static
|
||||
- name: Store Mage Binary
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
path: ./mage-static
|
||||
|
|
@ -36,16 +36,16 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
needs: mage
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Build
|
||||
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
chmod +x ./mage-static
|
||||
./mage-static build
|
||||
- name: Store Vikunja Binary
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: vikunja_bin
|
||||
path: ./vikunja
|
||||
|
|
@ -65,8 +65,8 @@ jobs:
|
|||
api-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: prepare frontend files
|
||||
|
|
@ -74,19 +74,19 @@ jobs:
|
|||
mkdir -p frontend/dist
|
||||
touch frontend/dist/index.html
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
|
||||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||
with:
|
||||
version: v2.10.1
|
||||
|
||||
veans-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
|
||||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||
with:
|
||||
version: v2.10.1
|
||||
working-directory: veans
|
||||
|
|
@ -94,8 +94,8 @@ jobs:
|
|||
veans-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Install mage
|
||||
|
|
@ -115,9 +115,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
needs: mage
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Check
|
||||
|
|
@ -152,7 +152,7 @@ jobs:
|
|||
ports:
|
||||
- 3306:3306
|
||||
migration-smoke-db-postgres:
|
||||
image: postgres:18@sha256:5773fe724c49c42a7a9ca70202e11e1dff21fb7235b335a73f39297d200b73a2
|
||||
image: postgres:18@sha256:4aabea78cf39b90e834caf3af7d602a18565f6fe2508705c8d01aa63245c2e20
|
||||
env:
|
||||
POSTGRES_PASSWORD: vikunjatest
|
||||
POSTGRES_DB: vikunjatest
|
||||
|
|
@ -164,7 +164,7 @@ jobs:
|
|||
wget https://dl.vikunja.io/vikunja/unstable/vikunja-unstable-linux-amd64-full.zip -q -O vikunja-latest.zip
|
||||
unzip vikunja-latest.zip vikunja-unstable-linux-amd64
|
||||
- name: Download Vikunja Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_bin
|
||||
- name: run migration
|
||||
|
|
@ -254,13 +254,13 @@ jobs:
|
|||
ports:
|
||||
- 389:389
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Configure Postgres for faster tests
|
||||
|
|
@ -300,13 +300,13 @@ jobs:
|
|||
needs:
|
||||
- mage
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: test
|
||||
|
|
@ -321,13 +321,13 @@ jobs:
|
|||
needs:
|
||||
- mage
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: test
|
||||
|
|
@ -351,13 +351,13 @@ jobs:
|
|||
ports:
|
||||
- 9000:9000
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: test S3 file storage integration
|
||||
|
|
@ -382,7 +382,7 @@ jobs:
|
|||
frontend-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Lint
|
||||
working-directory: frontend
|
||||
|
|
@ -391,7 +391,7 @@ jobs:
|
|||
frontend-stylelint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Lint styles
|
||||
working-directory: frontend
|
||||
|
|
@ -400,7 +400,7 @@ jobs:
|
|||
frontend-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Typecheck
|
||||
continue-on-error: true
|
||||
|
|
@ -410,7 +410,7 @@ jobs:
|
|||
test-frontend-unit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Run unit tests
|
||||
working-directory: frontend
|
||||
|
|
@ -419,11 +419,11 @@ jobs:
|
|||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- name: Inject frontend version
|
||||
working-directory: frontend
|
||||
run: |
|
||||
|
|
@ -432,7 +432,7 @@ jobs:
|
|||
working-directory: frontend
|
||||
run: pnpm build
|
||||
- name: Store Frontend
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: ./frontend/dist
|
||||
|
|
@ -442,13 +442,13 @@ jobs:
|
|||
needs:
|
||||
- api-build
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Vikunja Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Install mage
|
||||
|
|
@ -501,7 +501,7 @@ jobs:
|
|||
(cd veans && mage test:e2e)
|
||||
- name: Upload API log on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: veans-e2e-vikunja-log
|
||||
path: /tmp/vikunja.log
|
||||
|
|
@ -523,19 +523,19 @@ jobs:
|
|||
ports:
|
||||
- 5556:5556
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy@sha256:4698a73749c5848d3f5fcd42a2174d172fcad2b2283e087843b115424303a565
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-jammy@sha256:7b86926fff94374389e8e1f4fdc5c76d050d4a06a7886bb537bf412b20e2b71e
|
||||
options: --user 1001
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Vikunja Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_bin
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
install-e2e-binaries: false # Playwright browsers already in container
|
||||
- name: Download Frontend
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: ./frontend/dist
|
||||
|
|
@ -570,14 +570,14 @@ jobs:
|
|||
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTID: vikunja
|
||||
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTSECRET: secret
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-${{ matrix.shard }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 30
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-test-results-${{ matrix.shard }}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# syntax=docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6
|
||||
FROM --platform=$BUILDPLATFORM node:24.13.0-alpine@sha256:931d7d57f8c1fd0e2179dbff7cc7da4c9dd100998bc2b32afc85142d8efbc213 AS frontendbuilder
|
||||
# syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89
|
||||
FROM --platform=$BUILDPLATFORM node:24.18.0-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS frontendbuilder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ COPY frontend/ ./
|
|||
ARG RELEASE_VERSION=dev
|
||||
RUN echo "{\"VERSION\": \"${RELEASE_VERSION/-g/-}\"}" > src/version.json && pnpm run build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/techknowlogick/xgo:go-1.25.x@sha256:11ac5e6cb8767caea0c62c420e053cb69554638ec255f9bbef8ed411e70c9eec AS apibuilder
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/techknowlogick/xgo:go-1.26.x@sha256:57c62857168cee9213045d65044e990d8b181ed6df30ba7097d2dcddd42b9908 AS apibuilder
|
||||
|
||||
RUN go install github.com/magefile/mage@latest && \
|
||||
mv /go/bin/mage /usr/local/go/bin
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
module code.vikunja.io/build
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.4
|
||||
|
||||
require github.com/magefile/mage v1.17.2
|
||||
|
|
|
|||
|
|
@ -849,6 +849,11 @@
|
|||
"default_value": "(&(objectclass=*)(|(objectclass=group)(objectclass=groupOfNames)))",
|
||||
"comment": "The filter to search for group objects in the ldap directory. Only used when `groupsyncenabled` is set to `true`."
|
||||
},
|
||||
{
|
||||
"key": "groupsyncuseserviceaccount",
|
||||
"default_value": "false",
|
||||
"comment": "If true, Vikunja re-binds as the service account (binddn/bindpassword) before searching for groups during group sync. Enable this when the authenticating user does not have sufficient rights to enumerate group membership in the directory."
|
||||
},
|
||||
{
|
||||
"key": "avatarsyncattribute",
|
||||
"default_value": "",
|
||||
|
|
|
|||
|
|
@ -100,10 +100,15 @@ app.on('second-instance', (_event, argv) => {
|
|||
return
|
||||
}
|
||||
|
||||
// Focus the main window
|
||||
// Reveal the main window. It may be hidden in the tray (not just minimized),
|
||||
// so show() is required — focus() alone won't surface a hidden window, which
|
||||
// made the app look dead when relaunched while running in the tray.
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
} else if (serverPort) {
|
||||
createMainWindow()
|
||||
}
|
||||
|
||||
// Find the deep link URL in argv
|
||||
|
|
@ -236,6 +241,11 @@ function createMainWindow() {
|
|||
mainWindow = new BrowserWindow({
|
||||
width: 1680,
|
||||
height: 960,
|
||||
// Without an explicit window icon, X11/XWayland compositors (e.g. KDE
|
||||
// Plasma) fall back to a generic placeholder when WM_CLASS doesn't match
|
||||
// an installed .desktop file. icon.png lives at the app root because
|
||||
// build/ is electron-builder's buildResources dir and isn't packaged.
|
||||
icon: path.join(__dirname, 'icon.png'),
|
||||
webPreferences: {
|
||||
...BASE_WEB_PREFERENCES,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
|
|
@ -543,3 +553,14 @@ app.on('window-all-closed', () => {
|
|||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
// Quit on termination signals (DE/systemd shutdown, `kill`). Without an explicit
|
||||
// handler the app ignores SIGTERM because the tray and express server keep the
|
||||
// event loop alive — leaving users to `kill -9`. isQuitting must be set first so
|
||||
// the hide-to-tray close handler doesn't swallow the quit.
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => {
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"main": "main.js",
|
||||
"repository": "https://code.vikunja.io/desktop",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"packageManager": "pnpm@10.34.4",
|
||||
"author": {
|
||||
"email": "maintainers@vikunja.io",
|
||||
"name": "Vikunja Team"
|
||||
|
|
@ -61,9 +61,9 @@
|
|||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "40.10.4",
|
||||
"electron": "40.10.5",
|
||||
"electron-builder": "26.15.3",
|
||||
"unzipper": "0.12.3"
|
||||
"unzipper": "0.12.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "5.2.1"
|
||||
|
|
@ -73,14 +73,16 @@
|
|||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"minimatch": "^10.2.3",
|
||||
"tar": ">=7.5.16",
|
||||
"@tootallnate/once": "^3.0.1",
|
||||
"picomatch": ">=4.0.4",
|
||||
"tmp": ">=0.2.7",
|
||||
"ip-address": ">=10.1.1",
|
||||
"form-data": ">=4.0.6",
|
||||
"js-yaml": ">=4.2.0"
|
||||
"minimatch": "10.2.5",
|
||||
"tar": "7.5.17",
|
||||
"@tootallnate/once": "3.0.1",
|
||||
"picomatch": "4.0.4",
|
||||
"tmp": "0.2.7",
|
||||
"ip-address": "10.2.0",
|
||||
"form-data": "4.0.6",
|
||||
"js-yaml": "5.2.0",
|
||||
"undici@6": "6.27.0",
|
||||
"undici@7": "7.28.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,16 @@ settings:
|
|||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
minimatch: ^10.2.3
|
||||
tar: '>=7.5.16'
|
||||
'@tootallnate/once': ^3.0.1
|
||||
picomatch: '>=4.0.4'
|
||||
tmp: '>=0.2.7'
|
||||
ip-address: '>=10.1.1'
|
||||
form-data: '>=4.0.6'
|
||||
js-yaml: '>=4.2.0'
|
||||
minimatch: 10.2.5
|
||||
tar: 7.5.17
|
||||
'@tootallnate/once': 3.0.1
|
||||
picomatch: 4.0.4
|
||||
tmp: 0.2.7
|
||||
ip-address: 10.2.0
|
||||
form-data: 4.0.6
|
||||
js-yaml: 5.2.0
|
||||
undici@6: 6.27.0
|
||||
undici@7: 7.28.0
|
||||
|
||||
importers:
|
||||
|
||||
|
|
@ -23,14 +25,14 @@ importers:
|
|||
version: 5.2.1
|
||||
devDependencies:
|
||||
electron:
|
||||
specifier: 40.10.4
|
||||
version: 40.10.4
|
||||
specifier: 40.10.5
|
||||
version: 40.10.5
|
||||
electron-builder:
|
||||
specifier: 26.15.3
|
||||
version: 26.15.3(electron-builder-squirrel-windows@24.13.3)
|
||||
unzipper:
|
||||
specifier: 0.12.3
|
||||
version: 0.12.3
|
||||
specifier: 0.12.5
|
||||
version: 0.12.5
|
||||
|
||||
packages:
|
||||
|
||||
|
|
@ -535,8 +537,8 @@ packages:
|
|||
electron-publish@26.15.3:
|
||||
resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==}
|
||||
|
||||
electron@40.10.4:
|
||||
resolution: {integrity: sha512-ouNZrXXmdPL/wiTQ+xzXpb7B/BHg+j7XARig0SE7azFO3bjbYUd6lFjIAAiDQ02Pl/Oj7MUk+4C0hdf9yFtA1A==}
|
||||
electron@40.10.5:
|
||||
resolution: {integrity: sha512-VzTIvwOYXZZufT9B83GDQogR1TFqREygRYhm0LE++QhGPjvBeg+W7siOP9K5+9rHMUnRuCX4YU/0ivLekN/UZQ==}
|
||||
engines: {node: '>= 22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
|
|
@ -618,7 +620,7 @@ packages:
|
|||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
peerDependencies:
|
||||
picomatch: '>=4.0.4'
|
||||
picomatch: 4.0.4
|
||||
peerDependenciesMeta:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
|
@ -653,10 +655,6 @@ packages:
|
|||
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
fs-extra@11.2.0:
|
||||
resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
fs-extra@11.3.1:
|
||||
resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
|
@ -836,8 +834,8 @@ packages:
|
|||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@4.2.0:
|
||||
resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
|
||||
js-yaml@5.2.0:
|
||||
resolution: {integrity: sha512-YeLUMlvR4Ou1B119LIaM0r65JvbOBooJDc9yEu0dClb/uSC5P4FrLU8OCCz/HXWvtPoIrR0dRzABTjo1sTN9Bw==}
|
||||
hasBin: true
|
||||
|
||||
json-buffer@3.0.1:
|
||||
|
|
@ -860,9 +858,6 @@ packages:
|
|||
jsonfile@4.0.0:
|
||||
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
|
||||
|
||||
jsonfile@6.1.0:
|
||||
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
|
||||
|
||||
jsonfile@6.2.0:
|
||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||
|
||||
|
|
@ -1304,8 +1299,8 @@ packages:
|
|||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar@7.5.16:
|
||||
resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==}
|
||||
tar@7.5.17:
|
||||
resolution: {integrity: sha512-wPEBwzapC+2PaTYPH6e2L+cNOEE227S47wUYFqlegcs8zlLLmeb9Fcff1HVZY4Fwku/1Eyv38n7GYwB2aaS71g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
temp-file@3.4.0:
|
||||
|
|
@ -1351,12 +1346,12 @@ packages:
|
|||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
undici@6.26.0:
|
||||
resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==}
|
||||
undici@6.27.0:
|
||||
resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==}
|
||||
engines: {node: '>=18.17'}
|
||||
|
||||
undici@7.27.2:
|
||||
resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==}
|
||||
undici@7.28.0:
|
||||
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
universalify@0.1.2:
|
||||
|
|
@ -1371,8 +1366,8 @@ packages:
|
|||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
unzipper@0.12.3:
|
||||
resolution: {integrity: sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==}
|
||||
unzipper@0.12.5:
|
||||
resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==}
|
||||
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
|
@ -1493,7 +1488,7 @@ snapshots:
|
|||
semver: 7.8.1
|
||||
sumchecker: 3.0.1
|
||||
optionalDependencies:
|
||||
undici: 7.27.2
|
||||
undici: 7.28.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -1740,13 +1735,13 @@ snapshots:
|
|||
hosted-git-info: 4.1.0
|
||||
is-ci: 3.0.1
|
||||
isbinaryfile: 5.0.7
|
||||
js-yaml: 4.2.0
|
||||
js-yaml: 5.2.0
|
||||
lazy-val: 1.0.5
|
||||
minimatch: 10.2.5
|
||||
read-config-file: 6.3.2
|
||||
sanitize-filename: 1.6.4
|
||||
semver: 7.8.1
|
||||
tar: 7.5.16
|
||||
tar: 7.5.17
|
||||
temp-file: 3.4.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -1782,7 +1777,7 @@ snapshots:
|
|||
hosted-git-info: 4.1.0
|
||||
isbinaryfile: 5.0.7
|
||||
jiti: 2.6.1
|
||||
js-yaml: 4.2.0
|
||||
js-yaml: 5.2.0
|
||||
json5: 2.2.3
|
||||
lazy-val: 1.0.5
|
||||
minimatch: 10.2.5
|
||||
|
|
@ -1791,10 +1786,10 @@ snapshots:
|
|||
proper-lockfile: 4.1.2
|
||||
resedit: 1.7.2
|
||||
semver: 7.7.4
|
||||
tar: 7.5.16
|
||||
tar: 7.5.17
|
||||
temp-file: 3.4.0
|
||||
tiny-async-pool: 1.3.0
|
||||
unzipper: 0.12.3
|
||||
unzipper: 0.12.5
|
||||
which: 5.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -1929,7 +1924,7 @@ snapshots:
|
|||
http-proxy-agent: 5.0.0
|
||||
https-proxy-agent: 5.0.1
|
||||
is-ci: 3.0.1
|
||||
js-yaml: 4.2.0
|
||||
js-yaml: 5.2.0
|
||||
source-map-support: 0.5.21
|
||||
stat-mode: 1.0.0
|
||||
temp-file: 3.4.0
|
||||
|
|
@ -1946,7 +1941,7 @@ snapshots:
|
|||
fs-extra: 10.1.0
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.5
|
||||
js-yaml: 4.2.0
|
||||
js-yaml: 5.2.0
|
||||
sanitize-filename: 1.6.4
|
||||
source-map-support: 0.5.21
|
||||
stat-mode: 1.0.0
|
||||
|
|
@ -2099,7 +2094,7 @@ snapshots:
|
|||
app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3)
|
||||
builder-util: 26.15.3
|
||||
fs-extra: 10.1.0
|
||||
js-yaml: 4.2.0
|
||||
js-yaml: 5.2.0
|
||||
transitivePeerDependencies:
|
||||
- electron-builder-squirrel-windows
|
||||
- supports-color
|
||||
|
|
@ -2184,7 +2179,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron@40.10.4:
|
||||
electron@40.10.5:
|
||||
dependencies:
|
||||
'@electron-internal/extract-zip': 1.0.2
|
||||
'@electron/get': 5.0.0
|
||||
|
|
@ -2320,12 +2315,6 @@ snapshots:
|
|||
jsonfile: 6.2.0
|
||||
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:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
|
@ -2544,7 +2533,7 @@ snapshots:
|
|||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
js-yaml@4.2.0:
|
||||
js-yaml@5.2.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
|
|
@ -2563,12 +2552,6 @@ snapshots:
|
|||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
jsonfile@6.1.0:
|
||||
dependencies:
|
||||
universalify: 2.0.1
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
jsonfile@6.2.0:
|
||||
dependencies:
|
||||
universalify: 2.0.1
|
||||
|
|
@ -2666,9 +2649,9 @@ snapshots:
|
|||
nopt: 9.0.0
|
||||
proc-log: 6.1.0
|
||||
semver: 7.8.1
|
||||
tar: 7.5.16
|
||||
tar: 7.5.17
|
||||
tinyglobby: 0.2.15
|
||||
undici: 6.26.0
|
||||
undici: 6.27.0
|
||||
which: 6.0.1
|
||||
|
||||
node-int64@0.4.0: {}
|
||||
|
|
@ -2801,7 +2784,7 @@ snapshots:
|
|||
config-file-ts: 0.2.6
|
||||
dotenv: 9.0.2
|
||||
dotenv-expand: 5.1.0
|
||||
js-yaml: 4.2.0
|
||||
js-yaml: 5.2.0
|
||||
json5: 2.2.3
|
||||
lazy-val: 1.0.5
|
||||
|
||||
|
|
@ -3018,7 +3001,7 @@ snapshots:
|
|||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
tar@7.5.16:
|
||||
tar@7.5.17:
|
||||
dependencies:
|
||||
'@isaacs/fs-minipass': 4.0.1
|
||||
chownr: 3.0.0
|
||||
|
|
@ -3067,9 +3050,9 @@ snapshots:
|
|||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
undici@6.26.0: {}
|
||||
undici@6.27.0: {}
|
||||
|
||||
undici@7.27.2:
|
||||
undici@7.28.0:
|
||||
optional: true
|
||||
|
||||
universalify@0.1.2: {}
|
||||
|
|
@ -3078,11 +3061,11 @@ snapshots:
|
|||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
unzipper@0.12.3:
|
||||
unzipper@0.12.5:
|
||||
dependencies:
|
||||
bluebird: 3.7.2
|
||||
duplexer2: 0.1.4
|
||||
fs-extra: 11.2.0
|
||||
fs-extra: 11.3.1
|
||||
graceful-fs: 4.2.11
|
||||
node-int64: 0.4.0
|
||||
|
||||
|
|
|
|||
21
devenv.lock
21
devenv.lock
|
|
@ -3,10 +3,11 @@
|
|||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1773012232,
|
||||
"lastModified": 1782492839,
|
||||
"narHash": "sha256-j9wrcB4al5QhMelEghJ0Qs+RQPT+wyCcI4070NEgPLQ=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "46a4bd0299a26ad948b71d3053174ba7b90522f7",
|
||||
"rev": "3d39d0817d62069f7b18821c34a617b5141cb278",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -21,10 +22,11 @@
|
|||
"nixpkgs-src": "nixpkgs-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772749504,
|
||||
"lastModified": 1782132010,
|
||||
"narHash": "sha256-ZnAVHdVrotp80iIMm5CSR1fdxPlw7Uwmwxb+O/wsgZ8=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "08543693199362c1fddb8f52126030d0d374ba2e",
|
||||
"rev": "12866ae2dddbc0ab8b329915f8072bb9c75bde89",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -37,11 +39,11 @@
|
|||
"nixpkgs-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1769922788,
|
||||
"narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
|
||||
"lastModified": 1781607440,
|
||||
"narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
|
||||
"rev": "3e41b24abd260e8f71dbe2f5737d24122f972158",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -53,10 +55,11 @@
|
|||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1772773019,
|
||||
"lastModified": 1782467914,
|
||||
"narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "aca4d95fce4914b3892661bcb80b8087293536c6",
|
||||
"rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
24.13.0
|
||||
24.18.0
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
},
|
||||
"homepage": "https://vikunja.io/",
|
||||
"funding": "https://opencollective.com/vikunja",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"packageManager": "pnpm@10.34.4",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
|
|
@ -51,37 +51,37 @@
|
|||
"story:preview": "histoire preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "1.7.4",
|
||||
"@fortawesome/fontawesome-svg-core": "7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "7.1.0",
|
||||
"@fortawesome/vue-fontawesome": "3.1.3",
|
||||
"@intlify/unplugin-vue-i18n": "11.0.3",
|
||||
"@floating-ui/dom": "1.7.6",
|
||||
"@fortawesome/fontawesome-svg-core": "7.3.0",
|
||||
"@fortawesome/free-regular-svg-icons": "7.3.0",
|
||||
"@fortawesome/free-solid-svg-icons": "7.3.0",
|
||||
"@fortawesome/vue-fontawesome": "3.3.0",
|
||||
"@intlify/unplugin-vue-i18n": "11.2.4",
|
||||
"@kyvg/vue3-notification": "3.4.2",
|
||||
"@sentry/vue": "10.36.0",
|
||||
"@tiptap/core": "3.17.0",
|
||||
"@tiptap/extension-blockquote": "3.17.0",
|
||||
"@tiptap/extension-code-block-lowlight": "3.17.0",
|
||||
"@tiptap/extension-hard-break": "3.17.0",
|
||||
"@tiptap/extension-image": "3.17.0",
|
||||
"@tiptap/extension-link": "3.17.0",
|
||||
"@tiptap/extension-list": "3.17.0",
|
||||
"@tiptap/extension-mention": "3.17.0",
|
||||
"@tiptap/extension-table": "3.17.0",
|
||||
"@tiptap/extension-typography": "3.17.0",
|
||||
"@tiptap/extension-underline": "3.17.0",
|
||||
"@tiptap/extensions": "3.17.0",
|
||||
"@tiptap/pm": "3.17.0",
|
||||
"@tiptap/starter-kit": "3.17.0",
|
||||
"@tiptap/suggestion": "3.17.0",
|
||||
"@tiptap/vue-3": "3.17.0",
|
||||
"@vueuse/core": "14.1.0",
|
||||
"@vueuse/router": "14.1.0",
|
||||
"axios": "1.16.0",
|
||||
"@sentry/vue": "10.62.0",
|
||||
"@tiptap/core": "3.27.1",
|
||||
"@tiptap/extension-blockquote": "3.27.1",
|
||||
"@tiptap/extension-code-block-lowlight": "3.27.1",
|
||||
"@tiptap/extension-hard-break": "3.27.1",
|
||||
"@tiptap/extension-image": "3.27.1",
|
||||
"@tiptap/extension-link": "3.27.1",
|
||||
"@tiptap/extension-list": "3.27.1",
|
||||
"@tiptap/extension-mention": "3.27.1",
|
||||
"@tiptap/extension-table": "3.27.1",
|
||||
"@tiptap/extension-typography": "3.27.1",
|
||||
"@tiptap/extension-underline": "3.27.1",
|
||||
"@tiptap/extensions": "3.27.1",
|
||||
"@tiptap/pm": "3.27.1",
|
||||
"@tiptap/starter-kit": "3.27.1",
|
||||
"@tiptap/suggestion": "3.27.1",
|
||||
"@tiptap/vue-3": "3.27.1",
|
||||
"@vueuse/core": "14.3.0",
|
||||
"@vueuse/router": "14.3.0",
|
||||
"axios": "1.18.1",
|
||||
"blurhash": "2.0.5",
|
||||
"bulma-css-variables": "0.9.33",
|
||||
"change-case": "5.4.4",
|
||||
"dayjs": "1.11.19",
|
||||
"dayjs": "1.11.21",
|
||||
"dompurify": "3.4.11",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"flatpickr": "4.6.13",
|
||||
|
|
@ -89,16 +89,16 @@
|
|||
"is-touch-device": "1.0.1",
|
||||
"klona": "2.0.6",
|
||||
"lowlight": "3.3.0",
|
||||
"marked": "17.0.1",
|
||||
"nanoid": "5.1.6",
|
||||
"marked": "17.0.6",
|
||||
"nanoid": "5.1.16",
|
||||
"pinia": "3.0.4",
|
||||
"register-service-worker": "1.7.2",
|
||||
"sortablejs": "1.15.6",
|
||||
"ufo": "1.6.3",
|
||||
"vue": "3.5.27",
|
||||
"sortablejs": "1.15.7",
|
||||
"ufo": "1.6.4",
|
||||
"vue": "3.5.39",
|
||||
"vue-advanced-cropper": "2.8.9",
|
||||
"vue-flatpickr-component": "11.0.5",
|
||||
"vue-i18n": "11.2.8",
|
||||
"vue-i18n": "11.4.6",
|
||||
"vue-router": "4.6.4",
|
||||
"vuemoji-picker": "0.3.2",
|
||||
"workbox-precaching": "7.4.1",
|
||||
|
|
@ -108,7 +108,7 @@
|
|||
"@faker-js/faker": "10.5.0",
|
||||
"@histoire/plugin-screenshot": "1.0.0-beta.1",
|
||||
"@histoire/plugin-vue": "1.0.0-beta.1",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@sentry/vite-plugin": "3.6.1",
|
||||
"@tailwindcss/vite": "4.3.1",
|
||||
"@tsconfig/node24": "24.0.4",
|
||||
|
|
@ -117,15 +117,15 @@
|
|||
"@types/node": "24.13.2",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.62.0",
|
||||
"@typescript-eslint/parser": "8.62.0",
|
||||
"@vitejs/plugin-vue": "6.0.7",
|
||||
"@vue/eslint-config-typescript": "14.8.0",
|
||||
"@vue/eslint-config-typescript": "14.9.0",
|
||||
"@vue/test-utils": "2.4.11",
|
||||
"@vue/tsconfig": "0.9.1",
|
||||
"@vueuse/shared": "14.3.0",
|
||||
"autoprefixer": "10.5.0",
|
||||
"browserslist": "4.28.2",
|
||||
"autoprefixer": "10.5.2",
|
||||
"browserslist": "4.28.4",
|
||||
"caniuse-lite": "1.0.30001799",
|
||||
"csstype": "3.2.3",
|
||||
"esbuild": "0.28.1",
|
||||
|
|
@ -150,9 +150,9 @@
|
|||
"tailwindcss": "4.3.1",
|
||||
"typescript": "5.9.3",
|
||||
"unplugin-inject-preload": "3.0.0",
|
||||
"vite": "7.3.5",
|
||||
"vite": "7.3.6",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-vue-devtools": "8.1.3",
|
||||
"vite-plugin-vue-devtools": "8.1.4",
|
||||
"vite-svg-loader": "5.1.1",
|
||||
"vitest": "4.1.9",
|
||||
"vue-tsc": "3.3.5",
|
||||
|
|
@ -169,20 +169,20 @@
|
|||
"vue-demi"
|
||||
],
|
||||
"overrides": {
|
||||
"minimatch": "^10.2.3",
|
||||
"minimatch": "10.2.5",
|
||||
"rollup": "$rollup",
|
||||
"basic-ftp": ">=5.2.2",
|
||||
"serialize-javascript": "^7.0.5",
|
||||
"flatted": "^3.4.1",
|
||||
"ip-address": ">=10.1.1",
|
||||
"postcss": ">=8.5.10",
|
||||
"tmp": ">=0.2.7",
|
||||
"esbuild": ">=0.28.1",
|
||||
"form-data": ">=4.0.6",
|
||||
"markdown-it": ">=14.2.0",
|
||||
"launch-editor": ">=2.14.1",
|
||||
"@babel/core": ">=7.29.6",
|
||||
"js-yaml@4": ">=4.2.0"
|
||||
"basic-ftp": "6.0.1",
|
||||
"serialize-javascript": "7.0.6",
|
||||
"flatted": "3.4.2",
|
||||
"ip-address": "10.2.0",
|
||||
"postcss": "8.5.15",
|
||||
"tmp": "0.2.7",
|
||||
"esbuild": "0.28.1",
|
||||
"form-data": "4.0.6",
|
||||
"markdown-it": "14.2.0",
|
||||
"launch-editor": "2.14.1",
|
||||
"@babel/core": "8.0.1",
|
||||
"js-yaml@4": "5.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -13,14 +13,14 @@
|
|||
<div class="gantt-chart-wrapper">
|
||||
<GanttTimelineHeader
|
||||
:timeline-data="timelineData"
|
||||
:day-width-pixels="DAY_WIDTH_PIXELS"
|
||||
:day-width-pixels="dayWidthPixels"
|
||||
/>
|
||||
|
||||
<GanttVerticalGridLines
|
||||
:timeline-data="timelineData"
|
||||
:total-width="totalWidth"
|
||||
:height="ganttRows.length * 40"
|
||||
:day-width-pixels="DAY_WIDTH_PIXELS"
|
||||
:day-width-pixels="dayWidthPixels"
|
||||
/>
|
||||
|
||||
<GanttChartBody
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
:total-width="totalWidth"
|
||||
:date-from-date="dateFromDate"
|
||||
:date-to-date="dateToDate"
|
||||
:day-width-pixels="DAY_WIDTH_PIXELS"
|
||||
:day-width-pixels="dayWidthPixels"
|
||||
:is-dragging="isDragging"
|
||||
:is-resizing="isResizing"
|
||||
:drag-state="dragState"
|
||||
|
|
@ -89,7 +89,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch, toRefs, onUnmounted} from 'vue'
|
||||
import {computed, ref, watch, toRefs, nextTick, onMounted, onBeforeUnmount, onUnmounted} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
|
||||
|
|
@ -126,7 +126,9 @@ const emit = defineEmits<{
|
|||
(e: 'update:task', task: ITaskPartialWithId): void
|
||||
}>()
|
||||
|
||||
const DAY_WIDTH_PIXELS = 30
|
||||
const DAY_WIDTH_PIXELS_MIN = 30
|
||||
const dayWidthPixels = ref(0)
|
||||
let resizeObserver: ResizeObserver
|
||||
|
||||
const {tasks, filters} = toRefs(props)
|
||||
|
||||
|
|
@ -158,7 +160,7 @@ const dateToDate = computed(() => dayjs(filters.value.dateTo).endOf('day').toDat
|
|||
|
||||
const totalWidth = computed(() => {
|
||||
const dateDiff = Math.ceil((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
|
||||
return dateDiff * DAY_WIDTH_PIXELS
|
||||
return dateDiff * dayWidthPixels.value
|
||||
})
|
||||
|
||||
const timelineData = computed(() => {
|
||||
|
|
@ -297,6 +299,55 @@ function transformTaskToGanttBar(node: GanttTaskTreeNode): GanttBarModel {
|
|||
}
|
||||
}
|
||||
|
||||
function updateDayWidthPixels() {
|
||||
const node = ganttContainer.value
|
||||
if (!node) return
|
||||
|
||||
const rect = node.getBoundingClientRect()
|
||||
const styles = window.getComputedStyle(node)
|
||||
|
||||
const marginLeft = parseFloat(styles.marginLeft) || 0
|
||||
const marginRight = parseFloat(styles.marginRight) || 0
|
||||
|
||||
// max width without overflow
|
||||
const maxWidth = rect.width - marginLeft - marginRight
|
||||
|
||||
const dayCount = Math.ceil(
|
||||
(dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY,
|
||||
)
|
||||
|
||||
dayWidthPixels.value = Math.max(
|
||||
maxWidth / dayCount,
|
||||
DAY_WIDTH_PIXELS_MIN,
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
updateDayWidthPixels()
|
||||
|
||||
if (ganttContainer.value) {
|
||||
resizeObserver = new ResizeObserver(updateDayWidthPixels)
|
||||
resizeObserver.observe(ganttContainer.value)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateDayWidthPixels)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
window.removeEventListener('resize', updateDayWidthPixels)
|
||||
})
|
||||
|
||||
watch(
|
||||
[dateFromDate, dateToDate],
|
||||
async () => {
|
||||
await nextTick()
|
||||
updateDayWidthPixels()
|
||||
},
|
||||
{flush: 'post'},
|
||||
)
|
||||
|
||||
// Build the task tree when tasks change
|
||||
watch(
|
||||
[tasks, filters],
|
||||
|
|
@ -351,7 +402,7 @@ const ROW_HEIGHT = 40
|
|||
const barPositions = computed(() => {
|
||||
const positions = new Map<number, GanttBarPosition>()
|
||||
const ds = dragState.value
|
||||
const dragPixelOffset = ds ? ds.currentDays * DAY_WIDTH_PIXELS : 0
|
||||
const dragPixelOffset = ds ? ds.currentDays * dayWidthPixels.value : 0
|
||||
|
||||
ganttBars.value.forEach((rowBars, rowIndex) => {
|
||||
for (const bar of rowBars) {
|
||||
|
|
@ -386,7 +437,7 @@ function computeBarX(date: Date): number {
|
|||
(roundToNaturalDayBoundary(date, true).getTime() - dateFromDate.value.getTime()) /
|
||||
MILLISECONDS_A_DAY,
|
||||
)
|
||||
return diff * DAY_WIDTH_PIXELS
|
||||
return diff * dayWidthPixels.value
|
||||
}
|
||||
|
||||
function computeBarWidth(bar: GanttBarModel): number {
|
||||
|
|
@ -394,7 +445,7 @@ function computeBarWidth(bar: GanttBarModel): number {
|
|||
(roundToNaturalDayBoundary(bar.end).getTime() - roundToNaturalDayBoundary(bar.start, true).getTime()) /
|
||||
MILLISECONDS_A_DAY,
|
||||
)
|
||||
return diff * DAY_WIDTH_PIXELS
|
||||
return diff * dayWidthPixels.value
|
||||
}
|
||||
|
||||
// Compute relation arrows
|
||||
|
|
@ -590,7 +641,7 @@ function startDrag(bar: GanttBarModel, event: PointerEvent) {
|
|||
if (!dragState.value || !isDragging.value) return
|
||||
|
||||
const diff = e.clientX - dragState.value.startX
|
||||
const days = Math.round(diff / DAY_WIDTH_PIXELS)
|
||||
const days = Math.round(diff / dayWidthPixels.value)
|
||||
|
||||
if (days !== dragState.value.currentDays) {
|
||||
dragState.value.currentDays = days
|
||||
|
|
@ -652,7 +703,7 @@ function startResize(bar: GanttBarModel, edge: 'start' | 'end', event: PointerEv
|
|||
if (!dragState.value || !isResizing.value) return
|
||||
|
||||
const diff = e.clientX - dragState.value.startX
|
||||
const days = Math.round(diff / DAY_WIDTH_PIXELS)
|
||||
const days = Math.round(diff / dayWidthPixels.value)
|
||||
|
||||
if (edge === 'start') {
|
||||
const newStart = new Date(dragState.value.originalStart)
|
||||
|
|
|
|||
|
|
@ -722,7 +722,7 @@ async function addImage(event: Event) {
|
|||
return
|
||||
}
|
||||
|
||||
const url = await inputPrompt(event.target.getBoundingClientRect())
|
||||
const url = await inputPrompt(event.target.getBoundingClientRect(), '', editor.value)
|
||||
|
||||
if (url) {
|
||||
editor.value?.chain().focus().setImage({src: url}).run()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {PluginKey, type EditorState} from '@tiptap/pm/state'
|
|||
|
||||
import EmojiList from './EmojiList.vue'
|
||||
import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData'
|
||||
import {getPopupContainer} from '../popupContainer'
|
||||
|
||||
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
|
||||
|
||||
|
|
@ -78,7 +79,7 @@ export default function emojiSuggestionSetup() {
|
|||
popupElement.style.left = '0'
|
||||
popupElement.style.zIndex = '4700'
|
||||
popupElement.appendChild(component.element!)
|
||||
document.body.appendChild(popupElement)
|
||||
getPopupContainer(props.editor).appendChild(popupElement)
|
||||
|
||||
const rect = props.clientRect()
|
||||
if (!rect) {
|
||||
|
|
@ -108,7 +109,7 @@ export default function emojiSuggestionSetup() {
|
|||
cleanupFloating = null
|
||||
}
|
||||
if (popupElement) {
|
||||
document.body.removeChild(popupElement)
|
||||
popupElement.remove()
|
||||
popupElement = null
|
||||
}
|
||||
component?.destroy()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import inputPrompt from '@/helpers/inputPrompt'
|
|||
|
||||
export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) {
|
||||
const previousUrl = editor?.getAttributes('link').href || ''
|
||||
const url = await inputPrompt(pos, previousUrl)
|
||||
const url = await inputPrompt(pos, previousUrl, editor ?? undefined)
|
||||
|
||||
// empty
|
||||
if (url === '') {
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@
|
|||
</XButton>
|
||||
|
||||
<!-- Dropzone -->
|
||||
<Teleport to="body">
|
||||
<Teleport :to="dropzoneTeleportTarget">
|
||||
<div
|
||||
v-if="editEnabled"
|
||||
:class="{hidden: !showDropzone}"
|
||||
|
|
@ -185,7 +185,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive, computed, watch} from 'vue'
|
||||
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount} from 'vue'
|
||||
import {useDropZone} from '@vueuse/core'
|
||||
|
||||
import User from '@/components/misc/User.vue'
|
||||
|
|
@ -322,6 +322,34 @@ const showDropzone = computed(() =>
|
|||
props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value,
|
||||
)
|
||||
|
||||
// A <dialog> opened with showModal() (e.g. the Kanban task detail) renders in
|
||||
// the browser's top layer, so the full-screen dropzone overlay teleported to
|
||||
// <body> would paint behind it regardless of z-index. Teleport it into the
|
||||
// topmost open dialog instead, mirroring Notification.vue.
|
||||
const dropzoneTeleportTarget = ref<string | HTMLElement>('body')
|
||||
let dialogObserver: MutationObserver | null = null
|
||||
|
||||
function syncDropzoneTeleportTarget() {
|
||||
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.modal-dialog[open]')
|
||||
dropzoneTeleportTarget.value = dialogs.item(dialogs.length - 1) ?? 'body'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncDropzoneTeleportTarget()
|
||||
dialogObserver = new MutationObserver(syncDropzoneTeleportTarget)
|
||||
dialogObserver.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['open'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
dialogObserver?.disconnect()
|
||||
dialogObserver = null
|
||||
})
|
||||
|
||||
watch(() => props.editEnabled, enabled => {
|
||||
if (!enabled) {
|
||||
resetDragState()
|
||||
|
|
@ -478,7 +506,7 @@ defineExpose({
|
|||
inset-inline-start: 0;
|
||||
inset-block-end: 0;
|
||||
inset-inline-end: 0;
|
||||
z-index: 4001; // modal z-index is 4000
|
||||
z-index: 4001; // above app chrome when teleported to body (no modal open)
|
||||
text-align: center;
|
||||
|
||||
&.hidden {
|
||||
|
|
|
|||
|
|
@ -326,9 +326,17 @@ const isOverdue = computed(() => (
|
|||
let oldTask
|
||||
|
||||
async function markAsDone(checked: boolean, wasReverted: boolean = false) {
|
||||
const updateFunc = async () => {
|
||||
oldTask = {...task.value}
|
||||
const newTask = await taskStore.update(task.value)
|
||||
oldTask = {...task.value}
|
||||
|
||||
// Fire the request immediately and with the intended done value snapshotted, so a re-render or
|
||||
// teardown during the animation delay can neither drop the save nor make it send a stale state.
|
||||
const updatePromise = taskStore.update({
|
||||
...task.value,
|
||||
done: checked,
|
||||
})
|
||||
|
||||
const finish = async () => {
|
||||
const newTask = await updatePromise
|
||||
task.value = newTask
|
||||
|
||||
updateDueDate()
|
||||
|
|
@ -354,9 +362,9 @@ async function markAsDone(checked: boolean, wasReverted: boolean = false) {
|
|||
}
|
||||
|
||||
if (checked) {
|
||||
setTimeout(updateFunc, 300) // Delay it to show the animation when marking a task as done
|
||||
setTimeout(finish, 300) // Delay only the follow-up to show the animation when marking a task as done
|
||||
} else {
|
||||
await updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
|
||||
await finish() // Don't delay it when un-marking it as it doesn't have an animation the other way around
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
import {buildStoredQuery} from './useTaskList'
|
||||
|
||||
describe('buildStoredQuery', () => {
|
||||
it('includes sort when set', () => {
|
||||
expect(buildStoredQuery({sort: 'due_date:asc', filter: undefined, s: undefined, page: 1}))
|
||||
.toEqual({sort: 'due_date:asc'})
|
||||
})
|
||||
|
||||
it('includes filter and search when set', () => {
|
||||
expect(buildStoredQuery({sort: undefined, filter: 'done = false', s: 'foo', page: 1}))
|
||||
.toEqual({filter: 'done = false', s: 'foo'})
|
||||
})
|
||||
|
||||
it('omits page when it equals the default of 1', () => {
|
||||
expect(buildStoredQuery({sort: 'id:desc', filter: undefined, s: undefined, page: 1}))
|
||||
.toEqual({sort: 'id:desc'})
|
||||
})
|
||||
|
||||
it('includes page when greater than 1', () => {
|
||||
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 3}))
|
||||
.toEqual({page: '3'})
|
||||
})
|
||||
|
||||
it('returns an empty object when nothing is set', () => {
|
||||
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 1}))
|
||||
.toEqual({})
|
||||
})
|
||||
|
||||
it('skips empty strings', () => {
|
||||
expect(buildStoredQuery({sort: '', filter: '', s: '', page: 1}))
|
||||
.toEqual({})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
|
||||
import {useRouter, isNavigationFailure} from 'vue-router'
|
||||
import type {LocationQueryRaw} from 'vue-router'
|
||||
import {useRouteQuery} from '@vueuse/router'
|
||||
|
||||
import TaskCollectionService, {
|
||||
|
|
@ -10,6 +12,7 @@ import type {ITask} from '@/modelTypes/ITask'
|
|||
import {error} from '@/message'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useViewFiltersStore} from '@/stores/viewFilters'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
|
||||
export type Order = 'asc' | 'desc' | 'none'
|
||||
|
|
@ -59,6 +62,22 @@ const SORT_BY_DEFAULT: SortBy = {
|
|||
id: 'desc',
|
||||
}
|
||||
|
||||
interface TaskListQueryState {
|
||||
sort: string | undefined
|
||||
filter: string | undefined
|
||||
s: string | undefined
|
||||
page: number
|
||||
}
|
||||
|
||||
export function buildStoredQuery(state: TaskListQueryState): LocationQueryRaw {
|
||||
const query: LocationQueryRaw = {}
|
||||
if (state.sort) query.sort = state.sort
|
||||
if (state.filter) query.filter = state.filter
|
||||
if (state.s) query.s = state.s
|
||||
if (state.page > 1) query.page = String(state.page)
|
||||
return query
|
||||
}
|
||||
|
||||
// This makes sure an id sort order is always sorted last.
|
||||
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
|
||||
// precedence over everything else, making any other sort columns pretty useless.
|
||||
|
|
@ -94,6 +113,9 @@ export function useTaskList(
|
|||
const projectId = computed(() => projectIdGetter())
|
||||
const projectViewId = computed(() => projectViewIdGetter())
|
||||
|
||||
const router = useRouter()
|
||||
const viewFiltersStore = useViewFiltersStore()
|
||||
|
||||
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
|
||||
|
||||
const page = useRouteQuery('page', '1', { transform: Number })
|
||||
|
|
@ -119,6 +141,55 @@ export function useTaskList(
|
|||
},
|
||||
})
|
||||
|
||||
// Mirror the URL query bits this composable owns into the store so
|
||||
// in-project tab switches and sidebar re-visits can restore them.
|
||||
//
|
||||
// `ProjectList`/`ProjectTable` are reused across project switches (no
|
||||
// `:key` on them in ProjectView.vue), so setup runs only once. We track
|
||||
// the last viewId we synced — on every viewId transition, if the URL has
|
||||
// none of our params and the store has an entry, restore it via
|
||||
// `router.replace` and skip writing back the empty state we'd otherwise
|
||||
// clobber the saved entry with.
|
||||
let lastSyncedViewId: number | undefined
|
||||
watch(
|
||||
[projectViewId, sortQuery, filter, s, page],
|
||||
([viewId, sortValue, filterValue, sValue, pageValue]) => {
|
||||
const viewIdChanged = viewId !== lastSyncedViewId
|
||||
lastSyncedViewId = viewId
|
||||
|
||||
// An invalid `?page=` becomes NaN via `transform: Number`; treat it as
|
||||
// the default so it neither blocks restoration nor wipes stored state.
|
||||
const currentPage = Number.isInteger(pageValue) ? pageValue : 1
|
||||
const urlIsEmpty = !sortValue && !filterValue && !sValue && currentPage === 1
|
||||
if (viewIdChanged && urlIsEmpty) {
|
||||
const storedQuery = viewFiltersStore.getViewQuery(viewId)
|
||||
if (Object.keys(storedQuery).length > 0) {
|
||||
// Merge so unrelated query params on the route survive the restore.
|
||||
// Swallow navigation failures (e.g. aborted/duplicated) so the
|
||||
// ignored promise can't surface as an unhandled rejection.
|
||||
router.replace({query: {...router.currentRoute.value.query, ...storedQuery}})
|
||||
.catch(failure => {
|
||||
if (!isNavigationFailure(failure)) throw failure
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const query = buildStoredQuery({
|
||||
sort: sortValue as string | undefined,
|
||||
filter: filterValue as string | undefined,
|
||||
s: sValue as string | undefined,
|
||||
page: currentPage,
|
||||
})
|
||||
if (Object.keys(query).length > 0) {
|
||||
viewFiltersStore.setViewQuery(viewId, query)
|
||||
} else {
|
||||
viewFiltersStore.clearViewQuery(viewId)
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const allParams = computed(() => {
|
||||
const loadParams = {...params.value}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
|
||||
|
||||
import {refreshToken, removeToken} from './auth'
|
||||
|
||||
// Count how many times the refresh endpoint is actually POSTed. The whole point
|
||||
// of the in-flight dedup is that concurrent refreshToken() calls share a single
|
||||
// underlying POST, independent of the Web Locks API.
|
||||
let postCallCount = 0
|
||||
let resolvePost: ((value: unknown) => void) | null = null
|
||||
|
||||
vi.mock('@/helpers/fetcher', () => ({
|
||||
HTTPFactory: () => ({
|
||||
post: vi.fn(() => {
|
||||
postCallCount++
|
||||
return new Promise((resolve) => {
|
||||
resolvePost = resolve
|
||||
})
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/helpers/desktopAuth', () => ({
|
||||
isDesktopApp: () => false,
|
||||
refreshDesktopToken: vi.fn(),
|
||||
}))
|
||||
|
||||
const FAKE_TOKEN = 'header.payload.signature'
|
||||
|
||||
function settlePost() {
|
||||
resolvePost?.({data: {token: FAKE_TOKEN}})
|
||||
}
|
||||
|
||||
describe('refreshToken in-flight dedup', () => {
|
||||
const originalLocks = navigator.locks
|
||||
|
||||
beforeEach(() => {
|
||||
postCallCount = 0
|
||||
resolvePost = null
|
||||
removeToken()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
value: originalLocks,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('coalesces concurrent calls into a single POST when Web Locks is available', async () => {
|
||||
// Stub a minimal Web Locks API: happy-dom leaves navigator.locks
|
||||
// undefined, so without this the test would silently fall through to
|
||||
// the insecure-HTTP branch and never exercise navigator.locks.request.
|
||||
const requestSpy = vi.fn((_name: string, cb: () => unknown) => cb())
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
value: {request: requestSpy},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const p1 = refreshToken(true)
|
||||
const p2 = refreshToken(true)
|
||||
|
||||
// Both calls share one underlying request.
|
||||
expect(postCallCount).toBe(1)
|
||||
|
||||
settlePost()
|
||||
await Promise.all([p1, p2])
|
||||
|
||||
// The Web Locks branch actually ran...
|
||||
expect(requestSpy).toHaveBeenCalledWith('vikunja-token-refresh', expect.any(Function))
|
||||
// ...and the in-flight dedup still collapsed both calls into one POST.
|
||||
expect(postCallCount).toBe(1)
|
||||
})
|
||||
|
||||
it('coalesces concurrent calls into a single POST on insecure HTTP (no Web Locks)', async () => {
|
||||
// Simulate an insecure HTTP context where navigator.locks is undefined.
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const p1 = refreshToken(true)
|
||||
const p2 = refreshToken(true)
|
||||
const p3 = refreshToken(true)
|
||||
|
||||
expect(postCallCount).toBe(1)
|
||||
|
||||
settlePost()
|
||||
await Promise.all([p1, p2, p3])
|
||||
|
||||
expect(postCallCount).toBe(1)
|
||||
})
|
||||
|
||||
it('allows a fresh refresh after the previous one settled', async () => {
|
||||
const p1 = refreshToken(true)
|
||||
settlePost()
|
||||
await p1
|
||||
expect(postCallCount).toBe(1)
|
||||
|
||||
// The in-flight promise was reset, so a later refresh runs anew.
|
||||
const p2 = refreshToken(true)
|
||||
expect(postCallCount).toBe(2)
|
||||
settlePost()
|
||||
await p2
|
||||
})
|
||||
|
||||
it('does not re-persist the token when logout happens during an in-flight refresh', async () => {
|
||||
const p1 = refreshToken(true)
|
||||
expect(postCallCount).toBe(1)
|
||||
|
||||
// User logs out while the refresh POST is still in flight.
|
||||
removeToken()
|
||||
|
||||
// The in-flight POST resolves afterwards — it must not undo the logout.
|
||||
settlePost()
|
||||
await p1
|
||||
|
||||
expect(localStorage.getItem('token')).toBeNull()
|
||||
})
|
||||
|
||||
it('an older refresh settling does not clobber a newer in-flight one', async () => {
|
||||
// Refresh A starts and stays in flight.
|
||||
const pA = refreshToken(true)
|
||||
expect(postCallCount).toBe(1)
|
||||
const resolveA = resolvePost
|
||||
|
||||
// User logs out, which drops the in-flight reference to A.
|
||||
removeToken()
|
||||
|
||||
// Refresh B starts; it must claim the in-flight slot.
|
||||
const pB = refreshToken(true)
|
||||
expect(postCallCount).toBe(2)
|
||||
const resolveB = resolvePost
|
||||
|
||||
// A settles after B started. Its cleanup must NOT null the in-flight
|
||||
// slot, since that slot now belongs to B. Without the `=== p` guard,
|
||||
// A's .finally would clobber B and let a concurrent caller fire a
|
||||
// second parallel POST.
|
||||
resolveA?.({data: {token: FAKE_TOKEN}})
|
||||
await pA
|
||||
|
||||
// A concurrent caller while B is still in flight must dedup to B —
|
||||
// no third POST.
|
||||
const pB2 = refreshToken(true)
|
||||
expect(postCallCount).toBe(2)
|
||||
|
||||
resolveB?.({data: {token: FAKE_TOKEN}})
|
||||
await Promise.all([pB, pB2])
|
||||
})
|
||||
})
|
||||
|
|
@ -33,18 +33,53 @@ export const removeToken = () => {
|
|||
savedToken = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('desktopOAuthRefreshToken')
|
||||
|
||||
// Bump the epoch and drop the in-flight refresh so a refresh that started
|
||||
// before this logout can't re-persist a token after we cleared it.
|
||||
authEpoch++
|
||||
inFlightRefresh = null
|
||||
}
|
||||
|
||||
// Coalesces concurrent same-tab refreshes into one POST. Web Locks (below) is
|
||||
// secure-context-only, so on insecure HTTP there's no cross-tab coordination —
|
||||
// without this guard, refreshes firing close together each spend the single-use
|
||||
// cookie and all but one get a 401.
|
||||
let inFlightRefresh: Promise<void> | null = null
|
||||
|
||||
// Incremented on every removeToken()/logout. A refresh captures the epoch when
|
||||
// it starts and only persists its result if the epoch is unchanged, so a
|
||||
// refresh that resolves after a logout can't undo it.
|
||||
let authEpoch = 0
|
||||
|
||||
/**
|
||||
* Refreshes an auth token while ensuring it is updated everywhere.
|
||||
* The refresh token is sent automatically as an HttpOnly cookie.
|
||||
* The server rotates the cookie on every call.
|
||||
*
|
||||
* Uses the Web Locks API to coordinate across browser tabs. Only one tab
|
||||
* performs the actual refresh; other tabs waiting for the lock detect that
|
||||
* the token in localStorage was already updated and adopt it directly.
|
||||
* Same-tab concurrent calls share one in-flight refresh (always-on dedup); the
|
||||
* Web Locks API inside adds cross-tab coordination only in secure contexts.
|
||||
*/
|
||||
export async function refreshToken(persist: boolean): Promise<void> {
|
||||
if (inFlightRefresh) {
|
||||
return inFlightRefresh
|
||||
}
|
||||
const p = doRefresh(persist)
|
||||
inFlightRefresh = p
|
||||
// Only clear if it still points to this promise — a logout (or a newer
|
||||
// refresh started after it) may have replaced inFlightRefresh meanwhile.
|
||||
p.finally(() => {
|
||||
if (inFlightRefresh === p) {
|
||||
inFlightRefresh = null
|
||||
}
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
async function doRefresh(persist: boolean): Promise<void> {
|
||||
// Snapshot the epoch so we can tell if a logout happened while we awaited.
|
||||
const epochAtStart = authEpoch
|
||||
const loggedOutSinceStart = () => authEpoch !== epochAtStart
|
||||
|
||||
// In desktop mode, refresh via IPC to the Electron main process
|
||||
if (isDesktopApp()) {
|
||||
const storedRefreshToken = localStorage.getItem('desktopOAuthRefreshToken')
|
||||
|
|
@ -53,6 +88,9 @@ export async function refreshToken(persist: boolean): Promise<void> {
|
|||
}
|
||||
try {
|
||||
const tokens = await refreshDesktopToken(window.API_URL, storedRefreshToken)
|
||||
if (loggedOutSinceStart()) {
|
||||
return
|
||||
}
|
||||
saveToken(tokens.access_token, persist)
|
||||
localStorage.setItem('desktopOAuthRefreshToken', tokens.refresh_token)
|
||||
} catch (e) {
|
||||
|
|
@ -65,7 +103,13 @@ export async function refreshToken(persist: boolean): Promise<void> {
|
|||
// if another tab refreshed while we were queued.
|
||||
const tokenBeforeLock = localStorage.getItem('token')
|
||||
|
||||
const doRefresh = async () => {
|
||||
const refreshUnderLock = async () => {
|
||||
// A logout may have happened while we waited for the lock — don't
|
||||
// re-adopt or re-fetch a token after the user signed out.
|
||||
if (loggedOutSinceStart()) {
|
||||
return
|
||||
}
|
||||
|
||||
// If the token in localStorage changed while waiting for the lock,
|
||||
// another tab already refreshed. Just adopt the new token.
|
||||
const currentToken = localStorage.getItem('token')
|
||||
|
|
@ -78,6 +122,9 @@ export async function refreshToken(persist: boolean): Promise<void> {
|
|||
const HTTP = HTTPFactory()
|
||||
try {
|
||||
const response = await HTTP.post('user/token/refresh')
|
||||
if (loggedOutSinceStart()) {
|
||||
return
|
||||
}
|
||||
saveToken(response.data.token, persist)
|
||||
} catch (e) {
|
||||
throw new Error('Error renewing token: ', {cause: e})
|
||||
|
|
@ -85,10 +132,10 @@ export async function refreshToken(persist: boolean): Promise<void> {
|
|||
}
|
||||
|
||||
if (navigator.locks) {
|
||||
await navigator.locks.request('vikunja-token-refresh', doRefresh)
|
||||
await navigator.locks.request('vikunja-token-refresh', refreshUnderLock)
|
||||
} else {
|
||||
// Fallback for environments without Web Locks (e.g. insecure HTTP)
|
||||
await doRefresh()
|
||||
await refreshUnderLock()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,5 +10,9 @@ export function getProjectTitle(project: IProject) {
|
|||
return i18n.global.t('project.inboxTitle')
|
||||
}
|
||||
|
||||
if (project.title === 'My Open Tasks') {
|
||||
return i18n.global.t('project.myOpenTasksFilterTitle')
|
||||
}
|
||||
|
||||
return project.title
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,17 @@ import {createRandomID} from '@/helpers/randomId'
|
|||
import {computePosition, flip, shift, offset} from '@floating-ui/dom'
|
||||
import {nextTick} from 'vue'
|
||||
import {eventToShortcutString} from '@/helpers/shortcut'
|
||||
import type {Editor} from '@tiptap/core'
|
||||
import {getPopupContainer} from '@/components/input/editor/popupContainer'
|
||||
|
||||
export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Promise<string> {
|
||||
export default function inputPrompt(pos: ClientRect, oldValue: string = '', editor?: Editor): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const id = 'link-input-' + createRandomID()
|
||||
// Append inside the open task <dialog> (top-layer) when present, otherwise
|
||||
// document.body. A body-level popup is painted behind a showModal() dialog
|
||||
// and unfocusable through its focus trap, breaking the link prompt in the
|
||||
// Kanban task popup (#2940).
|
||||
const container = getPopupContainer(editor)
|
||||
|
||||
// Create popup element
|
||||
const popupElement = document.createElement('div')
|
||||
|
|
@ -26,7 +33,7 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
|
|||
inputElement.value = oldValue
|
||||
wrapperDiv.appendChild(inputElement)
|
||||
popupElement.appendChild(wrapperDiv)
|
||||
document.body.appendChild(popupElement)
|
||||
container.appendChild(popupElement)
|
||||
|
||||
// Create a local mutable copy of the position for scroll tracking
|
||||
let currentRect = new DOMRect(pos.left, pos.top, pos.width, pos.height)
|
||||
|
|
@ -82,15 +89,41 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
|
|||
|
||||
nextTick(() => document.getElementById(id)?.focus())
|
||||
|
||||
// The prompt is a sub-modal of the enclosing task <dialog>. Native modal
|
||||
// dialogs close themselves on Escape ("cancel"); swallow that while the
|
||||
// prompt is open so Escape only dismisses the prompt, not the task dialog.
|
||||
const dialog = container.closest('dialog') as HTMLDialogElement | null
|
||||
const handleDialogCancel = (event: Event) => event.preventDefault()
|
||||
dialog?.addEventListener('cancel', handleDialogCancel)
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!popupElement.contains(event.target as Node)) {
|
||||
resolve('')
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
if (document.body.contains(popupElement)) {
|
||||
document.body.removeChild(popupElement)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
dialog?.removeEventListener('cancel', handleDialogCancel)
|
||||
if (container.contains(popupElement)) {
|
||||
container.removeChild(popupElement)
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById(id)?.addEventListener('keydown', event => {
|
||||
const shortcutString = eventToShortcutString(event)
|
||||
|
||||
if (shortcutString === 'Escape') {
|
||||
// Stop the native <dialog> from closing on Escape; cancel the prompt only.
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resolve('')
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
if (shortcutString !== 'Enter') {
|
||||
return
|
||||
}
|
||||
|
|
@ -105,15 +138,6 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
|
|||
cleanup()
|
||||
})
|
||||
|
||||
// Close on click outside
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!popupElement.contains(event.target as Node)) {
|
||||
resolve('')
|
||||
cleanup()
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}
|
||||
|
||||
// Add slight delay to prevent immediate closing
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
|
|
|
|||
|
|
@ -24,8 +24,10 @@ export const redirectToProvider = (provider: IProvider) => {
|
|||
window.location.href = `${provider.authUrl}?client_id=${provider.clientId}&redirect_uri=${redirectUrl}&response_type=code&scope=${scope}&state=${state}`
|
||||
}
|
||||
|
||||
export const redirectToProviderOnLogout = (provider: IProvider) => {
|
||||
export const redirectToProviderOnLogout = (provider: IProvider): boolean => {
|
||||
if (provider.logoutUrl.length > 0) {
|
||||
window.location.href = `${provider.logoutUrl}`
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -349,6 +349,7 @@
|
|||
"shared": "Geteilte Projekte",
|
||||
"noDescriptionAvailable": "Keine Projektbeschreibung verfügbar.",
|
||||
"inboxTitle": "Eingang",
|
||||
"myOpenTasksFilterTitle": "Meine offenen Aufgaben",
|
||||
"favorite": "Dieses Projekt als Favorit markieren",
|
||||
"unfavorite": "Dieses Projekt von Favoriten entfernen",
|
||||
"openSettingsMenu": "Projekteinstellungen öffnen",
|
||||
|
|
@ -393,6 +394,7 @@
|
|||
"title": "Dupliziere dieses Projekt",
|
||||
"label": "Duplizieren",
|
||||
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
|
||||
"shares": "Freigaben kopieren (Benutzer:innen, Teams und Linkfreigaben)",
|
||||
"success": "Das Projekt wurde erfolgreich dupliziert."
|
||||
},
|
||||
"edit": {
|
||||
|
|
|
|||
|
|
@ -349,6 +349,7 @@
|
|||
"shared": "Geteilte Projekte",
|
||||
"noDescriptionAvailable": "Keine Projektbeschreibung verfügbar.",
|
||||
"inboxTitle": "Eingang",
|
||||
"myOpenTasksFilterTitle": "Meine offenen Aufgaben",
|
||||
"favorite": "Dieses Projekt als Favorit markieren",
|
||||
"unfavorite": "Dieses Projekt von Favoriten entfernen",
|
||||
"openSettingsMenu": "Projekteinstellungen öffnen",
|
||||
|
|
@ -393,6 +394,7 @@
|
|||
"title": "Dupliziere dieses Projekt",
|
||||
"label": "Duplizieren",
|
||||
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
|
||||
"shares": "Freigaben kopieren (Benutzer:innen, Teams und Linkfreigaben)",
|
||||
"success": "Das Projekt wurde erfolgreich dupliziert."
|
||||
},
|
||||
"edit": {
|
||||
|
|
|
|||
|
|
@ -393,6 +393,7 @@
|
|||
"title": "Αντιγραφή του έργου",
|
||||
"label": "Αντιγραφή",
|
||||
"text": "Επιλέξτε ένα γονικό έργο που θα περιλαμβάνει το αντίγραφο του έργου:",
|
||||
"shares": "Αντιγραφή διαμοιρασμών (χρήστες, ομάδες και σύνδεσμοι διαμοιρασμού) στο αντίγραφο",
|
||||
"success": "Το έργο αντιγράφηκε με επιτυχία."
|
||||
},
|
||||
"edit": {
|
||||
|
|
|
|||
|
|
@ -349,6 +349,7 @@
|
|||
"shared": "Shared Projects",
|
||||
"noDescriptionAvailable": "No project description is available.",
|
||||
"inboxTitle": "Inbox",
|
||||
"myOpenTasksFilterTitle": "My Open Tasks",
|
||||
"favorite": "Mark this project as favorite",
|
||||
"unfavorite": "Remove this project from favorites",
|
||||
"openSettingsMenu": "Open project settings menu",
|
||||
|
|
|
|||
|
|
@ -5,9 +5,32 @@
|
|||
},
|
||||
"home": {
|
||||
"welcomeNight": "Доброй ночи, {username}!",
|
||||
"welcomeNightOwl": "Привет, ночная сова {username}",
|
||||
"welcomeNightBurning": "Работаешь допоздна, {username}?",
|
||||
"welcomeNightQuiet": "Тихие часы, {username}",
|
||||
"welcomeNightLate": "Поздно, {username}",
|
||||
"welcomeMorning": "Доброе утро, {username}!",
|
||||
"welcomeMorningHey": "Привет, {username}, готов?",
|
||||
"welcomeMorningFresh": "Свежий старт, {username}",
|
||||
"welcomeMorningCoffee": "Кофе и задачи, {username}?",
|
||||
"welcomeMorningRise": "Проснись и планируй, {username}",
|
||||
"welcomeMorningBack": "С возвращением, {username}",
|
||||
"welcomeMondayFresh": "Свежая неделя, {username}",
|
||||
"welcomeTuesday": "Счастливого вторника, {username}",
|
||||
"welcomeWednesdayMid": "Уже середина недели, {username}",
|
||||
"welcomeThursday": "Почти готово, {username}",
|
||||
"welcomeFridayPush": "Пятница, {username}?",
|
||||
"welcomeSaturday": "Режим выходных, {username}",
|
||||
"welcomeSundaySession": "Воскресный сеанс, {username}?",
|
||||
"welcomeDay": "Привет, {username}!",
|
||||
"welcomeDayFocus": "Давайте сосредоточимся, {username}",
|
||||
"welcomeDayKeepGoing": "Так держать, {username}",
|
||||
"welcomeDayWhatsNext": "Что дальше, {username}?",
|
||||
"welcomeDayGood": "Добрый день, {username}",
|
||||
"welcomeEvening": "Добрый вечер, {username}!",
|
||||
"welcomeEveningWind": "Заканчиваешь, {username}?",
|
||||
"welcomeEveningReturns": "{username} возвращается",
|
||||
"welcomeEveningOneMore": "Ещё одна вещь, {username}?",
|
||||
"lastViewed": "Последние просмотренные",
|
||||
"addToHomeScreen": "Добавьте это приложение на домашний экран для быстрого доступа и удобной работы.",
|
||||
"goToOverview": "Перейти к обзору",
|
||||
|
|
@ -57,6 +80,11 @@
|
|||
"openIdTotpSubmit": "Продолжить",
|
||||
"oauthMissingParams": "Отсутствуют необходимые параметры OAuth: {params}",
|
||||
"oauthRedirectedToApp": "Вы были перенаправлены в приложение. Теперь вы можете закрыть эту вкладку.",
|
||||
"desktopTryDemo": "Попробовать демо-версию",
|
||||
"desktopCustomServer": "Пользовательский URL сервера",
|
||||
"desktopCustomServerDescription": "Введите URL сервера Vikunja, чтобы начать.",
|
||||
"desktopWaitingForAuth": "Ожидание аутентификации…",
|
||||
"desktopOAuthError": "Ошибка аутентификации: {error}",
|
||||
"logout": "Выйти",
|
||||
"emailInvalid": "Введите корректный email адрес.",
|
||||
"usernameRequired": "Введите имя пользователя.",
|
||||
|
|
@ -75,6 +103,19 @@
|
|||
"registrationFailed": "Произошла ошибка при регистрации. Проверьте введённые данные и повторите попытку."
|
||||
},
|
||||
"settings": {
|
||||
"bots": {
|
||||
"title": "Боты",
|
||||
"description": "Боты — это пользователи, которые принадлежат вам и которые имеют доступ только к API. Их можно добавить в проекты, назначить задачи, и аутентификация выполняется с помощью токенов API. Боты не могут использовать обычный интерфейс.",
|
||||
"namePlaceholder": "Мой помощник",
|
||||
"create": "Создать бота",
|
||||
"enable": "Включить",
|
||||
"badge": "Бот",
|
||||
"delete": {
|
||||
"header": "Удалить бота",
|
||||
"text1": "Удалить бота «{username}»?",
|
||||
"text2": "Это необратимо. Любые токены API, принадлежащие этому боту, будут аннулированы."
|
||||
}
|
||||
},
|
||||
"title": "Настройки",
|
||||
"newPasswordTitle": "Изменить пароль",
|
||||
"newPassword": "Новый пароль",
|
||||
|
|
@ -100,6 +141,11 @@
|
|||
"weekStart": "Первый день недели",
|
||||
"weekStartSunday": "Воскресенье",
|
||||
"weekStartMonday": "Понедельник",
|
||||
"weekStartTuesday": "Вторник",
|
||||
"weekStartWednesday": "Среда",
|
||||
"weekStartThursday": "Четверг",
|
||||
"weekStartFriday": "Пятница",
|
||||
"weekStartSaturday": "Суббота",
|
||||
"language": "Язык",
|
||||
"defaultProject": "Проект по умолчанию",
|
||||
"defaultView": "Представление по умолчанию",
|
||||
|
|
@ -133,7 +179,13 @@
|
|||
"taskAndNotifications": "Проекты и задачи",
|
||||
"privacy": "Конфиденциальность",
|
||||
"localization": "Локализация",
|
||||
"appearance": "Внешний вид и поведение"
|
||||
"appearance": "Внешний вид и поведение",
|
||||
"desktop": "Настольное приложение"
|
||||
},
|
||||
"desktop": {
|
||||
"quickEntryShortcut": "Ярлык быстрого входа",
|
||||
"shortcutRecorderPlaceholder": "Нажмите, чтобы задать ярлык",
|
||||
"shortcutRecorderRecording": "Нажмите комбинацию клавиш…"
|
||||
},
|
||||
"totp": {
|
||||
"title": "Двухфакторная аутентификация",
|
||||
|
|
@ -163,6 +215,13 @@
|
|||
"usernameIs": "Имя пользователя для CalDAV: {0}",
|
||||
"apiTokenHint": "Вы также можете использовать токен API с разрешением CalDAV. Создайте его в {link}."
|
||||
},
|
||||
"feeds": {
|
||||
"title": "Atom-лента",
|
||||
"howTo": "Вы можете подписаться на уведомления Vikunja в любом приложении для чтения новостей, поддерживающем Atom-ленты. Используйте следующий URL:",
|
||||
"usernameIs": "Имя пользователя для доступа к ленте: {0}",
|
||||
"apiTokenHint": "Для аутентификации используйте токен API с разрешением {scope}. Создайте его на странице {link}.",
|
||||
"tokenTitle": "Atom-лента"
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Аватар",
|
||||
"initials": "Инициалы",
|
||||
|
|
@ -285,6 +344,7 @@
|
|||
"shared": "Общие проекты",
|
||||
"noDescriptionAvailable": "Описание проекта отсутствует.",
|
||||
"inboxTitle": "Входящие",
|
||||
"myOpenTasksFilterTitle": "Мои открытые задачи",
|
||||
"favorite": "Отметить проект как избранный",
|
||||
"unfavorite": "Удалить проект из избранного",
|
||||
"openSettingsMenu": "Открыть настройки проекта",
|
||||
|
|
@ -329,6 +389,7 @@
|
|||
"title": "Создание копии проекта",
|
||||
"label": "Создать копию",
|
||||
"text": "Выберите родительский проект, в который поместить копию проекта:",
|
||||
"shares": "Скопировать настройки доступа (пользователей, групп и ссылок для обмена)",
|
||||
"success": "Копия проекта создана."
|
||||
},
|
||||
"edit": {
|
||||
|
|
@ -425,7 +486,8 @@
|
|||
"partialDatesStart": "Только дата начала (без окончания)",
|
||||
"partialDatesEnd": "Только дата окончания (без начала)",
|
||||
"expandGroup": "Развернуть группу: {task}",
|
||||
"collapseGroup": "Свернуть группу: {task}"
|
||||
"collapseGroup": "Свернуть группу: {task}",
|
||||
"toggleRelationArrows": "Переключить стрелки связи"
|
||||
},
|
||||
"table": {
|
||||
"title": "Таблица",
|
||||
|
|
@ -454,7 +516,8 @@
|
|||
"bucketTitleSavedSuccess": "Название колонки сохранено.",
|
||||
"bucketLimitSavedSuccess": "Лимит колонки сохранён.",
|
||||
"collapse": "Свернуть эту колонку",
|
||||
"bucketLimitReached": "Вы достигли лимита колонки. Удалите какие-нибудь задачи или увеличьте лимит, чтобы добавить новые задачи."
|
||||
"bucketLimitReached": "Вы достигли лимита колонки. Удалите какие-нибудь задачи или увеличьте лимит, чтобы добавить новые задачи.",
|
||||
"bucketOptions": "Настройки колонки"
|
||||
},
|
||||
"pseudo": {
|
||||
"favorites": {
|
||||
|
|
@ -677,7 +740,9 @@
|
|||
"upcoming": "Предстоящие задачи",
|
||||
"settings": "Настройки",
|
||||
"imprint": "Отпечаток",
|
||||
"privacy": "Политика конфиденциальности"
|
||||
"privacy": "Политика конфиденциальности",
|
||||
"closeSidebar": "Закрыть боковую панель",
|
||||
"home": "Главная страница Vikunja"
|
||||
},
|
||||
"misc": {
|
||||
"loading": "Загрузка…",
|
||||
|
|
@ -709,9 +774,17 @@
|
|||
"createdBy": "Создатель {0}",
|
||||
"actions": "Действия",
|
||||
"cannotBeUndone": "Это действие отменить нельзя!",
|
||||
"avatarOfUser": "Изображение профиля {user}"
|
||||
"avatarOfUser": "Изображение профиля {user}",
|
||||
"closeBanner": "Закрыть баннер",
|
||||
"closeDialog": "Закрыть диалог",
|
||||
"closeQuickActions": "Закрыть быстрые действия",
|
||||
"skipToContent": "Перейти к основному содержимому",
|
||||
"dateRange": "Диапазон",
|
||||
"notSet": "Не задано",
|
||||
"user": "Пользователь"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Цвет проекта",
|
||||
"resetColor": "Сбросить цвет",
|
||||
"datepicker": {
|
||||
"today": "Сегодня",
|
||||
|
|
@ -784,6 +857,7 @@
|
|||
"date": "Дата",
|
||||
"ranges": {
|
||||
"today": "Сегодня",
|
||||
"tomorrow": "Завтра",
|
||||
"thisWeek": "Эта неделя",
|
||||
"restOfThisWeek": "Остаток этой недели",
|
||||
"nextWeek": "Следующая неделя",
|
||||
|
|
@ -891,6 +965,8 @@
|
|||
"belongsToProject": "Задача принадлежит проекту «{project}»",
|
||||
"back": "Вернуться к проекту",
|
||||
"due": "Истекает {at}",
|
||||
"closeTaskDetail": "Закрыть детали задачи",
|
||||
"title": "Детали задачи",
|
||||
"scrollToBottom": "Прокрутить до конца страницы",
|
||||
"organization": "Организация",
|
||||
"management": "Управление",
|
||||
|
|
@ -984,7 +1060,10 @@
|
|||
"addedSuccess": "Комментарий добавлен.",
|
||||
"permalink": "Скопировать постоянную ссылку на комментарий",
|
||||
"sortNewestFirst": "Сначала новые",
|
||||
"sortOldestFirst": "Сначала старые"
|
||||
"sortOldestFirst": "Сначала старые",
|
||||
"reply": "Ответить",
|
||||
"jumpToOriginal": "Перейти к исходному комментарию",
|
||||
"deletedComment": "удалённый комментарий"
|
||||
},
|
||||
"mention": {
|
||||
"noUsersFound": "Пользователи не найдены"
|
||||
|
|
@ -1248,9 +1327,11 @@
|
|||
"none": "Уведомлений нет. Хорошего дня!",
|
||||
"explainer": "Здесь появятся уведомления, когда что-нибудь произойдёт с проектами или задачами, на которые вы подписаны.",
|
||||
"markAllRead": "Отметить всё как прочитанное",
|
||||
"markAllReadSuccess": "Все уведомления отмечены как прочитанные."
|
||||
"markAllReadSuccess": "Все уведомления отмечены как прочитанные.",
|
||||
"subscribeFeed": "Подписаться на уведомления через Atom-ленту"
|
||||
},
|
||||
"quickActions": {
|
||||
"notLoggedIn": "Сначала войдите в главное окно Vikunja.",
|
||||
"commands": "Команды",
|
||||
"placeholder": "Введите команду или поисковый запрос…",
|
||||
"hint": "Используйте {project}, чтобы ограничить поиск проектом. Комбинируйте {project} и {label} (метки) с поисковым запросом для поиска задачи с этими метками или на этом проекте. Используйте {assignee} для поиска команд.",
|
||||
|
|
@ -1377,5 +1458,66 @@
|
|||
"weeks": "неделя|недели|недель",
|
||||
"years": "год|года|лет"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Администрирование",
|
||||
"labels": {
|
||||
"users": "Пользователи",
|
||||
"tasks": "Задачи"
|
||||
},
|
||||
"overview": {
|
||||
"shares": "Общий доступ",
|
||||
"linkSharesShort": "ссылка",
|
||||
"teamSharesShort": "группа",
|
||||
"userSharesShort": "пользователь",
|
||||
"version": "Версия",
|
||||
"license": "Лицензия",
|
||||
"licenseValidUntil": "Истекает",
|
||||
"licenseExpiresIn": "через {days} дней",
|
||||
"licenseLastVerified": "Последняя проверка",
|
||||
"licenseNever": "никогда",
|
||||
"licenseLastCheckFailed": "последняя проверка не удалась",
|
||||
"licenseFeatures": "Возможности",
|
||||
"licenseInstance": "ID экземпляра",
|
||||
"licenseManage": "Управление"
|
||||
},
|
||||
"searchUsersPlaceholder": "Поиск по имени пользователя или электронной почте…",
|
||||
"users": {
|
||||
"status": "Статус",
|
||||
"details": "Детали",
|
||||
"detailsTitle": "Пользователь: {username}",
|
||||
"issuer": "Издатель",
|
||||
"issuerLocal": "Локальный",
|
||||
"issuerUrl": "URL издателя",
|
||||
"subject": "Тема",
|
||||
"statusActive": "Активен",
|
||||
"statusEmailConfirmation": "Нужно подтвердить почту",
|
||||
"statusDisabled": "Отключен",
|
||||
"statusLocked": "Заблокирован",
|
||||
"isAdminLabel": "Администратор",
|
||||
"addUser": "Добавить пользователя",
|
||||
"createTitle": "Создать пользователя",
|
||||
"nameLabel": "Имя",
|
||||
"skipEmailConfirm": "Пропустить подтверждение по электронной почте",
|
||||
"createSubmit": "Создать пользователя",
|
||||
"saveButton": "Сохранить изменения",
|
||||
"createdSuccess": "Пользователь {username} создан.",
|
||||
"updatedSuccess": "Пользователь {username} обновлён.",
|
||||
"deletedSuccess": "Пользователь {username} удалён.",
|
||||
"deleteScheduledSuccess": "Пользователь {username} получит подтверждение по электронной почте для запланированного удаления.",
|
||||
"confirmDeleteTitle": "Удалить пользователя?",
|
||||
"confirmDeleteIntro": "Как следует удалить пользователя {username}?",
|
||||
"deleteModeScheduled": "Запланировать удаление",
|
||||
"deleteModeScheduledHelp": "Запланированное удаление отправляет пользователю письмо с подтверждением, как если бы пользователь сам запросил удаление аккаунта.",
|
||||
"deleteModeNow": "Удалить сейчас",
|
||||
"deleteModeNowHelp": "Удаление сейчас удаляет пользователя и все его данные сразу. Это не может быть отменено."
|
||||
},
|
||||
"projects": {
|
||||
"ownerLabel": "Владелец",
|
||||
"reassignOwner": "Переназначить владельца",
|
||||
"reassignTitle": "Переназначить {title}",
|
||||
"reassignedSuccess": "Владелец проекта переназначен.",
|
||||
"newOwnerLabel": "Новый владелец"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -393,6 +393,7 @@
|
|||
"title": "Дублювати цей проєкт",
|
||||
"label": "Дублювати",
|
||||
"text": "Оберіть батьківський проєкт, який повинен складатися з дубльованих проєктів:",
|
||||
"shares": "Скопіювати налаштування спільного доступу (користувачів, команди та посилання) до копії проєкту",
|
||||
"success": "Проєкт дубльовано."
|
||||
},
|
||||
"edit": {
|
||||
|
|
@ -988,7 +989,7 @@
|
|||
"assign": "Доручити",
|
||||
"label": "Позначки",
|
||||
"priority": "Встановити пріоритет",
|
||||
"dueDate": "Встановити термін",
|
||||
"dueDate": "Встановити термін виконання",
|
||||
"startDate": "Почати",
|
||||
"endDate": "Встановити дату завершення",
|
||||
"reminders": "Нагадування",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -28,6 +28,11 @@ import {TIME_FORMAT} from '@/constants/timeFormat'
|
|||
import {RELATION_KIND} from '@/types/IRelationKind'
|
||||
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() {
|
||||
|
||||
const {auth} = useConfigStore()
|
||||
|
|
@ -55,6 +60,17 @@ 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 {
|
||||
return localStorage.getItem('loggedInViaProvider')
|
||||
}
|
||||
|
|
@ -352,7 +368,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
// refresh before giving up. This lets users who reopen the app
|
||||
// after the short JWT TTL seamlessly resume their session.
|
||||
try {
|
||||
await refreshToken(true)
|
||||
await refreshTokenWithRetry(true)
|
||||
const freshJwt = getToken()
|
||||
if (freshJwt) {
|
||||
const b64 = freshJwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
|
|
@ -512,7 +528,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
saveToken(response.data.token, false)
|
||||
} else {
|
||||
// User sessions renew via the refresh-token cookie.
|
||||
await refreshToken(true)
|
||||
await refreshTokenWithRetry(true)
|
||||
}
|
||||
await checkAuth()
|
||||
} catch (e) {
|
||||
|
|
@ -546,19 +562,25 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
const loggedInVia = getLoggedInVia()
|
||||
window.localStorage.clear() // Clear all settings and history we might have saved in local storage.
|
||||
lastUserInfoRefresh.value = null
|
||||
await router.push({name: 'user.login'})
|
||||
await checkAuth()
|
||||
|
||||
sessionStorage.setItem(JUST_LOGGED_OUT_KEY, 'true')
|
||||
|
||||
// Redirect to the OIDC provider to end its session too. Prefer the
|
||||
// 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) {
|
||||
window.location.href = oidcLogoutUrl
|
||||
return
|
||||
}
|
||||
const fullProvider: IProvider|undefined = configStore.auth.openidConnect.providers?.find((p: IProvider) => p.key === loggedInVia)
|
||||
if (fullProvider) {
|
||||
redirectToProviderOnLogout(fullProvider)
|
||||
if (fullProvider && redirectToProviderOnLogout(fullProvider)) {
|
||||
return
|
||||
}
|
||||
|
||||
await router.push({name: 'user.login'})
|
||||
await checkAuth()
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ import {redirectToProvider} from '@/helpers/redirectToProvider'
|
|||
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
|
||||
import {isDesktopApp} from '@/helpers/desktopAuth'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useAuthStore, JUST_LOGGED_OUT_KEY} from '@/stores/auth'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
|
@ -181,6 +181,25 @@ onBeforeMount(() => {
|
|||
// route before the submit() handler gets a chance to use it.
|
||||
if (authenticated.value) {
|
||||
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])
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
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
123
go.mod
|
|
@ -16,56 +16,58 @@
|
|||
|
||||
module code.vikunja.io/api
|
||||
|
||||
go 1.25.7
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
code.dny.dev/ssrf v0.2.0
|
||||
dario.cat/mergo v1.0.2
|
||||
github.com/ThreeDotsLabs/watermill v1.5.1
|
||||
github.com/JohannesKaufmann/dom v0.3.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/arran4/golang-ical v0.3.2
|
||||
github.com/arran4/golang-ical v0.3.5
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.10
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
|
||||
github.com/aws/smithy-go v1.24.2
|
||||
github.com/bbrks/go-blurhash v1.1.1
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.0
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.26
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.25
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1
|
||||
github.com/aws/smithy-go v1.27.3
|
||||
github.com/bbrks/go-blurhash v1.2.0
|
||||
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/coreos/go-oidc/v3 v3.19.0
|
||||
github.com/d4l3k/messagediff v1.2.1
|
||||
github.com/danielgtaylor/huma/v2 v2.37.3
|
||||
github.com/danielgtaylor/huma/v2 v2.38.0
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/fatih/color v1.19.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.13
|
||||
github.com/ganigeorgiev/fexpr v0.5.0
|
||||
github.com/getsentry/sentry-go v0.41.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/go-testfixtures/testfixtures/v3 v3.19.0
|
||||
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/feeds v1.2.0
|
||||
github.com/hashicorp/go-version v1.8.0
|
||||
github.com/hashicorp/go-version v1.9.0
|
||||
github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346
|
||||
github.com/huandu/go-clone/generic v1.7.3
|
||||
github.com/iancoleman/strcase v0.3.0
|
||||
github.com/jaswdr/faker/v2 v2.9.1
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6
|
||||
github.com/labstack/echo-jwt/v5 v5.0.0
|
||||
github.com/labstack/echo/v5 v5.0.3
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/magefile/mage v1.15.0
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
github.com/labstack/echo-jwt/v5 v5.0.1
|
||||
github.com/labstack/echo/v5 v5.2.1
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/magefile/mage v1.17.2
|
||||
github.com/mattn/go-sqlite3 v1.14.47
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/olekukonko/tablewriter v1.1.3
|
||||
github.com/olekukonko/tablewriter v1.1.4
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/redis/go-redis/v9 v9.17.3
|
||||
github.com/redis/go-redis/v9 v9.21.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/samedi/caldav-go v3.0.0+incompatible
|
||||
github.com/schollz/progressbar/v3 v3.19.0
|
||||
|
|
@ -76,43 +78,43 @@ require (
|
|||
github.com/tkuchiki/go-timezone v0.2.3
|
||||
github.com/traefik/yaegi v0.16.1
|
||||
github.com/ulule/limiter/v3 v3.11.2
|
||||
github.com/wneessen/go-mail v0.7.2
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
golang.org/x/crypto v0.48.0
|
||||
github.com/wneessen/go-mail v0.7.3
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.38.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/term v0.40.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/text v0.38.0
|
||||
gopkg.in/d4l3k/messagediff.v1 v1.2.1
|
||||
mvdan.cc/xurls/v2 v2.6.0
|
||||
src.techknowlogick.com/xormigrate v1.7.1
|
||||
xorm.io/builder v0.3.13
|
||||
xorm.io/xorm v1.3.11
|
||||
xorm.io/xorm v1.4.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.1 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // 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.18 // 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.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // 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.22 // 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.13 // 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.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // 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/checksum v1.9.22 // 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/s3shared v1.19.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.4 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beevik/etree v1.1.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
|
|
@ -134,7 +136,7 @@ require (
|
|||
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 // 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-jose/go-jose/v4 v4.1.4 // indirect
|
||||
|
|
@ -145,7 +147,7 @@ require (
|
|||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
|
|
@ -156,8 +158,8 @@ require (
|
|||
github.com/lithammer/shortuuid/v3 v3.0.7 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/moby/api v1.53.0 // indirect
|
||||
|
|
@ -166,18 +168,18 @@ require (
|
|||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/oklog/ulid v1.3.1 // indirect
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
|
||||
github.com/olekukonko/errors v1.1.0 // indirect
|
||||
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect
|
||||
github.com/olekukonko/errors v1.2.0 // indirect
|
||||
github.com/olekukonko/ll v0.1.6 // indirect
|
||||
github.com/onsi/ginkgo v1.16.4 // indirect
|
||||
github.com/onsi/gomega v1.16.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // 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/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
|
|
@ -197,12 +199,13 @@ require (
|
|||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
|
|
|||
179
go.sum
179
go.sum
|
|
@ -4,6 +4,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
|||
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/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/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
|
||||
gitee.com/travelliu/dm v1.8.11192/go.mod h1:DHTzyhCrM843x9VdKVbZ+GKXGRbKM2sJ4LxihRxShkE=
|
||||
|
|
@ -15,6 +17,10 @@ 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/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
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/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||
|
|
@ -24,56 +30,116 @@ 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/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY=
|
||||
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/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/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
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.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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
|
|
@ -103,12 +169,16 @@ 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/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.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/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/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
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.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-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=
|
||||
|
|
@ -122,6 +192,8 @@ 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/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.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/go.mod h1:kAivYNRnBeE/IJinqBvVFvLrX54xX//9zFYwADo4Bc8=
|
||||
github.com/danielgtaylor/shorthand/v2 v2.2.0 h1:hVsemdRq6v3JocP6YRTfu9rOoghZI9PFmkngdKqzAVQ=
|
||||
|
|
@ -152,6 +224,8 @@ 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/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.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/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
|
|
@ -162,6 +236,8 @@ 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/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.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/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||
|
|
@ -179,6 +255,8 @@ 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-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.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-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
|
|
@ -201,6 +279,8 @@ 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.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.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-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=
|
||||
|
|
@ -213,11 +293,15 @@ 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.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.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/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
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/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-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
|
|
@ -259,6 +343,8 @@ 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-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.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/go.mod h1:4ggHM2qnyyZjenBb7RpwVzIj+JMsu9kHCVxMjB30hGs=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
|
|
@ -356,8 +442,14 @@ 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/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.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/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/go.mod h1:4LATl0uhhtytR6p9n1AlktDyIz4u2iUnWEdI3L/hXiw=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
|
|
@ -367,10 +459,14 @@ 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.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
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/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
|
||||
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.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-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
|
|
@ -386,14 +482,18 @@ 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.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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
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.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.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/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/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||
|
|
@ -424,10 +524,16 @@ 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/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
|
||||
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/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/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.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
|
|
@ -444,6 +550,8 @@ 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/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.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/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
|
||||
|
|
@ -461,10 +569,16 @@ 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/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.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/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/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-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
|
|
@ -486,6 +600,10 @@ 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/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
|
||||
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 v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
|
|
@ -542,14 +660,16 @@ 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/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.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/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/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE=
|
||||
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.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
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=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
|
|
@ -570,6 +690,8 @@ 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.5.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/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
|
|
@ -581,6 +703,8 @@ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
|||
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/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/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
|
|
@ -596,8 +720,10 @@ 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-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
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/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
|
|
@ -608,8 +734,10 @@ 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.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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
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-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
|
|
@ -624,16 +752,20 @@ 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-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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
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.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-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-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/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-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
|
|
@ -661,15 +793,18 @@ 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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.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.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-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.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
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.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
|
|
@ -677,8 +812,10 @@ 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.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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
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.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
|
|
@ -694,8 +831,10 @@ 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-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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
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-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
@ -799,3 +938,5 @@ 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.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI=
|
||||
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=
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
[tools]
|
||||
node = "24.13.0" # keep in sync with frontend/.nvmrc
|
||||
pnpm = "10.28.1" # keep in sync with frontend/package.json#packageManager
|
||||
go = "1.25.7" # keep in sync with go.mod
|
||||
node = "24.18.0" # keep in sync with frontend/.nvmrc
|
||||
pnpm = "10.34.4" # keep in sync with frontend/package.json#packageManager
|
||||
go = "1.26.4" # keep in sync with go.mod
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/richtext"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/api/pkg/utils"
|
||||
)
|
||||
|
|
@ -179,8 +181,18 @@ DURATION:PT` + formatDuration(t.Duration)
|
|||
DTEND:` + makeCalDavTimeFromTimeStamp(t.End)
|
||||
}
|
||||
if t.Description != "" {
|
||||
caldavtodos += `
|
||||
DESCRIPTION:` + escapeICalText(t.Description)
|
||||
// CalDAV clients show plain text, so emit markdown. On the near-impossible
|
||||
// conversion error, log it and keep the stored value (GetContent can't
|
||||
// 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 {
|
||||
caldavtodos += `
|
||||
|
|
|
|||
|
|
@ -47,12 +47,11 @@ func TestParseTodos(t *testing.T) {
|
|||
},
|
||||
todos: []*Todo{
|
||||
{
|
||||
Summary: "Todo #1",
|
||||
Description: `Lorem Ipsum
|
||||
Dolor sit amet`,
|
||||
UID: "randommduid",
|
||||
Timestamp: time.Unix(1543626724, 0).In(config.GetTimeZone()),
|
||||
Color: "affffe",
|
||||
Summary: "Todo #1",
|
||||
Description: `<p>Lorem Ipsum</p><p>Dolor sit amet</p>`,
|
||||
UID: "randommduid",
|
||||
Timestamp: time.Unix(1543626724, 0).In(config.GetTimeZone()),
|
||||
Color: "affffe",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -73,7 +72,7 @@ X-APPLE-CALENDAR-COLOR:#affffeFF
|
|||
X-OUTLOOK-COLOR:#affffeFF
|
||||
X-FUNAMBOL-COLOR:#affffeFF
|
||||
COLOR:#affffeFF
|
||||
DESCRIPTION:Lorem Ipsum\nDolor sit amet
|
||||
DESCRIPTION:Lorem Ipsum\n\nDolor sit amet
|
||||
LAST-MODIFIED:00010101T000000Z
|
||||
END:VTODO
|
||||
END:VCALENDAR`,
|
||||
|
|
@ -438,6 +437,33 @@ 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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -92,14 +92,15 @@ const (
|
|||
AuthLdapVerifyTLS Key = `auth.ldap.verifytls`
|
||||
AuthLdapBindDN Key = `auth.ldap.binddn`
|
||||
// #nosec G101
|
||||
AuthLdapBindPassword Key = `auth.ldap.bindpassword`
|
||||
AuthLdapGroupSyncEnabled Key = `auth.ldap.groupsyncenabled`
|
||||
AuthLdapGroupSyncFilter Key = `auth.ldap.groupsyncfilter`
|
||||
AuthLdapAvatarSyncAttribute Key = `auth.ldap.avatarsyncattribute`
|
||||
AuthLdapAttributeUsername Key = `auth.ldap.attribute.username`
|
||||
AuthLdapAttributeEmail Key = `auth.ldap.attribute.email`
|
||||
AuthLdapAttributeDisplayname Key = `auth.ldap.attribute.displayname`
|
||||
AuthLdapAttributeMemberID Key = `auth.ldap.attribute.memberid`
|
||||
AuthLdapBindPassword Key = `auth.ldap.bindpassword`
|
||||
AuthLdapGroupSyncEnabled Key = `auth.ldap.groupsyncenabled`
|
||||
AuthLdapGroupSyncFilter Key = `auth.ldap.groupsyncfilter`
|
||||
AuthLdapGroupSyncUseServiceAccount Key = `auth.ldap.groupsyncuseserviceaccount`
|
||||
AuthLdapAvatarSyncAttribute Key = `auth.ldap.avatarsyncattribute`
|
||||
AuthLdapAttributeUsername Key = `auth.ldap.attribute.username`
|
||||
AuthLdapAttributeEmail Key = `auth.ldap.attribute.email`
|
||||
AuthLdapAttributeDisplayname Key = `auth.ldap.attribute.displayname`
|
||||
AuthLdapAttributeMemberID Key = `auth.ldap.attribute.memberid`
|
||||
|
||||
LegalImprintURL Key = `legal.imprinturl`
|
||||
LegalPrivacyURL Key = `legal.privacyurl`
|
||||
|
|
@ -389,6 +390,7 @@ func InitDefaultConfig() {
|
|||
AuthLdapVerifyTLS.setDefault(true)
|
||||
AuthLdapGroupSyncEnabled.setDefault(false)
|
||||
AuthLdapGroupSyncFilter.setDefault("(&(objectclass=*)(|(objectclass=group)(objectclass=groupOfNames)))")
|
||||
AuthLdapGroupSyncUseServiceAccount.setDefault(false)
|
||||
AuthLdapAttributeUsername.setDefault("uid")
|
||||
AuthLdapAttributeEmail.setDefault("mail")
|
||||
AuthLdapAttributeDisplayname.setDefault("displayName")
|
||||
|
|
|
|||
|
|
@ -74,6 +74,13 @@ func stripAPIVersion(path string) string {
|
|||
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) {
|
||||
parts := strings.Split(stripAPIVersion(path), "/")
|
||||
filteredParts = []string{}
|
||||
|
|
@ -82,7 +89,7 @@ func getRouteGroupName(path string) (finalName string, filteredParts []string) {
|
|||
continue
|
||||
}
|
||||
|
||||
filteredParts = append(filteredParts, part)
|
||||
filteredParts = append(filteredParts, canonicalAPITokenGroup(part))
|
||||
}
|
||||
|
||||
finalName = strings.Join(filteredParts, "_")
|
||||
|
|
@ -183,7 +190,7 @@ func isStandardCRUDRoute(routeGroupName string, routeParts []string, _ string) b
|
|||
"comments": true,
|
||||
"relations": true,
|
||||
"attachments": true,
|
||||
"time-entries": true,
|
||||
"time_entries": true,
|
||||
"projects_views": true,
|
||||
"projects_teams": true,
|
||||
"projects_users": true,
|
||||
|
|
@ -403,7 +410,8 @@ func CanDoAPIRoute(c *echo.Context, token *APIToken) (can bool) {
|
|||
}
|
||||
method := c.Request().Method
|
||||
|
||||
for group, perms := range token.APIPermissions {
|
||||
for rawGroup, perms := range token.APIPermissions {
|
||||
group := canonicalAPITokenGroup(rawGroup)
|
||||
tables := []APITokenRoute{apiTokenRoutes[group], apiTokenRoutesV2[group]}
|
||||
for _, routes := range tables {
|
||||
if routes == nil {
|
||||
|
|
@ -427,7 +435,8 @@ func CanDoAPIRoute(c *echo.Context, token *APIToken) (can bool) {
|
|||
// Two list endpoints share tasks.read_all but only one
|
||||
// survives collection, so allow either explicitly.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -447,8 +456,9 @@ func PermissionsAreValid(permissions APIPermissions) (err error) {
|
|||
// resources (no v1 counterpart) live solely in apiTokenRoutesV2, so
|
||||
// validating against the union lets tokens grant them. CanDoAPIRoute
|
||||
// already consults both tables when authorising.
|
||||
v1Routes := apiTokenRoutes[key]
|
||||
v2Routes := apiTokenRoutesV2[key]
|
||||
group := canonicalAPITokenGroup(key)
|
||||
v1Routes := apiTokenRoutes[group]
|
||||
v2Routes := apiTokenRoutesV2[group]
|
||||
if v1Routes == nil && v2Routes == nil {
|
||||
return &ErrInvalidAPITokenPermission{
|
||||
Group: key,
|
||||
|
|
|
|||
|
|
@ -121,9 +121,9 @@ func TestCollectRoutesV2(t *testing.T) {
|
|||
assert.Equal(t, "DELETE", labels["delete"].Method)
|
||||
}
|
||||
|
||||
// TestCollectRoutes_TimeEntriesV2 verifies the v2-only time-entries resource
|
||||
// lands under a clean "time-entries" group rather than the "other" catch-all,
|
||||
// so its scopes read sensibly for token clients.
|
||||
// TestCollectRoutes_TimeEntriesV2 pins the v2-only time-entries resource to a
|
||||
// snake_case "time_entries" group (not the "other" catch-all, not a hyphenated
|
||||
// key the frontend's snake_case transform would mangle on save).
|
||||
func TestCollectRoutes_TimeEntriesV2(t *testing.T) {
|
||||
apiTokenRoutes = make(map[string]APITokenRoute)
|
||||
apiTokenRoutesV2 = make(map[string]APITokenRoute)
|
||||
|
|
@ -137,8 +137,11 @@ func TestCollectRoutes_TimeEntriesV2(t *testing.T) {
|
|||
_, isOther := apiTokenRoutesV2["other"]
|
||||
assert.False(t, isOther, "time-entries CRUD must not fall into the 'other' bucket")
|
||||
|
||||
te, has := apiTokenRoutesV2["time-entries"]
|
||||
require.True(t, has, "time-entries group should exist in the v2 table")
|
||||
_, hyphenated := apiTokenRoutesV2["time-entries"]
|
||||
assert.False(t, hyphenated, "group key must be canonicalised to snake_case")
|
||||
|
||||
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, "/api/v2/time-entries", te["read_all"].Path)
|
||||
assert.Equal(t, "GET", te["read_one"].Method)
|
||||
|
|
@ -148,7 +151,7 @@ func TestCollectRoutes_TimeEntriesV2(t *testing.T) {
|
|||
}
|
||||
|
||||
// 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.
|
||||
func TestGetAPITokenRoutes_ExposesV2Only(t *testing.T) {
|
||||
apiTokenRoutes = make(map[string]APITokenRoute)
|
||||
|
|
@ -162,14 +165,35 @@ func TestGetAPITokenRoutes_ExposesV2Only(t *testing.T) {
|
|||
_, hasLabels := routes["labels"]
|
||||
assert.True(t, hasLabels, "v1 groups stay exposed")
|
||||
|
||||
te, hasTE := routes["time-entries"]
|
||||
require.True(t, hasTE, "v2-only time-entries must be exposed via /routes")
|
||||
te, hasTE := routes["time_entries"]
|
||||
require.True(t, hasTE, "v2-only time_entries must be exposed via /routes")
|
||||
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")
|
||||
}
|
||||
|
||||
// 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,
|
||||
// PUT/PATCH→update. v1 inverts POST and PUT so we need a separate mapping
|
||||
// path.
|
||||
|
|
@ -246,6 +270,40 @@ 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
|
||||
// integration test in pkg/webtests/huma_label_test.go (see the token-auth
|
||||
// scenarios in that file) which exercises the full auth pipeline.
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ func (r *Permission) UnmarshalJSON(data []byte) error {
|
|||
case 2:
|
||||
*r = PermissionAdmin
|
||||
default:
|
||||
return fmt.Errorf("invalid Permission %q", s)
|
||||
return fmt.Errorf("invalid Permission %d", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -760,6 +760,15 @@ func getRawProjectsForUser(s *xorm.Session, opts *projectOptions) (projects []*P
|
|||
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) {
|
||||
savedFilters, err := getSavedFiltersForUser(s, doer, search)
|
||||
if err != nil {
|
||||
|
|
@ -1108,6 +1117,10 @@ func RegisterUser(s *xorm.Session, u *user.User) (*user.User, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if err := CreateDefaultSavedFiltersForUser(s, newUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newUser, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -250,6 +250,23 @@ func AuthenticateUserInLDAP(s *xorm.Session, username, password string, syncGrou
|
|||
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)
|
||||
|
||||
return u, err
|
||||
|
|
|
|||
|
|
@ -104,6 +104,64 @@ func TestLdapLogin(t *testing.T) {
|
|||
}, 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) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
|
|
|
|||
|
|
@ -377,6 +377,46 @@ func syncUserAvatarFromOpenID(s *xorm.Session, u *user.User, pictureURL string)
|
|||
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) {
|
||||
|
||||
// set defaults
|
||||
|
|
@ -402,33 +442,21 @@ func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *o
|
|||
|
||||
if !alreadyCreatedFromIssuer && (provider.EmailFallback || provider.UsernameFallback) {
|
||||
|
||||
// try finding the user on fallback mappingproperties
|
||||
// try finding the user on fallback mapping properties
|
||||
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)
|
||||
|
||||
searchUser := &user.User{
|
||||
Issuer: user.IssuerLocal,
|
||||
}
|
||||
if provider.UsernameFallback {
|
||||
// Match oidc subject on username as each is unique identifier in its own referential
|
||||
// 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
|
||||
// Same as above: disabled/locked user found via fallback — return early.
|
||||
if fallbackMatchFound && user.IsErrUserStatusError(err) {
|
||||
return u, nil
|
||||
}
|
||||
if fallbackMatchFound {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -254,11 +254,61 @@ func TestGetOrCreateUser(t *testing.T) {
|
|||
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 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) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
usersBefore, err := s.Count(&user.User{})
|
||||
require.NoError(t, err)
|
||||
|
||||
cl := &claims{
|
||||
Email: "user11@example.com",
|
||||
}
|
||||
|
|
@ -272,6 +322,42 @@ func TestGetOrCreateUser(t *testing.T) {
|
|||
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, 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) {
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,10 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
|
|
@ -253,6 +255,72 @@ func parseDate(dateString string) (date time.Time, err error) {
|
|||
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) {
|
||||
|
||||
var pseudoParentID int64 = 1
|
||||
|
|
@ -358,6 +426,7 @@ func convertTodoistToVikunja(sync *sync, doneItems map[string]*doneItem) (fullVi
|
|||
return nil, err
|
||||
}
|
||||
task.DueDate = dueDate.In(config.GetTimeZone())
|
||||
task.RepeatAfter = parseTodoistRepeat(i.Due)
|
||||
}
|
||||
|
||||
// Put all labels together from earlier
|
||||
|
|
|
|||
|
|
@ -651,3 +651,47 @@ func TestConvertTodoistToVikunja(t *testing.T) {
|
|||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
// 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)
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
// 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>"))
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
// 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
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
// 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())
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
// 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
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
// 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>")
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
// 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 == '_'
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
// 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
// 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) == ""
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
// 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
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
// 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
|
||||
}
|
||||
|
|
@ -47,15 +47,24 @@ func RegisterBulkTaskRoutes(api huma.API) {
|
|||
func init() { AddRouteRegistrar(RegisterBulkTaskRoutes) }
|
||||
|
||||
func tasksBulkUpdate(ctx context.Context, in *struct {
|
||||
Body models.BulkTask
|
||||
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
|
||||
Body models.BulkTask
|
||||
}) (*singleBody[models.BulkTask], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ func NewAPI(e *echo.Echo, g *echo.Group) huma.API {
|
|||
|
||||
api := humaecho5.NewWithGroup(e, g, GroupPrefix, cfg)
|
||||
oapi := api.OpenAPI()
|
||||
if oapi.Info != nil {
|
||||
oapi.Info.Description = richTextFormatAPIDescription
|
||||
}
|
||||
if oapi.Components.SecuritySchemes == nil {
|
||||
oapi.Components.SecuritySchemes = map[string]*huma.SecurityScheme{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,10 @@ func RegisterLabelRoutes(api huma.API) {
|
|||
|
||||
func init() { AddRouteRegistrar(RegisterLabelRoutes) }
|
||||
|
||||
func labelsList(ctx context.Context, in *ListParams) (*labelListBody, error) {
|
||||
func labelsList(ctx context.Context, in *struct {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -100,6 +103,9 @@ func labelsList(ctx context.Context, in *ListParams) (*labelListBody, error) {
|
|||
if !ok {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +115,8 @@ type labelReadBody 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
|
||||
}) (*singleReadBody[labelReadBody], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
|
|
@ -122,26 +129,33 @@ func labelsRead(ctx context.Context, in *struct {
|
|||
return nil, translateDomainError(err)
|
||||
}
|
||||
body := &labelReadBody{Label: *label, MaxPermission: models.Permission(maxPermission)}
|
||||
convertToMarkdown(ctx, &body.Description)
|
||||
return conditionalReadResponse(&in.Params, body, label.Updated, maxPermission)
|
||||
}
|
||||
|
||||
func labelsCreate(ctx context.Context, in *struct {
|
||||
Body models.Label
|
||||
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
|
||||
Body models.Label
|
||||
}) (*singleBody[models.Label], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
convertToMarkdown(ctx, &in.Body.Description)
|
||||
return &singleBody[models.Label]{Body: &in.Body}, nil
|
||||
}
|
||||
|
||||
// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates.
|
||||
func labelsUpdate(ctx context.Context, in *struct {
|
||||
ID int64 `path:"id"`
|
||||
Body labelReadBody
|
||||
ID int64 `path:"id"`
|
||||
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
|
||||
Body labelReadBody
|
||||
}) (*singleBody[models.Label], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -149,9 +163,13 @@ func labelsUpdate(ctx context.Context, in *struct {
|
|||
}
|
||||
label := &in.Body.Label
|
||||
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 {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
convertToMarkdown(ctx, &label.Description)
|
||||
return &singleBody[models.Label]{Body: label}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ func projectsList(ctx context.Context, in *struct {
|
|||
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."`
|
||||
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) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -106,6 +107,9 @@ func projectsList(ctx context.Context, in *struct {
|
|||
if !ok {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +121,8 @@ type projectReadBody 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) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -132,22 +137,29 @@ func projectsRead(ctx context.Context, in *struct {
|
|||
// the Favorites pseudo-project and saved-filter-backed ones), so the field
|
||||
// is always meaningful here — surfaced unconditionally like labels/views.
|
||||
project.MaxPermission = models.Permission(maxPermission)
|
||||
body := &projectReadBody{Project: *project}
|
||||
convertToMarkdown(ctx, &body.Description)
|
||||
// No ETag/conditional read: a project response carries user-scoped, derived
|
||||
// state (subscription, favorite, views, computed archived state) that
|
||||
// changes without bumping project.Updated, so it's always served fresh.
|
||||
return &singleBody[projectReadBody]{Body: &projectReadBody{Project: *project}}, nil
|
||||
return &singleBody[projectReadBody]{Body: body}, nil
|
||||
}
|
||||
|
||||
func projectsCreate(ctx context.Context, in *struct {
|
||||
Body models.Project
|
||||
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
|
||||
Body models.Project
|
||||
}) (*singleBody[models.Project], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
convertToMarkdown(ctx, &in.Body.Description)
|
||||
// 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.
|
||||
in.Body.MaxPermission = models.PermissionUnknown
|
||||
|
|
@ -156,8 +168,9 @@ func projectsCreate(ctx context.Context, in *struct {
|
|||
|
||||
// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates.
|
||||
func projectsUpdate(ctx context.Context, in *struct {
|
||||
ID int64 `path:"id"`
|
||||
Body projectReadBody
|
||||
ID int64 `path:"id"`
|
||||
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
|
||||
Body projectReadBody
|
||||
}) (*singleBody[models.Project], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -165,9 +178,13 @@ func projectsUpdate(ctx context.Context, in *struct {
|
|||
}
|
||||
project := &in.Body.Project
|
||||
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 {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
convertToMarkdown(ctx, &project.Description)
|
||||
project.MaxPermission = models.PermissionUnknown // see projectsCreate
|
||||
return &singleBody[models.Project]{Body: project}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,4 +42,5 @@ func RegisterAll(api huma.API) {
|
|||
r(api)
|
||||
}
|
||||
EnableAutoPatch(api)
|
||||
stripPatchFormatQuery(api)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
// 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
|
||||
}
|
||||
|
|
@ -77,7 +77,8 @@ type savedFilterReadBody 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
|
||||
}) (*singleReadBody[savedFilterReadBody], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
|
|
@ -90,26 +91,33 @@ func savedFiltersRead(ctx context.Context, in *struct {
|
|||
return nil, translateDomainError(err)
|
||||
}
|
||||
body := &savedFilterReadBody{SavedFilter: *filter, MaxPermission: models.Permission(maxPermission)}
|
||||
convertToMarkdown(ctx, &body.Description)
|
||||
return conditionalReadResponse(&in.Params, body, filter.Updated, maxPermission)
|
||||
}
|
||||
|
||||
func savedFiltersCreate(ctx context.Context, in *struct {
|
||||
Body models.SavedFilter
|
||||
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
|
||||
Body models.SavedFilter
|
||||
}) (*singleBody[models.SavedFilter], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
convertToMarkdown(ctx, &in.Body.Description)
|
||||
return &singleBody[models.SavedFilter]{Body: &in.Body}, nil
|
||||
}
|
||||
|
||||
// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates.
|
||||
func savedFiltersUpdate(ctx context.Context, in *struct {
|
||||
ID int64 `path:"filter"`
|
||||
Body savedFilterReadBody
|
||||
ID int64 `path:"filter"`
|
||||
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
|
||||
Body savedFilterReadBody
|
||||
}) (*singleBody[models.SavedFilter], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -117,9 +125,13 @@ func savedFiltersUpdate(ctx context.Context, in *struct {
|
|||
}
|
||||
filter := &in.Body.SavedFilter
|
||||
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 {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
convertToMarkdown(ctx, &filter.Description)
|
||||
return &singleBody[models.SavedFilter]{Body: filter}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ type TaskListQueryParams struct {
|
|||
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."`
|
||||
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 {
|
||||
|
|
@ -201,6 +202,7 @@ func readFlatTasks(ctx context.Context, f taskListFilters, page, perPage int, pr
|
|||
if !ok {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -228,6 +230,11 @@ func projectViewBucketsTasksList(ctx context.Context, in *taskListViewInput) (*b
|
|||
}
|
||||
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.Body.Items = buckets
|
||||
out.Body.Total = total
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ func init() { AddRouteRegistrar(RegisterTaskCommentRoutes) }
|
|||
func taskCommentsList(ctx context.Context, in *struct {
|
||||
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)."`
|
||||
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
|
||||
ListParams
|
||||
}) (*taskCommentListBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
|
|
@ -110,6 +111,9 @@ func taskCommentsList(ctx context.Context, in *struct {
|
|||
if !ok {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -121,8 +125,9 @@ type taskCommentReadBody struct {
|
|||
}
|
||||
|
||||
func taskCommentsRead(ctx context.Context, in *struct {
|
||||
TaskID int64 `path:"task"`
|
||||
ID int64 `path:"commentid"`
|
||||
TaskID int64 `path:"task"`
|
||||
ID int64 `path:"commentid"`
|
||||
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
|
||||
conditional.Params
|
||||
}) (*singleReadBody[taskCommentReadBody], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
|
|
@ -137,11 +142,13 @@ func taskCommentsRead(ctx context.Context, in *struct {
|
|||
return nil, translateDomainError(err)
|
||||
}
|
||||
body := &taskCommentReadBody{TaskComment: *comment, MaxPermission: models.Permission(maxPermission)}
|
||||
convertToMarkdown(ctx, &body.Comment)
|
||||
return conditionalReadResponse(&in.Params, body, comment.Updated, maxPermission)
|
||||
}
|
||||
|
||||
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
|
||||
}) (*singleBody[models.TaskComment], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
|
|
@ -149,16 +156,21 @@ func taskCommentsCreate(ctx context.Context, in *struct {
|
|||
return nil, err
|
||||
}
|
||||
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 {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
convertToMarkdown(ctx, &in.Body.Comment)
|
||||
return &singleBody[models.TaskComment]{Body: &in.Body}, nil
|
||||
}
|
||||
|
||||
// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates.
|
||||
func taskCommentsUpdate(ctx context.Context, in *struct {
|
||||
TaskID int64 `path:"task"`
|
||||
ID int64 `path:"commentid"`
|
||||
TaskID int64 `path:"task"`
|
||||
ID int64 `path:"commentid"`
|
||||
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
|
||||
Body taskCommentReadBody
|
||||
}) (*singleBody[models.TaskComment], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
|
|
@ -168,9 +180,13 @@ func taskCommentsUpdate(ctx context.Context, in *struct {
|
|||
comment := &in.Body.TaskComment
|
||||
comment.ID = in.ID // URL wins over body
|
||||
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 {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
convertToMarkdown(ctx, &comment.Comment)
|
||||
return &singleBody[models.TaskComment]{Body: comment}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ type taskReadOneBody struct {
|
|||
func tasksRead(ctx context.Context, in *struct {
|
||||
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."`
|
||||
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
|
||||
conditional.Params
|
||||
}) (*singleReadBody[taskReadOneBody], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
|
|
@ -128,6 +129,7 @@ func tasksRead(ctx context.Context, in *struct {
|
|||
return nil, translateDomainError(err)
|
||||
}
|
||||
body := &taskReadOneBody{Task: *task, MaxPermission: models.Permission(maxPermission)}
|
||||
convertTasksToMarkdown(ctx, &body.Task)
|
||||
return conditionalReadResponse(&in.Params, body, task.Updated, maxPermission)
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +137,7 @@ func tasksReadByIndex(ctx context.Context, in *struct {
|
|||
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."`
|
||||
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
|
||||
}) (*singleReadBody[taskReadOneBody], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
|
|
@ -158,11 +161,13 @@ func tasksReadByIndex(ctx context.Context, in *struct {
|
|||
return nil, translateDomainError(err)
|
||||
}
|
||||
body := &taskReadOneBody{Task: *task, MaxPermission: models.Permission(maxPermission)}
|
||||
convertTasksToMarkdown(ctx, &body.Task)
|
||||
return conditionalReadResponse(&in.Params, body, task.Updated, maxPermission)
|
||||
}
|
||||
|
||||
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
|
||||
}) (*singleBody[models.Task], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
|
|
@ -171,16 +176,21 @@ func tasksCreate(ctx context.Context, in *struct {
|
|||
}
|
||||
task := &in.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 {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
convertTasksToMarkdown(ctx, task)
|
||||
return &singleBody[models.Task]{Body: task}, nil
|
||||
}
|
||||
|
||||
// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates.
|
||||
func tasksUpdate(ctx context.Context, in *struct {
|
||||
ID int64 `path:"projecttask"`
|
||||
Body taskReadOneBody
|
||||
ID int64 `path:"projecttask"`
|
||||
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
|
||||
Body taskReadOneBody
|
||||
}) (*singleBody[models.Task], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -188,9 +198,13 @@ func tasksUpdate(ctx context.Context, in *struct {
|
|||
}
|
||||
task := &in.Body.Task
|
||||
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 {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
convertTasksToMarkdown(ctx, task)
|
||||
return &singleBody[models.Task]{Body: task}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -90,7 +90,8 @@ func teamsList(ctx context.Context, in *struct {
|
|||
// IncludePublic mirrors the model's include_public query param; bound
|
||||
// onto the model below so ReadAll can honor it (gated by the instance
|
||||
// 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) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -104,6 +105,9 @@ func teamsList(ctx context.Context, in *struct {
|
|||
if !ok {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +117,8 @@ type teamReadBody 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
|
||||
}) (*singleReadBody[teamReadBody], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
|
|
@ -126,26 +131,33 @@ func teamsRead(ctx context.Context, in *struct {
|
|||
return nil, translateDomainError(err)
|
||||
}
|
||||
body := &teamReadBody{Team: *team, MaxPermission: models.Permission(maxPermission)}
|
||||
convertToMarkdown(ctx, &body.Description)
|
||||
return conditionalReadResponse(&in.Params, body, team.Updated, maxPermission)
|
||||
}
|
||||
|
||||
func teamsCreate(ctx context.Context, in *struct {
|
||||
Body models.Team
|
||||
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
|
||||
Body models.Team
|
||||
}) (*singleBody[models.Team], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
convertToMarkdown(ctx, &in.Body.Description)
|
||||
return &singleBody[models.Team]{Body: &in.Body}, nil
|
||||
}
|
||||
|
||||
// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates.
|
||||
func teamsUpdate(ctx context.Context, in *struct {
|
||||
ID int64 `path:"id"`
|
||||
Body teamReadBody
|
||||
ID int64 `path:"id"`
|
||||
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
|
||||
Body teamReadBody
|
||||
}) (*singleBody[models.Team], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -153,9 +165,13 @@ func teamsUpdate(ctx context.Context, in *struct {
|
|||
}
|
||||
team := &in.Body.Team
|
||||
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 {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
convertToMarkdown(ctx, &team.Description)
|
||||
return &singleBody[models.Team]{Body: team}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
// 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import (
|
|||
"code.vikunja.io/api/pkg/events"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/richtext"
|
||||
user2 "code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/api/pkg/web"
|
||||
"github.com/samedi/caldav-go/data"
|
||||
|
|
@ -364,6 +365,14 @@ func (vcls *VikunjaCaldavProjectStorage) CreateResource(rpath, content string) (
|
|||
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
|
||||
err = vTask.Create(s, vcls.user)
|
||||
if err != nil {
|
||||
|
|
@ -408,6 +417,23 @@ func (vcls *VikunjaCaldavProjectStorage) CreateResource(rpath, content string) (
|
|||
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
|
||||
func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) (*data.Resource, error) {
|
||||
|
||||
|
|
@ -443,6 +469,14 @@ func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) (
|
|||
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
|
||||
err = vTask.Update(s, vcls.user)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
// 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
|
||||
}
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
// 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ func init() {
|
|||
"BucketConfigurationModeNone": reflect.ValueOf(models.BucketConfigurationModeNone),
|
||||
"CanDoAPIRoute": reflect.ValueOf(models.CanDoAPIRoute),
|
||||
"CollectRoutesForAPITokenUsage": reflect.ValueOf(models.CollectRoutesForAPITokenUsage),
|
||||
"CreateDefaultSavedFiltersForUser": reflect.ValueOf(models.CreateDefaultSavedFiltersForUser),
|
||||
"CreateDefaultViewsForProject": reflect.ValueOf(models.CreateDefaultViewsForProject),
|
||||
"CreateNewProjectForUser": reflect.ValueOf(models.CreateNewProjectForUser),
|
||||
"CreateProject": reflect.ValueOf(models.CreateProject),
|
||||
|
|
|
|||
|
|
@ -46,9 +46,36 @@ this file is veans-specific.
|
|||
|
||||
## Vikunja wire-format gotchas
|
||||
|
||||
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:
|
||||
veans targets the Huma-backed **`/api/v2`** exclusively (`apiBasePath` in
|
||||
`internal/client/client.go`). v1 is frozen, and the kanban-bucket CRUD veans
|
||||
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
|
||||
strings**, not ints. The parent enums (`ProjectViewKind`,
|
||||
`BucketConfigurationModeKind`) have custom `MarshalJSON` that emits
|
||||
|
|
@ -58,11 +85,12 @@ what's bitten me; if a new endpoint behaves oddly, suspect one of these:
|
|||
`xorm:"-"` on it — the actual bucket lives in a separate
|
||||
`task_buckets` table. Fetch with `?expand=buckets` and use
|
||||
`task.CurrentBucketID(viewID)` to read it.
|
||||
- **`POST /tasks/{id}` does NOT move tasks between buckets.** The
|
||||
task↔bucket relation is row-shaped; use `client.MoveTaskToBucket()`
|
||||
which hits `POST /projects/{p}/views/{v}/buckets/{b}/tasks`. The
|
||||
Update path on the server only auto-moves on `done` flips.
|
||||
- **Bot user creation is `PUT /user/bots`**, not `/bots` — the routes
|
||||
- **Task updates do NOT move tasks between buckets.** The task↔bucket
|
||||
relation is row-shaped; use `client.MoveTaskToBucket()` which hits
|
||||
**`PUT /projects/{p}/views/{v}/buckets/{b}/tasks`** with a `{"task_id":N}`
|
||||
body (project/view/bucket all come from the URL). The Update path on the
|
||||
server only auto-moves on `done` flips.
|
||||
- **Bot user creation is `POST /user/bots`**, not `/bots` — the routes
|
||||
are registered under the `/user` subgroup. Same prefix for
|
||||
`GET /user/bots`.
|
||||
- **`APIToken.expires_at` is required.** The struct field has
|
||||
|
|
@ -88,6 +116,16 @@ what's bitten me; if a new endpoint behaves oddly, suspect one of these:
|
|||
- `/projects/:project/views/:view/buckets/:bucket/tasks` →
|
||||
group `projects`, action `views_buckets_tasks`
|
||||
- `/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
|
||||
grants only the intersection of what we want and what the server
|
||||
exposes. **Don't hard-code permission group names** — they drift
|
||||
|
|
@ -96,9 +134,9 @@ what's bitten me; if a new endpoint behaves oddly, suspect one of these:
|
|||
|
||||
## Bot ownership and token minting
|
||||
|
||||
- Creating a bot via `PUT /user/bots` automatically sets the bot's
|
||||
- Creating a bot via `POST /user/bots` automatically sets the bot's
|
||||
`bot_owner_id` to the calling user. Only the owner can mint tokens
|
||||
for the bot via `PUT /tokens` with `owner_id=<bot_id>`. The init
|
||||
for the bot via `POST /tokens` with `owner_id=<bot_id>`. The init
|
||||
flow does these as a single human-JWT-authenticated batch.
|
||||
- Bots have no password and **cannot** authenticate via `POST /login`.
|
||||
After init, `veans login` re-authenticates as the human (not the
|
||||
|
|
@ -115,9 +153,11 @@ what's bitten me; if a new endpoint behaves oddly, suspect one of these:
|
|||
browser, and captures the callback. The `Shutdown` defer uses
|
||||
`context.WithoutCancel(ctx)` so cancellation at the outer scope
|
||||
still drains the loopback server cleanly.
|
||||
- Token exchange is **JSON only**. Form-encoded POSTs to `/oauth/token`
|
||||
fail; the standard `golang.org/x/oauth2` client speaks form encoding,
|
||||
which is why we have a hand-rolled `client.ExchangeOAuthCode`.
|
||||
- Token exchange goes out as **JSON**. v2's `/oauth/token` accepts both JSON
|
||||
and form-encoded bodies (Huma picks the decoder off the `Content-Type`
|
||||
header), but the standard `golang.org/x/oauth2` client hard-codes form
|
||||
encoding and its own response shape, so we keep the hand-rolled
|
||||
`client.ExchangeOAuthCode` that speaks JSON.
|
||||
|
||||
## Credential store
|
||||
|
||||
|
|
|
|||
|
|
@ -102,11 +102,14 @@ func TestInit_HappyPath(t *testing.T) {
|
|||
t.Fatalf("bot %q not found on server", ws.BotUsername)
|
||||
}
|
||||
|
||||
// Project shared with the bot at write permission.
|
||||
var shares []map[string]any
|
||||
// Project shared with the bot at write permission. v2 lists come wrapped
|
||||
// in the standard {items,...} envelope.
|
||||
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)
|
||||
shareFound := false
|
||||
for _, s := range shares {
|
||||
for _, s := range shares.Items {
|
||||
if u, _ := s["username"].(string); u == ws.BotUsername {
|
||||
if p, _ := s["permission"].(float64); int(p) >= 1 {
|
||||
shareFound = true
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
module code.vikunja.io/veans
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
|
|
@ -8,11 +8,11 @@ require (
|
|||
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b
|
||||
github.com/magefile/mage v1.17.2
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
|
||||
github.com/sahilm/fuzzy v0.1.2
|
||||
github.com/sahilm/fuzzy v0.1.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/term v0.42.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/term v0.44.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ 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/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.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/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
|
|
@ -73,8 +75,12 @@ 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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
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/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/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ func Init(ctx context.Context, opts *Options) (*Result, error) {
|
|||
return nil, output.Wrap(output.CodeUnknown, err, "mint bot token: %v", err)
|
||||
}
|
||||
if mintedToken.Token == "" {
|
||||
return nil, output.New(output.CodeUnknown, "PUT /tokens did not return a token plaintext — cannot continue")
|
||||
return nil, output.New(output.CodeUnknown, "POST /tokens did not return a token plaintext — cannot continue")
|
||||
}
|
||||
|
||||
// 11. Persist credentials. Discard human JWT immediately after.
|
||||
|
|
|
|||
|
|
@ -211,8 +211,9 @@ func TestConfirmOverwriteExistingConfig(t *testing.T) {
|
|||
}
|
||||
|
||||
// bucketServer is a minimal httptest server modelling
|
||||
// GET/PUT /api/v1/projects/{p}/views/{v}/buckets. The caller pre-seeds
|
||||
// existing buckets; PUT requests append to that list with a synthetic ID.
|
||||
// GET/POST /api/v2/projects/{p}/views/{v}/buckets. The caller pre-seeds
|
||||
// existing buckets; POST requests append to that list with a synthetic ID.
|
||||
// GET returns the standard v2 list envelope; POST returns the bare bucket.
|
||||
type bucketServer struct {
|
||||
mu sync.Mutex
|
||||
existing []*client.Bucket
|
||||
|
|
@ -232,7 +233,7 @@ func newBucketServer(seed []*client.Bucket) *bucketServer {
|
|||
|
||||
func (s *bucketServer) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Path is /api/v1/projects/{p}/views/{v}/buckets.
|
||||
// Path is /api/v2/projects/{p}/views/{v}/buckets.
|
||||
if !strings.HasSuffix(r.URL.Path, "/buckets") || !strings.Contains(r.URL.Path, "/views/") {
|
||||
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -242,8 +243,15 @@ func (s *bucketServer) handler() http.Handler {
|
|||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(s.existing)
|
||||
case http.MethodPut:
|
||||
// v2 list envelope; the buckets list isn't server-paginated.
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"items": s.existing,
|
||||
"total": len(s.existing),
|
||||
"page": 1,
|
||||
"per_page": 50,
|
||||
"total_pages": 1,
|
||||
})
|
||||
case http.MethodPost:
|
||||
var b client.Bucket
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
|
|
|
|||
|
|
@ -23,5 +23,5 @@ import (
|
|||
|
||||
// AddAssignee assigns a user (typically the bot) to a task.
|
||||
func (c *Client) AddAssignee(ctx context.Context, taskID, userID int64) error {
|
||||
return c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/assignees", taskID), nil, &TaskAssignee{UserID: userID}, nil)
|
||||
return c.Do(ctx, "POST", fmt.Sprintf("/tasks/%d/assignees", taskID), nil, &TaskAssignee{UserID: userID}, nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,25 +21,28 @@ import (
|
|||
"fmt"
|
||||
)
|
||||
|
||||
// ListBuckets returns the buckets configured on a Kanban view.
|
||||
// ListBuckets returns the buckets configured on a Kanban view. Bucket.ReadAll
|
||||
// ignores page/per_page and returns every bucket in a single page (the envelope
|
||||
// total reflects the full set), so one GET gets them all — paging would
|
||||
// re-fetch the same buckets and duplicate them. Unwrap .items.
|
||||
func (c *Client) ListBuckets(ctx context.Context, projectID, viewID int64) ([]*Bucket, error) {
|
||||
var out []*Bucket
|
||||
path := fmt.Sprintf("/projects/%d/views/%d/buckets", projectID, viewID)
|
||||
if err := c.Do(ctx, "GET", path, nil, nil, &out); err != nil {
|
||||
items, _, err := doList[*Bucket](ctx, c, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// CreateBucket inserts a new bucket into a Kanban view.
|
||||
// CreateBucket inserts a new bucket into a Kanban view. The project and view
|
||||
// come from the URL; the v2 handler ignores project_view_id in the body.
|
||||
func (c *Client) CreateBucket(ctx context.Context, projectID, viewID int64, b *Bucket) (*Bucket, error) {
|
||||
var out Bucket
|
||||
path := fmt.Sprintf("/projects/%d/views/%d/buckets", projectID, viewID)
|
||||
if b == nil {
|
||||
b = &Bucket{}
|
||||
}
|
||||
b.ProjectViewID = viewID
|
||||
if err := c.Do(ctx, "PUT", path, nil, b, &out); err != nil {
|
||||
if err := c.Do(ctx, "POST", path, nil, b, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
|
|
@ -47,17 +50,13 @@ func (c *Client) CreateBucket(ctx context.Context, projectID, viewID int64, b *B
|
|||
|
||||
// MoveTaskToBucket positions an existing task in `bucketID` on the
|
||||
// project's view. Vikunja stores task↔bucket relations in a separate
|
||||
// table (`task_buckets`), so POST /tasks/{id} with bucket_id does not
|
||||
// reliably move tasks — this dedicated endpoint is the one the Kanban
|
||||
// UI's drag-and-drop uses.
|
||||
// table (`task_buckets`); a task update with bucket_id does not reliably
|
||||
// move tasks — this dedicated endpoint is the one the Kanban UI's
|
||||
// drag-and-drop uses. On v2 it's a PUT, and project/view/bucket all come
|
||||
// from the URL, so the body only carries the task id.
|
||||
func (c *Client) MoveTaskToBucket(ctx context.Context, projectID, viewID, bucketID, taskID int64) error {
|
||||
path := fmt.Sprintf("/projects/%d/views/%d/buckets/%d/tasks",
|
||||
projectID, viewID, bucketID)
|
||||
body := map[string]int64{
|
||||
"task_id": taskID,
|
||||
"project_view_id": viewID,
|
||||
"bucket_id": bucketID,
|
||||
"project_id": projectID,
|
||||
}
|
||||
return c.Do(ctx, "POST", path, nil, body, nil)
|
||||
body := map[string]int64{"task_id": taskID}
|
||||
return c.Do(ctx, "PUT", path, nil, body, nil)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue