Merge branch 'main' into codex/fix-drag-and-drop-behavior-inconsistency

This commit is contained in:
kolaente
2025-11-16 11:14:10 +01:00
committed by GitHub
108 changed files with 27710 additions and 2142 deletions

View File

@@ -14,14 +14,14 @@ jobs:
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
persist-credentials: true
- name: push source files
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2
uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2
with:
command: 'push'
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: pull translations
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2
uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2
with:
command: 'download'
command_args: '--export-only-approved --skip-untranslated-strings'

View File

@@ -8,11 +8,18 @@ jobs:
comment-on-issue-closure:
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
- name: Check if issue was closed by commit
id: check-commit
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const issueNumber = context.payload.issue.number;
@@ -32,6 +39,7 @@ jobs:
.filter(event => event.event === 'referenced')
.pop(); // Get the last (most recent) referenced event
console.log({closedEvent, referencedEvent});
if (closedEvent && (closedEvent.commit_id || referencedEvent)) {
const commitId = closedEvent.commit_id ?? referencedEvent.commit_id
console.log(`✅ Issue #${issueNumber} was closed by commit: ${commitId}`);
@@ -56,7 +64,7 @@ jobs:
if: steps.check-commit.outputs.closed_by_commit == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const issueNumber = context.payload.issue.number;
const commitSha = '${{ steps.check-commit.outputs.commit_sha }}';

59
.github/workflows/pr-docker.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: PR Docker Build
on:
# pull_request_target gives write access to GHCR even for PRs from forks.
# This is safe because:
# 1. We explicitly checkout the PR's head commit (no base branch code execution)
# 2. We ONLY build a Docker image (isolated container, no workflow scripts from PR)
# 3. No actions that execute PR code in the workflow context (no github-script, etc)
# 4. Build happens in isolated Docker container with well-defined Dockerfile
pull_request_target:
jobs:
docker:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
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.
# This is safe because no PR code is executed in workflow context.
# Only Docker build uses the PR code (isolated in container).
ref: refs/pull/${{ github.event.pull_request.number }}/head
- name: Git describe
id: ghd
uses: proudust/gh-describe@v2
- name: Login to GHCR
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
with:
version: latest
- name: Docker meta
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5
with:
images: ghcr.io/go-vikunja/vikunja
tags: |
type=ref,event=pr
type=sha,format=long
- name: Build and push PR image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}

View File

@@ -10,13 +10,19 @@ jobs:
- name: Git describe
id: ghd
uses: proudust/gh-describe@v2
- name: Login to GHCR
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
with:
@@ -24,10 +30,11 @@ jobs:
- name: Docker meta version
if: ${{ github.ref_type == 'tag' }}
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5
with:
images: |
vikunja/vikunja
ghcr.io/go-vikunja/vikunja
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
@@ -39,7 +46,9 @@ jobs:
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
push: true
tags: vikunja/vikunja:unstable
tags: |
vikunja/vikunja:unstable
ghcr.io/go-vikunja/vikunja:unstable
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
@@ -68,11 +77,11 @@ jobs:
with:
go-version: stable
- name: Download Mage Binary
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: mage_bin
- name: get frontend
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: frontend_dist
path: frontend/dist
@@ -121,12 +130,12 @@ jobs:
files: "dist/zip/*"
strip-path-prefix: dist/zip/
- name: Store Binaries
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: vikunja_bins
path: ./dist/binaries/*
- name: Store Binary Packages
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
if: ${{ github.ref_type == 'tag' }}
with:
name: vikunja_bin_packages
@@ -147,7 +156,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Download Vikunja Binary
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: vikunja_bins
pattern: vikunja-*-linux-amd64
@@ -155,7 +164,7 @@ jobs:
id: ghd
uses: proudust/gh-describe@v2
- name: Download Mage Binary
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: mage_bin
- name: Prepare
@@ -186,7 +195,7 @@ jobs:
files: "dist/os-packages/*"
strip-path-prefix: dist/os-packages/
- name: Store OS Packages
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
if: ${{ github.ref_type == 'tag' }}
with:
name: vikunja_os_package_${{ matrix.package }}
@@ -200,7 +209,7 @@ jobs:
id: ghd
uses: proudust/gh-describe@v2
- name: Download Mage Binary
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: mage_bin
- name: generate
@@ -248,7 +257,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@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: frontend_dist
path: frontend/dist
@@ -270,7 +279,7 @@ jobs:
strip-path-prefix: desktop/dist/
exclude: "desktop/dist/*.blockmap"
- name: Store Desktop Package
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
if: ${{ github.ref_type == 'tag' }}
with:
name: vikunja_desktop_packages_${{ matrix.os }}
@@ -289,7 +298,7 @@ jobs:
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
persist-credentials: true
- name: Download Mage Binary
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: mage_bin
- name: Set up Go
@@ -334,47 +343,47 @@ jobs:
contents: write
steps:
- name: Download Binaries
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: vikunja_bin_packages
- name: Download OS Package rpm
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: vikunja_os_package_rpm
- name: Download OS Package deb
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: vikunja_os_package_deb
- name: Download OS Package apk
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: vikunja_os_package_apk
- name: Download OS Package archlinux
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: vikunja_os_package_archlinux
- name: Download Desktop Package Linux
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: vikunja_desktop_packages_ubuntu-latest
- name: Download Desktop Package MacOS
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: vikunja_desktop_packages_macos-latest
- name: Download Desktop Package Windows
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: vikunja_desktop_packages_windows-latest
- name: Release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2
if: github.ref_type == 'tag'
with:
draft: true

View File

@@ -27,7 +27,7 @@ jobs:
version: latest
args: -compile ./mage-static
- name: Store Mage Binary
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: mage_bin
path: ./mage-static
@@ -38,7 +38,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Download Mage Binary
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: mage_bin
- name: Git describe
@@ -57,7 +57,7 @@ jobs:
chmod +x ./mage-static
./mage-static build
- name: Store Vikunja Binary
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: vikunja_bin
path: ./vikunja
@@ -74,9 +74,9 @@ jobs:
mkdir -p frontend/dist
touch frontend/dist/index.html
- name: golangci-lint
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8
uses: golangci/golangci-lint-action@0a35821d5c230e903fcfe077583637dea1b27b47 # v9
with:
version: v2.4.0
version: v2.6.0
api-check-translations:
runs-on: ubuntu-latest
@@ -84,7 +84,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Download Mage Binary
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: mage_bin
- name: Check
@@ -104,14 +104,14 @@ jobs:
- mysql
services:
migration-smoke-db-mysql:
image: mariadb:12@sha256:5b6a1eac15b85b981a61afb89aea2a22bf76b5f58809d05f0bcc13ab6ec44cb8
image: mariadb:12@sha256:607835cd628b78e2876f6a586d0ec37b296c47683b31ef750002d3d17d3d8f7a
env:
MYSQL_ROOT_PASSWORD: vikunjatest
MYSQL_DATABASE: vikunjatest
ports:
- 3306:3306
migration-smoke-db-postgres:
image: postgres:18@sha256:073e7c8b84e2197f94c8083634640ab37105effe1bc853ca4d5fbece3219b0e8
image: postgres:18@sha256:435fe973d0516bf24165976346eced9c841a890b1a2092cb68c45a93679b1b19
env:
POSTGRES_PASSWORD: vikunjatest
POSTGRES_DB: vikunjatest
@@ -123,7 +123,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@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: vikunja_bin
- name: run migration
@@ -180,21 +180,21 @@ jobs:
- web
services:
db-mysql:
image: mariadb:12@sha256:5b6a1eac15b85b981a61afb89aea2a22bf76b5f58809d05f0bcc13ab6ec44cb8
image: ${{ matrix.db == 'mysql' && 'mariadb:12@sha256:5b6a1eac15b85b981a61afb89aea2a22bf76b5f58809d05f0bcc13ab6ec44cb8' || '' }}
env:
MYSQL_ROOT_PASSWORD: vikunjatest
MYSQL_DATABASE: vikunjatest
ports:
- 3306:3306
db-postgres:
image: postgres:18@sha256:073e7c8b84e2197f94c8083634640ab37105effe1bc853ca4d5fbece3219b0e8
image: ${{ matrix.db == 'postgres' && 'postgres:18@sha256:073e7c8b84e2197f94c8083634640ab37105effe1bc853ca4d5fbece3219b0e8' || '' }}
env:
POSTGRES_PASSWORD: vikunjatest
POSTGRES_DB: vikunjatest
ports:
- 5432:5432
db-paradedb:
image: paradedb/paradedb:latest-pg17@sha256:842e7edf5c5577f5cf5e07262b6cba8969ece596d6b6fe96c1b5bfd2e93e68b9
image: ${{ matrix.db == 'paradedb' && 'paradedb/paradedb:latest-pg17@sha256:741010eaa8894d292203d9407d46fc95ee4d0cd587915513bf92e6bd70cbd65e' || '' }}
env:
POSTGRES_PASSWORD: vikunjatest
POSTGRES_DB: vikunjatest
@@ -207,7 +207,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Download Mage Binary
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: mage_bin
- name: Set up Go
@@ -246,6 +246,48 @@ jobs:
chmod +x mage-static
./mage-static test:${{ matrix.test }}
test-s3-integration:
runs-on: ubuntu-latest
needs:
- mage
services:
test-minio:
image: bitnamilegacy/minio:latest@sha256:451fe6858cb770cc9d0e77ba811ce287420f781c7c1b806a386f6896471a349c
env:
MINIO_ROOT_USER: vikunja
MINIO_ROOT_PASSWORD: vikunjatest
MINIO_DEFAULT_BUCKETS: vikunja-test
ports:
- 9000:9000
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Download Mage Binary
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: mage_bin
- name: Set up Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
with:
go-version: stable
- name: test S3 file storage integration
env:
VIKUNJA_TESTS_USE_CONFIG: 1
VIKUNJA_DATABASE_TYPE: sqlite
VIKUNJA_FILES_TYPE: s3
VIKUNJA_FILES_S3_ENDPOINT: http://localhost:9000
VIKUNJA_FILES_S3_BUCKET: vikunja-test
VIKUNJA_FILES_S3_REGION: us-east-1
VIKUNJA_FILES_S3_ACCESSKEY: vikunja
VIKUNJA_FILES_S3_SECRETKEY: vikunjatest
VIKUNJA_FILES_S3_USEPATHSTYLE: true
VIKUNJA_SERVICE_PUBLICURL: http://127.0.0.1:3456
run: |
mkdir -p frontend/dist
touch frontend/dist/index.html
chmod +x mage-static
# Run only the S3 file storage integration tests
./mage-static test:filter "TestFileStorageIntegration"
frontend-lint:
runs-on: ubuntu-latest
steps:
@@ -299,7 +341,7 @@ jobs:
working-directory: frontend
run: pnpm build
- name: Store Frontend
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: frontend_dist
path: ./frontend/dist
@@ -324,19 +366,19 @@ jobs:
# Only run parallel tests for non-fork PRs, single container for forks
containers: ${{ github.event.pull_request.head.repo.fork != true && fromJSON('[1, 2, 3, 4]') || fromJSON('[1]') }}
container:
image: cypress/browsers:latest@sha256:b7d45cdceac96f50c3353daacf0bdf4005f967ce4f465c2dcd996b171a78d438
image: cypress/browsers:latest@sha256:e85371fb0053c4f5ec2ffcbbcbfce2bf2bb6b4d0e8274c977cc36a283a7bd9e1
options: --user 1001
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Download Vikunja Binary
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: vikunja_bin
- uses: ./.github/actions/setup-frontend
with:
install-e2e-binaries: true
- name: Download Frontend
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
name: frontend_dist
path: ./frontend/dist

View File

@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6
FROM --platform=$BUILDPLATFORM node:22.20.0-alpine@sha256:dbcedd8aeab47fbc0f4dd4bffa55b7c3c729a707875968d467aaaea42d6225af AS frontendbuilder
FROM --platform=$BUILDPLATFORM node:24.11.1-alpine@sha256:2867d550cf9d8bb50059a0fff528741f11a84d985c732e60e19e8e75c7239c43 AS frontendbuilder
WORKDIR /build

View File

@@ -123,6 +123,11 @@
"default_value": "",
"comment": "Allow using a custom logo via external URL."
},
{
"key": "customlogourldark",
"default_value": "",
"comment": "Allow using a custom logo for dark mode via external URL. If not set, the regular logo will be used for both light and dark modes."
},
{
"key": "enablepublicteams",
"default_value": "false",
@@ -484,6 +489,47 @@
"key": "maxsize",
"default_value": "20MB",
"comment": "The maximum size of a file, as a human-readable string.\nWarning: The max size is limited 2^64-1 bytes due to the underlying datatype"
},
{
"key": "type",
"default_value": "local",
"comment": "The type of file storage backend. Supported values are `local` and `s3`."
},
{
"key": "s3",
"comment": "Configuration for S3 storage backend",
"children": [
{
"key": "endpoint",
"default_value": "",
"comment": "The S3 endpoint to use. Can be used with S3-compatible services like MinIO or Backblaze B2."
},
{
"key": "bucket",
"default_value": "",
"comment": "The name of the S3 bucket to store files in."
},
{
"key": "region",
"default_value": "",
"comment": "The S3 region where the bucket is located."
},
{
"key": "accesskey",
"default_value": "",
"comment": "The S3 access key ID."
},
{
"key": "secretkey",
"default_value": "",
"comment": "The S3 secret access key."
},
{
"key": "usepathstyle",
"default_value": "false",
"comment": "Whether to use path-style addressing (e.g., https://s3.amazonaws.com/bucket/key) instead of virtual-hosted-style (e.g., https://bucket.s3.amazonaws.com/key). This is commonly needed for self-hosted S3-compatible services. Some providers only support one style or the other."
}
]
}
]
},

View File

@@ -5,7 +5,7 @@
"main": "main.js",
"repository": "https://code.vikunja.io/desktop",
"license": "GPL-3.0-or-later",
"packageManager": "pnpm@10.18.3",
"packageManager": "pnpm@10.22.0",
"author": {
"email": "maintainers@vikunja.io",
"name": "Vikunja Team"
@@ -52,7 +52,7 @@
}
},
"devDependencies": {
"electron": "37.7.0",
"electron": "37.10.0",
"electron-builder": "26.0.12",
"unzipper": "0.12.3"
},

10
desktop/pnpm-lock.yaml generated
View File

@@ -13,8 +13,8 @@ importers:
version: 5.1.0
devDependencies:
electron:
specifier: 37.7.0
version: 37.7.0
specifier: 37.10.0
version: 37.10.0
electron-builder:
specifier: 26.0.12
version: 26.0.12(electron-builder-squirrel-windows@24.13.3)
@@ -589,8 +589,8 @@ packages:
electron-publish@26.0.11:
resolution: {integrity: sha512-a8QRH0rAPIWH9WyyS5LbNvW9Ark6qe63/LqDB7vu2JXYpi0Gma5Q60Dh4tmTqhOBQt0xsrzD8qE7C+D7j+B24A==}
electron@37.7.0:
resolution: {integrity: sha512-LBzvfrS0aalynOsnC11AD7zeoU8eOois090mzLpQM3K8yZ2N04i2ZW9qmHOTFLrXlKvrwRc7EbyQf1u8XHMl6Q==}
electron@37.10.0:
resolution: {integrity: sha512-cVa3/q6kdIMrCTW1eMfkcItTcdBTj2HSlwbEYcDY8ol66mARdPOEsnX4bi/DO0oo4WM5jfA0YjxllE8Jnby2mg==}
engines: {node: '>= 12.20.55'}
hasBin: true
@@ -2450,7 +2450,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron@37.7.0:
electron@37.10.0:
dependencies:
'@electron/get': 2.0.3
'@types/node': 22.18.0

View File

@@ -3,10 +3,10 @@
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1757511008,
"lastModified": 1761922975,
"owner": "cachix",
"repo": "devenv",
"rev": "241280d58ecd6282b239b259afae9f7ba61c02be",
"rev": "c9f0b47815a4895fadac87812de8a4de27e0ace1",
"type": "github"
},
"original": {
@@ -19,10 +19,10 @@
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1747046372,
"lastModified": 1761588595,
"owner": "edolstra",
"repo": "flake-compat",
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
"type": "github"
},
"original": {
@@ -40,10 +40,10 @@
]
},
"locked": {
"lastModified": 1757239681,
"lastModified": 1760663237,
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "ab82ab08d6bf74085bd328de2a8722c12d97bd9d",
"rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
"type": "github"
},
"original": {
@@ -74,10 +74,10 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1755783167,
"lastModified": 1761313199,
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "4a880fb247d24fbca57269af672e8f78935b0328",
"rev": "d1c30452ebecfc55185ae6d1c983c09da0c274ff",
"type": "github"
},
"original": {
@@ -89,10 +89,10 @@
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1757347588,
"lastModified": 1761672384,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b599843bad24621dcaa5ab60dac98f9b0eb1cabe",
"rev": "08dacfca559e1d7da38f3cf05f1f45ee9bfd213c",
"type": "github"
},
"original": {

View File

@@ -1 +1 @@
22.20.0
24.11.1

View File

@@ -4,7 +4,7 @@ services:
ports:
- 5556:5556
cypress:
image: cypress/browsers:latest@sha256:b7d45cdceac96f50c3353daacf0bdf4005f967ce4f465c2dcd996b171a78d438
image: cypress/browsers:latest@sha256:e85371fb0053c4f5ec2ffcbbcbfce2bf2bb6b4d0e8274c977cc36a283a7bd9e1
volumes:
- ..:/project
- $HOME/.cache:/home/node/.cache/

View File

@@ -3,6 +3,7 @@ import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {login} from '../../support/authenticateUser'
import {DATE_DISPLAY} from '../../../src/constants/dateDisplay'
import {TIME_FORMAT} from '../../../src/constants/timeFormat'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
@@ -13,30 +14,85 @@ const now = new Date(Date.UTC(2022, 6, 30, 12))
const expectedFormats = {
[DATE_DISPLAY.RELATIVE]: dayjs(createdDate).from(now),
[DATE_DISPLAY.MM_DD_YYYY]: dayjs(createdDate).format('MM-DD-YYYY'),
[DATE_DISPLAY.DD_MM_YYYY]: dayjs(createdDate).format('DD-MM-YYYY'),
[DATE_DISPLAY.YYYY_MM_DD]: dayjs(createdDate).format('YYYY-MM-DD'),
[DATE_DISPLAY.MM_SLASH_DD_YYYY]: dayjs(createdDate).format('MM/DD/YYYY'),
[DATE_DISPLAY.DD_SLASH_MM_YYYY]: dayjs(createdDate).format('DD/MM/YYYY'),
[DATE_DISPLAY.YYYY_SLASH_MM_DD]: dayjs(createdDate).format('YYYY/MM/DD'),
[DATE_DISPLAY.MM_DD_YYYY]: dayjs(createdDate).format('MM-DD-YYYY hh:mm A'),
[DATE_DISPLAY.DD_MM_YYYY]: dayjs(createdDate).format('DD-MM-YYYY hh:mm A'),
[DATE_DISPLAY.YYYY_MM_DD]: dayjs(createdDate).format('YYYY-MM-DD hh:mm A'),
[DATE_DISPLAY.MM_SLASH_DD_YYYY]: dayjs(createdDate).format('MM/DD/YYYY hh:mm A'),
[DATE_DISPLAY.DD_SLASH_MM_YYYY]: dayjs(createdDate).format('DD/MM/YYYY hh:mm A'),
[DATE_DISPLAY.YYYY_SLASH_MM_DD]: dayjs(createdDate).format('YYYY/MM/DD hh:mm A'),
[DATE_DISPLAY.DAY_MONTH_YEAR]: new Intl.DateTimeFormat('en', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true,
}).format(createdDate),
[DATE_DISPLAY.WEEKDAY_DAY_MONTH_YEAR]: new Intl.DateTimeFormat('en', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true,
}).format(createdDate),
}
const expectedFormats24h = {
[DATE_DISPLAY.RELATIVE]: dayjs(createdDate).from(now),
[DATE_DISPLAY.MM_DD_YYYY]: dayjs(createdDate).format('MM-DD-YYYY HH:mm'),
[DATE_DISPLAY.DD_MM_YYYY]: dayjs(createdDate).format('DD-MM-YYYY HH:mm'),
[DATE_DISPLAY.YYYY_MM_DD]: dayjs(createdDate).format('YYYY-MM-DD HH:mm'),
[DATE_DISPLAY.MM_SLASH_DD_YYYY]: dayjs(createdDate).format('MM/DD/YYYY HH:mm'),
[DATE_DISPLAY.DD_SLASH_MM_YYYY]: dayjs(createdDate).format('DD/MM/YYYY HH:mm'),
[DATE_DISPLAY.YYYY_SLASH_MM_DD]: dayjs(createdDate).format('YYYY/MM/DD HH:mm'),
[DATE_DISPLAY.DAY_MONTH_YEAR]: new Intl.DateTimeFormat('en', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: false,
}).format(createdDate),
[DATE_DISPLAY.WEEKDAY_DAY_MONTH_YEAR]: new Intl.DateTimeFormat('en', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: false,
}).format(createdDate),
}
describe('Date display setting', () => {
Object.entries(expectedFormats).forEach(([format, expected]) => {
it(`shows ${format}`, () => {
it(`shows ${format} with 12h time format`, () => {
const user = UserFactory.create(1, {
frontend_settings: JSON.stringify({dateDisplay: format}),
frontend_settings: JSON.stringify({dateDisplay: format, timeFormat: TIME_FORMAT.HOURS_12}),
})[0]
const project = ProjectFactory.create(1, {owner_id: user.id})[0]
TaskFactory.truncate()
const task = TaskFactory.create(1, {
id: 1,
project_id: project.id,
created_by_id: user.id,
created: createdDate.toISOString(),
updated: createdDate.toISOString(),
})[0]
cy.clock(now, ['Date'])
login(user)
cy.visit(`/tasks/${task.id}`)
cy.get('.task-view .created time span').should('contain', expected)
})
})
Object.entries(expectedFormats24h).forEach(([format, expected]) => {
it(`shows ${format} with 24h time format`, () => {
const user = UserFactory.create(1, {
frontend_settings: JSON.stringify({dateDisplay: format, timeFormat: TIME_FORMAT.HOURS_24}),
})[0]
const project = ProjectFactory.create(1, {owner_id: user.id})[0]
TaskFactory.truncate()

23055
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@
},
"homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@10.18.3",
"packageManager": "pnpm@10.22.0",
"keywords": [
"todo",
"productivity",
@@ -57,30 +57,30 @@
"@fortawesome/vue-fontawesome": "3.1.2",
"@github/hotkey": "3.1.1",
"@intlify/unplugin-vue-i18n": "11.0.1",
"@kyvg/vue3-notification": "3.4.1",
"@sentry/tracing": "7.120.4",
"@sentry/vue": "10.20.0",
"@tiptap/core": "3.7.2",
"@tiptap/extension-code-block-lowlight": "3.7.2",
"@tiptap/extension-hard-break": "3.7.2",
"@tiptap/extension-image": "3.7.2",
"@tiptap/extension-link": "3.7.2",
"@tiptap/extension-list": "3.7.2",
"@tiptap/extension-table": "3.7.2",
"@tiptap/extension-typography": "3.7.2",
"@tiptap/extension-underline": "3.7.2",
"@tiptap/extensions": "3.7.2",
"@tiptap/pm": "3.7.2",
"@tiptap/starter-kit": "3.7.2",
"@tiptap/suggestion": "3.7.2",
"@tiptap/vue-3": "3.7.2",
"@vueuse/core": "13.9.0",
"@vueuse/router": "13.9.0",
"axios": "1.12.2",
"@kyvg/vue3-notification": "3.4.2",
"@sentry/vue": "10.25.0",
"@tiptap/core": "3.8.0",
"@tiptap/extension-code-block-lowlight": "3.8.0",
"@tiptap/extension-hard-break": "3.8.0",
"@tiptap/extension-image": "3.8.0",
"@tiptap/extension-link": "3.8.0",
"@tiptap/extension-list": "3.8.0",
"@tiptap/extension-mention": "3.8.0",
"@tiptap/extension-table": "3.8.0",
"@tiptap/extension-typography": "3.8.0",
"@tiptap/extension-underline": "3.8.0",
"@tiptap/extensions": "3.8.0",
"@tiptap/pm": "3.8.0",
"@tiptap/starter-kit": "3.8.0",
"@tiptap/suggestion": "3.8.0",
"@tiptap/vue-3": "3.8.0",
"@vueuse/core": "14.0.0",
"@vueuse/router": "14.0.0",
"axios": "1.13.2",
"blurhash": "2.0.5",
"bulma-css-variables": "0.9.33",
"change-case": "5.4.4",
"dayjs": "1.11.18",
"dayjs": "1.11.19",
"dompurify": "3.3.0",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
@@ -89,13 +89,13 @@
"is-touch-device": "1.0.1",
"klona": "2.0.6",
"lowlight": "3.3.0",
"marked": "16.4.1",
"pinia": "3.0.3",
"marked": "16.4.2",
"pinia": "3.0.4",
"register-service-worker": "1.7.2",
"sortablejs": "1.15.6",
"tailwindcss": "3.4.18",
"ufo": "1.6.1",
"vue": "3.5.22",
"vue": "3.5.24",
"vue-advanced-cropper": "2.8.9",
"vue-flatpickr-component": "11.0.5",
"vue-i18n": "11.1.12",
@@ -106,38 +106,38 @@
},
"devDependencies": {
"@4tw/cypress-drag-drop": "2.3.1",
"@cypress/vite-dev-server": "7.0.0",
"@cypress/vite-dev-server": "7.0.1",
"@cypress/vue": "6.0.2",
"@faker-js/faker": "9.9.0",
"@histoire/plugin-screenshot": "1.0.0-alpha.3",
"@histoire/plugin-vue": "1.0.0-alpha.3",
"@tsconfig/node22": "22.0.2",
"@types/codemirror": "5.60.16",
"@histoire/plugin-screenshot": "1.0.0-alpha.4",
"@histoire/plugin-vue": "1.0.0-alpha.4",
"@tsconfig/node22": "22.0.3",
"@types/codemirror": "5.60.17",
"@types/is-touch-device": "1.0.3",
"@types/node": "22.18.11",
"@types/sortablejs": "1.15.8",
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",
"@types/node": "22.19.1",
"@types/sortablejs": "1.15.9",
"@typescript-eslint/eslint-plugin": "8.46.4",
"@typescript-eslint/parser": "8.46.4",
"@vitejs/plugin-vue": "6.0.1",
"@vue/eslint-config-typescript": "14.6.0",
"@vue/test-utils": "2.4.6",
"@vue/tsconfig": "0.8.1",
"autoprefixer": "10.4.21",
"browserslist": "4.26.3",
"caniuse-lite": "1.0.30001751",
"csstype": "3.1.3",
"autoprefixer": "10.4.22",
"browserslist": "4.28.0",
"caniuse-lite": "1.0.30001754",
"csstype": "3.2.1",
"cypress": "14.5.4",
"esbuild": "0.25.11",
"eslint": "9.38.0",
"esbuild": "0.27.0",
"eslint": "9.39.1",
"eslint-plugin-vue": "10.5.1",
"happy-dom": "20.0.5",
"histoire": "1.0.0-alpha.3",
"happy-dom": "20.0.10",
"histoire": "1.0.0-alpha.4",
"postcss": "8.5.6",
"postcss-easing-gradients": "3.0.1",
"postcss-preset-env": "10.4.0",
"rollup": "4.52.5",
"rollup": "4.53.2",
"rollup-plugin-visualizer": "6.0.5",
"sass-embedded": "1.93.2",
"sass-embedded": "1.93.3",
"start-server-and-test": "2.1.2",
"stylelint": "16.25.0",
"stylelint-config-property-sort-order-smacss": "10.0.0",
@@ -146,13 +146,13 @@
"stylelint-use-logical": "2.1.2",
"typescript": "5.9.3",
"unplugin-inject-preload": "3.0.0",
"vite": "7.1.10",
"vite": "7.2.2",
"vite-plugin-pwa": "1.1.0",
"vite-plugin-sentry": "1.4.1",
"vite-plugin-vue-devtools": "8.0.3",
"vite-svg-loader": "5.1.0",
"vitest": "3.2.4",
"vue-tsc": "3.1.1",
"vue-tsc": "3.1.4",
"wait-on": "8.0.5",
"workbox-cli": "7.3.0"
},

2682
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
import { computed } from 'vue'
import { useNow } from '@vueuse/core'
import { useAuthStore } from '@/stores/auth'
import { useColorScheme } from '@/composables/useColorScheme'
import LogoFull from '@/assets/logo-full.svg?component'
import LogoFullPride from '@/assets/logo-full-pride.svg?component'
@@ -12,12 +13,24 @@ const now = useNow({
})
const authStore = useAuthStore()
const Logo = computed(() => window.ALLOW_ICON_CHANGES
&& authStore.settings.frontendSettings.allowIconChanges
&& now.value.getMonth() === 5
? LogoFullPride
const { isDark } = useColorScheme()
const Logo = computed(() => window.ALLOW_ICON_CHANGES
&& authStore.settings.frontendSettings.allowIconChanges
&& now.value.getMonth() === 5
? LogoFullPride
: LogoFull)
const CustomLogo = computed(() => window.CUSTOM_LOGO_URL)
const CustomLogo = computed(() => {
const lightLogo = window.CUSTOM_LOGO_URL
const darkLogo = window.CUSTOM_LOGO_URL_DARK
if (!lightLogo && !darkLogo) return ''
if (!darkLogo) return lightLogo
if (!lightLogo) return darkLogo
return isDark.value ? darkLogo : lightLogo
})
</script>
<template>

View File

@@ -31,6 +31,7 @@
</span>
</BaseButton>
<BaseButton
v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)"
class="datepicker__quick-select-date"
@click.stop="setDate('thisWeekend')"
>

View File

@@ -146,7 +146,7 @@ import EditorToolbar from './EditorToolbar.vue'
import StarterKit from '@tiptap/starter-kit'
import {Extension, mergeAttributes} from '@tiptap/core'
import {EditorContent, type Extensions, useEditor} from '@tiptap/vue-3'
import {EditorContent, type Extensions, useEditor, VueNodeViewRenderer} from '@tiptap/vue-3'
import {Plugin, PluginKey} from '@tiptap/pm/state'
import {marked} from 'marked'
import {BubbleMenu} from '@tiptap/vue-3/menus'
@@ -158,6 +158,7 @@ import Typography from '@tiptap/extension-typography'
import Image from '@tiptap/extension-image'
import Underline from '@tiptap/extension-underline'
import {Placeholder} from '@tiptap/extensions'
import Mention from '@tiptap/extension-mention'
import {TaskItem, TaskList} from '@tiptap/extension-list'
import HardBreak from '@tiptap/extension-hard-break'
@@ -166,6 +167,8 @@ import {Node} from '@tiptap/pm/model'
import Commands from './commands'
import suggestionSetup from './suggestion'
import mentionSuggestionSetup from './mention/mentionSuggestion'
import MentionUser from './mention/MentionUser.vue'
import {common, createLowlight} from 'lowlight'
@@ -190,6 +193,8 @@ const props = withDefaults(defineProps<{
placeholder?: string,
editShortcut?: string,
enableDiscardShortcut?: boolean,
enableMentions?: boolean,
mentionProjectId?: number,
}>(), {
uploadCallback: undefined,
isEditEnabled: true,
@@ -198,6 +203,8 @@ const props = withDefaults(defineProps<{
placeholder: '',
editShortcut: '',
enableDiscardShortcut: false,
enableMentions: false,
mentionProjectId: 0,
})
const emit = defineEmits(['update:modelValue', 'save'])
@@ -480,6 +487,35 @@ const extensions : Extensions = [
PasteHandler,
]
// Add mention extension if enabled
if (props.enableMentions && props.mentionProjectId > 0) {
extensions.push(
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion: mentionSuggestionSetup(props.mentionProjectId),
}).extend({
parseHTML() {
return [
{
tag: 'mention-user',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['mention-user', mergeAttributes(HTMLAttributes)]
},
addNodeView() {
return VueNodeViewRenderer(MentionUser)
},
}),
)
}
// Add a custom extension for the Escape key
if (props.enableDiscardShortcut) {
extensions.push(Extension.create({

View File

@@ -0,0 +1,181 @@
<template>
<div class="mention-items">
<template v-if="items.length">
<button
v-for="(item, index) in items"
:key="item.id"
class="mention-item"
:class="{ 'is-selected': index === selectedIndex }"
@click="selectItem(index)"
>
<img
:src="item.avatarUrl"
alt=""
class="mention-avatar"
>
<div class="mention-info">
<p class="mention-name">
{{ item.label }}
</p>
<p
v-if="item.label !== item.username"
class="mention-username"
>
@{{ item.username }}
</p>
</div>
</button>
</template>
<div
v-else
class="mention-item no-results"
>
{{ $t('task.mention.noUsersFound') }}
</div>
</div>
</template>
<script lang="ts">
/* eslint-disable vue/component-api-style */
export default {
props: {
items: {
type: Array,
required: true,
},
command: {
type: Function,
required: true,
},
},
data() {
return {
selectedIndex: 0,
}
},
watch: {
items() {
this.selectedIndex = 0
},
},
methods: {
onKeyDown({event}: {event: KeyboardEvent}) {
if (event.key === 'ArrowUp') {
this.upHandler()
return true
}
if (event.key === 'ArrowDown') {
this.downHandler()
return true
}
if (event.key === 'Enter') {
if (event.isComposing) {
return false
}
this.enterHandler()
return true
}
return false
},
upHandler() {
this.selectedIndex = ((this.selectedIndex + this.items.length) - 1) % this.items.length
},
downHandler() {
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
},
enterHandler() {
this.selectItem(this.selectedIndex)
},
selectItem(index: number) {
const item = this.items[index]
if (item) {
this.command(item)
}
},
},
}
</script>
<style lang="scss" scoped>
.mention-items {
padding: 0.2rem;
position: relative;
border-radius: 0.5rem;
background: var(--white);
color: var(--grey-900);
overflow: hidden;
font-size: 0.9rem;
box-shadow: var(--shadow-md);
min-inline-size: 200px;
max-block-size: 300px;
overflow-y: auto;
}
.mention-item {
display: flex;
align-items: center;
margin: 0;
inline-size: 100%;
text-align: start;
background: transparent;
border-radius: $radius;
border: 0;
padding: 0.4rem 0.6rem;
transition: background-color $transition;
&.is-selected, &:hover {
background: var(--grey-100);
cursor: pointer;
}
&.no-results {
color: var(--grey-500);
cursor: default;
}
}
.mention-avatar {
inline-size: 32px;
block-size: 32px;
border-radius: 50%;
margin-inline-end: 0.75rem;
flex-shrink: 0;
}
.mention-info {
display: flex;
flex-direction: column;
min-inline-size: 0;
flex: 1;
p {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.mention-name {
font-size: 0.9rem;
color: var(--grey-800);
font-weight: 500;
}
.mention-username {
font-size: 0.75rem;
color: var(--grey-500);
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<NodeViewWrapper class="mention-user">
<img :src="avatarUrl">
<span class="mention__label">
{{ node.attrs.label ?? node.attrs.id }}
</span>
</NodeViewWrapper>
</template>
<script lang="ts" setup>
import { fetchAvatarBlobUrl } from '@/models/user'
import { nodeViewProps, NodeViewWrapper } from '@tiptap/vue-3'
import { watch, ref } from 'vue'
const props = defineProps(nodeViewProps)
const avatarUrl = ref('')
watch(
() => props.node.attrs.id,
async () => {
avatarUrl.value = await fetchAvatarBlobUrl({username: props.node.attrs.id}, 32)
},
{immediate: true},
)
</script>
<style lang="scss">
.tiptap .mention-user {
display: inline-flex;
align-items: center;
position: relative;
inset-block-end: 0;
padding-inline-start: 1.75rem;
> img {
border-radius: 100%;
inline-size: 1.5rem;
block-size: 1.5rem;
position: absolute;
inset-inline-start: 0;
}
}
</style>

View File

@@ -0,0 +1,189 @@
import { VueRenderer } from '@tiptap/vue-3'
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom'
import type { Editor } from '@tiptap/core'
import MentionList from './MentionList.vue'
import ProjectUserService from '@/services/projectUsers'
import { fetchAvatarBlobUrl, getDisplayName } from '@/models/user'
import type { IUser } from '@/modelTypes/IUser'
import type { MentionNodeAttrs } from '@tiptap/extension-mention'
interface MentionItem extends MentionNodeAttrs {
id: string
label: string
username: string
avatarUrl: string
}
async function searchUsersForProject(projectId: number, query: string): Promise<MentionItem[]> {
const projectUserService = new ProjectUserService()
// Use server-side search with the 's' parameter
const users = await projectUserService.getAll({ projectId }, { s: query }) as IUser[]
// Fetch avatar URLs for all users
const usersWithAvatars = await Promise.all(
users.map(async (user) => {
const avatarUrl = await fetchAvatarBlobUrl(user, 32)
return {
id: user.username,
label: getDisplayName(user),
username: user.username,
avatarUrl: avatarUrl as string,
}
}),
)
return usersWithAvatars
}
export default function mentionSuggestionSetup(projectId: number) {
let debounceTimer: ReturnType<typeof setTimeout> | null = null
return {
char: '@',
items: async ({ query }: { query: string }): Promise<MentionItem[]> => {
if (!projectId) {
return []
}
// Clear existing timer
if (debounceTimer) {
clearTimeout(debounceTimer)
}
// Return a promise that resolves after debounce delay
return new Promise((resolve) => {
debounceTimer = setTimeout(async () => {
try {
// Use server-side search - the backend will handle searching by username and display name
const users = await searchUsersForProject(projectId, query)
// Limit results to avoid overwhelming the UI
const limit = query ? 10 : 5
resolve(users.slice(0, limit))
} catch (error) {
console.error('Failed to fetch users for mentions:', error)
resolve([])
}
}, 300) // 300ms debounce delay
})
},
render: () => {
let component: VueRenderer
let popupElement: HTMLElement | null = null
let cleanupFloating: (() => void) | null = null
const virtualReference = {
getBoundingClientRect: () => ({
width: 0,
height: 0,
x: 0,
y: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
} as DOMRect),
}
return {
onStart: (props: {
editor: Editor
clientRect?: (() => DOMRect | null) | null
items: MentionItem[]
command: (item: MentionItem) => void
}) => {
component = new VueRenderer(MentionList, {
props,
editor: props.editor,
})
if (!props.clientRect) {
return
}
// Create popup element
popupElement = document.createElement('div')
popupElement.style.position = 'absolute'
popupElement.style.top = '0'
popupElement.style.left = '0'
popupElement.style.zIndex = '4700'
popupElement.appendChild(component.element!)
document.body.appendChild(popupElement) // Update virtual reference
const rect = props.clientRect()
if (rect) {
virtualReference.getBoundingClientRect = () => rect
// Set up floating positioning
const updatePosition = () => {
computePosition(virtualReference, popupElement!, {
placement: 'bottom-start',
middleware: [
offset(8),
flip(),
shift({ padding: 8 }),
],
}).then(({ x, y }) => {
if (popupElement) {
popupElement.style.left = `${x}px`
popupElement.style.top = `${y}px`
}
})
}
updatePosition()
cleanupFloating = autoUpdate(virtualReference, popupElement, updatePosition)
}
},
onUpdate(props: {
editor: Editor
clientRect?: (() => DOMRect | null) | null
items: MentionItem[]
command: (item: MentionItem) => void
}) {
component.updateProps(props)
if (!props.clientRect || !popupElement) {
return
}
// Update virtual reference
const rect = props.clientRect()
if (rect) {
virtualReference.getBoundingClientRect = () => rect
}
},
onKeyDown(props: { event: KeyboardEvent }) {
if (props.event.key === 'Escape') {
if (props.event.isComposing) {
return false
}
if (popupElement) {
popupElement.style.display = 'none'
}
return true
}
return component.ref?.onKeyDown(props)
},
onExit() {
if (cleanupFloating) {
cleanupFloating()
}
if (popupElement) {
document.body.removeChild(popupElement)
popupElement = null
}
component.destroy()
},
}
},
}
}

View File

@@ -190,7 +190,7 @@ export default function suggestionSetup(t) {
popupElement.style.position = 'absolute'
popupElement.style.top = '0'
popupElement.style.left = '0'
popupElement.style.zIndex = '1000'
popupElement.style.zIndex = '4700'
popupElement.appendChild(component.element!)
document.body.appendChild(popupElement)

View File

@@ -511,7 +511,7 @@ async function updateTaskPosition(e) {
: e.newIndex
const task = newBucket.tasks[newTaskIndex]
const oldBucket = buckets.value.find(b => b.id === task.bucketId)
const oldBucket = buckets.value.find(b => b.id === sourceBucket.value)
const taskBefore = newBucket.tasks[newTaskIndex - 1] ?? null
const taskAfter = newBucket.tasks[newTaskIndex + 1] ?? null
taskUpdating.value[task.id] = true

View File

@@ -149,8 +149,8 @@ const {
() => props.viewId,
{position: 'asc'},
() => projectId.value === -1
? null
: 'subtasks',
? 'comment_count'
: ['subtasks', 'comment_count'],
)
const taskPositionService = ref(new TaskPositionService())
@@ -162,11 +162,7 @@ const tasks = ref<ITask[]>([])
watch(
allTasks,
() => {
tasks.value = [...allTasks.value]
if (projectId.value < 0) {
return
}
tasks.value = tasks.value.filter(t => shouldShowTaskInListView(t, allTasks.value))
tasks.value = ([...allTasks.value]).filter(t => shouldShowTaskInListView(t, allTasks.value))
},
)

View File

@@ -41,6 +41,9 @@
<FancyCheckbox v-model="activeColumns.assignees">
{{ $t('task.attributes.assignees') }}
</FancyCheckbox>
<FancyCheckbox v-model="activeColumns.commentCount">
{{ $t('task.attributes.commentCount') }}
</FancyCheckbox>
<FancyCheckbox v-model="activeColumns.dueDate">
{{ $t('task.attributes.dueDate') }}
</FancyCheckbox>
@@ -132,6 +135,9 @@
@click="sort('due_date', $event)"
/>
</th>
<th v-if="activeColumns.commentCount">
{{ $t('task.attributes.commentCount') }}
</th>
<th v-if="activeColumns.startDate">
{{ $t('task.attributes.startDate') }}
<Sort
@@ -228,6 +234,15 @@
v-if="activeColumns.dueDate"
:date="t.dueDate"
/>
<td v-if="activeColumns.commentCount">
<span
v-if="t.commentCount && t.commentCount > 0"
class="comment-badge"
>
<Icon icon="comment" />
{{ t.commentCount }}
</span>
</td>
<DateTableCell
v-if="activeColumns.startDate"
:date="t.startDate"
@@ -320,6 +335,7 @@ const ACTIVE_COLUMNS_DEFAULT = {
updated: false,
createdBy: false,
doneAt: false,
commentCount: false,
}
const SORT_BY_DEFAULT: SortBy = {
@@ -329,7 +345,12 @@ const SORT_BY_DEFAULT: SortBy = {
const activeColumns = useStorage('tableViewColumns', {...ACTIVE_COLUMNS_DEFAULT})
const sortBy = useStorage<SortBy>('tableViewSortBy', {...SORT_BY_DEFAULT})
const taskList = useTaskList(() => props.projectId, () => props.viewId, sortBy.value)
const taskList = useTaskList(
() => props.projectId,
() => props.viewId,
sortBy.value,
() => 'comment_count',
)
const {
loading,

View File

@@ -12,7 +12,7 @@
<div class="sharables-project">
<XButton
v-if="!(linkShares.length === 0 || showNewForm)"
v-if="!showNewForm"
icon="plus"
class="mbe-4"
@click="showNewForm = true"
@@ -21,7 +21,7 @@
</XButton>
<div
v-if="linkShares.length === 0 || showNewForm"
v-if="showNewForm"
class="p-4"
>
<div class="field">

View File

@@ -34,7 +34,7 @@ defineEmits<{
<User
:key="'user'+user.id"
:avatar-size="avatarSize"
:show-username="false"
:show-username="true"
:user="user"
:class="{'m-2': canRemove && !disabled}"
/>
@@ -54,13 +54,39 @@ defineEmits<{
.assignees-list {
display: flex;
&.is-inline :deep(.user) {
display: inline;
}
&:hover .assignee:not(:first-child) {
margin-inline-start: -0.5rem;
}
// Hide usernames on desktop, show on mobile
:deep(.user .username) {
@media screen and (min-width: $tablet) {
display: none;
}
}
:deep(.user) {
display: flex;
align-content: center;
align-items: center;
gap: .5rem;
}
:deep(.user > .username) {
font-size: .75rem;
}
&.is-inline {
:deep(.user) {
display: inline;
text-wrap: nowrap;
}
:deep(.user > .username) {
margin-inline-start: .5rem;
}
}
}
.assignee {
@@ -83,7 +109,6 @@ defineEmits<{
inset-inline-start: 2px;
color: var(--danger);
background: var(--white);
padding: 0 4px;
display: block;
border-radius: 100%;
font-size: .75rem;

View File

@@ -101,6 +101,8 @@
:bottom-actions="actions[c.id]"
:show-save="true"
:enable-discard-shortcut="true"
:enable-mentions="true"
:mention-project-id="projectId"
initial-mode="preview"
@update:modelValue="
() => {
@@ -168,6 +170,8 @@
}"
:upload-callback="attachmentUpload"
:placeholder="$t('task.comment.placeholder')"
:enable-mentions="true"
:mention-project-id="projectId"
@save="addComment()"
/>
</div>
@@ -231,6 +235,7 @@ import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
const props = withDefaults(defineProps<{
taskId: number,
projectId: number,
canWrite?: boolean
initialComments: ITaskComment[]
}>(), {

View File

@@ -31,6 +31,8 @@
:show-save="true"
edit-shortcut="e"
:enable-discard-shortcut="true"
:enable-mentions="true"
:mention-project-id="modelValue.projectId"
@update:modelValue="saveWithDelay"
@save="save"
/>

View File

@@ -86,6 +86,14 @@
>
<Icon icon="paperclip" />
</span>
<span
v-if="task.commentCount && task.commentCount > 0"
v-tooltip="$t('task.attributes.comment', task.commentCount)"
class="project-task-icon comment-count-icon"
>
<Icon :icon="['far', 'comments']" />
<span class="comment-count-badge">{{ task.commentCount }}</span>
</span>
<span
v-if="!isEditorContentEmpty(task.description)"
class="icon"
@@ -396,4 +404,22 @@ $task-background: var(--white);
inline-size: 100%;
block-size: 0.5rem;
}
.comment-count-icon {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--grey-500);
background: var(--grey-100);
border-radius: $radius;
padding: 0.25rem;
margin-inline-end: .25rem;
.comment-count-badge {
font-weight: 600;
font-size: 0.75rem;
line-height: 1;
}
}
</style>

View File

@@ -120,6 +120,14 @@
>
<Icon icon="history" />
</span>
<span
v-if="task.commentCount && task.commentCount > 0"
class="project-task-icon comment-count-icon"
:title="`${task.commentCount} ${task.commentCount === 1 ? 'comment' : 'comments'}`"
>
<Icon :icon="['far', 'comments']" />
<span class="comment-count-badge">{{ task.commentCount }}</span>
</span>
</span>
<ChecklistSummary :task="task" />
@@ -565,4 +573,22 @@ defineExpose({
border: 1px solid var(--grey-200);
}
}
.comment-count-icon {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--grey-500);
.comment-count-badge {
font-weight: 600;
font-size: 0.75rem;
line-height: 1;
}
&:hover {
color: var(--primary);
}
}
</style>

View File

@@ -0,0 +1,9 @@
import {computed} from 'vue'
import {createSharedComposable} from '@vueuse/core'
import {useAuthStore} from '@/stores/auth'
export const useTimeFormat = createSharedComposable(() => {
const authStore = useAuthStore()
const store = computed(() => authStore.settings.frontendSettings.timeFormat)
return {store}
})

View File

@@ -0,0 +1,6 @@
export const TIME_FORMAT = {
HOURS_12: '12h',
HOURS_24: '24h',
} as const
export type TimeFormat = typeof TIME_FORMAT[keyof typeof TIME_FORMAT]

View File

@@ -1,24 +1,33 @@
export function calculateNearestHours(currentDate: Date = new Date()): number {
if (currentDate.getHours() <= 9 || currentDate.getHours() > 21) {
const hours = currentDate.getHours()
const minutes = currentDate.getMinutes()
// Helper to check if current time is before a given hour breakpoint
// Returns true if we're before the hour, or at the hour with 0 minutes
const isBeforeOrAt = (breakpoint: number): boolean => {
return hours < breakpoint || (hours === breakpoint && minutes === 0)
}
if (isBeforeOrAt(9) || hours > 21) {
return 9
}
if (currentDate.getHours() <= 12) {
if (isBeforeOrAt(12)) {
return 12
}
if (currentDate.getHours() <= 15) {
if (isBeforeOrAt(15)) {
return 15
}
if (currentDate.getHours() <= 18) {
if (isBeforeOrAt(18)) {
return 18
}
if (currentDate.getHours() <= 21) {
if (isBeforeOrAt(21)) {
return 21
}
// Same case as in the first if, will never be called
// After 21:00 with minutes > 0, return 9 for next day
return 9
}

View File

@@ -90,3 +90,39 @@ test('22:40', () => {
date.setMinutes(0)
expect(calculateNearestHours(date)).toBe(9)
})
// Test cases for the bug: when current time is past a breakpoint hour
test('12:30 should return next breakpoint (15), not current (12)', () => {
const date = new Date()
date.setHours(12)
date.setMinutes(30)
expect(calculateNearestHours(date)).toBe(15)
})
test('15:54 should return next breakpoint (18), not current (15)', () => {
const date = new Date()
date.setHours(15)
date.setMinutes(54)
expect(calculateNearestHours(date)).toBe(18)
})
test('18:45 should return next breakpoint (21), not current (18)', () => {
const date = new Date()
date.setHours(18)
date.setMinutes(45)
expect(calculateNearestHours(date)).toBe(21)
})
test('21:30 should return next day breakpoint (9), not current (21)', () => {
const date = new Date()
date.setHours(21)
date.setMinutes(30)
expect(calculateNearestHours(date)).toBe(9)
})
test('9:01 should return next breakpoint (12), not current (9)', () => {
const date = new Date()
date.setHours(9)
date.setMinutes(1)
expect(calculateNearestHours(date)).toBe(12)
})

View File

@@ -5,7 +5,9 @@ import {i18n} from '@/i18n'
import {createSharedComposable} from '@vueuse/core'
import {computed, toValue, type MaybeRefOrGetter} from 'vue'
import {useDateDisplay} from '@/composables/useDateDisplay'
import {useTimeFormat} from '@/composables/useTimeFormat'
import {DATE_DISPLAY, type DateDisplay} from '@/constants/dateDisplay'
import {TIME_FORMAT, type TimeFormat} from '@/constants/timeFormat'
import {DAYJS_LOCALE_MAPPING} from '@/i18n/useDayjsLanguageSync.ts'
export function dateIsValid(date: Date | null) {
@@ -71,13 +73,13 @@ export function useWeekDayFromDate() {
}
export function formatDisplayDate(date: Date | string | null) {
const {store} = useDateDisplay()
const current = store.value
const {store: dateDisplay} = useDateDisplay()
const {store: timeFormat} = useTimeFormat()
return formatDisplayDateFormat(date, current)
return formatDisplayDateFormat(date, dateDisplay.value, timeFormat.value)
}
export function formatDisplayDateFormat(date: Date | string | null, format: DateDisplay) {
export function formatDisplayDateFormat(date: Date | string | null, format: DateDisplay, timeFormat?: TimeFormat) {
if (typeof date === 'string') {
date = createDateFromString(date)
}
@@ -86,24 +88,31 @@ export function formatDisplayDateFormat(date: Date | string | null, format: Date
return ''
}
// Determine the time format string to use
// For 24-hour: HH:mm (24-hour format)
// For 12-hour: hh:mm A (explicit 12-hour format with AM/PM, ignoring locale default)
const timeFormatString = timeFormat === TIME_FORMAT.HOURS_24 ? 'HH:mm' : 'hh:mm A'
switch (format) {
case DATE_DISPLAY.MM_DD_YYYY:
return formatDate(date, 'MM-DD-YYYY')
return formatDate(date, `MM-DD-YYYY ${timeFormatString}`)
case DATE_DISPLAY.DD_MM_YYYY:
return formatDate(date, 'DD-MM-YYYY')
return formatDate(date, `DD-MM-YYYY ${timeFormatString}`)
case DATE_DISPLAY.YYYY_MM_DD:
return formatDate(date, 'YYYY-MM-DD')
return formatDate(date, `YYYY-MM-DD ${timeFormatString}`)
case DATE_DISPLAY.MM_SLASH_DD_YYYY:
return formatDate(date, 'MM/DD/YYYY')
return formatDate(date, `MM/DD/YYYY ${timeFormatString}`)
case DATE_DISPLAY.DD_SLASH_MM_YYYY:
return formatDate(date, 'DD/MM/YYYY')
return formatDate(date, `DD/MM/YYYY ${timeFormatString}`)
case DATE_DISPLAY.YYYY_SLASH_MM_DD:
return formatDate(date, 'YYYY/MM/DD')
return formatDate(date, `YYYY/MM/DD ${timeFormatString}`)
case DATE_DISPLAY.DAY_MONTH_YEAR: {
return new Intl.DateTimeFormat(i18n.global.locale.value, {day: 'numeric', month: 'long', year: 'numeric'}).format(date)
const hour12 = timeFormat !== TIME_FORMAT.HOURS_24
return new Intl.DateTimeFormat(i18n.global.locale.value, {day: 'numeric', month: 'long', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12}).format(date)
}
case DATE_DISPLAY.WEEKDAY_DAY_MONTH_YEAR: {
return new Intl.DateTimeFormat(i18n.global.locale.value, {weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'}).format(date)
const hour12 = timeFormat !== TIME_FORMAT.HOURS_24
return new Intl.DateTimeFormat(i18n.global.locale.value, {weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12}).format(date)
}
case DATE_DISPLAY.RELATIVE:
default:

View File

@@ -114,6 +114,11 @@
"dd/mm/yyyy": "TT/MM/JJJJ",
"yyyy/mm/dd": "JJJJ/MM/TT"
},
"timeFormat": "Zeitformat",
"timeFormatOptions": {
"12h": "12 Stunden (AM/PM)",
"24h": "24 Stunden (HH:mm)"
},
"externalUserNameChange": "Dein Name wird von deinem Login-Provider verwaltet ({provider}). Um ihn zu ändern, aktualisiere ihn bitte dort."
},
"sections": {
@@ -862,6 +867,8 @@
"relatedTasks": "Verwandte Aufgaben",
"reminders": "Erinnerungen",
"repeat": "Wiederholen",
"comment": "{count} Kommentar | {count} Kommentare",
"commentCount": "Anzahl der Kommentare",
"startDate": "Anfangsdatum",
"title": "Titel",
"updated": "Aktualisiert",
@@ -909,6 +916,9 @@
"addedSuccess": "Der Kommentar wurde erfolgreich hinzugefügt.",
"permalink": "Permalink zu diesem Kommentar kopieren"
},
"mention": {
"noUsersFound": "Keine Nutzer:innen gefunden"
},
"deferDueDate": {
"title": "Fälligkeitsdatum verschieben",
"1day": "1 Tag",

View File

@@ -114,6 +114,11 @@
"dd/mm/yyyy": "TT/MM/JJJJ",
"yyyy/mm/dd": "JJJJ/MM/TT"
},
"timeFormat": "Zeitformat",
"timeFormatOptions": {
"12h": "12 Stunden (AM/PM)",
"24h": "24 Stunden (HH:mm)"
},
"externalUserNameChange": "Dein Name wird von deinem Login-Provider verwaltet ({provider}). Um ihn zu ändern, aktualisiere ihn bitte dort."
},
"sections": {
@@ -862,6 +867,8 @@
"relatedTasks": "Verwandti Uufgabe",
"reminders": "Errinnerige",
"repeat": "Widerhole",
"comment": "{count} Kommentar | {count} Kommentare",
"commentCount": "Anzahl der Kommentare",
"startDate": "Aahfangs Datum",
"title": "Titl",
"updated": "Aktualisiert",
@@ -909,6 +916,9 @@
"addedSuccess": "Din Kommentar isch erfolgriich hinzuegfüegt worde.",
"permalink": "Permalink zu diesem Kommentar kopieren"
},
"mention": {
"noUsersFound": "Keine Nutzer:innen gefunden"
},
"deferDueDate": {
"title": "Fälligkeitsdatum verschiebe",
"1day": "Ein Taag",

View File

@@ -114,6 +114,11 @@
"dd\/mm\/yyyy": "DD\/MM\/YYYY",
"yyyy\/mm\/dd": "YYYY\/MM\/DD"
},
"timeFormat": "Time format",
"timeFormatOptions": {
"12h": "12-hour (AM/PM)",
"24h": "24-hour (HH:mm)"
},
"externalUserNameChange": "Your name is managed by your login provider ({provider}). To change it, please update it there instead."
},
"sections": {
@@ -802,7 +807,9 @@
"overdue": "Show overdue tasks",
"fromuntil": "Tasks from {from} until {until}",
"select": "Select a date range",
"noTasks": "Nothing to do — Have a nice day!"
"noTasks": "Nothing to do — Have a nice day!",
"filterByLabel": "Filtering by label {label}",
"clearLabelFilter": "Clear label filter"
},
"detail": {
"chooseDueDate": "Click here to set a due date",
@@ -862,6 +869,8 @@
"relatedTasks": "Related Tasks",
"reminders": "Reminders",
"repeat": "Repeat",
"comment": "{count} comment | {count} comments",
"commentCount": "Number of comments",
"startDate": "Start Date",
"title": "Title",
"updated": "Updated",
@@ -909,6 +918,9 @@
"addedSuccess": "The comment was added successfully.",
"permalink": "Copy permalink to this comment"
},
"mention": {
"noUsersFound": "No users found"
},
"deferDueDate": {
"title": "Defer due date",
"1day": "1 day",

View File

@@ -114,6 +114,11 @@
"dd/mm/yyyy": "DD/MM/YYYY",
"yyyy/mm/dd": "YYYY/MM/DD"
},
"timeFormat": "時刻の形式",
"timeFormatOptions": {
"12h": "12時間 (AM/PM)",
"24h": "24時間 (HH:mm)"
},
"externalUserNameChange": "名前はログインプロバイダー({provider})で管理されています。名前を変更する場合はそちらで行ってください。"
},
"sections": {
@@ -909,6 +914,9 @@
"addedSuccess": "コメントは正常に追加されました。",
"permalink": "コメントへのリンクをコピー"
},
"mention": {
"noUsersFound": "ユーザーが見つかりません"
},
"deferDueDate": {
"title": "延期",
"1day": "1日後",

View File

@@ -89,7 +89,7 @@
"savedSuccess": "De instellingen zijn succesvol bijgewerkt.",
"emailReminders": "Stuur me herinneringen voor taken via e-mail",
"overdueReminders": "Stuur me dagelijks een overzicht van mijn ongedaan gemaakte achterstallige taken",
"discoverableByName": "Sta andere gebruikers toe om mij als lid toe te voegen aan teams of projecten door mijn naam te zoeken",
"discoverableByName": "Andere gebruikers toestaan mij als lid toe te voegen aan teams of projecten door mijn naam te zoeken",
"discoverableByEmail": "Andere gebruikers toestaan mij als lid toe te voegen aan teams of projecten door mijn volledige e-mail te zoeken",
"playSoundWhenDone": "Een geluid afspelen wanneer taken als voltooid gemarkeerd worden",
"allowIconChanges": "Toon speciale logo's op bepaalde momenten",
@@ -340,7 +340,8 @@
"permission": {
"title": "Machtiging",
"read": "Alleen lezen",
"readWrite": "Lezen & schrijven"
"readWrite": "Lezen & schrijven",
"admin": "Beheren"
},
"attributes": {
"link": "Koppeling",
@@ -497,45 +498,45 @@
"link": "Hoe werkt dit?",
"canUseDatemath": "Je kunt datumberekeningen gebruiken om relatieve datums in te stellen. Klik op de datumwaarde in een zoekopdracht voor meer informatie.",
"fields": {
"done": "Of de taak voltooid is of niet",
"priority": "Het prioriteitsniveau van de taak (1-5)",
"percentDone": "Het percentage van voltooiing van de taak (0-100)",
"dueDate": "De vervaldatum van de taak",
"startDate": "De startdatum van de taak",
"endDate": "De einddatum van de taak",
"doneAt": "De datum en tijd waarop de taak is voltooid",
"assignees": "De verantwoordelijken van de taak",
"labels": "De labels die zijn gekoppeld aan de taak",
"project": "Het project waar de taak bij hoort (alleen beschikbaar voor opgeslagen filters, niet op projectniveau)",
"reminders": "De taakherinneringen als datumveld, zal alle taken opleveren met tenminste één herinnering die past in de zoekopdracht",
"created": "De tijd en datum waarop de taak is aangemaakt",
"updated": "De tijd en datum waarop de taak voor het laatst is gewijzigd"
"done": "of de taak voltooid is of niet",
"priority": "het prioriteitsniveau van de taak (1-5)",
"percentDone": "het percentage van voltooiing van de taak (0-100)",
"dueDate": "de vervaldatum van de taak",
"startDate": "de startdatum van de taak",
"endDate": "de einddatum van de taak",
"doneAt": "de datum en tijd waarop de taak is voltooid",
"assignees": "de verantwoordelijken van de taak",
"labels": "de labels die zijn gekoppeld aan de taak",
"project": "het project waar de taak bij hoort (alleen beschikbaar voor opgeslagen filters, niet op projectniveau)",
"reminders": "de taakherinneringen als datumveld, zal alle taken opleveren met tenminste één herinnering die past in de zoekopdracht",
"created": "de tijd en datum waarop de taak is aangemaakt",
"updated": "de tijd en datum waarop de taak voor het laatst is gewijzigd"
},
"operators": {
"intro": "De beschikbare operators voor het filteren zijn:",
"notEqual": "Is niet gelijk aan",
"equal": "Is gelijk aan",
"greaterThan": "Groter dan",
"greaterThanOrEqual": "Groter dan of gelijk aan",
"lessThan": "Kleiner dan",
"lessThanOrEqual": "Kleiner dan of gelijk aan",
"like": "Voldoet aan een patroon (met jokerteken %)",
"in": "Voldoet bij elke overeenkomst in een kommagescheiden lijst met waarden",
"notIn": "Voldoet bij elke waarde die niet voorkomt in een kommagescheiden lijst met waarden"
"notEqual": "is niet gelijk aan",
"equal": "is gelijk aan",
"greaterThan": "groter dan",
"greaterThanOrEqual": "groter dan of gelijk aan",
"lessThan": "kleiner dan",
"lessThanOrEqual": "kleiner dan of gelijk aan",
"like": "voldoet aan een patroon (met jokerteken %)",
"in": "voldoet bij elke overeenkomst in een kommagescheiden lijst met waarden",
"notIn": "voldoet bij elke waarde die niet voorkomt in een kommagescheiden lijst met waarden"
},
"logicalOperators": {
"intro": "Om meerdere voorwaarden te combineren, kun je de volgende logische operatoren gebruiken:",
"and": "AND operator, voldoet als alle voorwaarden waar zijn",
"or": "OR operator, voldoet als een van de voorwaarden waar is",
"parentheses": "Haakjes voor groeperen voorwaarden"
"parentheses": "haakjes voor groeperen voorwaarden"
},
"examples": {
"intro": "Hier zijn enkele voorbeelden van filterquery's:",
"priorityEqual": "Voldoet bij taken met prioriteit 4",
"dueDatePast": "Voldoet bij taken met een vervaldatum in het verleden",
"undoneHighPriority": "Voldoet bij ongedane taken met prioriteit 3 of hoger",
"assigneesIn": "Voldoet bij taken toegewezen aan \"gebruiker1\" of \"gebruiker2\"",
"priorityOneOrTwoPastDue": "Voldoet bij taken van prioriteit 1 of 2 en een vervaldatum in het verleden"
"priorityEqual": "voldoet bij taken met prioriteit 4",
"dueDatePast": "voldoet bij taken met een vervaldatum in het verleden",
"undoneHighPriority": "voldoet bij ongedane taken met prioriteit 3 of hoger",
"assigneesIn": "voldoet bij taken toegewezen aan \"gebruiker1\" of \"gebruiker2\"",
"priorityOneOrTwoPastDue": "voldoet bij taken van prioriteit 1 of 2 en een vervaldatum in het verleden"
}
}
}
@@ -810,7 +811,7 @@
"move": "Verplaats taak naar een ander project",
"done": "Markeer taak als voltooid!",
"undone": "Markeer als niet voltooid",
"created": "Gemaakt op {0} door {1}",
"created": "Gemaakt {0} door {1}",
"updated": "{0} bijgewerkt",
"doneAt": "{0} voltooid",
"updateSuccess": "De taak is succesvol opgeslagen.",
@@ -991,8 +992,8 @@
"each": "Elke",
"specifyAmount": "Kies hoeveelheid…",
"hours": "Uren",
"days": "Dagen",
"weeks": "Weken",
"days": "dagen",
"weeks": "weken",
"months": "Maanden",
"years": "Jaren",
"invalidAmount": "Voer meer dan nul in."

View File

@@ -185,9 +185,9 @@
"30d": "30 dager",
"60d": "60 dager",
"90d": "90 dager",
"permissionExplanation": "Tillatelser gir mulighet for å hente ut hva en api-plassholder har lov til å gjøre.",
"permissionExplanation": "Rettigheter gir mulighet for å hente ut hva en api-plassholder har lov til å gjøre.",
"titleRequired": "Tittel er nødvendig",
"permissionRequired": "Velg minst en tillatelse fra listen.",
"permissionRequired": "Velg minst en rettighet fra listen.",
"expired": "Denne plassholderen har utløpt {ago}.",
"tokenCreatedSuccess": "Her er din nye api-plassholder {token}",
"tokenCreatedNotSeeAgain": "Lagre på et trygt sted. Du vil ikke se dette flere ganger!",
@@ -588,7 +588,11 @@
"authenticating": "Autentiserer…",
"passwordRequired": "Dette delte prosjektet krever et passord. Vennligst skriv det nedenfor:",
"error": "Det oppsto en feil.",
"invalidPassword": "Det oppgitte passordet er ugyldig."
"invalidPassword": "Det oppgitte passordet er ugyldig.",
"accessDenied": "Ingen tilgang. Kontroller rettighetene og prøv på nytt.",
"serverError": "Det oppstod en tjenerfeil. Prøv igjen senere.",
"projectLoadError": "Kan ikke laste inn prosjektinformasjonen.",
"retry": "Prøv igjen"
},
"navigation": {
"overview": "Oversikt",
@@ -813,6 +817,7 @@
"updateSuccess": "Oppgaven ble lagret.",
"deleteSuccess": "Oppgaven ble slettet.",
"belongsToProject": "Denne oppgaven tilhører prosjektet '{project}'",
"back": "Tilbake til prosjekt",
"due": "Forfaller {at}",
"closePopup": "Lukk popup",
"organization": "Organisering",
@@ -1279,7 +1284,7 @@
"13002": "Den angitte linken for å dele passordet er ugyldig.",
"13003": "Den oppgitte delelenke plassholder er ugyldig.",
"14001": "Den oppgitte api-plassholder er ugyldig.",
"14002": "Tillatelsen {permission} for gruppen {group} er ugyldig.",
"14002": "Rettigheten {permission} for gruppen {group} er ugyldig.",
"error": "Feil",
"success": "Suksess",
"0001": "Du har ikke lov til å gjøre det."

View File

@@ -114,6 +114,11 @@
"dd/mm/yyyy": "DD/MM/AAAA",
"yyyy/mm/dd": "AAAA/MM/DD"
},
"timeFormat": "Formato da hora",
"timeFormatOptions": {
"12h": "12-horas (AM/PM)",
"24h": "24-horas (HH:mm)"
},
"externalUserNameChange": "O teu nome é gerido pelo teu fornecedor de início de sessão ({provider}). Para o alterar, atualiza-o antes lá."
},
"sections": {
@@ -862,6 +867,8 @@
"relatedTasks": "Tarefas Relacionadas",
"reminders": "Lembretes",
"repeat": "Repetir",
"comment": "{count} comentário | {count} comentários",
"commentCount": "Número de comentários",
"startDate": "Data de Início",
"title": "Título",
"updated": "Atualizado",
@@ -909,6 +916,9 @@
"addedSuccess": "O comentário foi adicionada com sucesso.",
"permalink": "Copiar o link permanente para este comentário"
},
"mention": {
"noUsersFound": "Nenhum utilizador encontrado"
},
"deferDueDate": {
"title": "Adiar data de vencimento",
"1day": "1 dia",

View File

@@ -908,6 +908,9 @@
"addedSuccess": "Комментарий добавлен.",
"permalink": "Скопировать постоянную ссылку на комментарий"
},
"mention": {
"noUsersFound": "Пользователи не найдены"
},
"deferDueDate": {
"title": "Отложить срок",
"1day": "1 день",

View File

@@ -18,7 +18,7 @@
},
"demo": {
"title": "Цей зразок слугує для показу. Не вживайте для справжніх відомостей!",
"everythingWillBeDeleted": "Все постійно вилучається!",
"everythingWillBeDeleted": "Все вилучається постійно! ",
"accountWillBeDeleted": "Обліковку буде вилучено зі справами, завданнями та вкладеннями, які Ви можете створити."
},
"ready": {
@@ -43,9 +43,9 @@
"forgotPassword": "Поновити пароль?",
"resetPassword": "Скинути пароль",
"resetPasswordAction": "Надіслати лист",
"resetPasswordSuccess": "Перевірте е-скриньку! У ній має бути лист з вказівками щодо поновлення паролю.",
"resetPasswordSuccess": "Перевірте е.скриньку! У ній має бути лист з вказівками щодо поновлення паролю.",
"passwordsDontMatch": "Паролі не збігаються",
"confirmEmailSuccess": "Е-скриньку підтверджено! Тепер можете увійти.",
"confirmEmailSuccess": "Е.скриньку підтверджено! Тепер можете увійти.",
"totpTitle": "Код дворівневої перевірки",
"totpPlaceholder": "напр. 123456",
"login": "Увійти",
@@ -55,8 +55,8 @@
"openIdStateError": "Стан не збігається, неможливо продовжити!",
"openIdGeneralError": "Сталася помилка під час автентифікації проти третьої сторони.",
"logout": "Вийти",
"emailInvalid": "Будь ласка, введіть дійсну е-скриньку.",
"usernameRequired": "Будь ласка, введіть ім'я або е-скриньку.",
"emailInvalid": "Будь ласка, введіть дійсну е.скриньку.",
"usernameRequired": "Будь ласка, введіть ім'я або е.скриньку.",
"usernameMustNotContainSpace": "Ім'я вживача не може містити пропусків.",
"usernameMustNotLookLikeUrl": "Ім'я вживача не має виглядати як посилання.",
"passwordRequired": "Будь ласка, введіть пароль.",
@@ -78,18 +78,18 @@
"currentPasswordPlaceholder": "Ваш чинний пароль",
"passwordsDontMatch": "Новий пароль та його підтвердження не збігаються.",
"passwordUpdateSuccess": "Пароль оновлено.",
"updateEmailTitle": "Зміна е-скриньки",
"updateEmailTitle": "Зміна е.скриньки",
"updateEmailNew": "Нова",
"updateEmailSuccess": "Е-скриньку оновлено. Вам надіслано посилання для підтвердження.",
"updateEmailSuccess": "Е.скриньку оновлено. Вам надіслано посилання для підтвердження.",
"general": {
"title": "Загальні відомості",
"name": "Моє ім'я",
"newName": "Нове ім'я",
"savedSuccess": "Наладження оновлено.",
"emailReminders": "Надсилати нагадування на е-скриньку",
"emailReminders": "Надсилати нагадування на е.скриньку",
"overdueReminders": "Надсилати звіт про мої невиконані завдання щодня",
"discoverableByName": "Дозволити вживачам долучати мене до спільнот або справ, знаходячи за ім'ям",
"discoverableByEmail": "Дозволити вживачам долучати мене до спільнот або справ, знаходячи за е-скринькою",
"discoverableByEmail": "Дозволити вживачам долучати мене до спільнот або справ, знаходячи за е.скринькою",
"playSoundWhenDone": "Грати звук при закінченні завдання",
"weekStart": "Початок тижня",
"weekStartSunday": "Неділя",
@@ -98,8 +98,24 @@
"defaultProject": "Основна справа",
"defaultView": "Основне подання",
"timezone": "Часовий пояс",
"overdueTasksRemindersTime": "Нагадувати про завдання на е-скриньку",
"filterUsedOnOverview": "Постійна вибірка для сторінки огляду"
"overdueTasksRemindersTime": "Нагадувати про завдання на е.скриньку",
"filterUsedOnOverview": "Постійна вибірка для сторінки огляду",
"dateDisplay": "Вид показу дня",
"dateDisplayOptions": {
"relative": "Приблизний (наприклад, 3 дні тому)",
"mm-dd-yyyy": "MM-DD-YYYY",
"dd-mm-yyyy": "DD-MM-YYYY",
"yyyy-mm-dd": "YYYY-MM-DD",
"mm/dd/yyyy": "MM/DD/YYYY",
"dd/mm/yyyy": "DD/MM/YYYY",
"yyyy/mm/dd": "YYYY/MM/DD"
}
},
"sections": {
"personalInformation": "Особисті відомості",
"taskAndNotifications": "Справи та Завдання",
"privacy": "Осібність",
"localization": "Умісцевлення"
},
"totp": {
"title": "Дворівнева перевірка",
@@ -183,9 +199,9 @@
"title": "Вилучити обліковку",
"text1": "Вилучення обліковки остаточне і призведе до втрати справ, завдань, які пов'язані з нею.",
"text2": "Щоб продовжити, введіть пароль. Ви одержите лист з подальшими вказівками.",
"text3": "Щоб продовжити, будь ласка, натисніть кнопку нижче. Ви одержите лист з подальшими вказівками на е-скриньку.",
"text3": "Щоб продовжити, будь ласка, натисніть кнопку нижче. Ви одержите лист з подальшими вказівками на е.скриньку.",
"confirm": "Вилучити обліковку",
"requestSuccess": "Запит успішний. Ви одержите лист з подальшими вказівками на е-скриньку.",
"requestSuccess": "Запит успішний. Ви одержите лист з подальшими вказівками на е.скриньку.",
"passwordRequired": "Будь ласка, введіть Ваш пароль.",
"confirmSuccess": "Вилучення обліковки підтверджено і проведеться встяж 3-х днів.",
"scheduled": "Ми видалимо Вашу обліковку Vikunja в {date} ({dateSince}).",
@@ -200,7 +216,7 @@
"description": "Тут можна замовити витяг усіх Ваших відомостей з Vikunja, який міститиме справи, завдання і все, що пов'язано з ними. Ці відомості можна додати в будь-який примірник Vikunja, вживши засоби перенесення.",
"descriptionPasswordRequired": "Будь ласка, введіть свій пароль, щоб продовжити:",
"request": "Запит відомостей з Vikunja",
"success": "Запит щодо подвійки Ваших відомостей з Vikunja успішно подано. Ми надішлемо Вам лист на е-скриньку, як тільки відомості будуть готові для витягування.",
"success": "Запит щодо подвійки Ваших відомостей з Vikunja успішно подано. Ми надішлемо Вам лист на е.скриньку, як тільки відомості будуть готові для витягування.",
"downloadTitle": "Витягти Ваші відомості Vikunja"
}
},
@@ -307,6 +323,9 @@
"addedSuccess": "{type} додано.",
"updatedSuccess": "{type} додано."
},
"permission": {
"title": "Дозвіл"
},
"attributes": {
"link": "Посилання",
"delete": "Вилучити"
@@ -404,6 +423,7 @@
"title": "Вибірки",
"clear": "Очистити",
"showResults": "Показати",
"noResults": "Нічого",
"attributes": {
"title": "Заголовок",
"titlePlaceholder": "Заголовок введіть тут…",
@@ -451,7 +471,9 @@
"doneAt": "День та час закінчення",
"assignees": "Виконавці",
"labels": "Позначки",
"project": "Справа, якій це завдання належить (доступно тільки для постійних вибірок, а не на рівні справи)"
"project": "Справа, якій це завдання належить (доступно тільки для постійних вибірок, а не на рівні справи)",
"created": "Час і день створення завдання",
"updated": "Час і день останньої зміни завдання"
},
"operators": {
"intro": "Доступні оператори для вибірки такі:",
@@ -534,7 +556,7 @@
"upcoming": "Прийдешнє",
"settings": "Наладження",
"imprint": "Наша адреса",
"privacy": олітика негласности"
"privacy": равила осібности"
},
"misc": {
"loading": "Запускаю…",
@@ -732,6 +754,7 @@
"show": {
"titleCurrent": "Поточні завдання",
"titleDates": "Завдання з {from} до {to}",
"noDates": "Показати без дня",
"overdue": "Показувати задавнені",
"fromuntil": "Завдання з {from} до {until}",
"select": "Обрати проміжок",
@@ -838,7 +861,8 @@
"delete": "Вилучається приписка",
"deleteText1": "Справді впровадити?",
"deleteSuccess": "Приписку вилучено.",
"addedSuccess": "Приписку додано."
"addedSuccess": "Приписку додано.",
"permalink": "Одержати посилання"
},
"deferDueDate": {
"title": "Перенести строк",
@@ -949,7 +973,7 @@
"date": "Будь-який день буде вжитий, як строк. Ви можете вживати дні будь-якого виду:",
"dateCurrentYear": "вживатиметься поточний рік",
"dateNth": "вживатиметься {day} число поточного місяця",
"dateTime": "Для встановки часу об'єднайте будь-який з видів дня з \"{time}\" (або {timePM}).",
"dateTime": "Для встановки часу додайте до будь-якого з видів дня: \"{time}\" (або {timePM}).",
"repeats": "Завдання повторювані",
"repeatsDescription": "Щоб встановити проміжок повтору, додайте '{suffix}'. Можна вказати проміжок як число або вид повтору (дивіться приклади)."
}
@@ -1115,7 +1139,7 @@
},
"error": {
"1001": "Користувач з таким ім'ям вже існує.",
"1002": "Вживає з також е-скринькою вже є.",
"1002": "Вживач з такою е.скринькою вже є.",
"1004": "Не вказано ім'я вживача і пароль.",
"1005": "Такого вживача немає.",
"1006": "Незмога одержати id вживача.",
@@ -1146,6 +1170,7 @@
"4013": "Хибний вимір упорядкування завдань.",
"4015": "Такої приписки немає.",
"4016": "Хибне поле завдання.",
"4021": "Вживач вже доданий до цього завдання.",
"6001": "Назва спільноти не може бути порожньою.",
"6002": "Такої спільноти немає.",
"6004": "Спільнота вже має доступ до цієї справи.",

View File

@@ -43,6 +43,7 @@
"forgotPassword": "忘记密码",
"resetPassword": "重置密码",
"resetPasswordAction": "发送密码重置链接",
"resetPasswordSuccess": "请检查收件箱!您应该会收到一封邮件,告知您如何重置密码。",
"passwordsDontMatch": "两次输入的密码不一致",
"confirmEmailSuccess": "已成功确认您的电子邮件!现在可以登录。",
"totpTitle": "两步验证码",
@@ -52,6 +53,7 @@
"loginWith": "以 {provider} 身份登录",
"authenticating": "验证中",
"openIdStateError": "状态不匹配,无法继续!",
"openIdGeneralError": "在对第三方进行身份验证时出错。",
"logout": "注销",
"emailInvalid": "请输入有效的电子邮件地址。",
"usernameRequired": "请输入用户名",
@@ -59,23 +61,33 @@
"usernameMustNotLookLikeUrl": "用户名不能像一个 URL。",
"passwordRequired": "请提供密码",
"passwordNotMin": "密码至少有8个字符",
"passwordNotMax": "密码不能超过 72 个字符",
"showPassword": "显示密码",
"hidePassword": "隐藏密码",
"noAccountYet": "还没有账号?",
"alreadyHaveAnAccount": "已有账户?",
"remember": "保持登录状态"
"remember": "保持登录状态",
"registrationDisabled": "已关闭注册。",
"passwordResetTokenMissing": "缺少密码重置令牌。"
},
"settings": {
"title": "设置",
"newPasswordTitle": "更新密码",
"newPassword": "新密码",
"newPasswordConfirm": "新密码确认",
"currentPassword": "当前密码",
"currentPasswordPlaceholder": "输入当前密码",
"passwordsDontMatch": "两次输入密码不一致",
"passwordUpdateSuccess": "已成功更新密码",
"updateEmailTitle": "更新电子邮件地址",
"updateEmailNew": "新邮箱地址",
"updateEmailSuccess": "您的电子邮件地址已成功更新。我们已经向您发送了一个链接来确认。",
"general": {
"title": "通用设置",
"name": "我的名字",
"newName": "新的名字",
"savedSuccess": "成功更新了设置",
"emailReminders": "通过电子邮件发送任务提醒",
"overdueReminders": "每天给我发送我未完成任务的摘要",
"discoverableByName": "允许其他用户在搜索我的名字时将我添加为团队或项目的成员",
"discoverableByEmail": "允许其他用户在搜索我的完整电子邮件时将我添加为团队或项目的成员",
@@ -84,18 +96,31 @@
"weekStartSunday": "星期日",
"weekStartMonday": "星期一",
"language": "语言设置",
"defaultProject": "默认项目",
"defaultView": "默认视图",
"timezone": "时区",
"overdueTasksRemindersTime": "逾期任务提醒邮件时间",
"filterUsedOnOverview": "概述页面上使用已保存过滤器"
},
"sections": {
"personalInformation": "个人信息",
"taskAndNotifications": "项目和任务",
"privacy": "隐私",
"localization": "本地化",
"appearance": "外观与行为"
},
"totp": {
"title": "两步验证",
"enroll": "展开使用",
"finishSetupPart1": "要完成您的设置,请在您的 TOTP 应用程序中使用此密文 (Google 身份验证器或类似的软件)",
"finishSetupPart2": "在下方输入您应用中生成的代码。",
"scanQR": "或者,您可以扫描此二维码:",
"passcode": "验证码",
"passcodePlaceholder": "由您的 TOTP 应用程序生成的代码",
"setupSuccess": "您已成功启用两步验证!",
"enterPassword": "请输入密码",
"disable": "禁用两步验证",
"confirmSuccess": "您已成功确认您的 TOTP 设置,并且可以从现在起使用!",
"disableSuccess": "两步验证已禁用。"
},
"caldav": {
@@ -118,7 +143,9 @@
"upload": "上传",
"uploadAvatar": "上传头像",
"statusUpdateSuccess": "头像状态更新成功!",
"setSuccess": "头像设置成功!"
"setSuccess": "头像设置成功!",
"ldap": "您的头像是从您的组织目录服务(LDAP)自动同步的。您可以询问您的IT团队如何更改它。",
"openid": "您的头像是从您的登陆提供方({provider})自动同步的。您可以在那里修改。"
},
"quickAddMagic": {
"title": "快速添加 Magic 模式",
@@ -146,6 +173,7 @@
"90d": "90 天",
"permissionExplanation": "权限允许您限制 api 令牌被允许做什么。",
"titleRequired": "需要指定标题",
"permissionRequired": "请从列表中选择至少一个权限。",
"expired": "Token {ago} 前到期",
"tokenCreatedSuccess": "这是您的令牌: {token}",
"tokenCreatedNotSeeAgain": "将其存储在一个安全的位置,你不会再看到它了!",
@@ -185,13 +213,16 @@
"descriptionPasswordRequired": "请输入您的密码以继续。",
"request": "请求我的 Vikunja 数据副本",
"success": "已成功请求您的 Vikunja 数据!一旦准备好下载,我们将向您发送一封电子邮件。",
"downloadTitle": "下载您导出的 Vikunja 数据"
"downloadTitle": "下载您导出的 Vikunja 数据",
"ready": "您的导出已准备好下载。您可以在 {0} 之前下载。",
"requestNew": "请求另一个导出"
}
},
"project": {
"archivedMessage": "该项目已存档。 无法为其创建新任务或编辑任务。",
"archived": "已归档",
"showArchived": "显示已归档",
"title": "标题",
"color": "颜色",
"projects": "项目",
"parent": "父项目",
@@ -200,6 +231,10 @@
"shared": "共享项目",
"noDescriptionAvailable": "没有可用的项目描述。",
"inboxTitle": "收件箱",
"favorite": "收藏这个项目",
"unfavorite": "从收藏夹中删除此项目",
"openSettingsMenu": "打开项目设置菜单",
"description": "项目描述",
"create": {
"header": "新项目",
"titlePlaceholder": "项目标题",
@@ -211,6 +246,8 @@
"title": "存档 \"{project}\"",
"archive": "存档此项目",
"unarchive": "取消存档此项目",
"unarchiveText": "您将能够创建或编辑任务",
"archiveText": "在您取消归档之前,您将无法编辑此项目或新建任务。",
"success": "项目已成功归档。"
},
"background": {
@@ -259,6 +296,7 @@
"title": "共享链接",
"what": "什么是共享链接?",
"explanation": "共享链接使您能够轻松地与其他未注册账户的访客共享一个列表。",
"create": "创建共享链接",
"name": "共享链接名称 (可选)",
"namePlaceholder": "例如Lorem Ipsum",
"nameExplanation": "此共享链接中的所有动作都将显示该名称。",
@@ -284,15 +322,26 @@
"addedSuccess": "{type} 已成功添加。",
"updatedSuccess": "{type} 已成功添加。"
},
"permission": {
"title": "权限",
"read": "只读",
"readWrite": "读和写",
"admin": "管理员"
},
"attributes": {
"link": "链接",
"delete": "删除"
}
},
"first": {
"title": "第一视图"
},
"list": {
"title": "列表",
"add": "添加",
"addPlaceholder": "添加任务…",
"empty": "此项目目前为空。",
"newTaskCta": "新建任务。",
"editTask": "编辑任务"
},
"gantt": {
@@ -302,7 +351,10 @@
"month": "月",
"day": "日",
"hour": "时",
"range": "日期范围"
"range": "日期范围",
"chartLabel": "项目甘特图",
"scheduledDates": "预定日期",
"estimatedDates": "估计日期"
},
"table": {
"title": "表格",
@@ -323,6 +375,7 @@
"addTaskPlaceholder": "输入新任务标题…",
"addTask": "添加任务",
"addAnotherTask": "添加另一个任务",
"addBucket": "创建一个新的存储桶。",
"addBucketPlaceholder": "输入新的存储桶标题…",
"deleteHeaderBucket": "删除存储桶",
"deleteBucketText1": "您确定要删除此存储桶吗?",
@@ -330,7 +383,8 @@
"deleteBucketSuccess": "存储桶已删除。",
"bucketTitleSavedSuccess": "存储桶标题已保存。",
"bucketLimitSavedSuccess": "存储桶限制已保存。",
"collapse": "折叠此存储桶"
"collapse": "折叠此存储桶",
"bucketLimitReached": "您已经达到数量上限,请删除任务或者提高上限然后再添加任务。"
},
"pseudo": {
"favorites": {
@@ -364,13 +418,19 @@
"createSuccess": "视图创建成功。",
"titleRequired": "请提供标题。",
"delete": "删除此视图",
"deleteText": "您确定要删除此视图吗?它将不再可能使用它来查看此项目中的任务。 此操作不会删除任何任务。此操作不能撤销!"
"deleteText": "您确定要删除此视图吗?它将不再可能使用它来查看此项目中的任务。 此操作不会删除任何任务。此操作不能撤销!",
"deleteSuccess": "视图删除成功。",
"onlyAdminsCanEdit": "只有项目管理员才能编辑视图。",
"updateSuccess": "视图更新成功。"
}
},
"filters": {
"title": "筛选器",
"clear": "清除筛选条件",
"showResults": "显示结果",
"noResults": "无结果",
"fromView": "当前视图还有一个过滤规则集:",
"fromViewBoth": "它将与您在这里输入的内容结合使用。",
"attributes": {
"title": "标题",
"titlePlaceholder": "填写筛选器标题",
@@ -390,6 +450,7 @@
"create": {
"title": "新保存的过滤器",
"description": "保存的过滤器是一个虚拟工程,每次访问时都从一组过滤器中计算。",
"action": "创建并保存筛选",
"titleRequired": "请为该过滤器提供名称。"
},
"delete": {
@@ -407,6 +468,7 @@
"help": {
"intro": "要过滤任务您可以使用类似于SQL的查询语法。可用的过滤字段包括",
"link": "这是如何运作的?",
"canUseDatemath": "您可以使用日期计算来设置相对日期。点击查询中的日期值来了解更多信息。",
"fields": {
"done": "任务是否完成",
"priority": "任务的优先级(1-5)",
@@ -417,7 +479,10 @@
"doneAt": "任务完成的日期和时间",
"assignees": "任务的指派人",
"labels": "与任务相关的标签",
"project": "任务属于的项目 (仅适用于保存的过滤器,不适用于项目级别)"
"project": "任务属于的项目 (仅适用于保存的过滤器,不适用于项目级别)",
"reminders": "作为日期字段的任务提醒将返回所有任务,并且至少有一个与查询匹配的提醒",
"created": "任务创建的时间和日期",
"updated": "任务最后更改的时间和日期"
},
"operators": {
"intro": "可用的过滤操作员包括:",
@@ -471,6 +536,7 @@
"search": "输入以搜索标签…",
"create": {
"header": "新建标记",
"title": "新建标签",
"titleRequired": "请指定标题",
"success": "已新建标签"
},
@@ -491,7 +557,12 @@
"sharing": {
"authenticating": "验证中……",
"passwordRequired": "此共享项目需要密码。请在下面输入:",
"invalidPassword": "密码错误"
"error": "发生了错误",
"invalidPassword": "密码错误",
"accessDenied": "访问被拒绝。请检查您的权限并重试。",
"serverError": "服务器开小差了,请稍后再试",
"projectLoadError": "加载项目信息失败。",
"retry": "重试"
},
"navigation": {
"overview": "概览",
@@ -604,6 +675,7 @@
}
},
"multiselect": {
"createPlaceholder": "新建",
"selectPlaceholder": "点击或按 Enter 选择"
},
"datepickerRange": {
@@ -682,8 +754,10 @@
},
"task": {
"task": "任务",
"new": "新建任务",
"delete": "删除此任务",
"createSuccess": "成功创建任务",
"addReminder": "添加提醒...",
"doneSuccess": "待办事项已标记为完成。",
"undoneSuccess": "待办事项已标记为未完成。",
"undo": "撤销",
@@ -693,6 +767,7 @@
"show": {
"titleCurrent": "当前任务",
"titleDates": "从 {from} 到 {to} 的任务",
"noDates": "显示没有日期的任务",
"overdue": "显示过期任务",
"fromuntil": "从 {from} 到 {until} 的任务",
"select": "选择一个日期范围",
@@ -711,6 +786,7 @@
"updateSuccess": "该任务已保存",
"deleteSuccess": "任务已删除",
"belongsToProject": "该任务属于项目'{project}'",
"back": "返回到项目",
"due": "截止至 {at}",
"closePopup": "关闭弹窗",
"organization": "机构",
@@ -755,6 +831,7 @@
"relatedTasks": "相关任务",
"reminders": "提醒",
"repeat": "重复",
"commentCount": "评论数量",
"startDate": "开始日期",
"title": "标题",
"updated": "已更新",
@@ -799,7 +876,11 @@
"delete": "删除此评论",
"deleteText1": "确实要删除此评论吗?",
"deleteSuccess": "评论已删除。",
"addedSuccess": "评论已添加。"
"addedSuccess": "评论已添加。",
"permalink": "复制此评论的永久链接"
},
"mention": {
"noUsersFound": "未找到用户"
},
"deferDueDate": {
"title": "推迟截止时间",
@@ -818,6 +899,7 @@
"unassignSuccess": "已成功取消分配用户。"
},
"label": {
"placeholder": "输入以添加标签...",
"createPlaceholder": "将此添加为新标签",
"addSuccess": "已成功添加标签。",
"createSuccess": "已成功创建标签。",
@@ -840,6 +922,8 @@
"relation": {
"add": "添加新任务关系",
"new": "新任务关系",
"searchPlaceholder": "输入来搜索任务以添加关联...",
"createPlaceholder": "将此添加为相关任务",
"differentProject": "此任务属于另一个项目。",
"noneYet": "还没有任务关联。",
"delete": "删除关联",
@@ -877,6 +961,7 @@
"every30d": "每 30 天",
"mode": "重复模式",
"monthly": "每月",
"fromCurrentDate": "添加完成日期",
"each": "每个",
"specifyAmount": "指定数量…",
"hours": "小时",
@@ -915,6 +1000,7 @@
"title": "团队",
"noTeams": "你目前不属于任何团队。",
"create": {
"title": "创建团队",
"success": "团队已成功创建。"
},
"edit": {
@@ -984,6 +1070,7 @@
"delete": "删除此任务",
"priority": "更改此任务的优先级",
"favorite": "将此任务标记为收藏/取消收藏",
"openProject": "打开该任务的项目",
"save": "保存当前任务"
},
"project": {
@@ -1000,6 +1087,15 @@
"labels": "导航到标签",
"teams": "导航到小组",
"projects": "导航到项目"
},
"list": {
"title": "任务列表",
"navigateDown": "高亮下一个任务",
"navigateUp": "高亮上一个任务",
"open": "打开高亮的任务"
},
"gantt": {
"title": "甘特图"
}
},
"update": {
@@ -1012,6 +1108,7 @@
"duplicate": "复制",
"delete": "删除",
"unarchive": "取消存档",
"setBackground": "背景设置",
"share": "共享",
"newProject": "新项目",
"createProject": "创建项目",
@@ -1036,6 +1133,7 @@
"notification": {
"title": "通知",
"none": "没有任何通知。 祝你今天过得愉快!",
"explainer": "当订阅的项目或任务发生操作时,通知会出现在这里。",
"markAllRead": "将所有通知标为已读",
"markAllReadSuccess": "成功标记所有通知为已读。"
},
@@ -1059,6 +1157,7 @@
}
},
"date": {
"altFormatLong": "Y.m.d H:i",
"altFormatShort": "j M Y"
},
"reaction": {
@@ -1080,7 +1179,15 @@
"1012": "用户的电子邮件地址未确认。",
"1013": "新密码为空。",
"1014": "旧密码为空。",
"1015": "此用户已启用TOTP。",
"1016": "该用户未启用TOTP。",
"1017": "TOTP 密码是无效的。",
"1018": "用户头像设置无效。",
"1019": "OpenID提供商没有提供电子邮件地址。请确保OpenID提供商公开为您的帐户提供电子邮件地址。",
"1020": "此帐户被禁用。请检查您的电子邮件或询问您的管理员。",
"1021": "此账户由第三方认证提供商管理。",
"1022": "用户名不能包含空格。",
"1023": "您不能将此作为链接共享。",
"2001": "ID 不能为空或 0。",
"2002": "一些请求数据无效。",
"3001": "项目不存在",
@@ -1089,6 +1196,8 @@
"3006": "项目共享不存在。",
"3007": "具有此标识符的项目已存在。",
"3008": "该项目已存档,因此只能读取。与该项目相关的所有任务也是如此。",
"4001": "任务标题不能为空。",
"4002": "该任务不存在。",
"4003": "所有批量编辑任务必须属于同一项目。",
"4004": "批量编辑任务时至少需要选择一项任务。",
"4005": "你没有权限查看此任务。",
@@ -1106,12 +1215,17 @@
"4017": "任务筛选比较器无效。",
"4018": "任务筛选连接器无效。",
"4019": "任务筛选值无效。",
"4020": "这个附件不属于这项任务。",
"4021": "此用户已被分配到该任务。",
"4022": "请提供提醒日期的相关信息。",
"6001": "团队名称不能为空。",
"6002": "团队不存在。",
"6004": "该团队已经可以访问该项目。",
"6005": "该用户已经是此团队的成员。",
"6006": "无法删除最后一个团队成员。",
"6007": "该团队没有权限访问此项目来执行操作。",
"6008": "没有找到给定的 OIDC ID 和 issuer 对应的团队。",
"6009": "找不到具有oidcId属性的团队。",
"7002": "用户已经有权访问此项目。",
"7003": "您无权访问此项目",
"8001": "此标签已存在于该任务中。",
@@ -1127,14 +1241,20 @@
"11002": "已保存的筛选器不适用于链接共享。",
"12001": "订阅实体类型无效。",
"12002": "你已经订阅实体本身或上级实体。",
"12003": "您必须提供一个用户才能获取订阅。",
"13001": "此链接共享需要密码进行身份验证,但没有提供密码。",
"13002": "提供的链接共享密码无效。",
"13003": "提供的链接共享令牌无效。",
"14001": "提供的 api 令牌无效。",
"error": "错误",
"success": "成功",
"0001": "不允许这样操作"
},
"about": {
"title": "关于",
"version": "版本:{version}"
"version": "版本:{version}",
"frontendVersion": "前端版本: {version}",
"apiVersion": "API 版本: {version}"
},
"time": {
"units": {

View File

@@ -21,6 +21,7 @@ declare global {
SENTRY_DSN?: string;
ALLOW_ICON_CHANGES: boolean;
CUSTOM_LOGO_URL?: string;
CUSTOM_LOGO_URL_DARK?: string;
}
}

View File

@@ -49,6 +49,7 @@ export interface ITask extends IAbstract {
reactions: IReactionPerEntity
comments: ITaskComment[]
commentCount?: number
createdBy: IUser
created: Date

View File

@@ -7,6 +7,7 @@ import type {SupportedLocale} from '@/i18n'
import type {DefaultProjectViewKind} from '@/modelTypes/IProjectView'
import type {Priority} from '@/constants/priorities'
import type {DateDisplay} from '@/constants/dateDisplay'
import type {TimeFormat} from '@/constants/timeFormat'
import type {IRelationKind} from '@/types/IRelationKind'
export interface IFrontendSettings {
@@ -18,6 +19,7 @@ export interface IFrontendSettings {
defaultView?: DefaultProjectViewKind
minimumPriority?: Priority
dateDisplay: DateDisplay
timeFormat: TimeFormat
defaultTaskRelationType: IRelationKind
}

View File

@@ -6,6 +6,7 @@ import {PrefixMode} from '@/modules/parseTaskText'
import {DEFAULT_PROJECT_VIEW_SETTINGS} from '@/modelTypes/IProjectView'
import {PRIORITIES} from '@/constants/priorities'
import {DATE_DISPLAY} from '@/constants/dateDisplay'
import {TIME_FORMAT} from '@/constants/timeFormat'
import {RELATION_KIND} from '@/types/IRelationKind'
export default class UserSettingsModel extends AbstractModel<IUserSettings> implements IUserSettings {
@@ -27,6 +28,7 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
defaultView: DEFAULT_PROJECT_VIEW_SETTINGS.FIRST,
minimumPriority: PRIORITIES.MEDIUM,
dateDisplay: DATE_DISPLAY.RELATIVE,
timeFormat: TIME_FORMAT.HOURS_24,
defaultTaskRelationType: RELATION_KIND.RELATED,
}
extraSettingsLinks = {}

View File

@@ -5,7 +5,6 @@ import {AxiosError} from 'axios'
export default async function setupSentry(app: App, router: Router) {
const Sentry = await import('@sentry/vue')
const {Integrations} = await import('@sentry/tracing')
Sentry.init({
app,
@@ -13,9 +12,8 @@ export default async function setupSentry(app: App, router: Router) {
release: import.meta.env.VITE_PLUGIN_SENTRY_CONFIG.release,
dist: import.meta.env.VITE_PLUGIN_SENTRY_CONFIG.dist,
integrations: [
new Integrations.BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(router),
tracingOrigins: ['localhost', /^\//],
Sentry.browserTracingIntegration({
router,
}),
],
tracesSampleRate: 1.0,

View File

@@ -4,7 +4,7 @@ import TaskModel from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import BucketModel from '@/models/bucket'
export type ExpandTaskFilterParam = 'subtasks' | 'buckets' | 'reactions' | null
export type ExpandTaskFilterParam = 'subtasks' | 'buckets' | 'reactions' | 'comment_count' | null
export interface TaskFilterParams {
sort_by: ('start_date' | 'end_date' | 'due_date' | 'done' | 'id' | 'position' | 'title')[],

View File

@@ -23,6 +23,7 @@ import UserSettingsModel from '@/models/userSettings'
import {MILLISECONDS_A_SECOND} from '@/constants/date'
import {PrefixMode} from '@/modules/parseTaskText'
import {DATE_DISPLAY} from '@/constants/dateDisplay'
import {TIME_FORMAT} from '@/constants/timeFormat'
import {RELATION_KIND} from '@/types/IRelationKind'
import type {IProvider} from '@/types/IProvider'
@@ -134,6 +135,7 @@ export const useAuthStore = defineStore('auth', () => {
colorSchema: 'auto',
allowIconChanges: true,
dateDisplay: DATE_DISPLAY.RELATIVE,
timeFormat: TIME_FORMAT.HOURS_24,
defaultTaskRelationType: RELATION_KIND.RELATED,
...newSettings.frontendSettings,
},

View File

@@ -262,6 +262,7 @@ export const useKanbanStore = defineStore('kanban', () => {
try {
const newBuckets = await taskCollectionService.getAll({projectId, viewId}, {
...params,
expand: 'comment_count',
per_page: TASKS_PER_BUCKET,
})
setBuckets(newBuckets)
@@ -300,6 +301,7 @@ export const useKanbanStore = defineStore('kanban', () => {
params.filter = `${params.filter === '' ? '' : params.filter + ' && '}bucket_id = ${bucketId}`
params.filter_timezone = authStore.settings.timezone
params.per_page = TASKS_PER_BUCKET
params.expand = 'comment_count'
const taskService = new TaskCollectionService()
try {

View File

@@ -38,14 +38,17 @@
<ShowTasks
v-if="projectStore.hasProjects"
:key="showTasksKey"
:label-ids="labelIds"
class="show-tasks"
@tasksLoaded="tasksLoaded = true"
@clearLabelFilter="handleClearLabelFilter"
/>
</div>
</template>
<script lang="ts" setup>
import {ref, computed} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import Message from '@/components/misc/Message.vue'
import ShowTasks from '@/views/tasks/ShowTasks.vue'
@@ -65,6 +68,8 @@ const salutation = useDaytimeSalutation()
const authStore = useAuthStore()
const projectStore = useProjectStore()
const route = useRoute()
const router = useRouter()
const projectHistory = computed(() => {
// If we don't check this, it tries to load the project background right after logging out
@@ -81,6 +86,15 @@ const tasksLoaded = ref(false)
const deletionScheduledAt = computed(() => parseDateOrNull(authStore.info?.deletionScheduledAt))
// Extract label IDs from query parameter
const labelIds = computed(() => {
const labelsParam = route.query.labels
if (!labelsParam) {
return undefined
}
return Array.isArray(labelsParam) ? labelsParam : [labelsParam]
})
// This is to reload the tasks list after adding a new task through the global task add.
// FIXME: Should use pinia (somehow?)
const showTasksKey = ref(0)
@@ -88,6 +102,15 @@ const showTasksKey = ref(0)
function updateTaskKey() {
showTasksKey.value++
}
function handleClearLabelFilter() {
const query = {...route.query}
delete query.labels
router.push({
name: route.name as string,
query,
})
}
</script>
<style scoped lang="scss">

View File

@@ -29,32 +29,25 @@
<div class="columns">
<div class="labels-list column">
<span
<RouterLink
v-for="label in labelStore.labelsArray"
:key="label.id"
:class="{'disabled': userInfo.id !== label.createdBy.id}"
:to="{name: 'home', query: {labels: label.id.toString()}}"
:style="getLabelStyles(label)"
class="tag"
>
<span
v-if="userInfo.id !== label.createdBy.id"
v-tooltip.bottom="$t('label.edit.forbidden')"
>
{{ label.title }}
</span>
<BaseButton
v-else
:style="{'color': label.textColor}"
@click="editLabel(label)"
>
{{ label.title }}
</BaseButton>
<span>{{ label.title }}</span>
<BaseButton
v-if="userInfo.id === label.createdBy.id"
class="delete is-small"
@click="showDeleteDialoge(label)"
/>
</span>
class="label-edit-button is-small"
@click.stop.prevent="editLabel(label)"
>
<Icon
icon="pen"
class="icon"
/>
</BaseButton>
</RouterLink>
</div>
<div
v-if="isLabelEdit"
@@ -212,3 +205,21 @@ function showDeleteDialoge(label: ILabel) {
showDeleteModal.value = true
}
</script>
<style lang="scss" scoped>
.label-edit-button {
border-radius: 100%;
background-color: rgba(0,0,0,0.2);
inline-size: 1rem;
block-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff; // always white
margin-inline-start: .25rem;
.icon {
block-size: .5rem;
}
}
</style>

View File

@@ -6,6 +6,31 @@
<h3 class="mbe-2 title">
{{ pageTitle }}
</h3>
<Message
v-if="filteredLabels.length > 0"
class="label-filter-info mbe-2"
>
<i18n-t
keypath="task.show.filterByLabel"
tag="span"
class="filter-label-text"
>
<template #label>
<XLabel
v-for="label in filteredLabels"
:key="label.id"
:label="label"
/>
</template>
</i18n-t>
<BaseButton
v-tooltip="$t('task.show.clearLabelFilter')"
class="clear-filter-button"
@click="clearLabelFilter"
>
<Icon icon="times" />
</BaseButton>
</Message>
<p
v-if="!showAll"
class="show-tasks-options"
@@ -76,15 +101,20 @@ import {useI18n} from 'vue-i18n'
import {formatDate} from '@/helpers/time/formatDate'
import {setTitle} from '@/helpers/setTitle'
import BaseButton from '@/components/base/BaseButton.vue'
import Icon from '@/components/misc/Icon'
import Message from '@/components/misc/Message.vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import SingleTaskInProject from '@/components/tasks/partials/SingleTaskInProject.vue'
import DatepickerWithRange from '@/components/date/DatepickerWithRange.vue'
import XLabel from '@/components/tasks/partials/Label.vue'
import {DATE_RANGES} from '@/components/date/dateRanges'
import LlamaCool from '@/assets/llama-cool.svg?component'
import type {ITask} from '@/modelTypes/ITask'
import {useAuthStore} from '@/stores/auth'
import {useTaskStore} from '@/stores/tasks'
import {useProjectStore} from '@/stores/projects'
import {useLabelStore} from '@/stores/labels'
import type {TaskFilterParams} from '@/services/taskCollection'
import TaskCollectionService from '@/services/taskCollection'
@@ -93,20 +123,24 @@ const props = withDefaults(defineProps<{
dateTo?: Date | string,
showNulls?: boolean,
showOverdue?: boolean,
labelIds?: string[],
}>(), {
showNulls: false,
showOverdue: false,
dateFrom: undefined,
dateTo: undefined,
labelIds: undefined,
})
const emit = defineEmits<{
'tasksLoaded': true,
'clearLabelFilter': void,
}>()
const authStore = useAuthStore()
const taskStore = useTaskStore()
const projectStore = useProjectStore()
const labelStore = useLabelStore()
const route = useRoute()
const router = useRouter()
@@ -120,6 +154,15 @@ setTimeout(() => showNothingToDo.value = true, 100)
const showAll = computed(() => typeof props.dateFrom === 'undefined' || typeof props.dateTo === 'undefined')
const filteredLabels = computed(() => {
if (!props.labelIds || props.labelIds.length === 0) {
return []
}
return props.labelIds
.map(id => labelStore.getLabelById(Number(id)))
.filter(label => label !== null && label !== undefined)
})
const pageTitle = computed(() => {
// We need to define "key" because it is the first parameter in the array and we need the second
const predefinedRange = Object.entries(DATE_RANGES)
@@ -177,6 +220,10 @@ function setShowNulls(show: boolean) {
})
}
function clearLabelFilter() {
emit('clearLabelFilter')
}
async function loadPendingTasks(from: Date|string, to: Date|string) {
// FIXME: HACK! This should never happen.
// Since this route is authentication only, users would get an error message if they access the page unauthenticated.
@@ -192,6 +239,7 @@ async function loadPendingTasks(from: Date|string, to: Date|string) {
filter: 'done = false',
filter_include_nulls: props.showNulls,
s: '',
expand: 'comment_count',
}
if (!showAll.value) {
@@ -206,6 +254,12 @@ async function loadPendingTasks(from: Date|string, to: Date|string) {
}
}
// Add label filtering
if (props.labelIds && props.labelIds.length > 0) {
const labelFilter = `labels in ${props.labelIds.join(', ')}`
params.filter += params.filter ? ` && ${labelFilter}` : labelFilter
}
let projectId = null
const filterId = authStore.settings.frontendSettings.filterIdUsedOnOverview
if (showAll.value && filterId && typeof projectStore.projects[filterId] !== 'undefined') {
@@ -245,4 +299,25 @@ watchEffect(() => setTitle(pageTitle.value))
margin: 3rem auto 0;
display: block;
}
.label-filter-info {
margin-block-end: 1rem;
.clear-filter-button {
margin-inline-start: auto;
padding: 0.25rem 0.5rem;
&:hover {
color: var(--danger);
}
}
:deep(.message.info) {
inline-size: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
}
</style>

View File

@@ -404,6 +404,7 @@
<Comments
:can-write="canWrite"
:task-id="taskId"
:project-id="task.projectId"
:initial-comments="task.comments"
/>
</div>

View File

@@ -214,6 +214,25 @@
</div>
</label>
</div>
<div
v-if="settings.frontendSettings.dateDisplay !== 'relative'"
class="field"
>
<label class="two-col">
<span>
{{ $t('user.settings.general.timeFormat') }}
</span>
<div class="select">
<select v-model="settings.frontendSettings.timeFormat">
<option
v-for="(label, value) in timeFormatSettings"
:key="value"
:value="value"
>{{ label }}</option>
</select>
</div>
</label>
</div>
</div>
</Card>
@@ -366,6 +385,7 @@ import {isSavedFilter} from '@/services/savedFilter'
import {DEFAULT_PROJECT_VIEW_SETTINGS} from '@/modelTypes/IProjectView'
import {PRIORITIES} from '@/constants/priorities'
import {DATE_DISPLAY} from '@/constants/dateDisplay'
import {TIME_FORMAT} from '@/constants/timeFormat'
import {RELATION_KINDS} from '@/types/IRelationKind'
defineOptions({name: 'UserSettingsGeneral'})
@@ -389,8 +409,13 @@ const dateDisplaySettings = computed(() => ({
[DATE_DISPLAY.MM_SLASH_DD_YYYY]: t('user.settings.general.dateDisplayOptions.mm/dd/yyyy'),
[DATE_DISPLAY.DD_SLASH_MM_YYYY]: t('user.settings.general.dateDisplayOptions.dd/mm/yyyy'),
[DATE_DISPLAY.YYYY_SLASH_MM_DD]: t('user.settings.general.dateDisplayOptions.yyyy/mm/dd'),
[DATE_DISPLAY.DAY_MONTH_YEAR]: formatDisplayDateFormat(new Date(), DATE_DISPLAY.DAY_MONTH_YEAR),
[DATE_DISPLAY.WEEKDAY_DAY_MONTH_YEAR]: formatDisplayDateFormat(new Date(), DATE_DISPLAY.WEEKDAY_DAY_MONTH_YEAR),
[DATE_DISPLAY.DAY_MONTH_YEAR]: formatDisplayDateFormat(new Date(), DATE_DISPLAY.DAY_MONTH_YEAR, settings.value?.frontendSettings?.timeFormat),
[DATE_DISPLAY.WEEKDAY_DAY_MONTH_YEAR]: formatDisplayDateFormat(new Date(), DATE_DISPLAY.WEEKDAY_DAY_MONTH_YEAR, settings.value?.frontendSettings?.timeFormat),
}))
const timeFormatSettings = computed(() => ({
[TIME_FORMAT.HOURS_12]: t('user.settings.general.timeFormatOptions.12h'),
[TIME_FORMAT.HOURS_24]: t('user.settings.general.timeFormatOptions.24h'),
}))
const authStore = useAuthStore()
@@ -408,6 +433,8 @@ const settings = ref<IUserSettings>({
// Add fallback for old settings that don't have the logo change setting set
allowIconChanges: authStore.settings.frontendSettings.allowIconChanges ?? true,
dateDisplay: authStore.settings.frontendSettings.dateDisplay ?? DATE_DISPLAY.RELATIVE,
// Add fallback for old settings that don't have the time format set
timeFormat: authStore.settings.frontendSettings.timeFormat ?? TIME_FORMAT.HOURS_12,
// Add fallback for old settings that don't have the default task relation type set
defaultTaskRelationType: authStore.settings.frontendSettings.defaultTaskRelationType ?? 'related',
},

62
go.mod
View File

@@ -22,29 +22,30 @@ require (
github.com/adlio/trello v1.12.0
github.com/arran4/golang-ical v0.3.2
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
github.com/aws/aws-sdk-go v1.55.8
github.com/bbrks/go-blurhash v1.1.1
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500
github.com/coreos/go-oidc/v3 v3.16.0
github.com/cweill/gotests v1.6.0
github.com/cweill/gotests v1.9.0
github.com/d4l3k/messagediff v1.2.1
github.com/disintegration/imaging v1.6.2
github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b
github.com/gabriel-vasile/mimetype v1.4.10
github.com/fclairamb/afero-s3 v0.3.1
github.com/gabriel-vasile/mimetype v1.4.11
github.com/ganigeorgiev/fexpr v0.5.0
github.com/getsentry/sentry-go v0.36.0
github.com/getsentry/sentry-go/echo v0.36.0
github.com/getsentry/sentry-go v0.37.0
github.com/getsentry/sentry-go/echo v0.37.0
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-sql-driver/mysql v1.9.3
github.com/go-testfixtures/testfixtures/v3 v3.18.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/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/google/uuid v1.6.0
github.com/hashicorp/go-version v1.7.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.8.1
github.com/jaswdr/faker/v2 v2.9.0
github.com/jinzhu/copier v0.4.0
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6
github.com/labstack/echo-jwt/v4 v4.3.1
@@ -54,10 +55,10 @@ require (
github.com/magefile/mage v1.15.0
github.com/mattn/go-sqlite3 v1.14.32
github.com/microcosm-cc/bluemonday v1.0.27
github.com/olekukonko/tablewriter v1.1.0
github.com/olekukonko/tablewriter v1.1.1
github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.14.1
github.com/redis/go-redis/v9 v9.16.0
github.com/robfig/cron/v3 v3.0.1
github.com/samedi/caldav-go v3.0.0+incompatible
github.com/spf13/afero v1.15.0
@@ -70,19 +71,20 @@ require (
github.com/ulule/limiter/v3 v3.11.2
github.com/wneessen/go-mail v0.7.2
github.com/yuin/goldmark v1.7.13
golang.org/x/crypto v0.43.0
golang.org/x/image v0.32.0
golang.org/x/oauth2 v0.32.0
golang.org/x/sync v0.17.0
golang.org/x/sys v0.37.0
golang.org/x/term v0.36.0
golang.org/x/text v0.30.0
golang.org/x/crypto v0.44.0
golang.org/x/image v0.33.0
golang.org/x/net v0.47.0
golang.org/x/oauth2 v0.33.0
golang.org/x/sync v0.18.0
golang.org/x/sys v0.38.0
golang.org/x/term v0.37.0
golang.org/x/text v0.31.0
gopkg.in/d4l3k/messagediff.v1 v1.2.1
mvdan.cc/xurls/v2 v2.6.0
src.techknowlogick.com/xgo v1.8.1-0.20241105013731-313dedef864f
src.techknowlogick.com/xormigrate v1.7.1
xorm.io/builder v0.3.13
xorm.io/xorm v1.3.10
xorm.io/xorm v1.3.11
)
require (
@@ -94,13 +96,14 @@ require (
github.com/beevik/etree v1.1.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.0.1 // indirect
github.com/cenkalti/backoff/v3 v3.2.2 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/displaywidth v0.3.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
@@ -111,28 +114,26 @@ 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.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
github.com/huandu/go-clone v1.7.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/laurent22/ical-go v0.1.1-0.20181107184520-7e5d6ade8eef // indirect
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.16 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oapi-codegen/runtime v1.1.1 // 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.0.9 // indirect
github.com/olekukonko/ll v0.1.2 // indirect
github.com/onsi/ginkgo v1.16.4 // indirect
github.com/onsi/gomega v1.16.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
@@ -141,7 +142,6 @@ require (
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/rivo/uniseg v0.4.4 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
@@ -156,13 +156,11 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.37.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
@@ -175,4 +173,4 @@ replace github.com/labstack/echo/v4 => github.com/kolaente/echo/v4 v4.0.0-202501
go 1.24.0
toolchain go1.25.3
toolchain go1.25.4

303
go.sum
View File

@@ -17,22 +17,21 @@ github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/ThreeDotsLabs/watermill v1.4.7 h1:LiF4wMP400/psRTdHL/IcV1YIv9htHYFggbe2d6cLeI=
github.com/ThreeDotsLabs/watermill v1.4.7/go.mod h1:Ks20MyglVnqjpha1qq0kjaQ+J9ay7bdnjszQ4cW9FMU=
github.com/ThreeDotsLabs/watermill v1.5.0 h1:lWk8WSBaoQD/GFJRw10jqJvPyOedZUiXyUG7BOXImhM=
github.com/ThreeDotsLabs/watermill v1.5.0/go.mod h1:qykQ1+u+K9ElNTBKyCWyTANnpFAeP7t3F3bZFw+n1rs=
github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY=
github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4=
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-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
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/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
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/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 v1.42.9/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
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=
@@ -51,11 +50,6 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 h1:6lhrsTEnloDPXyeZBvSYvQf8u86jbKehZPVDDlkgDl4=
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M=
github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs=
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -63,9 +57,13 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/clipperhouse/displaywidth v0.3.1 h1:k07iN9gD32177o1y4O1jQMzbLdCrsGJh+blirVYybsk=
github.com/clipperhouse/displaywidth v0.3.1/go.mod h1:tgLJKKyaDOCadywag3agw4snxS5kYEuYR6Y9+qWDDYM=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
@@ -75,8 +73,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cweill/gotests v1.6.0 h1:KJx+/p4EweijYzqPb4Y/8umDCip1Cv6hEVyOx0mE9W8=
github.com/cweill/gotests v1.6.0/go.mod h1:CaRYbxQZGQOxXDvM9l0XJVV2Tjb2E5H53vq+reR2GrA=
github.com/cweill/gotests v1.9.0 h1:2B0mA22tbAZemMvOzbRzxehXecRrc6Y2j4GDsmoz23U=
github.com/cweill/gotests v1.9.0/go.mod h1:ec4OTmXWVUEIznSTBJcO5s9df8C+4NGiEaUuVJW1pL0=
github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt9U=
github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -97,49 +95,31 @@ github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b h1:+
github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/fclairamb/afero-s3 v0.3.1 h1:JLxcl42wseOjKAdXfVkz7GoeyNRrvxkZ1jBshuDSDgA=
github.com/fclairamb/afero-s3 v0.3.1/go.mod h1:VZ/bvRox6Bq3U+vTGa12uyDu+5UJb40M7tpIXlByKkc=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/getsentry/sentry-go v0.35.1 h1:iopow6UVLE2aXu46xKVIs8Z9D/YZkJrHkgozrxa+tOQ=
github.com/getsentry/sentry-go v0.35.1/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE=
github.com/getsentry/sentry-go v0.35.2 h1:jKuujpRwa8FFRYMIwwZpu83Xh0voll9bmvyc6310WBM=
github.com/getsentry/sentry-go v0.35.2/go.mod h1:mdL49ixwT2yi57k5eh7mpnDyPybixPzlzEJFu0Z76QA=
github.com/getsentry/sentry-go v0.35.3 h1:u5IJaEqZyPdWqe/hKlBKBBnMTSxB/HenCqF3QLabeds=
github.com/getsentry/sentry-go v0.35.3/go.mod h1:mdL49ixwT2yi57k5eh7mpnDyPybixPzlzEJFu0Z76QA=
github.com/getsentry/sentry-go v0.36.0 h1:UkCk0zV28PiGf+2YIONSSYiYhxwlERE5Li3JPpZqEns=
github.com/getsentry/sentry-go v0.36.0/go.mod h1:p5Im24mJBeruET8Q4bbcMfCQ+F+Iadc4L48tB1apo2c=
github.com/getsentry/sentry-go/echo v0.35.1 h1:MIhSUyo7cpCdcw0/lIeAw5fukrDt3x9G7qbiyjbVllI=
github.com/getsentry/sentry-go/echo v0.35.1/go.mod h1:IjdEzgvwlP2/7A32tWk75UmSUsBqvKFdpkN6WhB1e6M=
github.com/getsentry/sentry-go/echo v0.35.2 h1:+I+aShrW00iA4GZLIFVAkeAZymVqd8ygePn7uma1ymE=
github.com/getsentry/sentry-go/echo v0.35.2/go.mod h1:hjViliudnHK+HWCo7NWaYw5A48ipKvs6aHrFjhToo8c=
github.com/getsentry/sentry-go/echo v0.35.3 h1:aJ0e4kGuH7T1ggAd3LOYwAyQV0bq37AX36vNPr6JYnM=
github.com/getsentry/sentry-go/echo v0.35.3/go.mod h1:zQn5wNGqJUwIlA6z/pi7CFeXiUGrWkzue28C0Mfbz/Q=
github.com/getsentry/sentry-go/echo v0.36.0 h1:PimJIxiH2O/nS+jegFLxx52RMpVY2ciAIvVkk8miVeM=
github.com/getsentry/sentry-go/echo v0.36.0/go.mod h1:Z4Q44b9OWBO18lFcC1yfCqOVex00nz2WPSH1AuUUC5I=
github.com/getsentry/sentry-go v0.37.0 h1:5bavywHxVkU/9aOIF4fn3s5RTJX5Hdw6K2W6jLYtM98=
github.com/getsentry/sentry-go v0.37.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
github.com/getsentry/sentry-go/echo v0.37.0 h1:Lzpg9MVmMD9jPyuKyilyDtrH6dOU3luSLSjj+r5KfVI=
github.com/getsentry/sentry-go/echo v0.37.0/go.mod h1:wbh4ppYCgmnuoIMGu/DrQzD0NoX6vt2qfoRxMe2wkUQ=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
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-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
@@ -161,17 +141,16 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
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.17.0 h1:oaWVDAOl13JszPM1jQZ2iS6bIBhy43WY3gpTeQEq/IU=
github.com/go-testfixtures/testfixtures/v3 v3.17.0/go.mod h1:HCIVT6p9uKXaCv898IT1iS0My5TF8kF785H4n+6049U=
github.com/go-testfixtures/testfixtures/v3 v3.18.0 h1:lApgMqmnAWYQZmoeORWh/mPedCoQBT3EO8fGMGx9RNo=
github.com/go-testfixtures/testfixtures/v3 v3.18.0/go.mod h1:4Do5isvhefdy+Ks5tGDyoilciKC3HtDgUnweuF+7Hnc=
github.com/go-testfixtures/testfixtures/v3 v3.19.0 h1:/Y0bars250zggm+1A2PvwaJQsJel7/tS4D/Hhwt66Bc=
github.com/go-testfixtures/testfixtures/v3 v3.19.0/go.mod h1:4/hVAuX2As0/ej3fLuAd+IvoCXV7/h2cj5nInI11uxM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a h1:RYfmiM0zluBJOiPDJseKLEN4BapJ42uSi9SZBQ2YyiA=
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI=
github.com/goccy/go-json v0.8.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
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-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
@@ -182,8 +161,6 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@@ -203,7 +180,6 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -215,8 +191,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
@@ -274,10 +248,8 @@ github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0f
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jaswdr/faker/v2 v2.8.0 h1:3AxdXW9U7dJmWckh/P0YgRbNlCcVsTyrUNUnLVP9b3Q=
github.com/jaswdr/faker/v2 v2.8.0/go.mod h1:jZq+qzNQr8/P+5fHd9t3txe2GNPnthrTfohtnJ7B+68=
github.com/jaswdr/faker/v2 v2.8.1 h1:2AcPgHDBXYQregFUH9LgVZKfFupc4SIquYhp29sf5wQ=
github.com/jaswdr/faker/v2 v2.8.1/go.mod h1:jZq+qzNQr8/P+5fHd9t3txe2GNPnthrTfohtnJ7B+68=
github.com/jaswdr/faker/v2 v2.9.0 h1:Sqqpp+pxduDO+MGOhYE3UHtI9Sowt9j95f8h8nVvips=
github.com/jaswdr/faker/v2 v2.9.0/go.mod h1:jZq+qzNQr8/P+5fHd9t3txe2GNPnthrTfohtnJ7B+68=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -292,12 +264,14 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 h1:SwcnSwBR7X/5EHJQlXBockkJVIMRVt5yKaesBPMtyZQ=
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6/go.mod h1:WrYiIuiXUMIvTDAQw97C+9l0CnBmCcvosPjN3XDqS/o=
@@ -313,6 +287,7 @@ github.com/kolaente/echo/v4 v4.0.0-20250124112709-682dfde74c31 h1:lUUZppO9AB30mf
github.com/kolaente/echo/v4 v4.0.0-20250124112709-682dfde74c31/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -357,8 +332,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
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-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
@@ -366,13 +341,13 @@ github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxU
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
@@ -381,14 +356,14 @@ github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmt
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
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/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/olekukonko/ll v0.1.2 h1:lkg/k/9mlsy0SxO5aC+WEpbdT5K83ddnNhAepz7TQc0=
github.com/olekukonko/ll v0.1.2/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
github.com/olekukonko/tablewriter v1.1.1 h1:b3reP6GCfrHwmKkYwNRFh2rxidGHcT6cgxj/sHiDDx0=
github.com/olekukonko/tablewriter v1.1.1/go.mod h1:De/bIcTF+gpBDB3Alv3fEsZA+9unTsSzAg/ZGADCtn4=
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=
@@ -399,8 +374,6 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c=
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
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/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
@@ -409,42 +382,25 @@ github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_golang v1.23.1 h1:w6gXMLQGgd0jXXlote9lRHMe0nG01EbnJT+C0EJru2Y=
github.com/prometheus/client_golang v1.23.1/go.mod h1:br8j//v2eg2K5Vvna5klK8Ku5pcU5r4ll73v6ik5dIQ=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/common v0.66.0 h1:K/rJPHrG3+AoQs50r2+0t7zMnMzek2Vbv31OFVsMeVY=
github.com/prometheus/common v0.66.0/go.mod h1:Ux6NtV1B4LatamKE63tJBntoxD++xmtI/lK0VtEplN4=
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/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
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/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg=
github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/redis/go-redis/v9 v9.13.0 h1:PpmlVykE0ODh8P43U0HqC+2NXHXwG+GUtQyz+MPKGRg=
github.com/redis/go-redis/v9 v9.13.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/redis/go-redis/v9 v9.14.1 h1:nDCrEiJmfOWhD76xlaw+HXT0c9hfNWeXgl0vIRYSDvQ=
github.com/redis/go-redis/v9 v9.14.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -456,8 +412,6 @@ github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThC
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
@@ -469,30 +423,18 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
@@ -511,8 +453,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -535,12 +475,6 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wneessen/go-mail v0.6.2 h1:c6V7c8D2mz868z9WJ+8zDKtUyLfZ1++uAZmo2GRFji8=
github.com/wneessen/go-mail v0.6.2/go.mod h1:L/PYjPK3/2ZlNb2/FjEBIn9n1rUWjW+Toy531oVmeb4=
github.com/wneessen/go-mail v0.7.0 h1:/Wmgd5AVjp5PA+Ken5EFfr+QR83gmqHli9HcAhh0vnU=
github.com/wneessen/go-mail v0.7.0/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
github.com/wneessen/go-mail v0.7.1 h1:rvy63sp14N06/kdGqCYwW8Na5gDCXjTQM1E7So4PuKk=
github.com/wneessen/go-mail v0.7.1/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
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/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts=
@@ -562,8 +496,6 @@ go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
@@ -585,38 +517,18 @@ 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.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
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=
@@ -628,38 +540,20 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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=
@@ -687,33 +581,14 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
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=
@@ -721,17 +596,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
@@ -742,21 +608,13 @@ golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgw
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191109212701-97ad0ed33101/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
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=
@@ -771,8 +629,6 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -821,23 +677,28 @@ modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0=
modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0=
modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.20.4 h1:J8+m2trkN+KKoE7jglyHYYYiaq5xmz2HoHJIiBlRzbE=
modernc.org/sqlite v1.20.4/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/tcl v1.15.0/go.mod h1:xRoGotBZ6dU+Zo2tca+2EqVEeMmOUBzHnhIwq4YrVnE=
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
@@ -851,5 +712,5 @@ xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978/go.mod h1:aUW0S9eb9VCaPohF
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
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.10 h1:yR83hTT4mKIPyA/lvWFTzS35xjLwkiYnwdw0Qupeh0o=
xorm.io/xorm v1.3.10/go.mod h1:Lo7hmsFF0F0GbDE7ubX5ZKa+eCf0eCuiJAUG3oI5cxQ=
xorm.io/xorm v1.3.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI=
xorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=

View File

@@ -15,7 +15,6 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//go:build mage
// +build mage
package main

View File

@@ -65,6 +65,7 @@ const (
ServiceMaxAvatarSize Key = `service.maxavatarsize`
ServiceAllowIconChanges Key = `service.allowiconchanges`
ServiceCustomLogoURL Key = `service.customlogourl`
ServiceCustomLogoURLDark Key = `service.customlogourldark`
ServiceEnablePublicTeams Key = `service.enablepublicteams`
ServiceBcryptRounds Key = `service.bcryptrounds`
ServiceEnableOpenIDTeamUserOnlySearch Key = `service.enableopenidteamusersearch`
@@ -158,6 +159,15 @@ const (
FilesBasePath Key = `files.basepath`
FilesMaxSize Key = `files.maxsize`
FilesType Key = `files.type`
// S3 Configuration
FilesS3Endpoint Key = `files.s3.endpoint`
FilesS3Bucket Key = `files.s3.bucket`
FilesS3Region Key = `files.s3.region`
FilesS3AccessKey Key = `files.s3.accesskey`
FilesS3SecretKey Key = `files.s3.secretkey`
FilesS3UsePathStyle Key = `files.s3.usepathstyle`
MigrationTodoistEnable Key = `migration.todoist.enable`
MigrationTodoistClientID Key = `migration.todoist.clientid`
@@ -426,6 +436,14 @@ func InitDefaultConfig() {
// Files
FilesBasePath.setDefault("files")
FilesMaxSize.setDefault("20MB")
FilesType.setDefault("local")
// S3 Configuration
FilesS3Endpoint.setDefault("")
FilesS3Bucket.setDefault("")
FilesS3Region.setDefault("")
FilesS3AccessKey.setDefault("")
FilesS3SecretKey.setDefault("")
FilesS3UsePathStyle.setDefault(false)
// Cors
CorsEnable.setDefault(true)
CorsOrigins.setDefault([]string{"http://127.0.0.1:*", "http://localhost:*"})

View File

@@ -226,7 +226,7 @@
updated: 2018-12-01 01:12:04
start_date: 2018-11-30 22:25:24
- id: 28
title: 'task #28 with repeat after'
title: 'task #28 with repeat after, start_date, end_date and due_date'
done: false
created_by_id: 1
repeat_after: 3600
@@ -234,6 +234,9 @@
index: 13
created: 2018-12-01 01:12:04
updated: 2018-12-01 01:12:04
due_date: 2018-12-02 22:25:24
start_date: 2018-11-30 22:25:24
end_date: 2018-12-13 11:20:00
- id: 29
title: 'task #29 with parent task (1)'
done: false

View File

@@ -61,3 +61,20 @@ func TestListener(t *testing.T, event Event, listener Listener) {
err = listener.Handle(msg)
require.NoError(t, err)
}
// ClearDispatchedEvents clears the list of dispatched test events. This is useful when you want to
// test event dispatch counts in a specific section of code without events from previous test operations.
func ClearDispatchedEvents() {
dispatchedTestEvents = nil
}
// CountDispatchedEvents counts how many events of a specific type have been dispatched.
func CountDispatchedEvents(eventName string) int {
count := 0
for _, testEvent := range dispatchedTestEvents {
if testEvent.Name() == eventName {
count++
}
}
return count
}

View File

@@ -17,6 +17,8 @@
package files
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -27,6 +29,10 @@ import (
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/modules/keyvalue"
"github.com/aws/aws-sdk-go/aws" //nolint:staticcheck // afero-s3 still requires aws-sdk-go v1
"github.com/aws/aws-sdk-go/aws/credentials" //nolint:staticcheck // afero-s3 still requires aws-sdk-go v1
"github.com/aws/aws-sdk-go/aws/session" //nolint:staticcheck // afero-s3 still requires aws-sdk-go v1
s3 "github.com/fclairamb/afero-s3"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
)
@@ -35,7 +41,7 @@ import (
var fs afero.Fs
var afs *afero.Afero
func setDefaultConfig() {
func setDefaultLocalConfig() {
if !strings.HasPrefix(config.FilesBasePath.GetString(), "/") {
config.FilesBasePath.Set(filepath.Join(
config.ServiceRootpath.GetString(),
@@ -44,18 +50,73 @@ func setDefaultConfig() {
}
}
// InitFileHandler creates a new file handler for the file backend we want to use
func InitFileHandler() {
// initS3FileHandler initializes the S3 file backend
func initS3FileHandler() error {
// Get S3 configuration
endpoint := config.FilesS3Endpoint.GetString()
bucket := config.FilesS3Bucket.GetString()
region := config.FilesS3Region.GetString()
accessKey := config.FilesS3AccessKey.GetString()
secretKey := config.FilesS3SecretKey.GetString()
if endpoint == "" {
return errors.New("S3 endpoint is not configured. Please set files.s3.endpoint")
}
if bucket == "" {
return errors.New("S3 bucket is not configured. Please set files.s3.bucket")
}
if accessKey == "" {
return errors.New("S3 access key is not configured. Please set files.s3.accesskey")
}
if secretKey == "" {
return errors.New("S3 secret key is not configured. Please set files.s3.secretkey")
}
// Create AWS session for afero-s3
sess, err := session.NewSession(&aws.Config{
Region: aws.String(region),
Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""),
Endpoint: aws.String(endpoint),
S3ForcePathStyle: aws.Bool(config.FilesS3UsePathStyle.GetBool()),
})
if err != nil {
return fmt.Errorf("failed to create AWS session: %w", err)
}
// Initialize S3 filesystem using afero-s3
fs = s3.NewFs(bucket, sess)
afs = &afero.Afero{Fs: fs}
return nil
}
// initLocalFileHandler initializes the local filesystem backend
func initLocalFileHandler() {
fs = afero.NewOsFs()
afs = &afero.Afero{Fs: fs}
setDefaultConfig()
setDefaultLocalConfig()
}
// InitFileHandler creates a new file handler for the file backend we want to use
func InitFileHandler() error {
fileType := config.FilesType.GetString()
switch fileType {
case "s3":
return initS3FileHandler()
case "local":
initLocalFileHandler()
return nil
default:
return fmt.Errorf("invalid file storage type '%s': must be 'local' or 's3'", fileType)
}
}
// InitTestFileHandler initializes a new memory file system for testing
func InitTestFileHandler() {
fs = afero.NewMemMapFs()
afs = &afero.Afero{Fs: fs}
setDefaultConfig()
setDefaultLocalConfig()
}
func initFixtures(t *testing.T) {

View File

@@ -0,0 +1,297 @@
// 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 files
import (
"bytes"
"io"
"os"
"testing"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestFileStorageIntegration tests end-to-end file storage and retrieval
// with S3/MinIO storage backend. This test specifically validates S3 functionality
// and will fail if S3 is not properly configured.
func TestFileStorageIntegration(t *testing.T) {
// Ensure S3 is configured for this test
if config.FilesType.GetString() != "s3" {
t.Skip("Skipping S3 integration tests - VIKUNJA_FILES_TYPE must be set to 's3'")
}
// Validate S3 configuration is present
if config.FilesS3Endpoint.GetString() == "" {
t.Fatal("S3 integration test requires VIKUNJA_FILES_S3_ENDPOINT to be set")
}
t.Run("Initialize file handler with s3", func(t *testing.T) {
err := InitFileHandler()
require.NoError(t, err, "Failed to initialize file handler with type: s3")
assert.NotNil(t, afs, "File system should be initialized")
})
t.Run("Create and retrieve file with s3", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
// Test data
testContent := []byte("This is a test file for storage integration testing with s3")
testFileName := "integration-test-file.txt"
testAuth := &testauth{id: 1}
// Create file
fileReader := bytes.NewReader(testContent)
createdFile, err := Create(fileReader, testFileName, uint64(len(testContent)), testAuth)
require.NoError(t, err, "Failed to create file")
require.NotNil(t, createdFile, "Created file should not be nil")
assert.Positive(t, createdFile.ID, "File ID should be assigned")
assert.Equal(t, testFileName, createdFile.Name, "File name should match")
assert.Equal(t, uint64(len(testContent)), createdFile.Size, "File size should match")
assert.Equal(t, int64(1), createdFile.CreatedByID, "Creator ID should match")
// Load file metadata from database
loadedFile := &File{ID: createdFile.ID}
err = loadedFile.LoadFileMetaByID()
require.NoError(t, err, "Failed to load file metadata")
assert.Equal(t, testFileName, loadedFile.Name, "Loaded file name should match")
assert.Equal(t, uint64(len(testContent)), loadedFile.Size, "Loaded file size should match")
// Load and verify file content
err = loadedFile.LoadFileByID()
require.NoError(t, err, "Failed to load file content")
require.NotNil(t, loadedFile.File, "File handle should not be nil")
retrievedContent, err := io.ReadAll(loadedFile.File)
require.NoError(t, err, "Failed to read file content")
assert.Equal(t, testContent, retrievedContent, "Retrieved content should match original")
_ = loadedFile.File.Close()
// Verify file exists in storage
fileInfo, err := FileStat(loadedFile)
require.NoError(t, err, "File should exist in storage")
assert.NotNil(t, fileInfo, "File info should not be nil")
// Delete file
s := db.NewSession()
defer s.Close()
err = loadedFile.Delete(s)
require.NoError(t, err, "Failed to delete file")
// Verify file is deleted from storage
_, err = FileStat(loadedFile)
require.Error(t, err, "File should not exist after deletion")
assert.True(t, os.IsNotExist(err), "Error should indicate file does not exist")
})
t.Run("Create multiple files with s3", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
testAuth := &testauth{id: 1}
fileIDs := make([]int64, 0, 3)
// Create multiple files
for i := 1; i <= 3; i++ {
content := []byte("Test file content number " + string(rune('0'+i)))
fileName := "test-file-" + string(rune('0'+i)) + ".txt"
file, err := Create(bytes.NewReader(content), fileName, uint64(len(content)), testAuth)
require.NoError(t, err, "Failed to create file %d", i)
fileIDs = append(fileIDs, file.ID)
}
// Verify all files exist and can be retrieved
for i, fileID := range fileIDs {
file := &File{ID: fileID}
err := file.LoadFileByID()
require.NoError(t, err, "Failed to load file %d", i+1)
content, err := io.ReadAll(file.File)
require.NoError(t, err, "Failed to read file %d", i+1)
expectedContent := "Test file content number " + string(rune('0'+i+1))
assert.Equal(t, []byte(expectedContent), content, "Content should match for file %d", i+1)
_ = file.File.Close()
}
// Clean up: delete all files
s := db.NewSession()
defer s.Close()
for _, fileID := range fileIDs {
file := &File{ID: fileID}
err := file.Delete(s)
require.NoError(t, err, "Failed to delete file")
}
})
t.Run("Handle large file with s3", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
testAuth := &testauth{id: 1}
// Create a 1MB file
largeContent := bytes.Repeat([]byte("X"), 1024*1024)
fileName := "large-test-file.bin"
file, err := Create(bytes.NewReader(largeContent), fileName, uint64(len(largeContent)), testAuth)
require.NoError(t, err, "Failed to create large file")
assert.Equal(t, uint64(len(largeContent)), file.Size, "File size should match")
// Retrieve and verify
loadedFile := &File{ID: file.ID}
err = loadedFile.LoadFileByID()
require.NoError(t, err, "Failed to load large file")
retrievedContent, err := io.ReadAll(loadedFile.File)
require.NoError(t, err, "Failed to read large file")
assert.Len(t, retrievedContent, len(largeContent), "Retrieved file size should match")
assert.Equal(t, largeContent, retrievedContent, "Large file content should match")
_ = loadedFile.File.Close()
// Clean up
s := db.NewSession()
defer s.Close()
err = loadedFile.Delete(s)
require.NoError(t, err, "Failed to delete large file")
})
t.Run("File not found with s3", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
// Try to load a file that doesn't exist
nonExistentFile := &File{ID: 999999}
err := nonExistentFile.LoadFileByID()
require.Error(t, err, "Loading non-existent file should error")
assert.True(t, os.IsNotExist(err), "Error should indicate file does not exist")
// Try to load metadata for non-existent file
err = nonExistentFile.LoadFileMetaByID()
require.Error(t, err, "Loading metadata for non-existent file should error")
assert.True(t, IsErrFileDoesNotExist(err), "Error should be ErrFileDoesNotExist")
})
}
// TestInitFileHandler_S3Configuration tests S3 configuration validation
func TestInitFileHandler_S3Configuration(t *testing.T) {
// Save original config values
originalType := config.FilesType.GetString()
originalEndpoint := config.FilesS3Endpoint.GetString()
originalBucket := config.FilesS3Bucket.GetString()
originalRegion := config.FilesS3Region.GetString()
originalAccessKey := config.FilesS3AccessKey.GetString()
originalSecretKey := config.FilesS3SecretKey.GetString()
// Restore config after test
defer func() {
config.FilesType.Set(originalType)
config.FilesS3Endpoint.Set(originalEndpoint)
config.FilesS3Bucket.Set(originalBucket)
config.FilesS3Region.Set(originalRegion)
config.FilesS3AccessKey.Set(originalAccessKey)
config.FilesS3SecretKey.Set(originalSecretKey)
_ = InitFileHandler()
}()
t.Run("valid S3 configuration", func(t *testing.T) {
config.FilesType.Set("s3")
config.FilesS3Endpoint.Set("https://s3.amazonaws.com")
config.FilesS3Bucket.Set("test-bucket")
config.FilesS3Region.Set("us-east-1")
config.FilesS3AccessKey.Set("test-access-key")
config.FilesS3SecretKey.Set("test-secret-key")
// This should not return an error with valid configuration
err := InitFileHandler()
assert.NoError(t, err)
})
t.Run("missing S3 endpoint", func(t *testing.T) {
config.FilesType.Set("s3")
config.FilesS3Endpoint.Set("")
config.FilesS3Bucket.Set("test-bucket")
config.FilesS3AccessKey.Set("test-access-key")
config.FilesS3SecretKey.Set("test-secret-key")
// This should return an error for missing endpoint
err := InitFileHandler()
require.Error(t, err)
assert.Contains(t, err.Error(), "endpoint")
})
t.Run("missing S3 bucket", func(t *testing.T) {
config.FilesType.Set("s3")
config.FilesS3Endpoint.Set("https://s3.amazonaws.com")
config.FilesS3Bucket.Set("")
config.FilesS3AccessKey.Set("test-access-key")
config.FilesS3SecretKey.Set("test-secret-key")
// This should return an error for missing bucket
err := InitFileHandler()
require.Error(t, err)
assert.Contains(t, err.Error(), "bucket")
})
t.Run("missing S3 access key", func(t *testing.T) {
config.FilesType.Set("s3")
config.FilesS3Endpoint.Set("https://s3.amazonaws.com")
config.FilesS3Bucket.Set("test-bucket")
config.FilesS3AccessKey.Set("")
config.FilesS3SecretKey.Set("test-secret-key")
// This should return an error for missing access key
err := InitFileHandler()
require.Error(t, err)
assert.Contains(t, err.Error(), "access key")
})
t.Run("missing S3 secret key", func(t *testing.T) {
config.FilesType.Set("s3")
config.FilesS3Endpoint.Set("https://s3.amazonaws.com")
config.FilesS3Bucket.Set("test-bucket")
config.FilesS3AccessKey.Set("test-access-key")
config.FilesS3SecretKey.Set("")
// This should return an error for missing secret key
err := InitFileHandler()
require.Error(t, err)
assert.Contains(t, err.Error(), "secret key")
})
}
func TestInitFileHandler_LocalFilesystem(t *testing.T) {
// Save original config values
originalType := config.FilesType.GetString()
// Restore config after test
defer func() {
config.FilesType.Set(originalType)
}()
// Test with local filesystem
config.FilesType.Set("local")
// This should not return an error
err := InitFileHandler()
require.NoError(t, err)
// Verify that afs is initialized
assert.NotNil(t, afs)
}

View File

@@ -1 +1,157 @@
{}
{
"notifications": {
"email_confirm": {
"subject": "%[1]s, 请在Vikunja确认你的邮箱地址",
"welcome": "欢迎使用 Vikunja",
"confirm": "要确认您的电子邮件地址,请点击下面的链接:"
},
"password": {
"changed": {
"subject": "您在 Vikunja上的密码已更改",
"success": "您的帐户密码修改成功。",
"warning": "如果不是你,这可能意味着有人破坏你的帐户。在这种情况下,请联系你的服务器管理员。"
},
"reset": {
"subject": "重置您在 Vikunja 上的密码",
"instructions": "如要重置您的密码,请点击下面的链接:",
"valid_duration": "此链接将有效期为24小时。"
}
},
"totp": {
"invalid": {
"subject": "有人刚刚尝试登录您的Vikunja帐户但失败了",
"message": "有人刚刚试图使用正确的用户名和密码登录到您的帐户但是TOTP 密码是错误的。",
"warning": "**如果不是你,意味着其他人知道你的密码。你应该立即设置一个新的密码!**"
},
"account_locked": {
"subject": "我们已在 Vikunja 禁用您的帐户",
"message": "有人试图使用您的凭据登录但未能提供有效的TOTP密码。",
"disabled": "在10次尝试失败后我们已禁用您的账户并重置您的密码。 若要设置一个新密码,请遵循我们刚刚发送给您的重置邮件中的说明。",
"reset_instructions": "如果您没有收到重置指令的电子邮件,您可以在 [%[1]s](%[2]s)请求新的重置指令。"
}
},
"login": {
"failed": {
"subject": "有人刚试图登录您的Vikunja帐户但未能提供正确的密码",
"message": "有人刚刚尝试使用错误密码登录到您的帐户,连续三次登录。",
"warning": "如果不是您,这可能是其他人试图破坏您的帐户。",
"enhance_security": "为了增强您帐户的安全您可能想要在设置中设置一个更强的密码或启用TOTP身份验证"
}
},
"account": {
"deletion": {
"confirm": {
"subject": "请确认删除您的 Vikunja 账户",
"request": "您已请求删除您的帐户。要确认这一点,请点击下面的链接:",
"valid_duration": "此链接的有效期为24小时。",
"schedule_info": "一旦您确认删除,我们将在三天内删除您的帐户,然后那时再发送一封电子邮件。",
"consequences": "如果您继续删除您的账户,我们将删除您创建的所有项目和任务。 您与其他用户或团队共享的一切都会将所有权转让给他们。",
"changed_mind": "如果你没有请求删除或改变主意,你可以忽略这封电子邮件。"
},
"scheduled": {
"subject_days": "您的 Vikunja 帐户将在 %[1]s 天内删除",
"subject_tomorrow": "您的Vikunja账户明天将被删除",
"request_reminder": "您最近请求删除您的Vikunja帐户。",
"deletion_time_days": "我们将在 %[1]s 天内删除您的帐户。",
"deletion_time_tomorrow": "我们将在明天删除您的帐户。",
"changed_mind": "如果你改变了主意,只需点击下面的链接取消删除并按照下面的说明操作:"
},
"completed": {
"subject": "您的 Vikunja 帐户已被删除",
"confirmation": "按照要求我们已删除您的Vikunja帐户。",
"permanent": "这个删除是永久性的。如果没有创建备份并且现在需要你的数据,请与你的管理员联系。"
}
}
},
"task": {
"reminder": {
"subject": "提醒 \"%[1]s\" (%[2]s)",
"message": "这是一个友好的提醒任务\"%[1]s\" (%[2]s)。"
},
"comment": {
"subject": "回复:%[1]s",
"mentioned_subject": "%[1]s在\"%[2]s\"的评论中提到了你",
"mentioned_message": "**%[1]s**在评论中提到了您:"
},
"assigned": {
"subject_to_assignee": "您已被分配到\"%[1]s\" (%[2]s)",
"message_to_assignee": "%[1]s已将您分配给\"%[2]s\"。",
"subject_to_others": "\"%[1]s\" (%[2]s)已分配给%[3]s",
"message_to_others": "%[1]s已将此任务分配给%[2]s。"
},
"deleted": {
"subject": "\"%[1]s\" (%[2]s) 已经删除",
"message": "%[1]s已删除任务\"%[2]s\" (%[3]s)"
},
"mentioned": {
"subject_new": "%[1]s在一个新任务\"%[2]s\"中提到了你",
"subject": "%[1]s在任务\"%[2]s\"中提到了你",
"message": "**%[1]s** 在任务中提到了您:"
},
"overdue": {
"subject": "任务 \"%[1]s\" (%[2]s) 已经逾期",
"message": "这是一个友情提示任务 \"%[1]s\" (%[2]s) ,这个任务已经逾期了 %[3]s 但尚未完成。",
"multiple_subject": "您逾期的任务",
"multiple_message": "您有以下逾期任务:",
"overdue_since": "从 %[1]s",
"overdue_now": "现在",
"overdue": "逾期 %[1]s"
}
},
"project": {
"created": "%[1]s 创建了项目 \"%[2]s\""
},
"team": {
"member_added": {
"subject": "%[1]s 将您添加到Vikunja的\"%[2]s\"团队",
"message": "%[1]s 刚刚将您添加到Vikunja的\"%[2]s\"团队。"
}
},
"data_export": {
"ready": {
"subject": "您的 Vikunja 数据导出已准备好",
"message": "您的 Vikunja 数据导出已准备好下载。点击下面的按钮下载它:",
"availability": "能够在7天内下载。"
}
},
"migration": {
"done": {
"subject": "从 %[1]s 到Vikunja 的迁移已完成",
"imported": "Vikunja从%[1]s中导入了所有列表/项目、任务、笔记、提醒和文件。",
"have_fun": "开始使用您的新项目(旧项目) "
},
"failed": {
"subject": "从 %[1]s 迁移到Vikunja 失败",
"message": "这次从 %[1]s 的移动似乎没有按计划进行。",
"retry": "不用担心!只要开始使用你以前的同样方式就让它再开一枪。 有时候,这些问题会因为在 %[1]s 的末尾有玻璃杯而出现,但又常常试图玩弄花招。",
"error": "我们在这段路上遇到了一些错误:`%[2]s`。",
"working_on_it": "我们在雷达上有错误消息,并且正在它上马上排序。"
}
},
"common": {
"have_nice_day": "祝你有愉快的一天!",
"copy_url": "如果上面的按钮无法工作请复制下面的URL并将其粘贴到您的浏览器地址栏",
"actions": {
"open_task": "打开任务",
"open_vikunja": "打开 Vikunja",
"open_project": "打开项目",
"open_team": "打开团队",
"download": "下载",
"reset_password": "重置密码",
"go_to_settings": "前往设置",
"confirm_email": "确认您的电子邮件地址",
"abort_deletion": "取消删除",
"confirm_account_deletion": "确认删除我的帐户",
"change_notification_settings_link": "您可以修改通知设置在 [这里](%[1]s)。"
}
}
},
"time": {
"since_years": "一年|%[1]d年",
"since_weeks": "一周|%[1]d 周",
"since_days": "一天|%[1]d 天",
"since_hours": "一小时|%[1]d 小时",
"since_minutes": "一分钟|%[1]d 分钟",
"list_last_separator": "和"
}
}

View File

@@ -80,7 +80,10 @@ func FullInitWithoutAsync() {
LightInit()
// Initialize the files handler
files.InitFileHandler()
err := files.InitFileHandler()
if err != nil {
log.Fatalf("Could not init file handler: %s", err)
}
// Run the migrations
migration.Migrate(nil)
@@ -98,7 +101,7 @@ func FullInitWithoutAsync() {
ldap.InitializeLDAPConnection()
// Check all OpenID Connect providers at startup
_, err := openid.GetAllProviders()
_, err = openid.GetAllProviders()
if err != nil {
log.Errorf("Error initializing OpenID Connect providers: %s", err)
}

View File

@@ -40,6 +40,7 @@ type Opts struct {
Headers []*header
Embeds map[string]io.Reader
EmbedFS map[string]*embed.FS
ThreadID string
}
// ContentType represents mail content types
@@ -88,6 +89,11 @@ func getMessage(opts *Opts) *mail.Msg {
m.SetGenHeader(h.Field, h.Content)
}
if opts.ThreadID != "" {
m.SetGenHeader(mail.HeaderInReplyTo, opts.ThreadID)
m.SetGenHeader(mail.HeaderReferences, opts.ThreadID)
}
for name, content := range opts.Embeds {
err := m.EmbedReader(name, content)
if err != nil {

View File

@@ -0,0 +1,36 @@
// 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 migration
import (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20251108154913",
Description: "Add index on task_comments.task_id for better query performance",
Migrate: func(tx *xorm.Engine) error {
_, err := tx.Exec("CREATE INDEX IF NOT EXISTS IDX_task_comments_task_id ON task_comments (task_id)")
return err
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@@ -88,7 +88,8 @@ func TestBulkTask_Update(t *testing.T) {
err = bt.Update(s, u)
require.Error(t, err)
assert.IsType(t, ErrInvalidTaskColumn{}, err)
var expectedErr ErrInvalidTaskColumn
assert.ErrorAs(t, err, &expectedErr)
})
t.Run("update done_at when bulk marking tasks done", func(t *testing.T) {
@@ -132,6 +133,7 @@ func TestBulkTask_Update(t *testing.T) {
err := bt.Update(s, u)
require.Error(t, err)
assert.IsType(t, ErrInvalidTaskColumn{}, err)
var expectedErr ErrInvalidTaskColumn
assert.ErrorAs(t, err, &expectedErr)
})
}

View File

@@ -83,23 +83,8 @@ func (b *TaskBucket) upsert(s *xorm.Session) (err error) {
return
}
// Update is the handler to update a task bucket
// @Summary Update a task bucket
// @Description Updates a task in a bucket
// @tags task
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param project path int true "Project ID"
// @Param view path int true "Project View ID"
// @Param bucket path int true "Bucket ID"
// @Param taskBucket body models.TaskBucket true "The id of the task you want to move into the bucket."
// @Success 200 {object} models.TaskBucket "The updated task bucket."
// @Failure 400 {object} web.HTTPError "Invalid task bucket object provided."
// @Failure 500 {object} models.Message "Internal error"
// @Router /projects/{project}/views/{view}/buckets/{bucket}/tasks [post]
func (b *TaskBucket) Update(s *xorm.Session, a web.Auth) (err error) {
// updateTaskBucket is internally used to actually do the update.
func updateTaskBucket(s *xorm.Session, a web.Auth, b *TaskBucket) (err error) {
oldTaskBucket := &TaskBucket{}
_, err = s.
Where("task_id = ? AND project_view_id = ?", b.TaskID, b.ProjectViewID).
@@ -157,9 +142,9 @@ func (b *TaskBucket) Update(s *xorm.Session, a web.Auth) (err error) {
doneChanged = true
task.Done = true
if task.isRepeating() {
oldTask := task
oldTask := *task
oldTask.Done = false
updateDone(oldTask, task)
updateDone(&oldTask, task)
updateBucket = false
b.BucketID = oldTaskBucket.BucketID
}
@@ -192,7 +177,7 @@ func (b *TaskBucket) Update(s *xorm.Session, a web.Auth) (err error) {
err = task.updateReminders(s, task)
if err != nil {
return err
return
}
// Since the done state of the task was changed, we need to move the task into all done buckets everywhere
@@ -230,9 +215,33 @@ func (b *TaskBucket) Update(s *xorm.Session, a web.Auth) (err error) {
b.Task = task
b.Bucket = bucket
return
}
// Update is the handler to update a task bucket
// @Summary Update a task bucket
// @Description Updates a task in a bucket
// @tags task
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param project path int true "Project ID"
// @Param view path int true "Project View ID"
// @Param bucket path int true "Bucket ID"
// @Param taskBucket body models.TaskBucket true "The id of the task you want to move into the bucket."
// @Success 200 {object} models.TaskBucket "The updated task bucket."
// @Failure 400 {object} web.HTTPError "Invalid task bucket object provided."
// @Failure 500 {object} models.Message "Internal error"
// @Router /projects/{project}/views/{view}/buckets/{bucket}/tasks [post]
func (b *TaskBucket) Update(s *xorm.Session, a web.Auth) (err error) {
err = updateTaskBucket(s, a, b)
if err != nil {
return err
}
doer, _ := user.GetFromAuth(a)
return events.Dispatch(&TaskUpdatedEvent{
Task: task,
Task: b.Task,
Doer: doer,
})
}

View File

@@ -141,7 +141,21 @@ func TestTaskBucket_Update(t *testing.T) {
ProjectViewID: 4,
ProjectID: 1, // In actual web requests set via the url
}
err := tb.Update(s, u)
// Before running the TaskBucket Update we retrieve the task and execute
// an updateDone to obtain the task with updated start/end/due dates
// This way we can later match them with what happens after running TaskBucket Update
u := &user.User{ID: 1}
oldTask := &Task{ID: tb.TaskID}
err := oldTask.ReadOne(s, u)
require.NoError(t, err)
updatedTask := &Task{ID: tb.TaskID}
err = updatedTask.ReadOne(s, u)
require.NoError(t, err)
updatedTask.Done = true
updateDone(oldTask, updatedTask) // updatedTask now contains the updated dates
err = tb.Update(s, u)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
@@ -156,6 +170,10 @@ func TestTaskBucket_Update(t *testing.T) {
"task_id": 1,
"bucket_id": 1,
}, false)
assert.Equal(t, updatedTask.DueDate.Unix(), tb.Task.DueDate.Unix())
assert.Equal(t, updatedTask.StartDate.Unix(), tb.Task.StartDate.Unix())
assert.Equal(t, updatedTask.EndDate.Unix(), tb.Task.EndDate.Unix())
})
t.Run("keep done timestamp when moving task between projects", func(t *testing.T) {

View File

@@ -1019,7 +1019,10 @@ func (wl *WebhookListener) Handle(msg *message.Message) (err error) {
for _, webhook := range matchingWebhooks {
if _, has := event["project"]; !has {
project := &Project{ID: webhook.ProjectID}
project, err := GetProjectSimpleByID(s, webhook.CreatedByID)
if err != nil && !IsErrProjectDoesNotExist(err) {
log.Errorf("Could not load project for webhook %d: %s", webhook.ID, err)
}
err = project.ReadOne(s, &user.User{ID: doerID})
if err != nil && !IsErrProjectDoesNotExist(err) {
log.Errorf("Could not load project for webhook %d: %s", webhook.ID, err)

View File

@@ -17,25 +17,68 @@
package models
import (
"regexp"
"strconv"
"strings"
"code.vikunja.io/api/pkg/user"
"golang.org/x/net/html"
"xorm.io/xorm"
)
func FindMentionedUsersInText(s *xorm.Session, text string) (users map[int64]*user.User, err error) {
reg := regexp.MustCompile(`@\w+`)
matches := reg.FindAllString(text, -1)
if matches == nil {
userIDs := extractMentionedUserIDs(text)
if len(userIDs) == 0 {
return
}
usernames := []string{}
for _, match := range matches {
usernames = append(usernames, strings.TrimPrefix(match, "@"))
return user.GetUsersByIDs(s, userIDs)
}
// extractMentionedUserIDs parses HTML content and extracts user IDs from mention spans.
// It looks for <span class="mention" data-id="123"> elements and returns the user IDs.
func extractMentionedUserIDs(htmlText string) []int64 {
doc, err := html.Parse(strings.NewReader(htmlText))
if err != nil {
return nil
}
return user.GetUsersByUsername(s, usernames, true)
var userIDs []int64
seen := make(map[int64]bool) // Deduplicate user IDs
var traverse func(*html.Node)
traverse = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "span" {
isMention := false
var dataID string
// Check if this span has class="mention" and extract data-id
for _, attr := range n.Attr {
if attr.Key == "class" && strings.Contains(attr.Val, "mention") {
isMention = true
}
if attr.Key == "data-id" {
dataID = attr.Val
}
}
// If this is a mention span with a valid data-id, extract the user ID
if isMention && dataID != "" {
if userID, err := strconv.ParseInt(dataID, 10, 64); err == nil {
if !seen[userID] {
userIDs = append(userIDs, userID)
seen[userID] = true
}
}
}
}
// Traverse child nodes
for child := n.FirstChild; child != nil; child = child.NextSibling {
traverse(child)
}
}
traverse(doc)
return userIDs
}

View File

@@ -43,31 +43,31 @@ func TestFindMentionedUsersInText(t *testing.T) {
}{
{
name: "no users mentioned",
text: "Lorem Ipsum dolor sit amet",
text: "<p>Lorem Ipsum dolor sit amet</p>",
},
{
name: "one user at the beginning",
text: "@user1 Lorem Ipsum",
text: `<p><span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> Lorem Ipsum</p>`,
wantUsers: []*user.User{user1},
},
{
name: "one user at the end",
text: "Lorem Ipsum @user1",
text: `<p>Lorem Ipsum <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span></p>`,
wantUsers: []*user.User{user1},
},
{
name: "one user in the middle",
text: "Lorem @user1 Ipsum",
text: `<p>Lorem <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> Ipsum</p>`,
wantUsers: []*user.User{user1},
},
{
name: "same user multiple times",
text: "Lorem @user1 Ipsum @user1 @user1",
text: `<p>Lorem <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> Ipsum <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span></p>`,
wantUsers: []*user.User{user1},
},
{
name: "Multiple users",
text: "Lorem @user1 Ipsum @user2",
text: `<p>Lorem <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> Ipsum <span class="mention" data-type="mention" data-id="2" data-label="user2">@user2</span></p>`,
wantUsers: []*user.User{user1, user2},
},
}
@@ -103,7 +103,7 @@ func TestSendingMentionNotification(t *testing.T) {
task, err := GetTaskByIDSimple(s, 32)
require.NoError(t, err)
tc := &TaskComment{
Comment: "Lorem Ipsum @user1 @user2 @user3 @user4 @user5 @user6",
Comment: `<p>Lorem Ipsum <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> <span class="mention" data-type="mention" data-id="2" data-label="user2">@user2</span> <span class="mention" data-type="mention" data-id="3" data-label="user3">@user3</span> <span class="mention" data-type="mention" data-id="4" data-label="user4">@user4</span> <span class="mention" data-type="mention" data-id="5" data-label="user5">@user5</span> <span class="mention" data-type="mention" data-id="6" data-label="user6">@user6</span></p>`,
TaskID: 32, // user2 has access to the project that task belongs to
}
err = tc.Create(s, u)
@@ -156,7 +156,7 @@ func TestSendingMentionNotification(t *testing.T) {
task, err := GetTaskByIDSimple(s, 32)
require.NoError(t, err)
tc := &TaskComment{
Comment: "Lorem Ipsum @user2",
Comment: `<p>Lorem Ipsum <span class="mention" data-type="mention" data-id="2" data-label="user2">@user2</span></p>`,
TaskID: 32, // user2 has access to the project that task belongs to
}
err = tc.Create(s, u)
@@ -170,7 +170,7 @@ func TestSendingMentionNotification(t *testing.T) {
_, err = notifyMentionedUsers(s, &task, tc.Comment, n)
require.NoError(t, err)
_, err = notifyMentionedUsers(s, &task, "Lorem Ipsum @user2 @user3", n)
_, err = notifyMentionedUsers(s, &task, `<p>Lorem Ipsum <span class="mention" data-type="mention" data-id="2" data-label="user2">@user2</span> <span class="mention" data-type="mention" data-id="3" data-label="user3">@user3</span></p>`, n)
require.NoError(t, err)
// The second time mentioning the user in the same task should not create another notification

View File

@@ -17,6 +17,8 @@
package models
import (
"fmt"
"net/url"
"sort"
"strconv"
"time"
@@ -28,6 +30,18 @@ import (
"code.vikunja.io/api/pkg/utils"
)
// getThreadID generates a Message-ID format thread ID for a task
func getThreadID(taskID int64) string {
domain := "vikunja"
publicURL := config.ServicePublicURL.GetString()
if publicURL != "" {
if parsedURL, err := url.Parse(publicURL); err == nil && parsedURL.Hostname() != "" {
domain = parsedURL.Hostname()
}
}
return fmt.Sprintf("<task-%d@%s>", taskID, domain)
}
// ReminderDueNotification represents a ReminderDueNotification notification
type ReminderDueNotification struct {
User *user.User `json:"user,omitempty"`
@@ -60,6 +74,11 @@ func (n *ReminderDueNotification) Name() string {
return "task.reminder"
}
// ThreadID returns the thread ID for email threading
func (n *ReminderDueNotification) ThreadID() string {
return getThreadID(n.Task.ID)
}
// TaskCommentNotification represents a TaskCommentNotification notification
type TaskCommentNotification struct {
Doer *user.User `json:"doer"`
@@ -101,6 +120,11 @@ func (n *TaskCommentNotification) Name() string {
return "task.comment"
}
// ThreadID returns the thread ID for email threading
func (n *TaskCommentNotification) ThreadID() string {
return getThreadID(n.Task.ID)
}
// TaskAssignedNotification represents a TaskAssignedNotification notification
type TaskAssignedNotification struct {
Doer *user.User `json:"doer"`
@@ -134,6 +158,11 @@ func (n *TaskAssignedNotification) Name() string {
return "task.assigned"
}
// ThreadID returns the thread ID for email threading
func (n *TaskAssignedNotification) ThreadID() string {
return getThreadID(n.Task.ID)
}
// TaskDeletedNotification represents a TaskDeletedNotification notification
type TaskDeletedNotification struct {
Doer *user.User `json:"doer"`
@@ -157,6 +186,11 @@ func (n *TaskDeletedNotification) Name() string {
return "task.deleted"
}
// ThreadID returns the thread ID for email threading
func (n *TaskDeletedNotification) ThreadID() string {
return getThreadID(n.Task.ID)
}
// ProjectCreatedNotification represents a ProjectCreatedNotification notification
type ProjectCreatedNotification struct {
Doer *user.User `json:"doer"`
@@ -245,6 +279,11 @@ func (n *UndoneTaskOverdueNotification) Name() string {
return "task.undone.overdue"
}
// ThreadID returns the thread ID for email threading
func (n *UndoneTaskOverdueNotification) ThreadID() string {
return getThreadID(n.Task.ID)
}
// UndoneTasksOverdueNotification represents a UndoneTasksOverdueNotification notification
type UndoneTasksOverdueNotification struct {
User *user.User
@@ -330,6 +369,11 @@ func (n *UserMentionedInTaskNotification) Name() string {
return "task.mentioned"
}
// ThreadID returns the thread ID for email threading
func (n *UserMentionedInTaskNotification) ThreadID() string {
return getThreadID(n.Task.ID)
}
// DataExportReadyNotification represents a DataExportReadyNotification notification
type DataExportReadyNotification struct {
User *user.User `json:"user"`

View File

@@ -0,0 +1,85 @@
// 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 models
import (
"testing"
"code.vikunja.io/api/pkg/config"
"github.com/stretchr/testify/assert"
)
func TestGetThreadID(t *testing.T) {
// Save original config value
originalPublicURL := config.ServicePublicURL.GetString()
defer func() {
config.ServicePublicURL.Set(originalPublicURL)
}()
t.Run("default domain when no public URL", func(t *testing.T) {
config.ServicePublicURL.Set("")
threadID := getThreadID(123)
assert.Equal(t, "<task-123@vikunja>", threadID)
})
t.Run("simple domain without port", func(t *testing.T) {
config.ServicePublicURL.Set("https://vikunja.example.com")
threadID := getThreadID(456)
assert.Equal(t, "<task-456@vikunja.example.com>", threadID)
})
t.Run("domain with standard HTTPS port", func(t *testing.T) {
config.ServicePublicURL.Set("https://vikunja.example.com:443")
threadID := getThreadID(789)
// Should strip port to create valid RFC 5322 domain
assert.Equal(t, "<task-789@vikunja.example.com>", threadID)
})
t.Run("domain with non-standard port", func(t *testing.T) {
config.ServicePublicURL.Set("http://localhost:8080")
threadID := getThreadID(999)
// Should strip port to create valid RFC 5322 domain
assert.Equal(t, "<task-999@localhost>", threadID)
})
t.Run("domain with port 3456", func(t *testing.T) {
config.ServicePublicURL.Set("http://vikunja.local:3456")
threadID := getThreadID(111)
// Should strip port to create valid RFC 5322 domain
assert.Equal(t, "<task-111@vikunja.local>", threadID)
})
t.Run("IP address with port", func(t *testing.T) {
config.ServicePublicURL.Set("http://192.168.1.100:8080")
threadID := getThreadID(222)
// Should strip port to create valid RFC 5322 domain
assert.Equal(t, "<task-222@192.168.1.100>", threadID)
})
t.Run("invalid URL falls back to default", func(t *testing.T) {
config.ServicePublicURL.Set("not a valid url")
threadID := getThreadID(333)
assert.Equal(t, "<task-333@vikunja>", threadID)
})
t.Run("URL with path", func(t *testing.T) {
config.ServicePublicURL.Set("https://example.com:9000/vikunja")
threadID := getThreadID(444)
// Should use hostname without port
assert.Equal(t, "<task-444@example.com>", threadID)
})
}

View File

@@ -70,6 +70,7 @@ const TaskCollectionExpandSubtasks TaskCollectionExpandable = `subtasks`
const TaskCollectionExpandBuckets TaskCollectionExpandable = `buckets`
const TaskCollectionExpandReactions TaskCollectionExpandable = `reactions`
const TaskCollectionExpandComments TaskCollectionExpandable = `comments`
const TaskCollectionExpandCommentCount TaskCollectionExpandable = `comment_count`
// Validate validates if the TaskCollectionExpandable value is valid.
func (t TaskCollectionExpandable) Validate() error {
@@ -82,9 +83,11 @@ func (t TaskCollectionExpandable) Validate() error {
return nil
case TaskCollectionExpandComments:
return nil
case TaskCollectionExpandCommentCount:
return nil
}
return InvalidFieldErrorWithMessage([]string{"expand"}, "Expand must be one of the following values: subtasks, buckets, reactions")
return InvalidFieldErrorWithMessage([]string{"expand"}, "Expand must be one of the following values: subtasks, buckets, reactions, comments, comment_count")
}
func validateTaskField(fieldName string) error {
@@ -286,6 +289,7 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa
tc.ProjectViewID = tf.ProjectViewID
tc.ProjectID = tf.ProjectID
tc.isSavedFilter = true
tc.Expand = tf.Expand
if tf.Filter != "" {
if tc.Filter != "" {
@@ -319,10 +323,6 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa
tf.FilterTimezone = view.Filter.FilterTimezone
}
if view.Filter.FilterIncludeNulls {
tf.FilterIncludeNulls = view.Filter.FilterIncludeNulls
}
if view.Filter.Search != "" {
search = view.Filter.Search
}

View File

@@ -502,7 +502,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
}
task28 := &Task{
ID: 28,
Title: "task #28 with repeat after",
Title: "task #28 with repeat after, start_date, end_date and due_date",
Identifier: "test1-13",
Index: 13,
CreatedByID: 1,
@@ -512,6 +512,9 @@ func TestTaskCollection_ReadAll(t *testing.T) {
RepeatAfter: 3600,
Created: time.Unix(1543626724, 0).In(loc),
Updated: time.Unix(1543626724, 0).In(loc),
DueDate: time.Unix(1543789524, 0).In(loc),
StartDate: time.Unix(1543616724, 0).In(loc),
EndDate: time.Unix(1544700000, 0).In(loc),
}
task29 := &Task{
ID: 29,
@@ -832,6 +835,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
task7,
task8,
task9,
task28,
},
wantErr: false,
},
@@ -844,6 +848,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
want: []*Task{
task8,
task9,
task28,
},
wantErr: false,
},
@@ -890,6 +895,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
task7,
task8,
task9,
task28,
},
wantErr: false,
},
@@ -1500,6 +1506,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
// The only tasks with a due date
task6,
task5,
task28,
// The other ones don't have a due date
task39,
task35,
@@ -1508,7 +1515,6 @@ func TestTaskCollection_ReadAll(t *testing.T) {
task31,
task30,
task29,
task28,
task27,
task26,
task25,
@@ -1550,6 +1556,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
task7,
task6,
task5,
task28,
},
},
{
@@ -1563,6 +1570,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
a: &user.User{ID: 1},
},
want: []*Task{
task28,
task5,
task6,
task7,
@@ -1583,6 +1591,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
want: []*Task{
task6,
task5,
task28,
task7,
task8,
task9,

View File

@@ -34,7 +34,7 @@ type TaskComment struct {
Comment string `xorm:"text not null" json:"comment" valid:"dbtext,required"`
AuthorID int64 `xorm:"not null" json:"-"`
Author *user.User `xorm:"-" json:"author"`
TaskID int64 `xorm:"not null" json:"-" param:"task"`
TaskID int64 `xorm:"index not null" json:"-" param:"task"`
Reactions ReactionMap `xorm:"-" json:"reactions"`
@@ -274,6 +274,43 @@ func addCommentsToTasks(s *xorm.Session, taskIDs []int64, taskMap map[int64]*Tas
return nil
}
func addCommentCountToTasks(s *xorm.Session, taskIDs []int64, taskMap map[int64]*Task) error {
if len(taskIDs) == 0 {
return nil
}
zero := int64(0)
for _, taskID := range taskIDs {
if task, ok := taskMap[taskID]; ok {
task.CommentCount = &zero
}
}
type CommentCount struct {
TaskID int64 `xorm:"task_id"`
Count int64 `xorm:"count"`
}
counts := []CommentCount{}
if err := s.
Select("task_id, COUNT(*) as count").
Where(builder.In("task_id", taskIDs)).
GroupBy("task_id").
Table("task_comments").
Find(&counts); err != nil {
return err
}
for _, c := range counts {
if task, ok := taskMap[c.TaskID]; ok {
task.CommentCount = &c.Count
}
}
return nil
}
func getAllCommentsForTasksWithoutPermissionCheck(s *xorm.Session, taskIDs []int64, search string, page int, perPage int) (result []*TaskComment, resultCount int, numberOfTotalItems int64, err error) {
// Because we can't extend the type in general, we need to do this here.
// Not a good solution, but saves performance.

View File

@@ -75,7 +75,7 @@ func TestTaskComment_Create(t *testing.T) {
task, err := GetTaskByIDSimple(s, 32)
require.NoError(t, err)
tc := &TaskComment{
Comment: "Lorem Ipsum @user2",
Comment: `<p>Lorem Ipsum <span class="mention" data-type="mention" data-id="2" data-label="user2">@user2</span></p>`,
TaskID: 32, // user2 has access to the project that task belongs to
}
err = tc.Create(s, u)

View File

@@ -54,21 +54,9 @@ func (tp *TaskPosition) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) {
return t.CanUpdate(s, a)
}
// Update is the handler to update a task position
// @Summary Updates a task position
// @Description Updates a task position.
// @tags task
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param id path int true "Task ID"
// @Param view body models.TaskPosition true "The task position with updated values you want to change."
// @Success 200 {object} models.TaskPosition "The updated task position."
// @Failure 400 {object} web.HTTPError "Invalid task position object provided."
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{id}/position [post]
func (tp *TaskPosition) Update(s *xorm.Session, a web.Auth) (err error) {
// updateTaskPosition is the internal function that performs the task position update logic
// without dispatching events. This is used by moveTaskToDoneBuckets to avoid duplicate events.
func updateTaskPosition(s *xorm.Session, a web.Auth, tp *TaskPosition) (err error) {
// Update all positions if the newly saved position is < 0.1
var shouldRecalculate bool
var view *ProjectView
@@ -110,6 +98,28 @@ func (tp *TaskPosition) Update(s *xorm.Session, a web.Auth) (err error) {
return RecalculateTaskPositions(s, view, a)
}
return nil
}
// Update is the handler to update a task position
// @Summary Updates a task position
// @Description Updates a task position.
// @tags task
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param id path int true "Task ID"
// @Param view body models.TaskPosition true "The task position with updated values you want to change."
// @Success 200 {object} models.TaskPosition "The updated task position."
// @Failure 400 {object} web.HTTPError "Invalid task position object provided."
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{id}/position [post]
func (tp *TaskPosition) Update(s *xorm.Session, a web.Auth) (err error) {
err = updateTaskPosition(s, a, tp)
if err != nil {
return err
}
return triggerTaskUpdatedEventForTaskID(s, a, tp.TaskID)
}

View File

@@ -125,6 +125,9 @@ type Task struct {
// All comments of this task. Only present when fetching tasks with the `expand` parameter set to `comments`.
Comments []*TaskComment `xorm:"-" json:"comments,omitempty"`
// Comment count of this task. Only present when fetching tasks with the `expand` parameter set to `comment_count`.
CommentCount *int64 `xorm:"-" json:"comment_count,omitempty"`
// Behaves exactly the same as with the TaskCollection.Expand parameter
Expand []TaskCollectionExpandable `xorm:"-" json:"-" query:"expand"`
@@ -587,6 +590,8 @@ func addBucketsToTasks(s *xorm.Session, a web.Auth, taskIDs []int64, taskMap map
// This function takes a map with pointers and returns a slice with pointers to tasks
// It adds more stuff like assignees/labels/etc to a bunch of tasks
//
//nolint:gocyclo
func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth, view *ProjectView, expand []TaskCollectionExpandable) (err error) {
// No need to iterate over users and stuff if the project doesn't have tasks
@@ -679,6 +684,11 @@ func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth, vi
if err != nil {
return err
}
case TaskCollectionExpandCommentCount:
err = addCommentCountToTasks(s, taskIDs, taskMap)
if err != nil {
return err
}
}
expanded[expandable] = true
}
@@ -1163,7 +1173,7 @@ func (t *Task) updateSingleTask(s *xorm.Session, a web.Auth, fields []string) (e
ProjectViewID: view.ID,
ProjectID: t.ProjectID,
}
err = tb.Update(s, a)
err = updateTaskBucket(s, a, tb)
if err != nil {
return err
}
@@ -1173,7 +1183,7 @@ func (t *Task) updateSingleTask(s *xorm.Session, a web.Auth, fields []string) (e
return err
}
err = tp.Update(s, a)
err = updateTaskPosition(s, a, tp)
if err != nil {
return err
}
@@ -1394,7 +1404,7 @@ func (t *Task) moveTaskToDoneBuckets(s *xorm.Session, a web.Auth, views []*Proje
ProjectViewID: view.ID,
ProjectID: t.ProjectID,
}
err = tb.Update(s, a)
err = updateTaskBucket(s, a, tb)
if err != nil {
return err
}
@@ -1404,7 +1414,7 @@ func (t *Task) moveTaskToDoneBuckets(s *xorm.Session, a web.Auth, views []*Proje
ProjectViewID: view.ID,
Position: calculateDefaultPosition(t.Index, t.Position),
}
err = tp.Update(s, a)
err = updateTaskPosition(s, a, &tp)
if err != nil {
return err
}
@@ -1560,7 +1570,7 @@ func setTaskDatesFromCurrentDateRepeat(oldTask, newTask *Task) {
newTask.Done = false
}
// This helper function updates the reminders, doneAt, start and end dates of the *old* task
// This helper function updates the reminders, doneAt, start, end and due dates of the *old* task
// and saves the new values in the newTask object.
// We make a few assumptions here:
// 1. Everything in oldTask is the truth - we figure out if we update anything at all if oldTask.RepeatAfter has a value > 0

View File

@@ -261,6 +261,27 @@ func TestTask_Update(t *testing.T) {
"bucket_id": 3,
}, false)
})
t.Run("marking a task as done should fire exactly ONE task.updated event", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Clear any events from previous operations
events.ClearDispatchedEvents()
task := &Task{
ID: 1,
Done: true,
}
err := task.Update(s, u)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
// Verify exactly ONE task.updated event was dispatched
count := events.CountDispatchedEvents("task.updated")
assert.Equal(t, 1, count, "Expected exactly 1 task.updated event, got %d", count)
})
t.Run("move task to another project should use the default bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()

View File

@@ -413,6 +413,7 @@ func TestMergeClaims(t *testing.T) {
// Verify error is returned for missing email
require.Error(t, err)
assert.IsType(t, &user.ErrNoOpenIDEmailProvided{}, err)
var expectedErr *user.ErrNoOpenIDEmailProvided
assert.ErrorAs(t, err, &expectedErr)
})
}

View File

@@ -17,232 +17,67 @@
package initials
import (
"bytes"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"html"
"strconv"
"strings"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/modules/keyvalue"
"code.vikunja.io/api/pkg/user"
"github.com/disintegration/imaging"
"github.com/golang/freetype/truetype"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/math/fixed"
)
// Provider represents the provider implementation of the initials provider
type Provider struct {
}
// FlushCache removes cached initials avatars for a user
func (p *Provider) FlushCache(u *user.User) error {
if err := keyvalue.Del(getCacheKey("full", u.ID)); err != nil {
return err
}
return keyvalue.DelPrefix(getCacheKey("resized", u.ID))
// FlushCache is a no-op for the initials provider since SVG generation is lightweight
func (p *Provider) FlushCache(_ *user.User) error { return nil }
var avatarBgColors = []string{
"#e0f8d9",
"#e3f5f8",
"#faeefb",
"#f1efff",
"#ffecf0",
"#ffefe4",
}
var (
avatarBgColors = []*color.RGBA{
{R: 69, G: 189, B: 243, A: 255},
{R: 224, G: 143, B: 112, A: 255},
{R: 77, G: 182, B: 172, A: 255},
{R: 149, G: 117, B: 205, A: 255},
{R: 176, G: 133, B: 94, A: 255},
{R: 240, G: 98, B: 146, A: 255},
{R: 163, G: 211, B: 108, A: 255},
{R: 121, G: 134, B: 203, A: 255},
{R: 241, G: 185, B: 29, A: 255},
}
)
const (
dpi = 72
defaultSize = 1024
)
func drawImage(text rune, bg *color.RGBA) (img *image.RGBA64, err error) {
size := defaultSize
fontSize := float64(size) * 0.8
// Inspired by https://github.com/holys/initials-avatar
// Get the font
f, err := truetype.Parse(goregular.TTF)
if err != nil {
return img, err
}
// Build the image background
img = image.NewRGBA64(image.Rect(0, 0, size, size))
draw.Draw(img, img.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src)
// Add the text
drawer := &font.Drawer{
Dst: img,
Src: image.White,
Face: truetype.NewFace(f, &truetype.Options{
Size: fontSize,
DPI: dpi,
Hinting: font.HintingNone,
}),
}
// Font Index
fi := f.Index(text)
// Glyph example: http://www.freetype.org/freetype2/docs/tutorial/metrics.png
var gbuf truetype.GlyphBuf
fsize := fixed.Int26_6(fontSize * dpi * (64.0 / 72.0))
err = gbuf.Load(f, fsize, fi, font.HintingFull)
if err != nil {
drawer.DrawString("")
return img, err
}
// Center
dY := (size - int(gbuf.Bounds.Max.Y-gbuf.Bounds.Min.Y)>>6) / 2
dX := (size - int(gbuf.Bounds.Max.X-gbuf.Bounds.Min.X)>>6) / 2
y := int(gbuf.Bounds.Max.Y>>6) + dY
x := 0 - int(gbuf.Bounds.Min.X>>6) + dX
drawer.Dot = fixed.Point26_6{
X: fixed.I(x),
Y: fixed.I(y),
}
drawer.DrawString(string(text))
return img, err
var avatarTextColors = []string{
"#005f00",
"#00548c",
"#822198",
"#5d26cd",
"#9f0850",
"#9b2200",
}
func getCacheKey(prefix string, keys ...int64) string {
result := "avatar_initials_" + prefix
for i, key := range keys {
result += strconv.Itoa(int(key))
if i < len(keys) {
result += "_"
}
}
return result
}
func getAvatarForUser(u *user.User) (fullSizeAvatar *image.RGBA64, err error) {
return getAvatarForUserWithDepth(u, 0)
}
func getAvatarForUserWithDepth(u *user.User, recursionDepth int) (fullSizeAvatar *image.RGBA64, err error) {
// Prevent infinite recursion - max 3 attempts
if recursionDepth >= 3 {
return nil, fmt.Errorf("maximum recursion depth reached while generating avatar for user %d", u.ID)
}
cacheKey := getCacheKey("full", u.ID)
result, err := keyvalue.Remember(cacheKey, func() (any, error) {
log.Debugf("Initials avatar for user %d not cached, creating...", u.ID)
avatarText := u.Name
if avatarText == "" {
avatarText = u.Username
}
firstRune := []rune(strings.ToUpper(avatarText))[0]
bg := avatarBgColors[int(u.ID)%len(avatarBgColors)] // Random color based on the user id
res, err := drawImage(firstRune, bg)
if err != nil {
return nil, err
}
return *res, nil
})
if err != nil {
return nil, err
}
// Safe type assertion to handle cases where cached data might be corrupted or in legacy format
aa, ok := result.(image.RGBA64)
if !ok {
// Log the type mismatch with the actual stored value for debugging
log.Errorf("Invalid cached image type for user %d. Expected image.RGBA64, got %T with value: %+v. Clearing cache and regenerating.", u.ID, result, result)
// Clear the invalid cache entry
if err := keyvalue.Del(cacheKey); err != nil {
log.Errorf("Failed to clear invalid cache entry for key %s: %v", cacheKey, err)
}
// Regenerate the avatar by calling the function again (without the corrupted cache)
return getAvatarForUserWithDepth(u, recursionDepth+1)
}
return &aa, nil
}
// CachedAvatar represents a cached avatar with its content and mime type
type CachedAvatar struct {
Content []byte
MimeType string
}
// GetAvatar returns an initials avatar for a user
// GetAvatar returns an initials avatar for a user as SVG
func (p *Provider) GetAvatar(u *user.User, size int64) (avatar []byte, mimeType string, err error) {
return p.getAvatarWithDepth(u, size, 0)
}
func (p *Provider) getAvatarWithDepth(u *user.User, size int64, recursionDepth int) (avatar []byte, mimeType string, err error) {
// Prevent infinite recursion - max 3 attempts
if recursionDepth >= 3 {
return nil, "", fmt.Errorf("maximum recursion depth reached while generating avatar for user %d, size %d", u.ID, size)
}
cacheKey := getCacheKey("resized", u.ID, size)
result, err := keyvalue.Remember(cacheKey, func() (any, error) {
log.Debugf("Initials avatar for user %d and size %d not cached, creating...", u.ID, size)
fullAvatar, err := getAvatarForUser(u)
if err != nil {
return nil, err
}
img := imaging.Resize(fullAvatar, int(size), int(size), imaging.Lanczos)
buf := &bytes.Buffer{}
err = png.Encode(buf, img)
if err != nil {
return nil, err
}
avatar := buf.Bytes()
mimeType := "image/png"
cachedAvatar := CachedAvatar{
Content: avatar,
MimeType: mimeType,
}
return cachedAvatar, nil
})
if err != nil {
return nil, "", err
}
// Safe type assertion to handle cases where cached data might be corrupted or in legacy format
cachedAvatar, ok := result.(CachedAvatar)
if !ok {
// Log the type mismatch with the actual stored value for debugging
log.Errorf("Invalid cached avatar type for user %d, size %d. Expected CachedAvatar, got %T with value: %+v. Clearing cache and regenerating.", u.ID, size, result, result)
// Clear the invalid cache entry
if err := keyvalue.Del(cacheKey); err != nil {
log.Errorf("Failed to clear invalid cache entry for key %s: %v", cacheKey, err)
}
// Regenerate the avatar by calling the function again (without the corrupted cache)
return p.getAvatarWithDepth(u, size, recursionDepth+1)
}
return cachedAvatar.Content, cachedAvatar.MimeType, nil
// Get the text to display
avatarText := u.Name
if avatarText == "" {
avatarText = u.Username
}
if avatarText == "" {
return nil, "", fmt.Errorf("user has no name or username")
}
// Get the first character and convert to uppercase
firstRune := []rune(strings.ToUpper(avatarText))[0]
initial := html.EscapeString(string(firstRune))
// Select background and text colors based on user ID
colorIndex := int(u.ID) % len(avatarBgColors)
bgColor := avatarBgColors[colorIndex]
textColor := avatarTextColors[colorIndex]
// Convert size to string
sizeStr := strconv.FormatInt(size, 10)
// Generate SVG
svg := fmt.Sprintf(`<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" width="%s" height="%s">
<rect width="100" height="100" fill="%s"/>
<text x="50" y="50" font-family="sans-serif" font-size="50" fill="%s" text-anchor="middle" dominant-baseline="central">%s</text>
</svg>`, sizeStr, sizeStr, bgColor, textColor, initial)
return []byte(svg), "image/svg+xml", nil
}

View File

@@ -17,163 +17,165 @@
package initials
import (
"image"
"os"
"testing"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/modules/keyvalue"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestMain initializes the test environment
func TestMain(m *testing.M) {
// Initialize logger for tests
log.InitLogger()
os.Exit(m.Run())
}
func TestGetAvatar(t *testing.T) {
// Initialize storage for testing
keyvalue.InitStorage()
t.Run("handles invalid cached type", func(t *testing.T) {
t.Run("generates valid SVG with name", func(t *testing.T) {
provider := &Provider{}
// Create a test user
testUser := &user.User{
ID: 999999, // Use a high ID to avoid conflicts
Name: "Test User",
Username: "testuser",
}
// Simulate corrupted cached data by storing a string instead of CachedAvatar
cacheKey := getCacheKey("resized", testUser.ID, 64)
err := keyvalue.Put(cacheKey, "corrupted_string_data")
require.NoError(t, err)
// This should not panic but should handle the type assertion gracefully
// and regenerate the avatar
avatar, mimeType, err := provider.GetAvatar(testUser, 64)
// The function should handle the type assertion failure gracefully
// and regenerate the avatar successfully
require.NoError(t, err)
assert.NotNil(t, avatar)
assert.Equal(t, "image/png", mimeType)
assert.NotEmpty(t, avatar, "Avatar should contain image data")
})
t.Run("handles valid cached type", func(t *testing.T) {
provider := &Provider{}
// Create a test user
testUser := &user.User{
ID: 888888, // Use a different ID to avoid cache conflicts
Name: "Valid User",
Username: "validuser",
}
// Store a valid cached avatar
cacheKey := getCacheKey("resized", testUser.ID, 32)
validCachedAvatar := CachedAvatar{
Content: []byte("fake_image_data"),
MimeType: "image/png",
}
err := keyvalue.Put(cacheKey, validCachedAvatar)
require.NoError(t, err)
// This should work correctly with the valid cached data
avatar, mimeType, err := provider.GetAvatar(testUser, 32)
// Should return the cached data successfully
require.NoError(t, err)
assert.Equal(t, []byte("fake_image_data"), avatar)
assert.Equal(t, "image/png", mimeType)
})
t.Run("generates valid initials", func(t *testing.T) {
provider := &Provider{}
// Test with name
testUser1 := &user.User{
ID: 555555,
ID: 1,
Name: "John Doe",
Username: "johndoe",
}
avatar1, mimeType1, err1 := provider.GetAvatar(testUser1, 128)
require.NoError(t, err1)
assert.NotNil(t, avatar1)
assert.Equal(t, "image/png", mimeType1)
assert.NotEmpty(t, avatar1)
avatar, mimeType, err := provider.GetAvatar(testUser, 128)
require.NoError(t, err)
assert.NotNil(t, avatar)
assert.Equal(t, "image/svg+xml", mimeType)
// Test with username when name is empty
testUser2 := &user.User{
ID: 444444,
// Verify it's valid SVG
svgString := string(avatar)
assert.Contains(t, svgString, "<svg")
assert.Contains(t, svgString, "viewBox=\"0 0 100 100\"")
assert.Contains(t, svgString, "width=\"128\"")
assert.Contains(t, svgString, "height=\"128\"")
// Should contain "J" from "John"
assert.Contains(t, svgString, ">J<")
// Should have a background color
assert.Contains(t, svgString, "fill=\"#")
})
t.Run("generates valid SVG with username when name is empty", func(t *testing.T) {
provider := &Provider{}
testUser := &user.User{
ID: 2,
Name: "", // Empty name
Username: "jane_smith",
}
avatar2, mimeType2, err2 := provider.GetAvatar(testUser2, 128)
require.NoError(t, err2)
assert.NotNil(t, avatar2)
assert.Equal(t, "image/png", mimeType2)
assert.NotEmpty(t, avatar2)
avatar, mimeType, err := provider.GetAvatar(testUser, 64)
require.NoError(t, err)
assert.NotNil(t, avatar)
assert.Equal(t, "image/svg+xml", mimeType)
// Verify it's valid SVG
svgString := string(avatar)
assert.Contains(t, svgString, "<svg")
assert.Contains(t, svgString, "width=\"64\"")
assert.Contains(t, svgString, "height=\"64\"")
// Should contain "J" from "jane_smith"
assert.Contains(t, svgString, ">J<")
})
t.Run("uses consistent colors based on user ID", func(t *testing.T) {
provider := &Provider{}
testUser := &user.User{
ID: 0,
Name: "Alice",
Username: "alice",
}
avatar1, _, err := provider.GetAvatar(testUser, 100)
require.NoError(t, err)
avatar2, _, err := provider.GetAvatar(testUser, 200)
require.NoError(t, err)
// Both should use the same colors (user ID 0 -> index 0)
svg1 := string(avatar1)
svg2 := string(avatar2)
// Should have the first background color (index 0)
assert.Contains(t, svg1, `fill="#e0f8d9"`)
assert.Contains(t, svg2, `fill="#e0f8d9"`)
// Should have the first text color (index 0)
assert.Contains(t, svg1, `fill="#005f00"`)
assert.Contains(t, svg2, `fill="#005f00"`)
})
t.Run("escapes special characters", func(t *testing.T) {
provider := &Provider{}
testUser := &user.User{
ID: 3,
Name: "<script>",
Username: "hacker",
}
avatar, mimeType, err := provider.GetAvatar(testUser, 50)
require.NoError(t, err)
assert.Equal(t, "image/svg+xml", mimeType)
svgString := string(avatar)
// Should escape the < character
assert.NotContains(t, svgString, "><script><")
assert.Contains(t, svgString, "&lt;")
})
t.Run("handles different sizes", func(t *testing.T) {
provider := &Provider{}
testUser := &user.User{
ID: 4,
Name: "Bob",
Username: "bob",
}
testCases := []struct {
size int64
expectedSize string
}{
{32, "32"},
{64, "64"},
{128, "128"},
{256, "256"},
{512, "512"},
}
for _, tc := range testCases {
avatar, mimeType, err := provider.GetAvatar(testUser, tc.size)
require.NoError(t, err)
assert.Equal(t, "image/svg+xml", mimeType)
svgString := string(avatar)
assert.Contains(t, svgString, `width="`+tc.expectedSize+`"`)
assert.Contains(t, svgString, `height="`+tc.expectedSize+`"`)
assert.Contains(t, svgString, ">B<")
}
})
t.Run("returns error for user without name or username", func(t *testing.T) {
provider := &Provider{}
testUser := &user.User{
ID: 5,
Name: "",
Username: "",
}
_, _, err := provider.GetAvatar(testUser, 100)
require.Error(t, err)
assert.Contains(t, err.Error(), "no name or username")
})
}
func TestGetAvatarForUser(t *testing.T) {
// Initialize storage for testing
keyvalue.InitStorage()
func TestFlushCache(t *testing.T) {
provider := &Provider{}
testUser := &user.User{
ID: 999,
Name: "Test",
Username: "test",
}
t.Run("handles invalid cached type", func(t *testing.T) {
// Create a test user
testUser := &user.User{
ID: 777777, // Use another unique ID
Name: "Full Size Test User",
Username: "fullsizeuser",
}
// Simulate corrupted cached data by storing a string instead of image.RGBA64
cacheKey := getCacheKey("full", testUser.ID)
err := keyvalue.Put(cacheKey, "corrupted_image_data")
require.NoError(t, err)
// This should not panic but should handle the type assertion gracefully
// and regenerate the full size avatar
fullAvatar, err := getAvatarForUser(testUser)
// The function should handle the type assertion failure gracefully
// and generate a new avatar successfully
require.NoError(t, err)
assert.NotNil(t, fullAvatar)
assert.IsType(t, &image.RGBA64{}, fullAvatar)
})
t.Run("handles valid cached type", func(t *testing.T) {
// Create a test user
testUser := &user.User{
ID: 666666, // Use another unique ID
Name: "Valid Full Size User",
Username: "validfulluser",
}
// Create a valid image.RGBA64 for caching
validImage := image.NewRGBA64(image.Rect(0, 0, 64, 64))
cacheKey := getCacheKey("full", testUser.ID)
err := keyvalue.Put(cacheKey, *validImage)
require.NoError(t, err)
// This should work correctly with the valid cached data
fullAvatar, err := getAvatarForUser(testUser)
// Should return the cached image successfully
require.NoError(t, err)
assert.NotNil(t, fullAvatar)
assert.IsType(t, &image.RGBA64{}, fullAvatar)
})
// FlushCache should be a no-op and not return an error
err := provider.FlushCache(testUser)
assert.NoError(t, err)
}

View File

@@ -110,7 +110,10 @@ func Restore(filename string) error {
// Init the configFile again since the restored configuration is most likely different from the one before
initialize.LightInit()
initialize.InitEngines()
files.InitFileHandler()
err = files.InitFileHandler()
if err != nil {
return fmt.Errorf("could not init file handler: %w", err)
}
///////
// Restore the db

View File

@@ -19,9 +19,15 @@ package migration
import (
"bytes"
"context"
"crypto/rand"
"fmt"
"io"
"math"
"math/big"
"net/http"
"net/url"
"strings"
"time"
)
// DownloadFile downloads a file and returns its contents
@@ -61,17 +67,58 @@ func DoPost(url string, form url.Values) (resp *http.Response, err error) {
// DoPostWithHeaders does an api request and allows to pass in arbitrary headers
func DoPostWithHeaders(url string, form url.Values, headers map[string]string) (resp *http.Response, err error) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, strings.NewReader(form.Encode()))
if err != nil {
return
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
for key, value := range headers {
req.Header.Add(key, value)
}
const maxRetries = 3
const baseDelay = 100 * time.Millisecond
hc := http.Client{}
return hc.Do(req)
for attempt := 0; attempt < maxRetries; attempt++ {
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
for key, value := range headers {
req.Header.Add(key, value)
}
resp, err = hc.Do(req)
if err != nil {
return nil, err
}
// Don't retry on non-5xx status codes
if resp.StatusCode < 500 {
return resp, nil
}
// Return error on last attempt if still getting 5xx
if attempt == maxRetries-1 {
bodyBytes, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
// Re-create the body so the caller can still read it if needed
resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
if readErr != nil {
return resp, fmt.Errorf("request failed after %d attempts with status code %d (could not read response body: %w)", maxRetries, resp.StatusCode, readErr)
}
return resp, fmt.Errorf("request failed after %d attempts with status code %d: %s", maxRetries, resp.StatusCode, string(bodyBytes))
}
// Close the body before retrying
resp.Body.Close()
// Exponential backoff with jitter
delay := baseDelay * time.Duration(math.Pow(2, float64(attempt)))
maxJitter := int64(delay / 2)
jitterBig, _ := rand.Int(rand.Reader, big.NewInt(maxJitter))
jitter := time.Duration(jitterBig.Int64())
time.Sleep(delay + jitter)
}
return nil, fmt.Errorf("request failed after %d attempts", maxRetries)
}

View File

@@ -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 migration
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
)
func TestDoPostWithHeaders_RetriesOn500(t *testing.T) {
var attempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
count := attempts.Add(1)
if count < 3 {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
form := url.Values{"key": {"value"}}
resp, err := DoPostWithHeaders(server.URL, form, map[string]string{})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
if attempts.Load() != 3 {
t.Errorf("expected 3 attempts, got %d", attempts.Load())
}
}
func TestDoPostWithHeaders_GivesUpAfter3Retries(t *testing.T) {
var attempts atomic.Int32
expectedBody := "Internal Server Error: database connection failed"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(expectedBody))
}))
defer server.Close()
form := url.Values{"key": {"value"}}
resp, err := DoPostWithHeaders(server.URL, form, map[string]string{})
if err == nil {
t.Fatal("expected error after exhausted retries, got nil")
}
if !strings.Contains(err.Error(), expectedBody) {
t.Errorf("expected error message to contain response body %q, got: %s", expectedBody, err.Error())
}
if resp == nil {
t.Fatal("expected response to be returned with error, got nil")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", resp.StatusCode)
}
if attempts.Load() != 3 {
t.Errorf("expected 3 attempts, got %d", attempts.Load())
}
}
func TestDoPostWithHeaders_DoesNotRetryOn4xx(t *testing.T) {
var attempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusBadRequest)
}))
defer server.Close()
form := url.Values{"key": {"value"}}
resp, err := DoPostWithHeaders(server.URL, form, map[string]string{})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", resp.StatusCode)
}
if attempts.Load() != 1 {
t.Errorf("expected 1 attempt (no retries on 4xx), got %d", attempts.Load())
}
}

View File

@@ -35,6 +35,7 @@ type Mail struct {
introLines []*mailLine
outroLines []*mailLine
footerLines []*mailLine
threadID string
}
type mailLine struct {
@@ -100,6 +101,12 @@ func (m *Mail) HTML(line string) *Mail {
return m.appendLine(line, true)
}
// ThreadID sets the thread ID of the mail message for email threading
func (m *Mail) ThreadID(threadID string) *Mail {
m.threadID = threadID
return m
}
func (m *Mail) appendLine(line string, isHTML bool) *Mail {
if m.actionURL == "" {
m.introLines = append(m.introLines, &mailLine{

View File

@@ -116,7 +116,7 @@ func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err e
continue
}
md := []byte(templatehtml.HTMLEscapeString(line.Text))
md := []byte(line.Text)
var buf bytes.Buffer
err = goldmark.Convert(md, &buf)
if err != nil {
@@ -195,6 +195,7 @@ func RenderMail(m *Mail, lang string) (mailOpts *mail.Opts, err error) {
Message: plainContent.String(),
HTMLMessage: htmlContent.String(),
Boundary: boundary,
ThreadID: m.threadID,
EmbedFS: map[string]*embed.FS{
"logo.png": &logo,
},

View File

@@ -407,4 +407,205 @@ This is a footer line
</html>
`, mailopts.HTMLMessage)
})
t.Run("with thread ID", func(t *testing.T) {
mail := NewMail().
From("test@example.com").
To("test@otherdomain.com").
Subject("Testmail").
Greeting("Hi there,").
Line("This is a line").
ThreadID("<task-123@vikunja>")
mailopts, err := RenderMail(mail, "en")
require.NoError(t, err)
assert.Equal(t, mail.from, mailopts.From)
assert.Equal(t, mail.to, mailopts.To)
assert.Equal(t, "<task-123@vikunja>", mailopts.ThreadID)
})
t.Run("with special characters in task title", func(t *testing.T) {
mail := NewMail().
From("test@example.com").
To("test@otherdomain.com").
Subject("Testmail").
Greeting("Hi there,").
Line(`This is a friendly reminder of the task "Fix structured data Value in property "reviewCount" must be positive" (My Project).`)
mailopts, err := RenderMail(mail, "en")
require.NoError(t, err)
assert.Equal(t, mail.from, mailopts.From)
assert.Equal(t, mail.to, mailopts.To)
// Plain text should keep quotes as-is
assert.Contains(t, mailopts.Message, `"Fix structured data Value in property "reviewCount" must be positive"`)
// HTML should have proper HTML entities for quotes
// &#34; is the correct HTML entity for the quote character and will render as " in the browser
assert.Contains(t, mailopts.HTMLMessage, `&#34;Fix structured data Value in property &#34;reviewCount&#34; must be positive&#34;`)
})
t.Run("with pre-escaped HTML entities", func(t *testing.T) {
// This tests the fix for issue #1664 where HTML entities were being double-escaped
mail := NewMail().
From("test@example.com").
To("test@otherdomain.com").
Subject("Testmail").
Greeting("Hi there,").
Line(`Task with entity: &#34;already escaped&#34; should render correctly`)
mailopts, err := RenderMail(mail, "en")
require.NoError(t, err)
// Plain text should contain the HTML entity as-is (it will be interpreted by email client)
assert.Contains(t, mailopts.Message, `&#34;`)
// HTML should properly handle the pre-escaped entity without double-escaping
// The entity should remain as &#34; (not become &amp;#34;)
assert.Contains(t, mailopts.HTMLMessage, `&#34;already escaped&#34;`)
// Should NOT double-escape to &amp;#34; which would display as literal &#34;
assert.NotContains(t, mailopts.HTMLMessage, `&amp;#34;`)
})
t.Run("with XSS attempt via script tag", func(t *testing.T) {
mail := NewMail().
From("test@example.com").
To("test@otherdomain.com").
Subject("Testmail").
Greeting("Hi there,").
Line(`Task: <script>alert('XSS')</script>`)
mailopts, err := RenderMail(mail, "en")
require.NoError(t, err)
// Script tags should be stripped by bluemonday sanitization
assert.NotContains(t, mailopts.HTMLMessage, `<script>`)
assert.NotContains(t, mailopts.HTMLMessage, `</script>`)
assert.NotContains(t, mailopts.HTMLMessage, `alert('XSS')`)
// The text should be present but sanitized
assert.Contains(t, mailopts.HTMLMessage, `Task:`)
})
t.Run("with XSS attempt via img onerror", func(t *testing.T) {
mail := NewMail().
From("test@example.com").
To("test@otherdomain.com").
Subject("Testmail").
Greeting("Hi there,").
Line(`Task: <img src=x onerror=alert('XSS')>`)
mailopts, err := RenderMail(mail, "en")
require.NoError(t, err)
// The dangerous HTML should be escaped, not rendered as actual HTML
// This makes it safe - it will display as text, not execute
assert.Contains(t, mailopts.HTMLMessage, `&lt;img`)
assert.Contains(t, mailopts.HTMLMessage, `&gt;`)
// Verify it's not an actual executable img tag
assert.NotContains(t, mailopts.HTMLMessage, `<img src=x onerror=`)
// Task text should remain
assert.Contains(t, mailopts.HTMLMessage, `Task:`)
})
t.Run("with XSS attempt via javascript protocol", func(t *testing.T) {
mail := NewMail().
From("test@example.com").
To("test@otherdomain.com").
Subject("Testmail").
Greeting("Hi there,").
Line(`Task: <a href="javascript:alert('XSS')">Click me</a>`)
mailopts, err := RenderMail(mail, "en")
require.NoError(t, err)
// JavaScript protocol should be stripped
assert.NotContains(t, mailopts.HTMLMessage, `javascript:alert`)
assert.NotContains(t, mailopts.HTMLMessage, `href="javascript:`)
// Text content should remain
assert.Contains(t, mailopts.HTMLMessage, `Task:`)
})
t.Run("with XSS attempt via iframe", func(t *testing.T) {
mail := NewMail().
From("test@example.com").
To("test@otherdomain.com").
Subject("Testmail").
Greeting("Hi there,").
Line(`Task: <iframe src="http://evil.com"></iframe>`)
mailopts, err := RenderMail(mail, "en")
require.NoError(t, err)
// Iframes should be completely stripped by bluemonday
assert.NotContains(t, mailopts.HTMLMessage, `<iframe`)
assert.NotContains(t, mailopts.HTMLMessage, `http://evil.com`)
// Task text should remain
assert.Contains(t, mailopts.HTMLMessage, `Task:`)
})
t.Run("with XSS attempt via HTML injection", func(t *testing.T) {
mail := NewMail().
From("test@example.com").
To("test@otherdomain.com").
Subject("Testmail").
Greeting("Hi there,").
Line(`Task: <div onclick="alert('XSS')">Dangerous</div>`)
mailopts, err := RenderMail(mail, "en")
require.NoError(t, err)
// onclick handler should be stripped
assert.NotContains(t, mailopts.HTMLMessage, `onclick=`)
assert.NotContains(t, mailopts.HTMLMessage, `onclick="alert`)
// Text content may remain but without the dangerous attributes
assert.Contains(t, mailopts.HTMLMessage, `Task:`)
})
t.Run("with XSS attempt via data URI", func(t *testing.T) {
mail := NewMail().
From("test@example.com").
To("test@otherdomain.com").
Subject("Testmail").
Greeting("Hi there,").
Line(`Task: <img src="data:text/html,<script>alert('XSS')</script>">`)
mailopts, err := RenderMail(mail, "en")
require.NoError(t, err)
// Script tags should not appear in final HTML
assert.NotContains(t, mailopts.HTMLMessage, `<script>alert('XSS')</script>`)
assert.NotContains(t, mailopts.HTMLMessage, `<script>`)
// Task text should remain
assert.Contains(t, mailopts.HTMLMessage, `Task:`)
})
t.Run("with XSS attempt via style tag", func(t *testing.T) {
mail := NewMail().
From("test@example.com").
To("test@otherdomain.com").
Subject("Testmail").
Greeting("Hi there,").
Line(`Task: <style>body{background:url('javascript:alert(1)')}</style>`)
mailopts, err := RenderMail(mail, "en")
require.NoError(t, err)
// Style tags should be stripped by bluemonday
assert.NotContains(t, mailopts.HTMLMessage, `<style>`)
// Task text should remain
assert.Contains(t, mailopts.HTMLMessage, `Task:`)
})
t.Run("with mixed XSS and legitimate content", func(t *testing.T) {
mail := NewMail().
From("test@example.com").
To("test@otherdomain.com").
Subject("Testmail").
Greeting("Hi there,").
Line(`Task "Fix Bug" has <script>alert('XSS')</script> priority & needs **attention**`)
mailopts, err := RenderMail(mail, "en")
require.NoError(t, err)
// Malicious content should be stripped
assert.NotContains(t, mailopts.HTMLMessage, `<script>`)
assert.NotContains(t, mailopts.HTMLMessage, `alert('XSS')`)
// Legitimate content should be preserved
assert.Contains(t, mailopts.HTMLMessage, `Task`)
assert.Contains(t, mailopts.HTMLMessage, `Fix Bug`)
// Ampersand should be escaped
assert.Contains(t, mailopts.HTMLMessage, `&amp;`)
// Markdown bold should be converted to strong
assert.Contains(t, mailopts.HTMLMessage, `<strong>attention</strong>`)
})
}

View File

@@ -39,6 +39,10 @@ type NotificationWithSubject interface {
SubjectID
}
type ThreadID interface {
ThreadID() string
}
// Notifiable is an entity which can be notified. Usually a user.
type Notifiable interface {
// RouteForMail should return the email address this notifiable has.
@@ -85,6 +89,10 @@ func notifyMail(notifiable Notifiable, notification Notification) error {
}
mail.To(to)
if threadID, is := notification.(ThreadID); is {
mail.ThreadID(threadID.ThreadID())
}
return SendMail(mail, notifiable.Lang())
}

View File

@@ -18,9 +18,12 @@ package v1
import (
"errors"
"io"
"net/http"
"strconv"
"strings"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
auth2 "code.vikunja.io/api/pkg/modules/auth"
@@ -179,7 +182,21 @@ func GetTaskAttachment(c echo.Context) error {
_ = s.Rollback()
return handler.HandleHTTPError(err)
}
if config.FilesType.GetString() == "s3" {
// s3 files cannot use http.ServeContent as it requires a Seekable file
// Set response headers
c.Response().Header().Set("Content-Type", taskAttachment.File.Mime)
c.Response().Header().Set("Content-Disposition", "inline; filename=\""+taskAttachment.File.Name+"\"")
c.Response().Header().Set("Content-Length", strconv.FormatUint(taskAttachment.File.Size, 10))
c.Response().Header().Set("Last-Modified", taskAttachment.File.Created.UTC().Format(http.TimeFormat))
http.ServeContent(c.Response(), c.Request(), taskAttachment.File.Name, taskAttachment.File.Created, taskAttachment.File.File)
// Stream the file content directly to the response
_, err = io.Copy(c.Response().Writer, taskAttachment.File.File)
if err != nil {
return handler.HandleHTTPError(err)
}
} else {
http.ServeContent(c.Response(), c.Request(), taskAttachment.File.Name, taskAttachment.File.Created, taskAttachment.File.File)
}
return nil
}

View File

@@ -50,6 +50,7 @@ const (
window.SENTRY_DSN = '{{ .SENTRY_DSN }}'
window.ALLOW_ICON_CHANGES = {{ .ALLOW_ICON_CHANGES }}
window.CUSTOM_LOGO_URL = '{{ .CUSTOM_LOGO_URL }}'
window.CUSTOM_LOGO_URL_DARK = '{{ .CUSTOM_LOGO_URL_DARK }}'
</script>`
)
@@ -96,6 +97,7 @@ func serveIndexFile(c echo.Context, assetFs http.FileSystem) (err error) {
data["ALLOW_ICON_CHANGES"] = "true"
}
data["CUSTOM_LOGO_URL"] = config.ServiceCustomLogoURL.GetString()
data["CUSTOM_LOGO_URL_DARK"] = config.ServiceCustomLogoURLDark.GetString()
err = tmpl.Execute(&tplOutput, data)
if err != nil {

View File

@@ -8587,6 +8587,10 @@ const docTemplate = `{
"$ref": "#/definitions/models.Bucket"
}
},
"comment_count": {
"description": "Comment count of this task. Only present when fetching tasks with the ` + "`" + `expand` + "`" + ` parameter set to ` + "`" + `comment_count` + "`" + `.",
"type": "integer"
},
"comments": {
"description": "All comments of this task. Only present when fetching tasks with the ` + "`" + `expand` + "`" + ` parameter set to ` + "`" + `comments` + "`" + `.",
"type": "array",

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