mirror of
https://github.com/go-vikunja/vikunja.git
synced 2025-12-05 19:16:51 -06:00
Merge branch 'main' into codex/fix-drag-and-drop-behavior-inconsistency
This commit is contained in:
4
.github/workflows/crowdin.yml
vendored
4
.github/workflows/crowdin.yml
vendored
@@ -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'
|
||||
|
||||
12
.github/workflows/issue-closed-comment.yml
vendored
12
.github/workflows/issue-closed-comment.yml
vendored
@@ -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
59
.github/workflows/pr-docker.yml
vendored
Normal 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 }}
|
||||
57
.github/workflows/release.yml
vendored
57
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
76
.github/workflows/test.yml
vendored
76
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
10
desktop/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
20
devenv.lock
20
devenv.lock
@@ -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": {
|
||||
|
||||
@@ -1 +1 @@
|
||||
22.20.0
|
||||
24.11.1
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
23055
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
2682
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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')"
|
||||
>
|
||||
|
||||
@@ -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({
|
||||
|
||||
181
frontend/src/components/input/editor/mention/MentionList.vue
Normal file
181
frontend/src/components/input/editor/mention/MentionList.vue
Normal 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>
|
||||
44
frontend/src/components/input/editor/mention/MentionUser.vue
Normal file
44
frontend/src/components/input/editor/mention/MentionUser.vue
Normal 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>
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]
|
||||
}>(), {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
9
frontend/src/composables/useTimeFormat.ts
Normal file
9
frontend/src/composables/useTimeFormat.ts
Normal 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}
|
||||
})
|
||||
6
frontend/src/constants/timeFormat.ts
Normal file
6
frontend/src/constants/timeFormat.ts
Normal 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]
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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日後",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -908,6 +908,9 @@
|
||||
"addedSuccess": "Комментарий добавлен.",
|
||||
"permalink": "Скопировать постоянную ссылку на комментарий"
|
||||
},
|
||||
"mention": {
|
||||
"noUsersFound": "Пользователи не найдены"
|
||||
},
|
||||
"deferDueDate": {
|
||||
"title": "Отложить срок",
|
||||
"1day": "1 день",
|
||||
|
||||
@@ -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": "Спільнота вже має доступ до цієї справи.",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -21,6 +21,7 @@ declare global {
|
||||
SENTRY_DSN?: string;
|
||||
ALLOW_ICON_CHANGES: boolean;
|
||||
CUSTOM_LOGO_URL?: string;
|
||||
CUSTOM_LOGO_URL_DARK?: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface ITask extends IAbstract {
|
||||
|
||||
reactions: IReactionPerEntity
|
||||
comments: ITaskComment[]
|
||||
commentCount?: number
|
||||
|
||||
createdBy: IUser
|
||||
created: Date
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')[],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -404,6 +404,7 @@
|
||||
<Comments
|
||||
:can-write="canWrite"
|
||||
:task-id="taskId"
|
||||
:project-id="task.projectId"
|
||||
:initial-comments="task.comments"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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
62
go.mod
@@ -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
303
go.sum
@@ -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=
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
//go:build mage
|
||||
// +build mage
|
||||
|
||||
package main
|
||||
|
||||
|
||||
@@ -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:*"})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
297
pkg/files/s3_integration_test.go
Normal file
297
pkg/files/s3_integration_test.go
Normal 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)
|
||||
}
|
||||
@@ -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": "和"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
36
pkg/migration/20251108154913.go
Normal file
36
pkg/migration/20251108154913.go
Normal 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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
85
pkg/models/notifications_test.go
Normal file
85
pkg/models/notifications_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "<")
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
107
pkg/modules/migration/helpers_test.go
Normal file
107
pkg/modules/migration/helpers_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
// " is the correct HTML entity for the quote character and will render as " in the browser
|
||||
assert.Contains(t, mailopts.HTMLMessage, `"Fix structured data Value in property "reviewCount" must be positive"`)
|
||||
})
|
||||
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: "already escaped" 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, `"`)
|
||||
|
||||
// HTML should properly handle the pre-escaped entity without double-escaping
|
||||
// The entity should remain as " (not become &#34;)
|
||||
assert.Contains(t, mailopts.HTMLMessage, `"already escaped"`)
|
||||
// Should NOT double-escape to &#34; which would display as literal "
|
||||
assert.NotContains(t, mailopts.HTMLMessage, `&#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, `<img`)
|
||||
assert.Contains(t, mailopts.HTMLMessage, `>`)
|
||||
// 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, `&`)
|
||||
// Markdown bold should be converted to strong
|
||||
assert.Contains(t, mailopts.HTMLMessage, `<strong>attention</strong>`)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user