Compare commits

...

98 Commits

Author SHA1 Message Date
renovate[bot] 076cd214fe fix(deps): update module github.com/lib/pq to v1.12.3 2026-06-30 03:54:47 +00:00
renovate[bot] 8a4a1c1af7 fix(deps): update module golang.org/x/term to v0.44.0 2026-06-30 03:00:28 +00:00
renovate[bot] 655f553bdb fix(deps): update module github.com/hashicorp/go-version to v1.9.0 2026-06-30 02:16:01 +00:00
renovate[bot] 911c9dd3d0 fix(deps): update module github.com/go-sql-driver/mysql to v1.10.0 2026-06-30 02:15:59 +00:00
renovate[bot] 3aab4ab51a fix(deps): update aws-sdk-go-v2 monorepo 2026-06-30 02:15:57 +00:00
renovate[bot] 5bd55e0322 fix(deps): update module github.com/danielgtaylor/huma/v2 to v2.38.0 2026-06-30 02:15:55 +00:00
renovate[bot] a74bb408ee fix(deps): update module xorm.io/xorm to v1.4.1 2026-06-29 22:14:17 +00:00
renovate[bot] 5d368b849a fix(deps): update vueuse to v14.3.0 2026-06-29 22:08:01 +00:00
renovate[bot] 9657cff19a fix(deps): update module github.com/magefile/mage to v1.17.2 2026-06-29 21:59:55 +00:00
renovate[bot] e1bff274c7 fix(deps): update module golang.org/x/crypto to v0.53.0 2026-06-29 21:56:14 +00:00
renovate[bot] 756ecb3ec0 fix(deps): update module github.com/labstack/echo/v5 to v5.2.1 2026-06-29 21:52:38 +00:00
renovate[bot] 0d1f44cb2a fix(deps): update module github.com/redis/go-redis/v9 to v9.21.0 2026-06-29 21:52:01 +00:00
renovate[bot] 03a9056d8f fix(deps): update module github.com/fatih/color to v1.19.0 2026-06-29 19:29:05 +00:00
renovate[bot] 73f68f61c1 fix(deps): update module github.com/coreos/go-oidc/v3 to v3.19.0 2026-06-29 18:42:03 +00:00
renovate[bot] 9f9711cdfe fix(deps): update module github.com/bbrks/go-blurhash to v1.2.0 2026-06-29 18:41:08 +00:00
renovate[bot] 8f68b3f396 fix(deps): update font awesome 2026-06-29 17:55:47 +00:00
renovate[bot] 988dfa0b3a chore(deps): update golangci/golangci-lint-action action to v9.3.0 2026-06-29 17:52:24 +00:00
renovate[bot] 01a851ca72 fix(deps): update dependency vue-i18n to v11.4.6 2026-06-29 16:58:17 +00:00
renovate[bot] 65a498dd50 fix(deps): update dependency @sentry/vue to v10.62.0 2026-06-29 16:57:00 +00:00
renovate[bot] d9804c3e00 fix(deps): update module github.com/aws/smithy-go to v1.27.3 2026-06-29 16:55:06 +00:00
renovate[bot] 83f353aee9 fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.47 2026-06-29 16:49:22 +00:00
renovate[bot] bb0055293b chore(deps): update pnpm to v10.34.4 2026-06-29 16:14:58 +00:00
confor b947e892d0 feat(packaging): add systemd security hardening to service 2026-06-29 18:42:30 +02:00
renovate[bot] d2fcd2efa5 fix(deps): update dependency axios to v1.18.1 2026-06-29 15:22:25 +00:00
renovate[bot] 8ae1ee0645 fix(deps): update aws-sdk-go-v2 monorepo 2026-06-29 15:22:24 +00:00
renovate[bot] bfead87452 chore(deps): update node.js to v24.18.0 2026-06-29 15:22:22 +00:00
renovate[bot] 8f429ac643 fix(deps): update module github.com/wneessen/go-mail to v0.7.3 2026-06-29 15:22:21 +00:00
renovate[bot] 90d57f4b38 fix(deps): update module github.com/threedotslabs/watermill to v1.5.2 2026-06-29 15:22:19 +00:00
renovate[bot] 07c872eb2b fix(deps): update dependency vue to v3.5.39 2026-06-29 15:22:17 +00:00
kolaente e1afa039cb
fix: use correct type for permission error 2026-06-29 17:18:05 +02:00
kolaente ee8c759f0b
chore(deps): update go to 1.26.4 in all places 2026-06-29 13:12:57 +02:00
renovate[bot] b866ba3f58 fix(deps): update dependency @intlify/unplugin-vue-i18n to v11.2.4 2026-06-29 08:16:56 +02:00
renovate[bot] b0bbfa677a chore(deps): update playwright to v1.61.1 2026-06-29 08:16:15 +02:00
renovate[bot] 82f03d94b6 chore(deps): update node.js to v24.18.0 2026-06-29 08:15:34 +02:00
renovate[bot] 59ef240a4d fix(deps): update module github.com/sahilm/fuzzy to v0.1.3 2026-06-29 08:13:58 +02:00
renovate[bot] 421c45e60b fix(deps): update module github.com/olekukonko/tablewriter to v1.1.4 2026-06-29 08:13:28 +02:00
renovate[bot] 837339b894 fix(deps): update module github.com/labstack/echo-jwt/v5 to v5.0.1 2026-06-29 08:13:11 +02:00
renovate[bot] 7f687236d4 fix(deps): update module github.com/golang-jwt/jwt/v5 to v5.3.1 2026-06-29 08:12:56 +02:00
kolaente fa0c9a8584 feat(api): exchange rich-text fields as markdown on v2
Wire the conversion helpers into every rich-text handler: read/list/echo
convert HTML to markdown, create/update convert markdown to HTML before
persisting, and each op documents the format query field. Opt-in via
?format=markdown or the X-Vikunja-Format header.
2026-06-29 08:12:35 +02:00
kolaente 71639a3dc5 feat(api): add v2 markdown conversion helpers
Adds the opt-in format plumbing for v2: requestWantsMarkdown (query or
X-Vikunja-Format header), convertToMarkdown/convertToHTML/convertTasksToMarkdown
field converters, the cross-cutting API description, and stripPatchFormatQuery
(AutoPatch drops the query, so PATCH advertises only the header).
2026-06-29 08:12:35 +02:00
kolaente 8d10e053d4 fix(caldav): store markdown descriptions as HTML, skip spurious updates
Incoming CalDAV descriptions are markdown; convert them back to canonical HTML
(rebuilding mentions) before persisting. Skip the conversion when the markdown
is unchanged from the stored HTML so a passthrough sync doesn't churn the value
or bump the ETag.
2026-06-29 08:12:35 +02:00
kolaente a728e50796 feat(caldav): serialize task descriptions as markdown
CalDAV clients render DESCRIPTION as plain text, so convert the stored HTML to
markdown when serializing VTODOs. On the near-impossible conversion error, log
it and keep the stored value.
2026-06-29 08:12:35 +02:00
kolaente 9015bad65c feat(richtext): add markdown-domain change detection
Changed reports whether inbound markdown differs from stored HTML by comparing
in the markdown domain, so callers can skip rewriting unchanged fields.
2026-06-29 08:12:35 +02:00
kolaente 3459158b99 feat(richtext): add Markdown to HTML conversion with mention rebuild
Converts GFM Markdown to canonical HTML via goldmark (GFM, no WithUnsafe),
rewriting task lists into TipTap's structure and resolving @username mentions
to <mention-user> tags against real users.
2026-06-29 08:12:35 +02:00
kolaente 3abe8d650a feat(richtext): add HTML to Markdown conversion
Converts rich-text HTML to GFM Markdown (standard + GFM extensions) including
the Vikunja/TipTap-specific nodes (mentions, task lists). Adds the
html-to-markdown/v2 dependency.
2026-06-29 08:12:35 +02:00
renovate[bot] a2063a27a8 chore(deps): update actions/ai-inference action to v2.1.1 2026-06-29 08:12:04 +02:00
Frederick [Bot] cf1273c1d9 chore(i18n): update translations via Crowdin 2026-06-29 00:45:18 +00:00
kolaente 8d0814e460
chore(ci): remove stale label from PR when there is activity 2026-06-28 19:41:30 +02:00
renovate[bot] 2690b7153e fix(deps): update module github.com/go-ldap/ldap/v3 to v3.4.13 2026-06-28 12:52:42 +00:00
renovate[bot] c55ee0b742 fix(deps): update module github.com/coder/websocket to v1.8.15 2026-06-28 12:49:21 +00:00
renovate[bot] c72cfdf50d chore(deps): update dev-dependencies 2026-06-28 12:46:39 +00:00
renovate[bot] c6b3c7cddc fix(deps): update module github.com/arran4/golang-ical to v0.3.5 2026-06-28 12:10:52 +00:00
renovate[bot] 12952516cf fix(deps): update dependency ufo to v1.6.4 2026-06-28 12:10:14 +00:00
renovate[bot] 9946ca9031 fix(deps): update dependency nanoid to v5.1.16 2026-06-28 12:10:03 +00:00
renovate[bot] a73761f4c5 fix(deps): update dependency sortablejs to v1.15.7 2026-06-28 09:08:08 +00:00
renovate[bot] ac9811826e fix(deps): update dependency marked to v17.0.6 2026-06-28 09:07:29 +00:00
renovate[bot] 0369b61001 fix(deps): update dependency dayjs to v1.11.21 2026-06-28 09:07:12 +00:00
renovate[bot] 59da1d9514 fix(deps): update dependency @floating-ui/dom to v1.7.6 2026-06-28 09:06:49 +00:00
renovate[bot] d374c8e6f9 chore(deps): update actions/checkout action to v7 2026-06-28 09:06:15 +00:00
renovate[bot] aa8c5974ae chore(deps): update node.js to cd6fb7e 2026-06-28 09:05:56 +00:00
Frederick [Bot] 0dba563a03 chore(i18n): update translations via Crowdin 2026-06-28 00:29:43 +00:00
renovate[bot] dab2ac473f chore(deps): update postgres:18 docker digest to 4aabea7 2026-06-27 19:40:01 +00:00
renovate[bot] 57b6d530f3 chore(deps): update ghcr.io/techknowlogick/xgo:go-1.25.x docker digest to 57c6285 2026-06-27 19:39:40 +00:00
renovate[bot] ba5c09f962 chore(deps): update actions/cache action to v6 2026-06-27 19:39:18 +00:00
renovate[bot] eed762097a fix(deps): update tiptap to v3.27.1 2026-06-27 19:39:07 +00:00
renovate[bot] f6baa7d472 chore(deps): update docker/dockerfile:1 docker digest to 87999aa 2026-06-27 19:38:32 +00:00
renovate[bot] 07d39b4290 chore(deps): pin dependencies 2026-06-27 18:01:23 +00:00
karl Einziger 0efae572cd fix(auth): use binddn as group sync dn instead of userbind 2026-06-27 15:12:10 +00:00
kolaente 9e880e98a5 fix(api): export api-token permission groups in snake_case
The api-token permission group key is derived from the route slug. Every
group is snake_case except "time-entries", whose URL slug carries a hyphen.
The frontend snake_cases request payloads, rewriting that group key to
"time_entries", which the backend then rejected — so a token granted the
Time Entries scope could not be saved.

Canonicalise group and path-segment names to snake_case where they are
derived, and normalise the group key on token validation and authorisation
so any token stored under the old hyphenated key keeps resolving. No data
migration is needed: the v2 time-entries resource has never shipped in a
release.
2026-06-27 15:01:54 +00:00
kolaente e25ca7ab9a fix: don't re-login after logout when OIDC auto-redirect is enabled
Set the just-logged-out flag before navigating, and skip the intermediate
router.push to login when redirecting to the IdP — otherwise Login.vue's
onBeforeMount consumed the flag before the logout round-trip landed, so the
single-provider auto-redirect fired and logged the user straight back in.

redirectToProviderOnLogout now reports whether it navigated, so logout can fall through to the login page when there's no static logout URL.
2026-06-27 14:20:05 +00:00
kolaente 18ee92f227 feat: auto-redirect to OIDC provider on login when it's the only option 2026-06-27 14:20:05 +00:00
kolaente 96452f0b71 fix(desktop): set the main window icon on Linux
On X11/XWayland (Electron's default on Wayland sessions) the window had no
icon, so KDE Plasma showed the generic placeholder. Point BrowserWindow at
the packaged icon.png so the compositor has an icon to render.
2026-06-27 14:12:10 +00:00
kolaente 3f8ce93636 fix(desktop): show hidden window when relaunched from tray
When the app is hidden in the tray, closing then relaunching it triggered
the single-instance second-instance handler, which only called focus() — a
hidden window stays hidden on focus(), so the app appeared not to start
(notably on KDE Plasma Wayland where the tray icon may also be unreachable).
Call show() to surface it, and recreate the window if it no longer exists.
2026-06-27 14:12:10 +00:00
kolaente 626e1e267e fix(desktop): quit on SIGTERM and SIGINT
The desktop app ignored termination signals because the tray and embedded
express server keep the Electron event loop alive, forcing users to kill -9
on logout/shutdown. Add SIGINT/SIGTERM handlers that set isQuitting before
app.quit() so the hide-to-tray close handler doesn't swallow the quit.
2026-06-27 14:12:10 +00:00
BlackFuffey f18813f3ff feat(projects): make gantt chart zoom in if there are space available 2026-06-27 13:44:03 +00:00
gabe f7ac69d01a feat(filters): translate My Open Tasks title in frontend 2026-06-27 13:35:50 +00:00
gabe 98b3613247 feat(filters): generate open task saved filter on user creation 2026-06-27 13:35:50 +00:00
kolaente 18a0df505b
fix(deps): bump desktop undici to patched versions
node-gyp's undici 6.26.0 -> 6.27.0 and @electron/get's 7.27.2 -> 7.28.0,
each pinned within its major via overrides so only the security patch is
taken (an open >= range would jump across majors, e.g. to undici 8).

Resolves the 9 open desktop undici Dependabot alerts.
2026-06-27 14:34:20 +02:00
kolaente 7b5b8ecad2
chore(dev): remove leftover .envrc
Now handled directly by devenv
2026-06-27 14:26:28 +02:00
kolaente 4b18d08993
chore(dev): move devcontainer config to .devcontainer/ directory
Newer devenv generates the devcontainer config at .devcontainer/devcontainer.json
instead of the legacy root-level .devcontainer.json, which left an untracked
duplicate. Track the new path so regeneration is a no-op.
2026-06-27 14:25:45 +02:00
kolaente 7c9b9e3352
chore(deps): update devenv 2026-06-27 14:25:44 +02:00
kolaente 08890895de fix(task): don't drop the list-view done save during the check animation
Marking a task done via the list-view checkbox deferred the entire update
— including the network request — by 300ms to play the check animation. If
the page was torn down within that window (a refresh, tab close, or leaving
the app), the request was never sent and the save was silently lost. For
repeating tasks this is especially confusing: the due date never advances,
yet the checkbox un-checks itself anyway, so the failure looks identical to
success.

Fire the request immediately with the intended done value snapshotted, and
defer only the animation-coupled follow-up (result swap, pop sound, toast).
The optimistic v-model state already drives the check animation during the
300ms, so nothing visual is lost.
2026-06-27 12:01:51 +00:00
kolaente 330b94c3c4 feat(migration): import recurring tasks from todoist 2026-06-26 13:32:08 +00:00
kolaente 7691f282cf fix(veans): preserve unsent task fields on update via PATCH (#2962)
The v1 update was a whole-object POST /tasks/{id}: omitted scalars were
zeroed, so a status-only `veans update` silently wiped a task's
description and priority. The v1->v2 migration replaced that with
PATCH /tasks/{id} carrying a JSON Merge Patch built from only the
changed fields (client.TaskPatch, all-pointer + omitempty), which fixes
this by construction — absent fields are left untouched server-side.

Pin it with the acceptance tests from the issue: a title-only and a
status-only update must send only the field(s) they change, so the
stored description and priority survive.
2026-06-26 11:23:14 +00:00
kolaente 6cee626383 refactor(veans): migrate API client from v1 to v2
veans is unreleased and targets bleeding-edge Vikunja, so the CLI now
speaks the Huma-backed /api/v2 exclusively (v1 is frozen and the kanban
bucket CRUD veans relies on only exists on v2).

- Transport: base path /api/v1 -> /api/v2 in Do/DoRaw; add a
  content-type-aware path (DoMerge for application/merge-patch+json).
- Pagination: drop the x-pagination-total-pages header reader; every v2
  list returns the {items,total,page,per_page,total_pages} envelope.
  Decode it with a generic Paginated[T]/doList[T] and page until
  page >= total_pages. Previously-single-GET lists (views, buckets,
  comments, bots) are enveloped too — unwrap .items.
- Verbs: creates flip PUT -> POST (projects, labels, tokens, bot users,
  shares, task create, comments, relations, assignees, label-attach,
  bucket create); the bucket-task move flips POST -> PUT with a bare
  {"task_id":N} body (URL owns project/view/bucket); task update moves
  to PATCH merge-patch with a partial body.
- Errors: parse the RFC 9457 problem+json body (detail/title/code)
  instead of v1's {code,message}; the status -> output.Code mapping is
  unchanged.
- Discovery probes /api/v2/info, which doubles as the "new enough" check.
- Label search param s -> q; add views_buckets_tasks_put to the bot's
  projects scope so the move is authorized regardless of route-init order.

Tests and the veans agent guide are updated for the new paths, verbs and
envelope. Verified end-to-end against a local v2 server: init, create,
show, list, claim, update and prime all work.
2026-06-26 11:23:14 +00:00
kolaente 0d043e80e4 feat(api/v2): add kanban bucket CRUD endpoints
Port the standalone bucket list/create/update/delete from v1 to the
Huma-backed /api/v2, under /projects/{project}/views/{view}/buckets,
using v2 verb conventions (POST creates, PUT updates). The handlers
reuse the generic handler.Do* functions, so permissions are enforced
by the Bucket model's existing Can* methods.

Mirrors v1: no read-one route (the model has no ReadOne/CanRead), so
AutoPatch synthesises no PATCH. No model changes.
2026-06-26 08:56:15 +00:00
Frederick [Bot] 9390199ce0 chore(i18n): update translations via Crowdin 2026-06-26 00:32:25 +00:00
Bradley Erickson f8eacca7c8 fix(auth): allow api tokens to access global v2 task list endpoint
The tasks.read_all special case in CanDoAPIRoute only covered v1 paths.
Both GET /api/v2/tasks and GET /api/v2/projects/:project/tasks normalize
to the same tasks.read_all map key, but only one RouteDetail survives —
the project-scoped path overwrites the global one. The exact path
comparison then rejects the global endpoint with 401.

Extend the special case to include the v2 paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 17:49:02 +00:00
renovate[bot] 7a182817ee chore(deps): update dev-dependencies 2026-06-24 17:37:15 +00:00
Frederick [Bot] aaa2428f6c chore(i18n): update translations via Crowdin 2026-06-24 00:26:43 +00:00
renovate[bot] 0f3a8a7e39 chore(deps): update dev-dependencies 2026-06-22 12:33:44 +00:00
Tink f4bbe80144
fix(auth): dedupe and retry token refresh to prevent spurious logouts (#2948) 2026-06-21 18:22:30 +02:00
Frederick [Bot] 02d46944ac chore(i18n): update translations via Crowdin 2026-06-21 00:33:22 +00:00
kolaente 0e17556a16 fix(editor): make link prompt a sub-modal — Escape cancels it without closing the task dialog
Review point (#2950, comment 3444116036): when the surrounding task
<dialog> closed while the link prompt was open, the prompt was orphaned
and cleanup() never ran, leaking listeners and an unresolved promise.

Treat the prompt as a sub-modal of the task dialog: pressing Escape while
it is open now preventDefault()/stopPropagation()s the keydown so the
native modal <dialog> does not close on Escape, resolves the prompt with
'' (cancel) and runs cleanup() — only the prompt is dismissed, the task
dialog stays open. A one-shot 'cancel' listener on the enclosing dialog
backs this up in case the keydown handling is insufficient in some browser.

Tighten cleanup() so the prompt fully tears down regardless of how it
closes (Enter / Escape / click-outside): it now removes the scroll
listener, the document click listener and the dialog cancel listener, and
removes the element. handleClickOutside was hoisted so cleanup() can
remove it, closing the leaked-listener gap directly.

Adds an e2e asserting Escape cancels the prompt while the task dialog
stays open; the existing 'Enter creates the link' case still passes.
2026-06-19 20:14:19 +00:00
kolaente 84dc57c562 fix(editor): render link prompt inside the task dialog so it works in the Kanban popup (#2940)
The Kanban task popup renders the description editor inside a native
<dialog> opened via showModal(), which lives in the browser's top-layer.
inputPrompt appended its URL <input> to document.body, so it was painted
behind the top-layer dialog (z-index cannot beat the top-layer) and could
not be focused through the dialog's focus trap. As a result clicking "Link"
in the popup did nothing, while it worked on the full task page (no modal).

Thread the TipTap editor through inputPrompt and append the prompt to
getPopupContainer(editor) — the open dialog ancestor when present, falling
back to document.body otherwise, so non-modal usage is unchanged. This is
the same helper the slash menu and mentions already use to escape the
top-layer (#1746).

Fixes #2940
2026-06-19 20:14:19 +00:00
Tink 82dae774f1
fix(views): persist list/table sort across sidebar navigation (#2778) 2026-06-19 22:08:06 +02:00
kolaente 63b7f32379 fix(editor): render floating popups inside the task dialog (Kanban popup)
The Kanban task detail opens as a native <dialog> via showModal(), which
paints in the browser top-layer. Floating UI appended to document.body
(or teleported to <body>) then renders behind the dialog regardless of
z-index, matching the bug class of #2940 / #1746 / #1899 / #1929.

- Emoji autocomplete popup: append to getPopupContainer(editor) (the open
  dialog ancestor, else body), the same helper the slash menu and mentions
  already use. Also switch its unmount to popupElement.remove() so it works
  no matter which container it was appended to.
- Attachment dropzone overlay: teleport into the topmost open
  dialog.modal-dialog instead of always <body>, mirroring Notification.vue,
  so the drag-and-drop hint is visible while a task detail dialog is open.
2026-06-19 19:03:20 +00:00
Tink 81791fd346
fix(auth): link OIDC username fallback on preferred_username, not just sub (#2945) 2026-06-19 20:47:05 +02:00
118 changed files with 6917 additions and 2462 deletions

3
.envrc
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,10 +3,11 @@
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1773012232,
"lastModified": 1782492839,
"narHash": "sha256-j9wrcB4al5QhMelEghJ0Qs+RQPT+wyCcI4070NEgPLQ=",
"owner": "cachix",
"repo": "devenv",
"rev": "46a4bd0299a26ad948b71d3053174ba7b90522f7",
"rev": "3d39d0817d62069f7b18821c34a617b5141cb278",
"type": "github"
},
"original": {
@ -21,10 +22,11 @@
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1772749504,
"lastModified": 1782132010,
"narHash": "sha256-ZnAVHdVrotp80iIMm5CSR1fdxPlw7Uwmwxb+O/wsgZ8=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "08543693199362c1fddb8f52126030d0d374ba2e",
"rev": "12866ae2dddbc0ab8b329915f8072bb9c75bde89",
"type": "github"
},
"original": {
@ -37,11 +39,11 @@
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1769922788,
"narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
"lastModified": 1781607440,
"narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
"rev": "3e41b24abd260e8f71dbe2f5737d24122f972158",
"type": "github"
},
"original": {
@ -53,10 +55,11 @@
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1772773019,
"lastModified": 1782467914,
"narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "aca4d95fce4914b3892661bcb80b8087293536c6",
"rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
"type": "github"
},
"original": {

View File

@ -1 +1 @@
24.13.0
24.18.0

View File

@ -13,7 +13,7 @@
},
"homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@10.28.1",
"packageManager": "pnpm@10.34.4",
"engines": {
"node": ">=24.0.0"
},
@ -51,37 +51,37 @@
"story:preview": "histoire preview"
},
"dependencies": {
"@floating-ui/dom": "1.7.4",
"@fortawesome/fontawesome-svg-core": "7.1.0",
"@fortawesome/free-regular-svg-icons": "7.1.0",
"@fortawesome/free-solid-svg-icons": "7.1.0",
"@fortawesome/vue-fontawesome": "3.1.3",
"@intlify/unplugin-vue-i18n": "11.0.3",
"@floating-ui/dom": "1.7.6",
"@fortawesome/fontawesome-svg-core": "7.3.0",
"@fortawesome/free-regular-svg-icons": "7.3.0",
"@fortawesome/free-solid-svg-icons": "7.3.0",
"@fortawesome/vue-fontawesome": "3.3.0",
"@intlify/unplugin-vue-i18n": "11.2.4",
"@kyvg/vue3-notification": "3.4.2",
"@sentry/vue": "10.36.0",
"@tiptap/core": "3.17.0",
"@tiptap/extension-blockquote": "3.17.0",
"@tiptap/extension-code-block-lowlight": "3.17.0",
"@tiptap/extension-hard-break": "3.17.0",
"@tiptap/extension-image": "3.17.0",
"@tiptap/extension-link": "3.17.0",
"@tiptap/extension-list": "3.17.0",
"@tiptap/extension-mention": "3.17.0",
"@tiptap/extension-table": "3.17.0",
"@tiptap/extension-typography": "3.17.0",
"@tiptap/extension-underline": "3.17.0",
"@tiptap/extensions": "3.17.0",
"@tiptap/pm": "3.17.0",
"@tiptap/starter-kit": "3.17.0",
"@tiptap/suggestion": "3.17.0",
"@tiptap/vue-3": "3.17.0",
"@vueuse/core": "14.1.0",
"@vueuse/router": "14.1.0",
"axios": "1.16.0",
"@sentry/vue": "10.62.0",
"@tiptap/core": "3.27.1",
"@tiptap/extension-blockquote": "3.27.1",
"@tiptap/extension-code-block-lowlight": "3.27.1",
"@tiptap/extension-hard-break": "3.27.1",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-link": "3.27.1",
"@tiptap/extension-list": "3.27.1",
"@tiptap/extension-mention": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/extension-typography": "3.27.1",
"@tiptap/extension-underline": "3.27.1",
"@tiptap/extensions": "3.27.1",
"@tiptap/pm": "3.27.1",
"@tiptap/starter-kit": "3.27.1",
"@tiptap/suggestion": "3.27.1",
"@tiptap/vue-3": "3.27.1",
"@vueuse/core": "14.3.0",
"@vueuse/router": "14.3.0",
"axios": "1.18.1",
"blurhash": "2.0.5",
"bulma-css-variables": "0.9.33",
"change-case": "5.4.4",
"dayjs": "1.11.19",
"dayjs": "1.11.21",
"dompurify": "3.4.11",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
@ -89,16 +89,16 @@
"is-touch-device": "1.0.1",
"klona": "2.0.6",
"lowlight": "3.3.0",
"marked": "17.0.1",
"nanoid": "5.1.6",
"marked": "17.0.6",
"nanoid": "5.1.16",
"pinia": "3.0.4",
"register-service-worker": "1.7.2",
"sortablejs": "1.15.6",
"ufo": "1.6.3",
"vue": "3.5.27",
"sortablejs": "1.15.7",
"ufo": "1.6.4",
"vue": "3.5.39",
"vue-advanced-cropper": "2.8.9",
"vue-flatpickr-component": "11.0.5",
"vue-i18n": "11.2.8",
"vue-i18n": "11.4.6",
"vue-router": "4.6.4",
"vuemoji-picker": "0.3.2",
"workbox-precaching": "7.4.1",
@ -108,7 +108,7 @@
"@faker-js/faker": "10.5.0",
"@histoire/plugin-screenshot": "1.0.0-beta.1",
"@histoire/plugin-vue": "1.0.0-beta.1",
"@playwright/test": "1.58.2",
"@playwright/test": "1.61.1",
"@sentry/vite-plugin": "3.6.1",
"@tailwindcss/vite": "4.3.1",
"@tsconfig/node24": "24.0.4",
@ -117,15 +117,15 @@
"@types/node": "24.13.2",
"@types/sortablejs": "1.15.9",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@vitejs/plugin-vue": "6.0.7",
"@vue/eslint-config-typescript": "14.8.0",
"@vue/eslint-config-typescript": "14.9.0",
"@vue/test-utils": "2.4.11",
"@vue/tsconfig": "0.9.1",
"@vueuse/shared": "14.3.0",
"autoprefixer": "10.5.0",
"browserslist": "4.28.2",
"autoprefixer": "10.5.2",
"browserslist": "4.28.4",
"caniuse-lite": "1.0.30001799",
"csstype": "3.2.3",
"esbuild": "0.28.1",
@ -150,9 +150,9 @@
"tailwindcss": "4.3.1",
"typescript": "5.9.3",
"unplugin-inject-preload": "3.0.0",
"vite": "7.3.5",
"vite": "7.3.6",
"vite-plugin-pwa": "1.3.0",
"vite-plugin-vue-devtools": "8.1.3",
"vite-plugin-vue-devtools": "8.1.4",
"vite-svg-loader": "5.1.1",
"vitest": "4.1.9",
"vue-tsc": "3.3.5",
@ -169,20 +169,20 @@
"vue-demi"
],
"overrides": {
"minimatch": "^10.2.3",
"minimatch": "10.2.5",
"rollup": "$rollup",
"basic-ftp": ">=5.2.2",
"serialize-javascript": "^7.0.5",
"flatted": "^3.4.1",
"ip-address": ">=10.1.1",
"postcss": ">=8.5.10",
"tmp": ">=0.2.7",
"esbuild": ">=0.28.1",
"form-data": ">=4.0.6",
"markdown-it": ">=14.2.0",
"launch-editor": ">=2.14.1",
"@babel/core": ">=7.29.6",
"js-yaml@4": ">=4.2.0"
"basic-ftp": "6.0.1",
"serialize-javascript": "7.0.6",
"flatted": "3.4.2",
"ip-address": "10.2.0",
"postcss": "8.5.15",
"tmp": "0.2.7",
"esbuild": "0.28.1",
"form-data": "4.0.6",
"markdown-it": "14.2.0",
"launch-editor": "2.14.1",
"@babel/core": "8.0.1",
"js-yaml@4": "5.2.0"
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,34 @@
import {describe, it, expect} from 'vitest'
import {buildStoredQuery} from './useTaskList'
describe('buildStoredQuery', () => {
it('includes sort when set', () => {
expect(buildStoredQuery({sort: 'due_date:asc', filter: undefined, s: undefined, page: 1}))
.toEqual({sort: 'due_date:asc'})
})
it('includes filter and search when set', () => {
expect(buildStoredQuery({sort: undefined, filter: 'done = false', s: 'foo', page: 1}))
.toEqual({filter: 'done = false', s: 'foo'})
})
it('omits page when it equals the default of 1', () => {
expect(buildStoredQuery({sort: 'id:desc', filter: undefined, s: undefined, page: 1}))
.toEqual({sort: 'id:desc'})
})
it('includes page when greater than 1', () => {
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 3}))
.toEqual({page: '3'})
})
it('returns an empty object when nothing is set', () => {
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 1}))
.toEqual({})
})
it('skips empty strings', () => {
expect(buildStoredQuery({sort: '', filter: '', s: '', page: 1}))
.toEqual({})
})
})

View File

@ -1,4 +1,6 @@
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
import {useRouter, isNavigationFailure} from 'vue-router'
import type {LocationQueryRaw} from 'vue-router'
import {useRouteQuery} from '@vueuse/router'
import TaskCollectionService, {
@ -10,6 +12,7 @@ import type {ITask} from '@/modelTypes/ITask'
import {error} from '@/message'
import type {IProject} from '@/modelTypes/IProject'
import {useAuthStore} from '@/stores/auth'
import {useViewFiltersStore} from '@/stores/viewFilters'
import type {IProjectView} from '@/modelTypes/IProjectView'
export type Order = 'asc' | 'desc' | 'none'
@ -59,6 +62,22 @@ const SORT_BY_DEFAULT: SortBy = {
id: 'desc',
}
interface TaskListQueryState {
sort: string | undefined
filter: string | undefined
s: string | undefined
page: number
}
export function buildStoredQuery(state: TaskListQueryState): LocationQueryRaw {
const query: LocationQueryRaw = {}
if (state.sort) query.sort = state.sort
if (state.filter) query.filter = state.filter
if (state.s) query.s = state.s
if (state.page > 1) query.page = String(state.page)
return query
}
// This makes sure an id sort order is always sorted last.
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
// precedence over everything else, making any other sort columns pretty useless.
@ -94,6 +113,9 @@ export function useTaskList(
const projectId = computed(() => projectIdGetter())
const projectViewId = computed(() => projectViewIdGetter())
const router = useRouter()
const viewFiltersStore = useViewFiltersStore()
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
const page = useRouteQuery('page', '1', { transform: Number })
@ -119,6 +141,55 @@ export function useTaskList(
},
})
// Mirror the URL query bits this composable owns into the store so
// in-project tab switches and sidebar re-visits can restore them.
//
// `ProjectList`/`ProjectTable` are reused across project switches (no
// `:key` on them in ProjectView.vue), so setup runs only once. We track
// the last viewId we synced — on every viewId transition, if the URL has
// none of our params and the store has an entry, restore it via
// `router.replace` and skip writing back the empty state we'd otherwise
// clobber the saved entry with.
let lastSyncedViewId: number | undefined
watch(
[projectViewId, sortQuery, filter, s, page],
([viewId, sortValue, filterValue, sValue, pageValue]) => {
const viewIdChanged = viewId !== lastSyncedViewId
lastSyncedViewId = viewId
// An invalid `?page=` becomes NaN via `transform: Number`; treat it as
// the default so it neither blocks restoration nor wipes stored state.
const currentPage = Number.isInteger(pageValue) ? pageValue : 1
const urlIsEmpty = !sortValue && !filterValue && !sValue && currentPage === 1
if (viewIdChanged && urlIsEmpty) {
const storedQuery = viewFiltersStore.getViewQuery(viewId)
if (Object.keys(storedQuery).length > 0) {
// Merge so unrelated query params on the route survive the restore.
// Swallow navigation failures (e.g. aborted/duplicated) so the
// ignored promise can't surface as an unhandled rejection.
router.replace({query: {...router.currentRoute.value.query, ...storedQuery}})
.catch(failure => {
if (!isNavigationFailure(failure)) throw failure
})
return
}
}
const query = buildStoredQuery({
sort: sortValue as string | undefined,
filter: filterValue as string | undefined,
s: sValue as string | undefined,
page: currentPage,
})
if (Object.keys(query).length > 0) {
viewFiltersStore.setViewQuery(viewId, query)
} else {
viewFiltersStore.clearViewQuery(viewId)
}
},
{immediate: true},
)
const allParams = computed(() => {
const loadParams = {...params.value}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -349,6 +349,7 @@
"shared": "Geteilte Projekte",
"noDescriptionAvailable": "Keine Projektbeschreibung verfügbar.",
"inboxTitle": "Eingang",
"myOpenTasksFilterTitle": "Meine offenen Aufgaben",
"favorite": "Dieses Projekt als Favorit markieren",
"unfavorite": "Dieses Projekt von Favoriten entfernen",
"openSettingsMenu": "Projekteinstellungen öffnen",
@ -393,6 +394,7 @@
"title": "Dupliziere dieses Projekt",
"label": "Duplizieren",
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
"shares": "Freigaben kopieren (Benutzer:innen, Teams und Linkfreigaben)",
"success": "Das Projekt wurde erfolgreich dupliziert."
},
"edit": {

View File

@ -349,6 +349,7 @@
"shared": "Geteilte Projekte",
"noDescriptionAvailable": "Keine Projektbeschreibung verfügbar.",
"inboxTitle": "Eingang",
"myOpenTasksFilterTitle": "Meine offenen Aufgaben",
"favorite": "Dieses Projekt als Favorit markieren",
"unfavorite": "Dieses Projekt von Favoriten entfernen",
"openSettingsMenu": "Projekteinstellungen öffnen",
@ -393,6 +394,7 @@
"title": "Dupliziere dieses Projekt",
"label": "Duplizieren",
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
"shares": "Freigaben kopieren (Benutzer:innen, Teams und Linkfreigaben)",
"success": "Das Projekt wurde erfolgreich dupliziert."
},
"edit": {

View File

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

View File

@ -349,6 +349,7 @@
"shared": "Shared Projects",
"noDescriptionAvailable": "No project description is available.",
"inboxTitle": "Inbox",
"myOpenTasksFilterTitle": "My Open Tasks",
"favorite": "Mark this project as favorite",
"unfavorite": "Remove this project from favorites",
"openSettingsMenu": "Open project settings menu",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

123
go.mod
View File

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

179
go.sum
View File

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

View File

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

View File

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

View File

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

View File

@ -95,6 +95,7 @@ const (
AuthLdapBindPassword Key = `auth.ldap.bindpassword`
AuthLdapGroupSyncEnabled Key = `auth.ldap.groupsyncenabled`
AuthLdapGroupSyncFilter Key = `auth.ldap.groupsyncfilter`
AuthLdapGroupSyncUseServiceAccount Key = `auth.ldap.groupsyncuseserviceaccount`
AuthLdapAvatarSyncAttribute Key = `auth.ldap.avatarsyncattribute`
AuthLdapAttributeUsername Key = `auth.ldap.attribute.username`
AuthLdapAttributeEmail Key = `auth.ldap.attribute.email`
@ -389,6 +390,7 @@ func InitDefaultConfig() {
AuthLdapVerifyTLS.setDefault(true)
AuthLdapGroupSyncEnabled.setDefault(false)
AuthLdapGroupSyncFilter.setDefault("(&(objectclass=*)(|(objectclass=group)(objectclass=groupOfNames)))")
AuthLdapGroupSyncUseServiceAccount.setDefault(false)
AuthLdapAttributeUsername.setDefault("uid")
AuthLdapAttributeEmail.setDefault("mail")
AuthLdapAttributeDisplayname.setDefault("displayName")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

47
pkg/richtext/main_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

153
pkg/richtext/tiptap.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -257,7 +257,7 @@ func Init(ctx context.Context, opts *Options) (*Result, error) {
return nil, output.Wrap(output.CodeUnknown, err, "mint bot token: %v", err)
}
if mintedToken.Token == "" {
return nil, output.New(output.CodeUnknown, "PUT /tokens did not return a token plaintext — cannot continue")
return nil, output.New(output.CodeUnknown, "POST /tokens did not return a token plaintext — cannot continue")
}
// 11. Persist credentials. Discard human JWT immediately after.

View File

@ -211,8 +211,9 @@ func TestConfirmOverwriteExistingConfig(t *testing.T) {
}
// bucketServer is a minimal httptest server modelling
// GET/PUT /api/v1/projects/{p}/views/{v}/buckets. The caller pre-seeds
// existing buckets; PUT requests append to that list with a synthetic ID.
// GET/POST /api/v2/projects/{p}/views/{v}/buckets. The caller pre-seeds
// existing buckets; POST requests append to that list with a synthetic ID.
// GET returns the standard v2 list envelope; POST returns the bare bucket.
type bucketServer struct {
mu sync.Mutex
existing []*client.Bucket
@ -232,7 +233,7 @@ func newBucketServer(seed []*client.Bucket) *bucketServer {
func (s *bucketServer) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Path is /api/v1/projects/{p}/views/{v}/buckets.
// Path is /api/v2/projects/{p}/views/{v}/buckets.
if !strings.HasSuffix(r.URL.Path, "/buckets") || !strings.Contains(r.URL.Path, "/views/") {
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusInternalServerError)
return
@ -242,8 +243,15 @@ func (s *bucketServer) handler() http.Handler {
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(s.existing)
case http.MethodPut:
// v2 list envelope; the buckets list isn't server-paginated.
_ = json.NewEncoder(w).Encode(map[string]any{
"items": s.existing,
"total": len(s.existing),
"page": 1,
"per_page": 50,
"total_pages": 1,
})
case http.MethodPost:
var b client.Bucket
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)

View File

@ -23,5 +23,5 @@ import (
// AddAssignee assigns a user (typically the bot) to a task.
func (c *Client) AddAssignee(ctx context.Context, taskID, userID int64) error {
return c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/assignees", taskID), nil, &TaskAssignee{UserID: userID}, nil)
return c.Do(ctx, "POST", fmt.Sprintf("/tasks/%d/assignees", taskID), nil, &TaskAssignee{UserID: userID}, nil)
}

View File

@ -21,25 +21,28 @@ import (
"fmt"
)
// ListBuckets returns the buckets configured on a Kanban view.
// ListBuckets returns the buckets configured on a Kanban view. Bucket.ReadAll
// ignores page/per_page and returns every bucket in a single page (the envelope
// total reflects the full set), so one GET gets them all — paging would
// re-fetch the same buckets and duplicate them. Unwrap .items.
func (c *Client) ListBuckets(ctx context.Context, projectID, viewID int64) ([]*Bucket, error) {
var out []*Bucket
path := fmt.Sprintf("/projects/%d/views/%d/buckets", projectID, viewID)
if err := c.Do(ctx, "GET", path, nil, nil, &out); err != nil {
items, _, err := doList[*Bucket](ctx, c, path, nil)
if err != nil {
return nil, err
}
return out, nil
return items, nil
}
// CreateBucket inserts a new bucket into a Kanban view.
// CreateBucket inserts a new bucket into a Kanban view. The project and view
// come from the URL; the v2 handler ignores project_view_id in the body.
func (c *Client) CreateBucket(ctx context.Context, projectID, viewID int64, b *Bucket) (*Bucket, error) {
var out Bucket
path := fmt.Sprintf("/projects/%d/views/%d/buckets", projectID, viewID)
if b == nil {
b = &Bucket{}
}
b.ProjectViewID = viewID
if err := c.Do(ctx, "PUT", path, nil, b, &out); err != nil {
if err := c.Do(ctx, "POST", path, nil, b, &out); err != nil {
return nil, err
}
return &out, nil
@ -47,17 +50,13 @@ func (c *Client) CreateBucket(ctx context.Context, projectID, viewID int64, b *B
// MoveTaskToBucket positions an existing task in `bucketID` on the
// project's view. Vikunja stores task↔bucket relations in a separate
// table (`task_buckets`), so POST /tasks/{id} with bucket_id does not
// reliably move tasks — this dedicated endpoint is the one the Kanban
// UI's drag-and-drop uses.
// table (`task_buckets`); a task update with bucket_id does not reliably
// move tasks — this dedicated endpoint is the one the Kanban UI's
// drag-and-drop uses. On v2 it's a PUT, and project/view/bucket all come
// from the URL, so the body only carries the task id.
func (c *Client) MoveTaskToBucket(ctx context.Context, projectID, viewID, bucketID, taskID int64) error {
path := fmt.Sprintf("/projects/%d/views/%d/buckets/%d/tasks",
projectID, viewID, bucketID)
body := map[string]int64{
"task_id": taskID,
"project_view_id": viewID,
"bucket_id": bucketID,
"project_id": projectID,
}
return c.Do(ctx, "POST", path, nil, body, nil)
body := map[string]int64{"task_id": taskID}
return c.Do(ctx, "PUT", path, nil, body, nil)
}

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