Compare commits

..
Author SHA1 Message Date
kolaente c058835874 feat(filters): move filter query to contenteditable 2024-03-05 17:47:06 +01:00
kolaente 11bc4764de feat(filters): add basic autocomplete component 2024-03-05 10:37:26 +01:00
kolaente 9f7f187440 chore: update lockfile 2024-03-05 09:58:36 +01:00
kolaente e843438efd feat(filters): show user name and avatar for assignee filters 2024-03-05 09:58:10 +01:00
kolaente 9381f65ceb fix(filters): date filter value not populated 2024-03-05 09:58:10 +01:00
kolaente 712f8fc13b feat(filters): add date values 2024-03-05 09:58:09 +01:00
kolaente f699b53744 feat(filters): make date values in filter query editable 2024-03-05 09:58:09 +01:00
kolaente 74a39a5cf0 chore(filters): copy datepicker 2024-03-05 09:58:09 +01:00
kolaente f137064ea9 chore(filters): add histoire story file 2024-03-05 09:58:09 +01:00
kolaente caf3cb216d feat(filters): parse date properties to enable datepicker button 2024-03-05 09:58:09 +01:00
kolaente 4f15f27fe1 fix(filters): use readable colors for dark and light mode 2024-03-05 09:58:09 +01:00
kolaente d75c20ea48 feat(filter): add auto resize for filter query input 2024-03-05 09:58:09 +01:00
kolaente 0359b12648 feat(filter): add basic highlighting filter query component 2024-03-05 09:58:09 +01:00
kolaente bcd414b5e7 feat(filters): make new filter syntax work with Typesense 2024-03-05 09:58:04 +01:00
kolaente 7c47930f8e fix(filters): lint 2024-03-05 09:58:00 +01:00
kolaente fc7c873dd6 chore(filters): cleanup old variables 2024-03-05 09:58:00 +01:00
kolaente 8d34f9b260 fix(tests): make filter tests work again 2024-03-05 09:57:59 +01:00
kolaente ffcfc85b00 fix(filter): correctly filter for buckets 2024-03-05 09:57:59 +01:00
kolaente 52c8ed9738 feat(filter): add in keyword 2024-03-05 09:57:59 +01:00
kolaente cc78411866 feat(filter): add better error message when passing an invalid filter expression 2024-03-05 09:57:59 +01:00
kolaente b5e781fedb chore(filter): cleanup 2024-03-05 09:57:59 +01:00
kolaente 5fe9fc73a9 feat(filter): migrate existing saved filters 2024-03-05 09:57:59 +01:00
kolaente d30615d527 feat(filter): nesting 2024-03-05 09:57:59 +01:00
kolaente 605a2131ba feat(filter): more tests 2024-03-05 09:57:59 +01:00
kolaente 9cd88e97e4 fix(filter): translate all tests 2024-03-05 09:57:59 +01:00
kolaente afb425f0c2 fix(filter): allow filtering for "project" 2024-03-05 09:57:59 +01:00
kolaente 16f206b3cc fix(filter): allow filtering on "in" condition 2024-03-05 09:57:59 +01:00
kolaente d9cb2d1755 fix(filter): don't crash on empty filter 2024-03-05 09:57:59 +01:00
kolaente 54a9ea84d5 fix(filter): make sure single filter condition works 2024-03-05 09:57:59 +01:00
kolaente f470c0c297 feat(filters): basic text filter works now 2024-03-05 09:57:58 +01:00
kolaente 8d2f6c8567 feat(filters): very basic filter parsing 2024-03-05 09:57:58 +01:00
renovateandkonrad 43b0689c1a chore(deps): update dev-dependencies 2024-03-05 07:21:24 +00:00
renovateandkonrad 48d1937bd4 fix(deps): update module golang.org/x/oauth2 to v0.18.0 2024-03-05 07:21:02 +00:00
renovate 3e5d55d9e8 fix(deps): update dependency vue-flatpickr-component to v11.0.5 2024-03-05 05:06:23 +00:00
a3154e805c fix(auth): use (issuer, name) to check for uniqueness of oidc teams (#2152)
The change introduced in #2150 introduces a bug where a Team would be re-created every time a user logs in, as the check if a team already exists was based on both the unique `oidcID` and the `name`. This PR proposes to only base the check on the ID, as this should be unique.

Co-authored-by: Daniel Herrmann <daniel.herrmann1@gmail.com>
Reviewed-on: https://kolaente.dev/vikunja/vikunja/pulls/2152
Reviewed-by: konrad <k@knt.li>
Co-authored-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
Co-committed-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
2024-03-04 20:26:45 +00:00
renovate 2414b580c1 fix(deps): update module golang.org/x/crypto to v0.21.0 2024-03-04 19:06:31 +00:00
renovate 87ac9e261f fix(deps): update module golang.org/x/term to v0.18.0 2024-03-04 18:06:30 +00:00
renovateandkonrad 63e9f8f682 fix(deps): update module github.com/arran4/golang-ical to v0.2.7 2024-03-04 16:37:53 +00:00
renovate 5a9de579cc fix(deps): update module golang.org/x/sys to v0.18.0 2024-03-04 16:05:48 +00:00
renovateandkonrad 42e660c3d6 fix(deps): update dependency vue-i18n to v9.10.1 2024-03-04 11:27:12 +00:00
renovateandkonrad 6533e75496 fix(deps): update module github.com/stretchr/testify to v1.9.0 2024-03-04 11:27:09 +00:00
renovateandkonrad f8c5e314df fix(deps): update sentry-javascript monorepo to v7.105.0 2024-03-04 10:14:01 +00:00
renovate 429b140cad chore(deps): update dependency vue-tsc to v2 2024-03-04 05:07:03 +00:00
renovateandkonrad dc7ee851ec chore(deps): update dev-dependencies 2024-03-03 22:31:33 +00:00
92d9c31101 docs: improve OpenID documentation (#2151)
This PR stems from issue #2150, in turn following up on PR #1393

It adds additional details around the OIDC authentication feature, as well as details about how the team assignment works.

Co-authored-by: Daniel Herrmann <daniel.herrmann1@gmail.com>
Reviewed-on: https://kolaente.dev/vikunja/vikunja/pulls/2151
Reviewed-by: konrad <k@knt.li>
Co-authored-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
Co-committed-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
2024-03-03 22:19:16 +00:00
kolaente ac8751e1be fix(task): move done tasks to the done bucket when they are moved between projects and the new project has a done bucket 2024-03-03 18:13:47 +01:00
kolaente f5b90517c4 fix(sentry): send unwrapped error to sentry instead of http error 2024-03-03 17:36:57 +01:00
kolaente fe27dd59ad feat(subscription): use a recursive cte to fetch subscriptions of parent projects
Testing this locally resulted in improved response times from ~50ms to ~20ms when creating a project. It looks like even though the code running these sql queries uses different go routines, they affect each other (caused by IO or context switching?)
2024-03-03 15:34:18 +01:00
kolaente 22933dac4a fix(project): typo in table name 2024-03-03 12:47:00 +01:00
kolaente fe02f4da2c fix(project): check for project nesting cycles with a single recursive cte instead of a loop 2024-03-03 11:40:43 +01:00
Frederick [Bot]andkolaente 4bb09b69be [skip ci] Updated swagger docs 2024-03-02 14:50:56 +00:00
kolaente 379b0b24b3 fix(auth): test assertion 2024-03-02 15:38:01 +01:00
kolaente 6db8728420 docs: add missing front matter 2024-03-02 15:34:59 +01:00
kolaente a34ca20c1a fix(teams): use the same color for border between teams in list 2024-03-02 15:31:54 +01:00
kolaente a4a0ea973a feat(auth): update team name in Vikunja when it was changed in the openid provider 2024-03-02 15:27:15 +01:00
kolaente fc4303a778 chore(auth): add oidc suffix to openid team name in db
Related to https://kolaente.dev/vikunja/vikunja/issues/2150
2024-03-02 15:23:19 +01:00
kolaente 4f1f96f1e9 chore(auth): refactor openid team creation 2024-03-02 15:22:37 +01:00
kolaente 10ff864e0c fix(projects): load projects only one when fetching subscriptions for a bunch of projects at once
This change ensures already loaded projects are passed down when fetching their subscription  instead of re-loading each project with a single sql statement. When loading all projects, this meant all projects were loaded twice, which was highly inefficient. This roughly added 25ms to each request, assuming the per page limit was maxed out at 50 projects.

Empirical testing shows this change reduces load times by ~20ms. Because the request is already pretty fast, this is ~30% of the overall request time, making the loading of projects now even faster
2024-03-02 14:27:11 +01:00
kolaente 89b01e86bc fix(projects): load all projects when first opening Vikunja 2024-03-02 13:43:04 +01:00
kolaente a3932a0a19 fix(projects): return correct project pagination count 2024-03-02 13:30:34 +01:00
kolaente e42a605597 fix: add root ca to final docker image
Since Vikunja's docker image is now based on the scratch image, the root ca certificates are not included by default anymore. This meant Vikunja could not check if the certificate presented by a remote host was valid, thus failing the connection. This meant it was impossible to use features communicating with external hosts such as webhooks, openid auth or gravatar.
2024-03-02 11:48:14 +01:00
kolaente 67f55510bf feat: nest api token permissions under their parents
This change removes the "select all" first checkbox of api token permissions and replaces it with the title instead.

Resolves https://kolaente.dev/vikunja/vikunja/issues/2148
2024-03-02 11:28:46 +01:00
kolaente 178cd8c392 fix: open external migration service in current tab 2024-03-02 11:22:03 +01:00
viehliebandkonrad ed4da96ab1 feat: assign users to teams via OIDC claims (#1393)
This change adds the ability to sync teams via a custom openid claim. Vikunja will automatically create and delete teams as necessary, it will also add and remove users when they log in. These teams are fully managed by Vikunja and cannot be updated by a user.

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/vikunja/pulls/1393
Resolves https://kolaente.dev/vikunja/vikunja/issues/1279
Resolves https://github.com/go-vikunja/vikunja/issues/42
Resolves https://kolaente.dev/vikunja/vikunja/issues/950
Co-authored-by: viehlieb <pf@pragma-shift.net>
Co-committed-by: viehlieb <pf@pragma-shift.net>
2024-03-02 08:47:10 +00:00
kolaente f18cde269b chore: remove unused docker entrypoint script 2024-03-01 11:47:29 +01:00
kolaente 09d446765d docs: update config docs 2024-03-01 11:47:29 +01:00
renovate a99e7f9aa3 fix(deps): update dependency express to v4.18.3 2024-02-29 12:05:06 +00:00
renovate e93e48c4c2 fix(deps): update vueuse to v10.9.0 2024-02-29 10:05:25 +00:00
renovate 06688fa8d4 fix(deps): update sentry-javascript monorepo to v7.103.0 2024-02-29 09:05:28 +00:00
renovateandkonrad 1a15c865e2 fix(deps): update module golang.org/x/crypto to v0.20.0 2024-02-29 08:18:30 +00:00
renovateandkonrad 7b9a72ebe1 fix(deps): update module github.com/prometheus/client_golang to v1.19.0 2024-02-29 08:15:51 +00:00
renovate a0f4bdbdad fix(deps): update dependency vue to v3.4.21 2024-02-28 22:06:03 +00:00
renovate eea356e570 chore(deps): update dev-dependencies 2024-02-28 18:05:31 +00:00
kolaente 5b70609ba7 fix: usage of limit and order by usage in recursive cte 2024-02-28 14:35:09 +01:00
kolaente 6b1e67485b feat: fetch all projects with a recursive cte instead of recursive query
This change modifies the fetching of all projects to use a recursive common table expression instead of recursively calling the method.
2024-02-28 13:42:45 +01:00
kolaente 5d127c2897 feat: run frontend tests with api build from the same branch (#2137)
Reviewed-on: https://kolaente.dev/vikunja/vikunja/pulls/2137
Co-authored-by: kolaente <k@knt.li>
Co-committed-by: kolaente <k@knt.li>
2024-02-27 18:06:34 +00:00
kolaente 1275dfc260 fix: lint 2024-02-27 16:38:20 +01:00
kolaente 997fb6bc54 fix(migration): show correct help message when a migration was started 2024-02-27 16:36:18 +01:00
kolaente b2e5de88ff fix(labels): make sure labels are aligned in the middle 2024-02-27 16:29:27 +01:00
kolaente baa5d14ca6 fix(assignees): spacing of users 2024-02-27 16:15:35 +01:00
kolaente 2d5c496397 fix(kanban): pass active filters down to task lazy loading
Before this change, applying a filter and then scrolling a bucket would not use that filter when lazy loading the tasks in that bucket. That resulted in all tasks being loaded, regardless if the filter applied to them.
2024-02-27 16:10:19 +01:00
kolaente b8533d2bfc fix(gantt): use color variables for gantt header so that it works in dark mode 2024-02-27 15:55:07 +01:00
kolaente 8a82093233 fix(project): don't allow archival or deletion of default projects in UI 2024-02-27 15:37:30 +01:00
kolaente 3d39fc3960 fix(ci): sign drone config 2024-02-26 10:56:21 +01:00
kolaente 43e13d9cdd fix(ci): run test db in memory 2024-02-26 10:54:53 +01:00
kolaente a0e812395f feat(docker)!: use scratch as base image
This change modifies the docker image so that it uses the scratch image instead of alpine. This is possible because the Vikunja binary is now entirely statically compiled, wich no dependencies on any system c libraries.

This also changes the default path for the sqlite file in the docker image (breaking). When using sqlite in docker, the VIKUNJA_DATABASE_PATH config variable or the file mount must be changed to prevent data loss.
2024-02-25 22:49:04 +01:00
kolaente 2e5c19352e docs: update publiccode.yml [skip ci] 2024-02-25 14:06:23 +01:00
kolaente 1ffb93b63c fix: clarify preview deployment text and fix typo 2024-02-25 13:51:43 +01:00
renovateandkonrad 1bf8659423 fix(deps): update sentry-javascript monorepo to v7.102.1 2024-02-24 14:43:38 +00:00
renovate 7629c8426e chore(deps): update pnpm to v8.15.4 2024-02-24 14:05:08 +00:00
renovate a83acc0300 fix(deps): update tiptap to v2.2.4 2024-02-23 13:06:11 +00:00
kolaente 837360f122 chore: add publiccode.yml 2024-02-22 10:04:50 +01:00
renovate 34c31e2f03 fix(deps): update dependency vue-router to v4.3.0 2024-02-21 18:06:09 +00:00
renovateandkonrad 829f504d3b fix(deps): update dependency dompurify to v3.0.9 2024-02-21 10:04:30 +00:00
renovate 899bc67853 chore(deps): update dependency electron to v29 2024-02-21 00:06:09 +00:00
renovate b419df3156 fix(deps): update module github.com/redis/go-redis/v9 to v9.5.1 2024-02-20 16:06:59 +00:00
renovateandkonrad 52d0930034 fix(deps): update sentry-javascript monorepo to v7.102.0 2024-02-20 14:41:55 +00:00
renovate 318f00d252 fix(deps): update vueuse to v10.8.0 2024-02-20 12:06:28 +00:00
kolaente 4d11dd0383 docs: mention how to support the project in readme 2024-02-19 23:16:22 +01:00
kolaente e40a0043d4 fix(migration): do not halt the whole migration when copying a background file failed 2024-02-19 19:21:06 +01:00
kolaente e532979101 docs: clarify public url usage in installation 2024-02-19 19:04:16 +01:00
renovate 503036abff fix(deps): update module github.com/redis/go-redis/v9 to v9.5.0 2024-02-18 18:05:57 +00:00
renovateandkonrad 70265e176a fix(deps): update tiptap to v2.2.3 2024-02-18 11:16:39 +00:00
renovateandkonrad 68873e1d0d fix(deps): update module github.com/go-testfixtures/testfixtures/v3 to v3.10.0 2024-02-18 11:16:11 +00:00
renovate be66ec8608 fix(deps): update dependency @kyvg/vue3-notification to v3.2.0 2024-02-18 09:06:16 +00:00
renovateandkonrad 814c142b71 fix(deps): update sentry-javascript monorepo to v7.101.1 2024-02-18 08:28:03 +00:00
renovate ea75657d45 fix(deps): update dependency vue-flatpickr-component to v11.0.4 2024-02-18 08:06:08 +00:00
kolaente ff1730e323 fix: lint 2024-02-17 23:25:09 +01:00
kolaente f120d72211 fix(editor): revert task list dependence on ids
This partially reverts 3969f6ae66. Adding ids to task list items is not as simple and actually made it worse in some cases. Hence we stick to comparing the content of nodes for now, until this is properly fixed in tiptap.

Related https://kolaente.dev/vikunja/vikunja/issues/2091#issuecomment-60063
2024-02-17 23:13:51 +01:00
renovateandkonrad a2951570a7 fix(deps): update module github.com/arran4/golang-ical to v0.2.6 2024-02-17 20:08:08 +00:00
kolaente f4efdaa5de fix(password): don't validate password min length on login page
This would cause the login to fail when the actual password was shorter than 8 characters.
2024-02-17 21:07:35 +01:00
kolaente 32edef2d38 fix(editor): set default id of tasklist items
This fixes a bug where all tasklist items would have the same id which would check all of them when checking one of them.
2024-02-17 11:38:20 +01:00
renovate 44c1f0d281 chore(deps): update pnpm to v8.15.3 2024-02-15 11:07:31 +00:00
kolaente 2dab2ccedd feat: allow using sqlite in memory database
This allows running vikunja for testing purposes. You almost never want to run this in production.
2024-02-15 10:48:48 +01:00
kolaente 827c43fe12 chore(magefile): add aliases for lint 2024-02-14 15:05:11 +01:00
kolaente 415c6380a5 feat(api tokens): add task attachment to api scopes
This explicitly adds download and upload of task attachments. Because these are not handled with the usual CRUDables, they were not picked up automatically.

Resolves https://github.com/go-vikunja/vikunja/issues/112
2024-02-14 15:00:16 +01:00
renovateandkonrad ebe25ee2d7 fix(deps): update module github.com/arran4/golang-ical to v0.2.5 2024-02-14 13:35:06 +00:00
kolaente cc5f48eb74 fix: lint 2024-02-14 14:13:17 +01:00
kolaente 3969f6ae66 fix(editor): ensure task list clicks are only fired once
Before this fix, clicking on a task list item with the same name as another one, both would get marked as done. This was due to the mechanism which walks the dom tree to look for the node to update used its content for comparison. To prevent this, this fix first added unique ids to all task list items and then compared the nodes based on their id instead of the content.

Resolves https://kolaente.dev/vikunja/vikunja/issues/2091
2024-02-14 14:13:03 +01:00
kolaente 5ab720d709 docs: remove outdated information 2024-02-14 10:21:51 +01:00
kolaente 7ae38c5ac1 docs: fix postgres example healthcheck 2024-02-14 10:21:27 +01:00
kolaente 162741e940 fix: lint 2024-02-13 22:24:46 +01:00
kolaente 205f330f8a fix(migration): make sure to correctly check if a migration was already running
This change fixes a bug where Vikunja would not correctly check if a migration was already running. That meant it was not possible for users who had never before migrated anything to start a migration, because Vikunja assumed they already had a migration running for them.
This state was neither properly reflected in the frontend, which is now fixed as well.
2024-02-13 22:21:59 +01:00
kolaente a12c169ce8 fix: do not send etag when serving the frontend index file
Without this change, the browser may serve an outdated index.html file which usually does not work, showing the user only a blank page.
2024-02-13 21:32:41 +01:00
kolaente 2facbae0d7 fix(dump): only allow imports from the same version they were dumped on
Previously, Vikunja would allow imports from any version which then caused problems since the table structure might have changed between releases. This change now checks if the current version is the same as the one the dump was created on.
2024-02-13 21:25:31 +01:00
kolaente 77a779acea fix(dump): do not export files which do not exist in storage 2024-02-13 21:14:31 +01:00
kolaente 89e349f2fd docs: fix database healthcheck command 2024-02-13 20:38:53 +01:00
renovate 77feae47d6 fix(deps): update sentry-javascript monorepo to v7.101.0 2024-02-13 15:05:51 +00:00
renovate 2fb263a109 fix(deps): update dependency vue to v3.4.19 2024-02-13 11:06:25 +00:00
kolaente 641fec1215 fix: never return frontend on routes starting with /api
This fixes a problem where Vikunja would sometimes return the html for the frontend when accessing an api route for a nonexistent ressource, because the static handler was the next best.

Resolves https://kolaente.dev/vikunja/vikunja/issues/2110
2024-02-13 10:05:15 +01:00
kolaente 18374c2e52 docs: fix healthcheck and mariadb password 2024-02-13 09:55:12 +01:00
renovateandkonrad 9921551f96 chore(deps): update pnpm to v8.15.2 2024-02-13 08:24:39 +00:00
renovate b26c359ce6 chore(deps): update dev-dependencies to v7 2024-02-13 00:06:22 +00:00
kolaente d7d3ffeebd feat(navigation): persist project open state in navigation
With this change, the project's collapsed open/closed state in the navigation survives a browser reload. Previously, all state would be lost after reloading.

Resolves https://kolaente.dev/vikunja/vikunja/issues/2067
2024-02-12 22:22:55 +01:00
kolaente 0b61885e89 fix(tasks): correctly show different project in related tasks 2024-02-12 22:05:33 +01:00
kolaente 8d5cb335bd fix(tasks): sort done tasks last in relations
When adding a new task relation, the task search input would previously show all tasks in a seemingly random order, including done tasks. Usually, you don't care about these done tasks when adding relations. This change modifies the sort order so that done tasks show up last in the search results.
2024-02-12 22:00:33 +01:00
kolaente 71e62541a1 fix(ci): escape bash script for drone variable substitution 2024-02-12 21:56:25 +01:00
kolaente ee065d9238 feat(tasks): make done at column available for selection in table view
This change adds the done at column of tasks to the table view. It also ensures it is possible to sort the tasks by that column.

https://community.vikunja.io/t/is-it-possible-to-list-done-tasks-with-their-completion-date/1922
2024-02-12 17:56:24 +01:00
kolaente 28ed754c66 fix(ci): correctly set shell for rename command
It looks like Drone executes all commands with sh, even if the default shell of the container is something else. Because sh does not have support for whitespaces in string extrapolation, the rename command would fail. This change always specifies bash as the shell.
2024-02-12 17:37:20 +01:00
kolaente 390f71b0c6 feat(registration): improve username and password validation
Username and password are validated in the api for length and whitespaces. Previously, the api would tell the user "hey you got it wrong" but the error was not reflected properly in the UI. This change implements a client-side validation which mirrors the one from the api, allowing instant validation and better error UX.
2024-02-12 17:32:24 +01:00
kolaente 8c6d98bb02 feat(kanban): debounce bucket limit setting 2024-02-12 17:05:48 +01:00
kolaente ef30868514 fix(ci): use correct docker image for desktop rename 2024-02-12 16:39:05 +01:00
kolaente 75eb878e33 fix(ci): use correct docker image 2024-02-12 14:24:51 +01:00
kolaente 1ab6fef70a fix(metrics): typo 2024-02-12 14:23:16 +01:00
kolaente 6defc4cba2 fix(ci): use correct docker image for frontend testing 2024-02-11 18:46:46 +01:00
kolaente 3abdf2ccdd feat(ci): automatically create a gitea release when tagging 2024-02-11 18:45:15 +01:00
kolaente 001268a33e docs: add healthcheck to docker compose examples 2024-02-11 18:39:53 +01:00
kolaente 3129051a9c fix(ci): enable cors for api testing 2024-02-11 18:19:57 +01:00
kolaente 45c97a456d feat(ci): rename unstable desktop packages 2024-02-11 17:02:37 +01:00
kolaente 1255bdc4ab docs: add desktop packages 2024-02-11 16:54:53 +01:00
kolaente 99856b2031 docs: add missing traefik label 2024-02-11 15:57:22 +01:00
kolaente eec53e8a54 docs: mention installation of rpm packages 2024-02-11 15:56:34 +01:00
kolaente d4a389279c chore: don't send http errors with a status < 500 to sentry 2024-02-11 15:29:27 +01:00
renovateandkonrad 3a65324a7d fix(deps): update tiptap to v2.2.2 2024-02-11 09:37:38 +00:00
renovateandkonrad bec11d42fc fix(deps): update module golang.org/x/oauth2 to v0.17.0 2024-02-11 09:36:08 +00:00
renovate d2eeac2dbe fix(deps): pin dependencies 2024-02-11 09:05:38 +00:00
renovateandkonrad 05e7468135 fix(deps): update module xorm.io/xorm to v1.3.8 2024-02-11 08:34:26 +00:00
renovateandkonrad 4babe72f24 fix(deps): update module github.com/getsentry/sentry-go to v0.27.0 2024-02-11 08:34:04 +00:00
renovateandkonrad e9fe927644 fix(deps): update module golang.org/x/sys to v0.17.0 2024-02-11 08:33:24 +00:00
renovate 7cefb86a78 fix(deps): update dependency vue to v3.4.18 2024-02-11 08:05:32 +00:00
108 changed files with 5196 additions and 2365 deletions
+1
View File
@@ -1,6 +1,7 @@
files/
dist/
logs/
docs/
Dockerfile
docker-manifest.tmpl
+83 -59
View File
@@ -1,7 +1,7 @@
---
kind: pipeline
type: docker
name: build-and-test-api
name: build-and-test
workspace:
base: /go
@@ -122,7 +122,7 @@ steps:
when:
event: [ push, tag, pull_request ]
- name: build
- name: api-build
image: vikunja/golang-build:latest
pull: always
environment:
@@ -133,7 +133,7 @@ steps:
when:
event: [ push, tag, pull_request ]
- name: lint
- name: api-lint
image: golangci/golangci-lint:v1.55.2
pull: always
environment:
@@ -156,7 +156,9 @@ steps:
- name: test-migration-sqlite
image: vikunja/golang-build:latest
pull: always
depends_on: [ test-migration-prepare, build ]
depends_on:
- test-migration-prepare
- api-build
environment:
VIKUNJA_DATABASE_TYPE: sqlite
VIKUNJA_DATABASE_PATH: /db/vikunja-migration-test.db
@@ -175,7 +177,9 @@ steps:
- name: test-migration-mysql
image: vikunja/golang-build:latest
pull: always
depends_on: [ test-migration-prepare, build ]
depends_on:
- test-migration-prepare
- api-build
environment:
VIKUNJA_DATABASE_TYPE: mysql
VIKUNJA_DATABASE_HOST: test-mysql-migration
@@ -194,7 +198,9 @@ steps:
- name: test-migration-psql
image: vikunja/golang-build:latest
pull: always
depends_on: [ test-migration-prepare, build ]
depends_on:
- test-migration-prepare
- api-build
environment:
VIKUNJA_DATABASE_TYPE: postgres
VIKUNJA_DATABASE_HOST: test-postgres-migration
@@ -211,7 +217,7 @@ steps:
when:
event: [ push, tag, pull_request ]
- name: test
- name: api-test-unit
image: vikunja/golang-build:latest
pull: always
environment:
@@ -222,7 +228,7 @@ steps:
when:
event: [ push, tag, pull_request ]
- name: test-sqlite
- name: api-test-unit-sqlite
image: vikunja/golang-build:latest
pull: always
environment:
@@ -239,7 +245,7 @@ steps:
when:
event: [ push, tag, pull_request ]
- name: test-mysql
- name: api-test-unit-mysql
image: vikunja/golang-build:latest
pull: always
environment:
@@ -256,7 +262,7 @@ steps:
when:
event: [ push, tag, pull_request ]
- name: test-postgres
- name: api-test-unit-postgres
image: vikunja/golang-build:latest
pull: always
environment:
@@ -337,30 +343,22 @@ steps:
when:
event: [ push, tag, pull_request ]
---
kind: pipeline
type: docker
name: build-and-test-frontend
trigger:
branch:
include:
- main
event:
include:
- push
- pull_request
services:
- name: api
image: vikunja/api:unstable
- name: test-api-run
image: vikunja/golang-build:latest
pull: always
environment:
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
VIKUNJA_LOG_LEVEL: DEBUG
VIKUNJA_CORS_ENABLE: 1
VIKUNJA_DATABASE_PATH: memory
VIKUNJA_DATABASE_TYPE: sqlite
commands:
- ./vikunja
detach: true
depends_on:
- api-build
steps:
- name: dependencies
- name: frontend-dependencies
image: node:20.11.0-alpine
pull: always
environment:
@@ -374,7 +372,7 @@ steps:
# depends_on:
# - restore-cache
- name: lint
- name: frontend-lint
image: node:20.11.0-alpine
pull: always
environment:
@@ -384,9 +382,9 @@ steps:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run lint
depends_on:
- dependencies
- frontend-dependencies
- name: build-prod
- name: frontend-build-prod
image: node:20.11.0-alpine
pull: always
environment:
@@ -394,11 +392,11 @@ steps:
commands:
- cd frontend
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run build
- pnpm run build:test
depends_on:
- dependencies
- frontend-dependencies
- name: test-unit
- name: frontend-test-unit
image: node:20.11.0-alpine
pull: always
commands:
@@ -406,9 +404,9 @@ steps:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run test:unit
depends_on:
- dependencies
- frontend-dependencies
- name: typecheck
- name: frontend-typecheck
failure: ignore
image: node:20.11.0-alpine
pull: always
@@ -419,13 +417,13 @@ steps:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run typecheck
depends_on:
- dependencies
- frontend-dependencies
- name: test-frontend
- name: frontend-test
image: cypress/browsers:node18.12.0-chrome107
pull: always
environment:
CYPRESS_API_URL: http://api:3456/api/v1
CYPRESS_API_URL: http://test-api-run:3456/api/v1
CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB
PNPM_CACHE_FOLDER: .cache/pnpm
CYPRESS_CACHE_FOLDER: .cache/cypress
@@ -434,14 +432,15 @@ steps:
from_secret: cypress_project_key
commands:
- cd frontend
- sed -i 's/localhost/api/g' dist/index.html
- sed -i 's/localhost/test-api-run/g' dist-test/index.html
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm cypress install
- pnpm run test:e2e-record
- pnpm run test:e2e-record-test
depends_on:
- build-prod
- frontend-build-prod
- test-api-run
- name: deploy-preview
- name: frontend-deploy-preview
image: williamjackson/netlify-cli
pull: always
user: root # The rest runs as root and thus the permissions wouldn't work
@@ -454,7 +453,7 @@ steps:
from_secret: gitea_token
commands:
- cd frontend
- cp -r dist dist-preview
- cp -r dist-test dist-preview
# Override the default api url used for preview
- sed -i 's|http://localhost:3456|https://try.vikunja.io|g' dist-preview/index.html
- apk add --no-cache perl-utils
@@ -463,7 +462,7 @@ steps:
- shasum -a 384 -c ./scripts/deploy-preview-netlify.mjs.sha384
- node ./scripts/deploy-preview-netlify.mjs
depends_on:
- build-prod
- frontend-build-prod
when:
event:
include:
@@ -475,7 +474,7 @@ type: docker
name: generate-swagger-docs
depends_on:
- build-and-test-api
- build-and-test
workspace:
base: /go
@@ -519,8 +518,7 @@ type: docker
name: release
depends_on:
- build-and-test-api
- build-and-test-frontend
- build-and-test
workspace:
base: /source
@@ -783,6 +781,20 @@ steps:
- tag
depends_on: [ build-os-packages-version ]
- name: gitea-release
image: plugins/gitea-release
pull: true
settings:
api_key:
from_secret: gitea_token
base_url: https://kolaente.dev
files: dist/zip/*
prerelease: true
title: ${DRONE_TAG##v}
when:
event:
- tag
depends_on: [ sign-release ]
---
kind: pipeline
@@ -790,8 +802,7 @@ type: docker
name: docker-release
depends_on:
- build-and-test-api
- build-and-test-frontend
- build-and-test
trigger:
ref:
@@ -865,7 +876,7 @@ type: docker
name: frontend-release-unstable
depends_on:
- build-and-test-frontend
- build-and-test
trigger:
branch:
@@ -928,7 +939,7 @@ type: docker
name: frontend-release-version
depends_on:
- build-and-test-frontend
- build-and-test
trigger:
event:
@@ -1199,8 +1210,21 @@ steps:
# - '.cache'
# depends_on:
# - build
- name: rename-unstable
image: bash
pull: true
commands:
- cd desktop/dist
- bash -c 'for file in Vikunja*; do suffix=".$${file##*.}"; if [[ ! -d $file ]]; then mv "$file" "Vikunja-Desktop-unstable$${suffix}"; fi; done'
depends_on:
- build
when:
event:
exclude:
- tag
- name: release-latest
- name: release-unstable
image: plugins/s3
pull: true
settings:
@@ -1213,13 +1237,14 @@ steps:
region: fr-par
path_style: true
strip_prefix: desktop/dist/
source: desktop/dist/*
source: desktop/dist/Vikunja-Desktop*
target: /desktop/unstable/
when:
event:
exclude:
- tag
depends_on: [ build ]
depends_on:
- rename-unstable
- name: release-version
image: plugins/s3
@@ -1337,8 +1362,7 @@ trigger:
- "refs/tags/**"
depends_on:
- build-and-test-api
- build-and-test-frontend
- build-and-test
- release
- deploy-docs
- docker-release
@@ -1360,6 +1384,6 @@ steps:
- failure
---
kind: signature
hmac: 128214182f90834702da16ed7b0f1dec09dc61bbbe7f293aea7cb6c46e7edae2
hmac: 008b86263a8d03806da907c128a837a380901f1a2190a658c22d4e06cadc1b64
...
+5 -12
View File
@@ -33,22 +33,15 @@ RUN export PATH=$PATH:$GOPATH/bin && \
# ┘└┘┘─┘┘└┘┘└┘┴─┘┘└┘
# The actual image
# Note: I wanted to use the scratch image here, but unfortunatly the go-sqlite bindings require cgo and
# because of this, the container would not start when I compiled the image without cgo.
FROM alpine:3.19 AS runner
FROM scratch
LABEL maintainer="maintainers@vikunja.io"
WORKDIR /app/vikunja
ENTRYPOINT [ "/sbin/tini", "-g", "--", "/entrypoint.sh" ]
ENTRYPOINT [ "/app/vikunja/vikunja" ]
EXPOSE 3456
USER 1000
ENV VIKUNJA_SERVICE_ROOTPATH=/app/vikunja/
ENV PUID 1000
ENV PGID 1000
RUN apk --update --no-cache add tzdata tini shadow && \
addgroup vikunja && \
adduser -s /bin/sh -D -G vikunja vikunja -h /app/vikunja -H
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod 0755 /entrypoint.sh && mkdir files
ENV VIKUNJA_DATABASE_PATH=/db/vikunja.db
COPY --from=apibuilder /build/vikunja-* vikunja
COPY --from=apibuilder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
+3
View File
@@ -11,6 +11,9 @@
> The Todo-app to organize your life.
If Vikunja is useful to you, please consider [buying me a coffee](https://www.buymeacoffee.com/kolaente), [sponsoring me on GitHub](https://github.com/sponsors/kolaente) or buying [a sticker pack](https://vikunja.cloud/stickers).
I'm also offering [a hosted version of Vikunja](https://vikunja.cloud/) if you want a hassle-free solution for yourself or your team.
# Table of contents
* [Security Reports](#security-reports)
+13 -11
View File
@@ -6,7 +6,7 @@ service:
# The duration of the issued JWT tokens in seconds.
# The default is 259200 seconds (3 Days).
jwtttl: 259200
# The duration of the "remember me" time in seconds. When the login request is made with
# The duration of the "remember me" time in seconds. When the login request is made with
# the long param set, the token returned will be valid for this period.
# The default is 2592000 seconds (30 Days).
jwtttllong: 2592000
@@ -48,7 +48,7 @@ service:
# If enabled, vikunja will send an email to everyone who is either assigned to a task or created it when a task reminder
# is due.
enableemailreminders: true
# If true, will allow users to request the complete deletion of their account. When using external authentication methods
# If true, will allow users to request the complete deletion of their account. When using external authentication methods
# it may be required to coordinate with them in order to delete the account. This setting will not affect the cli commands
# for user deletion.
enableuserdeletion: true
@@ -76,7 +76,7 @@ sentry:
frontenddsn: "https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480"
database:
# Database type to use. Supported types are mysql, postgres and sqlite.
# Database type to use. Supported values are mysql, postgres and sqlite. Vikunja is able to run with MySQL 8.0+, Mariadb 10.2+, PostgreSQL 12+, and sqlite.
type: "sqlite"
# Database user which is used to connect to the database.
user: "vikunja"
@@ -109,7 +109,7 @@ database:
typesense:
# Whether to enable the Typesense integration. If true, all tasks will be synced to the configured Typesense
# instance and all search and filtering will run through Typesense instead of only through the database.
# Typesense allows fast fulltext search including fuzzy matching support. It may return different results than
# Typesense allows fast fulltext search including fuzzy matching support. It may return different results than
# what you'd get with a database-only search.
enabled: false
# The url to the Typesense instance you want to use. Can be hosted locally or in Typesense Cloud as long
@@ -203,7 +203,7 @@ ratelimit:
# Possible values are "keyvalue", "memory" or "redis".
# When choosing "keyvalue" this setting follows the one configured in the "keyvalue" section.
store: keyvalue
# The number of requests a user can make from the same IP to all unauthenticated routes (login, register,
# The number of requests a user can make from the same IP to all unauthenticated routes (login, register,
# password confirmation, email verification, password reset request) per minute. This limit cannot be disabled.
# You should only change this if you know what you're doing.
noauthlimit: 10
@@ -301,13 +301,11 @@ auth:
enabled: true
# OpenID configuration will allow users to authenticate through a third-party OpenID Connect compatible provider.<br/>
# The provider needs to support the `openid`, `profile` and `email` scopes.<br/>
# **Note:** Some openid providers (like gitlab) only make the email of the user available through openid claims if they have set it to be publicly visible.
# **Note:** Some openid providers (like Gitlab) only make the email of the user available through OpenID if they have set it to be publicly visible.
# If the email is not public in those cases, authenticating will fail.
# **Note 2:** The frontend expects to be redirected after authentication by the third party
# to <frontend-url>/auth/openid/<auth key>. Please make sure to configure the redirect url in your third party
# auth service accordingly if you're using the default vikunja frontend.
# The frontend will automatically provide the api with the redirect url, composed from the current url where it's hosted.
# If you want to use the desktop client with openid, make sure to allow redirects to `127.0.0.1`.
# **Note 2:** The frontend expects the third party to rediect the user <frontend-url>/auth/openid/<auth key> after authentication. Please make sure to configure the redirect url in your third party auth service accordingly if you're using the default vikunja frontend.
# The frontend will automatically provide the API with the redirect url, composed from the current url where it's hosted.
# If you want to use the desktop client with OpenID, make sure to allow redirects to `127.0.0.1`.
# Take a look at the [default config file](https://kolaente.dev/vikunja/vikunja/src/branch/main/config.yml.sample) for more information about how to configure openid authentication.
openid:
# Enable or disable OpenID Connect authentication
@@ -325,6 +323,10 @@ auth:
clientid:
# The client secret used to authenticate Vikunja at the OpenID Connect provider.
clientsecret:
# The scope necessary to use oidc.
# If you want to use the Feature to create and assign to vikunja teams via oidc, you have to add the custom "vikunja_scope" and check [openid.md](https://vikunja.io/docs/openid/).
# e.g. scope: openid email profile vikunja_scope
scope: openid email profile
# Prometheus metrics endpoint
metrics:
+4 -4
View File
@@ -51,11 +51,11 @@
}
},
"devDependencies": {
"electron": "28.2.2",
"electron-builder": "24.9.1"
"electron": "29.1.0",
"electron-builder": "24.13.3"
},
"dependencies": {
"connect-history-api-fallback": "^2.0.0",
"express": "^4.17.1"
"connect-history-api-fallback": "2.0.0",
"express": "4.18.3"
}
}
+80 -74
View File
@@ -40,10 +40,10 @@
optionalDependencies:
global-agent "^3.0.0"
"@electron/notarize@2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@electron/notarize/-/notarize-2.1.0.tgz#76aaec10c8687225e8d0a427cc9df67611c46ff3"
integrity sha512-Q02xem1D0sg4v437xHgmBLxI2iz/fc0D4K7fiVWHa/AnW8o7D751xyKNXgziA6HrTOme9ul1JfWN5ark8WH1xA==
"@electron/notarize@2.2.1":
version "2.2.1"
resolved "https://registry.yarnpkg.com/@electron/notarize/-/notarize-2.2.1.tgz#d0aa6bc43cba830c41bfd840b85dbe0e273f59fe"
integrity sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==
dependencies:
debug "^4.1.1"
fs-extra "^9.0.1"
@@ -61,10 +61,10 @@
minimist "^1.2.6"
plist "^3.0.5"
"@electron/universal@1.4.1":
version "1.4.1"
resolved "https://registry.yarnpkg.com/@electron/universal/-/universal-1.4.1.tgz#3fbda2a5ed9ff9f3304c8e8316b94c1e3a7b3785"
integrity sha512-lE/U3UNw1YHuowNbTmKNs9UlS3En3cPgwM5MI+agIgr/B1hSze9NdOP0qn7boZaI9Lph8IDv3/24g9IxnJP7aQ==
"@electron/universal@1.5.1":
version "1.5.1"
resolved "https://registry.yarnpkg.com/@electron/universal/-/universal-1.5.1.tgz#f338bc5bcefef88573cf0ab1d5920fac10d06ee5"
integrity sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==
dependencies:
"@electron/asar" "^3.2.1"
"@malept/cross-spawn-promise" "^1.1.0"
@@ -166,10 +166,12 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.23.tgz#676fa0883450ed9da0bb24156213636290892806"
integrity sha512-Z4U8yDAl5TFkmYsZdFPdjeMa57NOvnaf1tljHzhouaPEp7LCj2JKkejpI1ODviIAQuW4CcQmxkQ77rnLsOOoKw==
"@types/node@^18.11.18":
version "18.15.11"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.11.tgz#b3b790f09cb1696cffcec605de025b088fa4225f"
integrity sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==
"@types/node@^20.9.0":
version "20.11.19"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.19.tgz#b466de054e9cb5b3831bee38938de64ac7f81195"
integrity sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==
dependencies:
undici-types "~5.26.4"
"@types/plist@^3.0.1":
version "3.0.2"
@@ -251,26 +253,25 @@ app-builder-bin@4.0.0:
resolved "https://registry.yarnpkg.com/app-builder-bin/-/app-builder-bin-4.0.0.tgz#1df8e654bd1395e4a319d82545c98667d7eed2f0"
integrity sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==
app-builder-lib@24.9.1:
version "24.9.1"
resolved "https://registry.yarnpkg.com/app-builder-lib/-/app-builder-lib-24.9.1.tgz#bf3568529298b4de8595ed1acbb351fe27db5ba4"
integrity sha512-Q1nYxZcio4r+W72cnIRVYofEAyjBd3mG47o+zms8HlD51zWtA/YxJb01Jei5F+jkWhge/PTQK+uldsPh6d0/4g==
app-builder-lib@24.13.3:
version "24.13.3"
resolved "https://registry.yarnpkg.com/app-builder-lib/-/app-builder-lib-24.13.3.tgz#36e47b65fecb8780bb73bff0fee4e0480c28274b"
integrity sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig==
dependencies:
"7zip-bin" "~5.2.0"
"@develar/schema-utils" "~2.6.5"
"@electron/notarize" "2.1.0"
"@electron/notarize" "2.2.1"
"@electron/osx-sign" "1.0.5"
"@electron/universal" "1.4.1"
"@electron/universal" "1.5.1"
"@malept/flatpak-bundler" "^0.4.0"
"@types/fs-extra" "9.0.13"
async-exit-hook "^2.0.1"
bluebird-lst "^1.0.9"
builder-util "24.8.1"
builder-util-runtime "9.2.3"
builder-util "24.13.1"
builder-util-runtime "9.2.4"
chromium-pickle-js "^0.2.0"
debug "^4.3.4"
ejs "^3.1.8"
electron-publish "24.8.1"
electron-publish "24.13.1"
form-data "^4.0.0"
fs-extra "^10.1.0"
hosted-git-info "^4.1.0"
@@ -347,13 +348,13 @@ bluebird@^3.5.5:
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
body-parser@1.20.1:
version "1.20.1"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668"
integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==
body-parser@1.20.2:
version "1.20.2"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd"
integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==
dependencies:
bytes "3.1.2"
content-type "~1.0.4"
content-type "~1.0.5"
debug "2.6.9"
depd "2.0.0"
destroy "1.2.0"
@@ -361,7 +362,7 @@ body-parser@1.20.1:
iconv-lite "0.4.24"
on-finished "2.4.1"
qs "6.11.0"
raw-body "2.5.1"
raw-body "2.5.2"
type-is "~1.6.18"
unpipe "1.0.0"
@@ -408,24 +409,24 @@ buffer@^5.1.0:
base64-js "^1.3.1"
ieee754 "^1.1.13"
builder-util-runtime@9.2.3:
version "9.2.3"
resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-9.2.3.tgz#0a82c7aca8eadef46d67b353c638f052c206b83c"
integrity sha512-FGhkqXdFFZ5dNC4C+yuQB9ak311rpGAw+/ASz8ZdxwODCv1GGMWgLDeofRkdi0F3VCHQEWy/aXcJQozx2nOPiw==
builder-util-runtime@9.2.4:
version "9.2.4"
resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-9.2.4.tgz#13cd1763da621e53458739a1e63f7fcba673c42a"
integrity sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==
dependencies:
debug "^4.3.4"
sax "^1.2.4"
builder-util@24.8.1:
version "24.8.1"
resolved "https://registry.yarnpkg.com/builder-util/-/builder-util-24.8.1.tgz#594d45b0c86d1d17f5c7bebbb77405080b2571c2"
integrity sha512-ibmQ4BnnqCnJTNrdmdNlnhF48kfqhNzSeqFMXHLIl+o9/yhn6QfOaVrloZ9YUu3m0k3rexvlT5wcki6LWpjTZw==
builder-util@24.13.1:
version "24.13.1"
resolved "https://registry.yarnpkg.com/builder-util/-/builder-util-24.13.1.tgz#4a4c4f9466b016b85c6990a0ea15aa14edec6816"
integrity sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==
dependencies:
"7zip-bin" "~5.2.0"
"@types/debug" "^4.1.6"
app-builder-bin "4.0.0"
bluebird-lst "^1.0.9"
builder-util-runtime "9.2.3"
builder-util-runtime "9.2.4"
chalk "^4.1.2"
cross-spawn "^7.0.3"
debug "^4.3.4"
@@ -558,7 +559,7 @@ config-file-ts@^0.2.4:
glob "^7.1.6"
typescript "^4.0.2"
connect-history-api-fallback@^2.0.0:
connect-history-api-fallback@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz#647264845251a0daf25b97ce87834cace0f5f1c8"
integrity sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==
@@ -570,7 +571,7 @@ content-disposition@0.5.4:
dependencies:
safe-buffer "5.2.1"
content-type@~1.0.4:
content-type@~1.0.4, content-type@~1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
@@ -688,14 +689,14 @@ dir-compare@^3.0.0:
buffer-equal "^1.0.0"
minimatch "^3.0.4"
dmg-builder@24.9.1:
version "24.9.1"
resolved "https://registry.yarnpkg.com/dmg-builder/-/dmg-builder-24.9.1.tgz#04bf6c0dcd235f6214511f2358a78ed2b9379421"
integrity sha512-huC+O6hvHd24Ubj3cy2GMiGLe2xGFKN3klqVMLAdcbB6SWMd1yPSdZvV8W1O01ICzCCRlZDHiv4VrNUgnPUfbQ==
dmg-builder@24.13.3:
version "24.13.3"
resolved "https://registry.yarnpkg.com/dmg-builder/-/dmg-builder-24.13.3.tgz#95d5b99c587c592f90d168a616d7ec55907c7e55"
integrity sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==
dependencies:
app-builder-lib "24.9.1"
builder-util "24.8.1"
builder-util-runtime "9.2.3"
app-builder-lib "24.13.3"
builder-util "24.13.1"
builder-util-runtime "9.2.4"
fs-extra "^10.1.0"
iconv-lite "^0.6.2"
js-yaml "^4.1.0"
@@ -738,16 +739,16 @@ ejs@^3.1.8:
dependencies:
jake "^10.8.5"
electron-builder@24.9.1:
version "24.9.1"
resolved "https://registry.yarnpkg.com/electron-builder/-/electron-builder-24.9.1.tgz#4aee03947963b829a7f48a850fe02c219311ef63"
integrity sha512-v7BuakDuY6sKMUYM8mfQGrwyjBpZ/ObaqnenU0H+igEL10nc6ht049rsCw2HghRBdEwJxGIBuzs3jbEhNaMDmg==
electron-builder@24.13.3:
version "24.13.3"
resolved "https://registry.yarnpkg.com/electron-builder/-/electron-builder-24.13.3.tgz#c506dfebd36d9a50a83ee8aa32d803d83dbe4616"
integrity sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==
dependencies:
app-builder-lib "24.9.1"
builder-util "24.8.1"
builder-util-runtime "9.2.3"
app-builder-lib "24.13.3"
builder-util "24.13.1"
builder-util-runtime "9.2.4"
chalk "^4.1.2"
dmg-builder "24.9.1"
dmg-builder "24.13.3"
fs-extra "^10.1.0"
is-ci "^3.0.0"
lazy-val "^1.0.5"
@@ -755,26 +756,26 @@ electron-builder@24.9.1:
simple-update-notifier "2.0.0"
yargs "^17.6.2"
electron-publish@24.8.1:
version "24.8.1"
resolved "https://registry.yarnpkg.com/electron-publish/-/electron-publish-24.8.1.tgz#4216740372bf4297a429543402a1a15ce8c3560b"
integrity sha512-IFNXkdxMVzUdweoLJNXSupXkqnvgbrn3J4vognuOY06LaS/m0xvfFYIf+o1CM8if6DuWYWoQFKPcWZt/FUjZPw==
electron-publish@24.13.1:
version "24.13.1"
resolved "https://registry.yarnpkg.com/electron-publish/-/electron-publish-24.13.1.tgz#57289b2f7af18737dc2ad134668cdd4a1b574a0c"
integrity sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==
dependencies:
"@types/fs-extra" "^9.0.11"
builder-util "24.8.1"
builder-util-runtime "9.2.3"
builder-util "24.13.1"
builder-util-runtime "9.2.4"
chalk "^4.1.2"
fs-extra "^10.1.0"
lazy-val "^1.0.5"
mime "^2.5.2"
electron@28.2.2:
version "28.2.2"
resolved "https://registry.yarnpkg.com/electron/-/electron-28.2.2.tgz#d5aa4a33c00927d83ca893f8726f7c62aad98c41"
integrity sha512-8UcvIGFcjplHdjPFNAHVFg5bS0atDyT3Zx21WwuE4iLfxcAMsyMEOgrQX3im5LibA8srwsUZs7Cx0JAUfcQRpw==
electron@29.1.0:
version "29.1.0"
resolved "https://registry.yarnpkg.com/electron/-/electron-29.1.0.tgz#37f0e4915226db3c87bc54b187795272bf61fc39"
integrity sha512-giJVIm0sWVp+8V1GXrKqKTb+h7no0P3ooYqEd34AD9wMJzGnAeL+usj+R0155/0pdvvP1mgydnA7lcaFA2M9lw==
dependencies:
"@electron/get" "^2.0.0"
"@types/node" "^18.11.18"
"@types/node" "^20.9.0"
extract-zip "^2.0.1"
emoji-regex@^8.0.0:
@@ -829,14 +830,14 @@ etag@~1.8.1:
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
express@^4.17.1:
version "4.18.2"
resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59"
integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==
express@4.18.3:
version "4.18.3"
resolved "https://registry.yarnpkg.com/express/-/express-4.18.3.tgz#6870746f3ff904dee1819b82e4b51509afffb0d4"
integrity sha512-6VyCijWQ+9O7WuVMTRBTl+cjNNIzD5cY5mQ1WM8r/LEkI2u8EYpOotESNwzNlyCn3g+dmjKYI6BmNneSr/FSRw==
dependencies:
accepts "~1.3.8"
array-flatten "1.1.1"
body-parser "1.20.1"
body-parser "1.20.2"
content-disposition "0.5.4"
content-type "~1.0.4"
cookie "0.5.0"
@@ -1568,10 +1569,10 @@ range-parser@~1.2.1:
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
raw-body@2.5.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857"
integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==
raw-body@2.5.2:
version "2.5.2"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a"
integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==
dependencies:
bytes "3.1.2"
http-errors "2.0.0"
@@ -1903,6 +1904,11 @@ typescript@^4.0.2:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
undici-types@~5.26.4:
version "5.26.5"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
universalify@^0.1.0:
version "0.1.2"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env sh
set -e
if [ -n "$PUID" ] && [ "$PUID" -ne 0 ] && \
[ -n "$PGID" ] && [ "$PGID" -ne 0 ] ; then
echo "info: creating the new user vikunja with $PUID:$PGID"
groupmod -g "$PGID" -o vikunja
usermod -u "$PUID" -o vikunja
chown -R vikunja:vikunja ./files/
chown vikunja:vikunja .
exec su vikunja -c /app/vikunja/vikunja "$@"
else
echo "info: creation of non-root user is skipped"
exec /app/vikunja/vikunja "$@"
fi
+4 -22
View File
@@ -41,24 +41,13 @@ There are multiple categories of subcommands in the magefile:
These tasks are automatically run in our CI every time someone pushes to main or you update a pull request:
* `mage check:lint`
* `mage check:fmt`
* `mage check:ineffassign`
* `mage check:misspell`
* `mage check:goconst`
* `mage build:generate`
* `mage lint`
* `mage build:build`
## Build
### Build Vikunja
```
mage build:build
```
or
```
mage build
```
@@ -79,16 +68,9 @@ All check sub-commands exit with a status code of 1 if the check fails.
Various code-checks are available:
* `mage check:all`: Runs fmt-check, lint, got-swag, misspell-check, ineffasign-check, gocyclo-check, static-check, gosec-check, goconst-check all in parallel
* `mage check:fmt`: Checks if the code is properly formatted with go fmt
* `mage check:go-sec`: Checks the source code for potential security issues by scanning the Go AST using the [gosec tool](https://github.com/securego/gosec)
* `mage check:goconst`: Checks for repeated strings that could be replaced by a constant using [goconst](https://github.com/jgautheron/goconst/)
* `mage check:gocyclo`: Checks for the cyclomatic complexity of the source code using [gocyclo](https://github.com/fzipp/gocyclo)
* `mage check:got-swag`: Checks if the swagger docs need to be re-generated from the code annotations
* `mage check:ineffassign`: Checks the source code for ineffectual assigns using [ineffassign](https://github.com/gordonklaus/ineffassign)
* `mage check:lint`: Runs golint on all packages
* `mage check:misspell`: Checks the source code for misspellings
* `mage check:static`: Statically analyzes the source code about a range of different problems using [staticcheck](https://staticcheck.io/docs/)
* `mage check:all`: Runs golangci and swagger documentation check
* `mage lint`: Checks if the code follows the rules as defined in the `.golangci.yml` config file.
* `mage lint:fix`: Fixes all code style issues which are easily fixable.
## Release
+9 -11
View File
@@ -94,7 +94,7 @@ Environment path: `VIKUNJA_SERVICE_JWTTTL`
### jwtttllong
The duration of the "remember me" time in seconds. When the login request is made with
The duration of the "remember me" time in seconds. When the login request is made with
the long param set, the token returned will be valid for this period.
The default is 2592000 seconds (30 Days).
@@ -289,7 +289,7 @@ Environment path: `VIKUNJA_SERVICE_ENABLEEMAILREMINDERS`
### enableuserdeletion
If true, will allow users to request the complete deletion of their account. When using external authentication methods
If true, will allow users to request the complete deletion of their account. When using external authentication methods
it may be required to coordinate with them in order to delete the account. This setting will not affect the cli commands
for user deletion.
@@ -406,7 +406,7 @@ Environment path: `VIKUNJA_SENTRY_FRONTENDDSN`
### type
Database type to use. Supported types are mysql, postgres and sqlite.
Database type to use. Supported values are mysql, postgres and sqlite. Vikunja is able to run with MySQL 8.0+, Mariadb 10.2+, PostgreSQL 12+, and sqlite.
Default: `sqlite`
@@ -569,7 +569,7 @@ Environment path: `VIKUNJA_DATABASE_TLS`
Whether to enable the Typesense integration. If true, all tasks will be synced to the configured Typesense
instance and all search and filtering will run through Typesense instead of only through the database.
Typesense allows fast fulltext search including fuzzy matching support. It may return different results than
Typesense allows fast fulltext search including fuzzy matching support. It may return different results than
what you'd get with a database-only search.
Default: `false`
@@ -1024,7 +1024,7 @@ Environment path: `VIKUNJA_RATELIMIT_STORE`
### noauthlimit
The number of requests a user can make from the same IP to all unauthenticated routes (login, register,
The number of requests a user can make from the same IP to all unauthenticated routes (login, register,
password confirmation, email verification, password reset request) per minute. This limit cannot be disabled.
You should only change this if you know what you're doing.
@@ -1209,13 +1209,11 @@ Environment path: `VIKUNJA_AUTH_LOCAL`
OpenID configuration will allow users to authenticate through a third-party OpenID Connect compatible provider.<br/>
The provider needs to support the `openid`, `profile` and `email` scopes.<br/>
**Note:** Some openid providers (like gitlab) only make the email of the user available through openid claims if they have set it to be publicly visible.
**Note:** Some openid providers (like Gitlab) only make the email of the user available through OpenID if they have set it to be publicly visible.
If the email is not public in those cases, authenticating will fail.
**Note 2:** The frontend expects to be redirected after authentication by the third party
to <frontend-url>/auth/openid/<auth key>. Please make sure to configure the redirect url in your third party
auth service accordingly if you're using the default vikunja frontend.
The frontend will automatically provide the api with the redirect url, composed from the current url where it's hosted.
If you want to use the desktop client with openid, make sure to allow redirects to `127.0.0.1`.
**Note 2:** The frontend expects the third party to rediect the user <frontend-url>/auth/openid/<auth key> after authentication. Please make sure to configure the redirect url in your third party auth service accordingly if you're using the default vikunja frontend.
The frontend will automatically provide the API with the redirect url, composed from the current url where it's hosted.
If you want to use the desktop client with OpenID, make sure to allow redirects to `127.0.0.1`.
Take a look at the [default config file](https://kolaente.dev/vikunja/vikunja/src/branch/main/config.yml.sample) for more information about how to configure openid authentication.
Default: `<empty>`
+30
View File
@@ -0,0 +1,30 @@
---
title: "Desktop Packages"
date: 2024-02-11T15:58:18+01:00
draft: false
type: "doc"
menu:
sidebar:
parent: "setup"
---
# Desktop Packages
Vikunja is available as an electron-based desktop application for Linux and Windows.
## Installation
1. Download the latest release for your platform from [the download page](https://dl.vikunja.io/desktop/).
* For Windows, choose the file with the `.exe` or `.msi` file ending
* For a Linux-based operating system, choose a file with an ending for your operating system - we have builds for Alpine, AppImage, Arch Linux, Debian-based systems, FreeBSD, Fedora and Snap.
2. Run the downloaded package in the same way you would normally install a package for your OS.
## Flatpack
Vikunja Desktop can be installed via the [Flathub](https://flathub.org/apps/io.vikunja.Vikunja).
To install it, run the following command:
```
flatpak install flathub io.vikunja.Vikunja
```
@@ -27,7 +27,6 @@ Create a directory for the project where all data and the compose file will live
Create a `docker-compose.yml` file with the following contents in your directory:
```yaml
version: '3'
@@ -37,7 +36,7 @@ services:
environment:
VIKUNJA_SERVICE_PUBLICURL: http://<the public url where vikunja is reachable>
VIKUNJA_DATABASE_HOST: db
VIKUNJA_DATABASE_PASSWORD: secret
VIKUNJA_DATABASE_PASSWORD: changeme
VIKUNJA_DATABASE_TYPE: mysql
VIKUNJA_DATABASE_USER: vikunja
VIKUNJA_DATABASE_DATABASE: vikunja
@@ -47,19 +46,24 @@ services:
volumes:
- ./files:/app/vikunja/files
depends_on:
- db
db:
condition: service_healthy
restart: unless-stopped
db:
image: mariadb:10
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
environment:
MYSQL_ROOT_PASSWORD: supersupersecret
MYSQL_ROOT_PASSWORD: supersecret
MYSQL_USER: vikunja
MYSQL_PASSWORD: supersecret
MYSQL_PASSWORD: changeme
MYSQL_DATABASE: vikunja
volumes:
- ./db:/var/lib/mysql
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u $$MYSQL_USER --password=$$MYSQL_PASSWORD"]
interval: 2s
start_period: 30s
```
This defines two services, each with their own container:
@@ -75,6 +79,18 @@ The number before the colon is the host port - This is where you can reach vikun
You'll need to change the value of the `VIKUNJA_SERVICE_PUBLICURL` environment variable to the public port or hostname where Vikunja is reachable.
## Ensure adequate file permissions
Vikunja runs as user `1000` and no group by default.
To be able to upload task attachments or change the background of project, Vikunja must be able to write into the `files` directory.
To do this, create the folder and chown it before starting the stack:
```
mkdir $PWD/files
chown 1000 $PWD/files
```
## Run it
Run `sudo docker-compose up` in your directory and take a look at the output you get.
+79 -24
View File
@@ -15,8 +15,6 @@ It uses a proxy configuration to make it available under a domain.
For all available configuration options, see [configuration]({{< ref "config.md">}}).
Once deployed, you might want to change the [`PUID` and `GUID` settings]({{< ref "install.md">}}#setting-user-and-group-id-of-the-user-running-vikunja) or [set the time zone]({{< ref "config.md">}}#timezone).
After registering all your users, you might also want to [disable the user registration]({{<ref "config.md">}}#enableregistration).
<div class="notification is-warning">
@@ -27,6 +25,23 @@ All examples on this page already reflect this and do not require additional wor
{{< table_of_contents >}}
## File permissions
Vikunja runs as user `1000` and no group by default.
You can use Docker's [`--user`](https://docs.docker.com/engine/reference/run/#user) flag to change that.
You must ensure Vikunja is able to write into the `files` directory.
To do this, create the folder and chown it before starting the stack:
```
mkdir $PWD/files
chown 1000 $PWD/files
```
You'll need to do this before running any of the examples on this page.
Vikunja will not try to aquire ownership of the files folder, as that would mean it had to run as root.
## PostgreSQL
Vikunja supports postgres, mysql and sqlite as a database backend. The examples on this page use mysql with a mariadb container.
@@ -34,21 +49,20 @@ To use postgres as a database backend, change the `db` section of the examples t
```yaml
db:
image: postgres:13
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
POSTGRES_PASSWORD: changeme
POSTGRES_USER: vikunja
volumes:
- ./db:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -h localhost -U $$POSTGRES_USER"]
interval: 2s
```
You'll also need to change the `VIKUNJA_DATABASE_TYPE` to `postgres` on the api container declaration.
<div class="notification is-warning">
<b>NOTE:</b> The mariadb container can sometimes take a while to initialize, especially on the first run. During this time, the api container will fail to start at all. It will automatically restart every few seconds.
</div>
## Sqlite
Vikunja supports postgres, mysql and sqlite as a database backend. The examples on this page use mysql with a mariadb container.
@@ -79,6 +93,13 @@ You'll also need to remove or change the `VIKUNJA_DATABASE_TYPE` to `sqlite` on
You can also remove the db section.
To run the container, you need to create the directories first and make sure they have all required permissions:
```
mkdir $PWD/files $PWD/db
chown 1000 $PWD/files $PWD/db
```
<div class="notification is-warning">
<b>NOTE:</b> If you'll use your instance with more than a handful of users, we recommend using mysql or postgres.
</div>
@@ -89,8 +110,13 @@ This example lets you host Vikunja without any reverse proxy in front of it.
This is the absolute minimum configuration you need to get something up and running.
If you want to make Vikunja available on a domain or need tls termination, check out one of the other examples.
Note that you need to change the [`VIKUNJA_SERVICE_PUBLICURL`]({{< ref "config.md" >}}#publicurl) environment variable to the ip (the docker host you're running this on) is reachable at.
Because the browser you'll use to access the Vikunja frontend uses that url to make the requests, it has to be able to reach that ip + port from the outside.
Note that you need to change the [`VIKUNJA_SERVICE_PUBLICURL`]({{< ref "config.md" >}}#publicurl) environment variable to the public ip or hostname including the port (the docker host you're running this on) is reachable at, prefixed with `http://`.
Because the browser you'll use to access the Vikunja frontend uses that url to make the requests, it has to be able to reach it from the outside.
<div class="notification is-warning">
<b>NOTE:</b> You must ensure Vikunja has write permissions on the `files` directory before starting the stack.
To do this, <a href="#file-permissions">check out the related commands here</a>.
</div>
```yaml
version: '3'
@@ -99,9 +125,9 @@ services:
vikunja:
image: vikunja/vikunja
environment:
VIKUNJA_SERVICE_PUBLICURL: http://<the public url where vikunja is reachable>
VIKUNJA_SERVICE_PUBLICURL: http://<the public ip or host where vikunja is reachable>
VIKUNJA_DATABASE_HOST: db
VIKUNJA_DATABASE_PASSWORD: secret
VIKUNJA_DATABASE_PASSWORD: changeme
VIKUNJA_DATABASE_TYPE: mysql
VIKUNJA_DATABASE_USER: vikunja
VIKUNJA_DATABASE_DATABASE: vikunja
@@ -111,19 +137,24 @@ services:
volumes:
- ./files:/app/vikunja/files
depends_on:
- db
db:
condition: service_healthy
restart: unless-stopped
db:
image: mariadb:10
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
environment:
MYSQL_ROOT_PASSWORD: supersupersecret
MYSQL_ROOT_PASSWORD: supersecret
MYSQL_USER: vikunja
MYSQL_PASSWORD: supersecret
MYSQL_PASSWORD: changeme
MYSQL_DATABASE: vikunja
volumes:
- ./db:/var/lib/mysql
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u $$MYSQL_USER --password=$$MYSQL_PASSWORD"]
interval: 2s
start_period: 30s
```
## Example with Traefik 2
@@ -136,6 +167,11 @@ We also make a few assumptions here which you'll most likely need to adjust for
* The entrypoint you want to make vikunja available from is called `https`
* The tls cert resolver is called `acme`
<div class="notification is-warning">
<b>NOTE:</b> You must ensure Vikunja has write permissions on the `files` directory before starting the stack.
To do this, <a href="#file-permissions">check out the related commands here</a>.
</div>
```yaml
version: '3'
@@ -145,7 +181,7 @@ services:
environment:
VIKUNJA_SERVICE_PUBLICURL: http://<the public url where vikunja is reachable>
VIKUNJA_DATABASE_HOST: db
VIKUNJA_DATABASE_PASSWORD: supersecret
VIKUNJA_DATABASE_PASSWORD: changeme
VIKUNJA_DATABASE_TYPE: mysql
VIKUNJA_DATABASE_USER: vikunja
VIKUNJA_DATABASE_DATABASE: vikunja
@@ -156,10 +192,12 @@ services:
- web
- default
depends_on:
- db
db:
condition: service_healthy
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.docker.network=web"
- "traefik.http.routers.vikunja.rule=Host(`vikunja.example.com`)"
- "traefik.http.routers.vikunja.entrypoints=https"
- "traefik.http.routers.vikunja.tls.certResolver=acme"
@@ -169,11 +207,15 @@ services:
environment:
MYSQL_ROOT_PASSWORD: supersupersecret
MYSQL_USER: vikunja
MYSQL_PASSWORD: supersecret
MYSQL_PASSWORD: changeme
MYSQL_DATABASE: vikunja
volumes:
- ./db:/var/lib/mysql
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u $$MYSQL_USER --password=$$MYSQL_PASSWORD"]
interval: 2s
start_period: 30s
networks:
web:
@@ -193,6 +235,11 @@ vikunja.example.com {
Note that you need to change the [`VIKUNJA_SERVICE_PUBLICURL`]({{< ref "config.md" >}}#publicurl) environment variable to the ip (the docker host you're running this on) is reachable at.
Because the browser you'll use to access the Vikunja frontend uses that url to make the requests, it has to be able to reach that ip + port from the outside.
<div class="notification is-warning">
<b>NOTE:</b> You must ensure Vikunja has write permissions on the `files` directory before starting the stack.
To do this, <a href="#file-permissions">check out the related commands here</a>.
</div>
Docker Compose config:
```yaml
@@ -204,7 +251,7 @@ services:
environment:
VIKUNJA_SERVICE_PUBLICURL: http://<the public url where vikunja is reachable>
VIKUNJA_DATABASE_HOST: db
VIKUNJA_DATABASE_PASSWORD: secret
VIKUNJA_DATABASE_PASSWORD: changeme
VIKUNJA_DATABASE_TYPE: mysql
VIKUNJA_DATABASE_USER: vikunja
VIKUNJA_DATABASE_DATABASE: vikunja
@@ -214,19 +261,24 @@ services:
volumes:
- ./files:/app/vikunja/files
depends_on:
- db
db:
condition: service_healthy
restart: unless-stopped
db:
image: mariadb:10
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
environment:
MYSQL_ROOT_PASSWORD: supersupersecret
MYSQL_ROOT_PASSWORD: supersecret
MYSQL_USER: vikunja
MYSQL_PASSWORD: supersecret
MYSQL_PASSWORD: changeme
MYSQL_DATABASE: vikunja
volumes:
- ./db:/var/lib/mysql
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u $$MYSQL_USER --password=$$MYSQL_PASSWORD"]
interval: 2s
start_period: 30s
caddy:
image: caddy
restart: unless-stopped
@@ -271,10 +323,13 @@ The docker-compose file we're going to use is exactly the same from the [example
You may want to change the volumes to match the rest of your setup.
Once deployed, you might want to change the [`PUID` and `GUID` settings]({{< ref "install.md">}}#setting-user-and-group-id-of-the-user-running-vikunja) or [set the time zone]({{< ref "config.md">}}#timezone).
After registering all your users, you might also want to [disable the user registration]({{<ref "config.md">}}#enableregistration).
<div class="notification is-warning">
<b>NOTE:</b> You must ensure Vikunja has write permissions on the `files` directory before starting the stack.
To do this, <a href="#file-permissions">check out the related commands here</a>.
</div>
## Redis
While Vikunja has support to use redis as a caching backend, you'll probably not need it unless you're using Vikunja with more than a handful of users.
+26 -14
View File
@@ -29,10 +29,11 @@ You can also:
Vikunja can be installed in various ways.
This document provides an overview and instructions for the different methods:
* [Installing from binary](#install-from-binary)
* [Installing from binary (manual)](#install-from-binary)
* [Build from source]({{< ref "build-from-source.md">}})
* [Docker](#docker)
* [Debian packages](#debian-packages)
* [Debian](#debian-packages)
* [RPM](#rpm)
* [FreeBSD](#freebsd--freenas)
* [Kubernetes]({{< ref "k8s.md" >}})
@@ -139,17 +140,23 @@ It will automatically run all necessary database migrations.
To get up and running quickly, use this command:
```
touch vikunja.db
docker run -p 3456:3456 -v $PWD/files:/app/vikunja/files -v $PWD/vikunja.db:/app/vikunja/vikunja.db vikunja/vikunja
mkdir $PWD/files $PWD/db
chown 1000 $PWD/files $PWD/db
docker run -p 3456:3456 -v $PWD/files:/app/vikunja/files -v $PWD/db:/db vikunja/vikunja
```
This will expose vikunja on port `3456` on the host running the container and use sqlite as database backend.
**Note**: The container runs as the user `1000` and no group by default.
You can use Docker's [`--user`](https://docs.docker.com/engine/reference/run/#user) flag to change that.
Make sure the new user has required permissions on the `db` and `files` folder.
You can mount a local configuration like so:
```
touch vikunja.db
docker run -p 3456:3456 -v /path/to/config/on/host.yml:/app/vikunja/config.yml:ro -v $PWD/files:/app/vikunja/files -v $PWD/vikunja.db:/app/vikunja/vikunja.db vikunja/vikunja
mkdir $PWD/files $PWD/db
chown 1000 $PWD/files $PWD/db
docker run -p 3456:3456 -v /path/to/config/on/host.yml:/app/vikunja/config.yml:ro -v $PWD/files:/app/vikunja/files -v $PWD/db:/db vikunja/vikunja
```
Though it is recommended to use environment variables or `.env` files to configure Vikunja in docker.
@@ -162,20 +169,13 @@ Check out the [docker examples]({{<ref "full-docker-example.md">}}) for more adv
By default, the container stores all files uploaded and used through vikunja inside of `/app/vikunja/files` which is created as a docker volume.
You should mount the volume somewhere to the host to permanently store the files and don't lose them if the container restarts.
### Setting user and group id of the user running vikunja
You can set the user and group id of the user running vikunja with the `PUID` and `PGID` environment variables.
This follows the pattern used by [the linuxserver.io](https://docs.linuxserver.io/general/understanding-puid-and-pgid) docker images.
This is useful to solve general permission problems when host-mounting volumes such as the volume used for task attachments.
### Docker compose
Check out the [docker examples]({{<ref "full-docker-example.md">}}) for more advanced configuration using docker compose.
## Debian packages
Vikunja is available as debian packages.
Vikunja is available as deb package for installation on debian-like systems.
To install these, grab a `.deb` file from [the download page](https://dl.vikunja.io/vikunja) and run
@@ -186,6 +186,18 @@ dpkg -i vikunja.deb
This will install Vikunja to `/opt/vikunja`.
To configure it, use the config file in `/etc/vikunja/config.yml`.
## RPM
Vikunja is available as rpm package for installation on Fedora, CentOS and others.
To install these, grab a `.rpm` file from [the download page](https://dl.vikunja.io/vikunja) and run
```
rpm -i vikunja.rpm
```
To configure Vikunja, use the config file in `/etc/vikunja/config.yml`.
## FreeBSD / FreeNAS
Unfortunately, we currently can't provide pre-built binaries for FreeBSD.
+6 -3
View File
@@ -10,7 +10,7 @@ menu:
# OpenID example configurations
On this page you will find examples about how to set up Vikunja with a third-party OpenID provider.
On this page you will find examples about how to set up Vikunja with a third-party OAuth 2.0 provider using OpenID Connect.
To add another example, please [edit this document](https://kolaente.dev/vikunja/vikunja/src/branch/main/docs/content/doc/setup/openid-examples.md) and send a PR.
{{< table_of_contents >}}
@@ -67,7 +67,7 @@ Google config:
Note that there currently seems to be no way to stop creation of new users, even when `enableregistration` is `false` in the configuration. This means that this approach works well only with an "Internal Organization" app for Google Workspace, which limits the allowed users to organizational accounts only. External / public applications will potentially allow every Google user to register.
## Keycloak
## Keycloak
Vikunja Config:
```yaml
@@ -111,4 +111,7 @@ auth:
clientsecret: "" # copy from Authentik
```
**Note:** The `authurl` that Vikunja requires is not the `Authorize URL` that you can see in the Provider. Vikunja uses Open ID Discovery to find the correct endpoint to use. Vikunja does this by automatically accessing the `OpenID Configuration URL` (usually `https://authentik.mydomain.com/application/o/vikunja/.well-known/openid-configuration`). Use this URL without the `.well-known/openid-configuration` as the `authurl`.
**Note:** The `authurl` that Vikunja requires is not the `Authorize URL` that you can see in the Provider.
OpenID Discovery is used to find the correct endpoint to use automatically, by accessing the `OpenID Configuration URL` (usually `https://authentik.mydomain.com/application/o/vikunja/.well-known/openid-configuration`).
Use this URL without the `.well-known/openid-configuration` as the `authurl`.
Typically this URL can be found in the metadata section within your identity provider.
+180
View File
@@ -0,0 +1,180 @@
---
date: "2022-08-09:00:00+02:00"
title: "OpenID"
draft: false
type: "doc"
menu:
sidebar:
parent: "setup"
---
# OpenID
Vikunja allows for authentication with an external identity source such as Authentik, Keycloak or similar via the
[OpenID Connect](https://openid.net/developers/specs/) standard.
{{< table_of_contents >}}
## OpenID Connect Overview
OpenID Connect is a standardized identity layer built on top of the more generic OAuth 2.0 specification, simplying interaction between the involved parties significantly.
While the [OpenID specification](https://openid.net/specs/openid-connect-core-1_0.html#Overview) is worth a read, we summarize the most important basics here.
The involved parties are:
- **Resource Owner:** typically the end-user
- **Resource Server:** the application server handling requests from the client, the Vikunja API in our case
- **Client:** the application or client accessing the RS on behalf of the RO. Vikunja web frontend or any of the apps
- **Authorization Server:** the server verifying the user identity and issuing tokens. These docs also use the words `OAuth 2.0 provider`, `Identity Provider` interchangeably.
After the user is authenticated, the provider issues a token to the user, containing various claims.
There's different types of tokens (ID token, access token, refresh token), and all of them are created as [JSON Web Token](https://www.rfc-editor.org/info/rfc7519).
Claims in turn are assertions containing information about the token bearer, usually the user.
**Scopes** are requested by the client when redirecting the end-user to the Authorization Server for authentication, and indirectly control which claims are included in the resulting tokens.
There's certain default scopes, but its also possible to define custom scopes, which are used by the feature assigning users to Teams automatically.
## Configuring OIDC Authentication
To achieve authentication via an external provider, it is required to (a) configure a confidential Client on your OAuth 2.0 provider and (b) configure Vikunja to authenticate against this provider.
[Example configurations]({{< ref "openid-examples.md">}}) are provided for various different identity providers, below you can find generic guides though.
OpenID Connect defines various flow types indicating how exactly the interaction between the involved parties work, Vikunja makes use of the standard **Authorization Code Flow**.
### Step 1: Configure your Authorization Server
The first step is to configure the Authorization Server to correctly handle requests coming from Vikunja.
In general, this involves the following steps at a minimum:
- Create a confidential client and obtain the client ID and client secret
- Configure (whitelist) redirect URLs that can be used by Vikunja
- Make sure the required scopes (`openid profile email` are the default scopes used by Vikunja) are supported
- Optional: configure an additional scope for automatic team assignment, see below for details
More detailled instructions for various different identity providers can be [found here]({{< ref "openid-examples.md">}})
### Step 2: Configure Vikunja
Vikunja has to be configured to use the identity provider. Please note that there is currently no option to configure these settings via environment variables, they have to be defined using the configuration file. The configuration schema is as follows:
```yaml
auth:
openid:
enabled: true
redirecturl: https://vikunja.mydomain.com/auth/openid/ <---- slash at the end is important
providers:
- name: <provider-name>
authurl: <auth-url>
clientid: <vikunja client-id>
clientsecret: <vikunja client-secret>
scope: openid profile email
```
The values for `authurl` can be obtained from the Metadata of your provider, while `clientid` and `clientsecret` are obtained when configuring the client.
The scope usually doesn't need to be specified or changed, unless you want to configure the automatic team assignment.
Optionally it is possible to disable local authentication and therefore forcing users to login via OpenID connect:
```yaml
auth:
local:
enabled: false
```
## Automatically assign users to teams
Vikunja is capable of automatically adding users to a team based on OIDC claims added by the identity provider.
If configured, Vikunja will sync teams, automatically create new ones and make sure the members are part of the configured teams.
Teams which exist only because they were created from oidc attributes are not editable in Vikunja.
To distinguish between teams created in Vikunja and teams generated automatically via oidc, generated teams have an `oidcID` assigned internally.
Within the UI, the teams created through OIDC get a `(OIDC)` suffix to make them distinguishable from locally created teams.
On a high level, you need to make sure that the **ID token** issued by your identity provider contains a `vikunja_groups` claim, following the structure defined below.
It depends on the provider being used as well as the preferences of the administrator how this is achieved.
Typically you'd want to request an additional scope (e.g. `vikunja_scope`) which then triggers the identity provider to add the claim.
If the `vikunja_groups` is part of the **ID token**, Vikunja will start the procedure and import teams and team memberships.
The claim structure expexted by Vikunja is as follows:
```json
{
"vikunja_groups": [
{
"name": "team 1",
"oidcID": 33349
},
{
"name": "team 2",
"oidcID": 35933
}
]
}
```
For each team, you need to define a team `name` and an `oidcID`, where the `oidcID` can be any string with a length of less than 250 characters.
The `oidcID` is used to uniquely identify the team, so please make sure to keep this unique.
Below you'll find two example implementations for Authentik and Keycloak.
If you've successfully implemented this with another identity provider, please let us know and submit a PR to improve the docs.
### Setup in Authentik
To configure automatic team management through Authentik, we assume you have already [set up Authentik]({{< ref "openid-examples.md">}}#authentik) as an OIDC provider for authentication with Vikunja.
To use Authentik's group assignment feature, follow these steps:
1. Edit [your config]({{< ref "config.md">}}) to include the following scopes: `openid profile email vikunja_scope`
2. Open `<your authentik url>/if/admin/#/core/property-mappings`
3. Create a new property mapping called `vikunja_scope` as scope mapping. There is a field `expression` to enter python expressions that will be delivered with the oidc token.
4. Write a small script like the following to add group information to `vikunja_scope`:
```python
groupsDict = {"vikunja_groups": []}
for group in request.user.ak_groups.all():
groupsDict["vikunja_groups"].append({"name": group.name, "oidcID": group.num_pk})
return groupsDict
```
5. In Authentik's menu on the left, go to Applications > Providers > Select the Vikunja provider. Then click on "Edit", on the bottom open "Advanced protocol settings", select the newly created property mapping under "Scopes". Save the provider.
Now when you log into Vikunja via Authentik it will show you a list of scopes you are claiming.
You should see the description you entered on the OIDC provider's admin area.
Proceed to vikunja and open the teams page in the sidebar menu.
You should see "(OIDC)" written next to each team you were assigned through OIDC.
### Setup in Keycloak
The kind people from Makerspace Darmstadt e.V. have written [a guide on how to create a mapper for Vikunja here](https://github.com/makerspace-darmstadt/keycloak-vikunja-mapper).
## Use cases
All examples assume one team called "Team 1" to be configured within your provider.
* *Token delivers team.name +team.oidcID and Vikunja team does not exist:* \
New team will be created called "Team 1" with attribute oidcID: "33929"
2. *In Vikunja Team with name "team 1" already exists in vikunja, but has no oidcID set:* \
new team will be created called "team 1" with attribute oidcID: "33929"
3. *In Vikunja Team with name "team 1" already exists in vikunja, but has different oidcID set:* \
new team will be created called "team 1" with attribute oidcID: "33929"
4. *In Vikunja Team with oidcID "33929" already exists in vikunja, but has different name than "team1":* \
new team will be created called "team 1" with attribute oidcID: "33929"
5. *Scope vikunja_scope is not set:* \
nothing happens
6. *oidcID is not set:* \
You'll get error.
Custom Scope malformed
"The custom scope set by the OIDC provider is malformed. Please make sure the openid provider sets the data correctly for your scope. Check especially to have set an oidcID."
7. *In Vikunja I am in "team 3" with oidcID "", but the token does not deliver any data for "team 3":* \
You will stay in team 3 since it was not set by the oidc provider
8. *In Vikunja I am in "team 3" with oidcID "12345", but the token does not deliver any data for "team 3"*:\
You will be signed out of all teams, which have an oidcID set and are not contained in the token.
Especially if you've been the last team member, the team will be deleted.
+28 -23
View File
@@ -44,6 +44,7 @@ This document describes the different errors Vikunja can return.
| 1020 | 412 | This user account is disabled. |
| 1021 | 412 | This account is managed by a third-party authentication provider. |
| 1021 | 412 | The username must not contain spaces. |
| 1022 | 412 | The custom scope set by the OIDC provider is malformed. Please make sure the openid provider sets the data correctly for your scope. Check especially to have set an oidcID. |
## Validation
@@ -72,29 +73,30 @@ This document describes the different errors Vikunja can return.
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|----------------------------------------------------------------------------|
| 4001 | 400 | The project task text cannot be empty. |
| 4002 | 404 | The project task does not exist. |
| 4003 | 403 | All bulk editing tasks must belong to the same project. |
| 4004 | 403 | Need at least one task when bulk editing tasks. |
| 4005 | 403 | The user does not have the right to see the task. |
| 4006 | 403 | The user tried to set a parent task as the task itself. |
| 4007 | 400 | The user tried to create a task relation with an invalid kind of relation. |
| 4008 | 409 | The user tried to create a task relation which already exists. |
| 4009 | 404 | The task relation does not exist. |
| 4010 | 400 | Cannot relate a task with itself. |
| 4011 | 404 | The task attachment does not exist. |
| 4012 | 400 | The task attachment is too large. |
| 4013 | 400 | The task sort param is invalid. |
| 4014 | 400 | The task sort order is invalid. |
| 4015 | 404 | The task comment does not exist. |
| 4016 | 400 | Invalid task field. |
| 4017 | 400 | Invalid task filter comparator. |
| 4018 | 400 | Invalid task filter concatinator. |
| 4019 | 400 | Invalid task filter value. |
| 4020 | 400 | The provided attachment does not belong to that task. |
| 4021 | 400 | This user is already assigned to that task. |
| 4022 | 400 | The task has a relative reminder which does not specify relative to what. |
| 4023 | 409 | Tried to create a task relation which would create a cycle. |
| 4001 | 400 | The project task text cannot be empty. |
| 4002 | 404 | The project task does not exist. |
| 4003 | 403 | All bulk editing tasks must belong to the same project. |
| 4004 | 403 | Need at least one task when bulk editing tasks. |
| 4005 | 403 | The user does not have the right to see the task. |
| 4006 | 403 | The user tried to set a parent task as the task itself. |
| 4007 | 400 | The user tried to create a task relation with an invalid kind of relation. |
| 4008 | 409 | The user tried to create a task relation which already exists. |
| 4009 | 404 | The task relation does not exist. |
| 4010 | 400 | Cannot relate a task with itself. |
| 4011 | 404 | The task attachment does not exist. |
| 4012 | 400 | The task attachment is too large. |
| 4013 | 400 | The task sort param is invalid. |
| 4014 | 400 | The task sort order is invalid. |
| 4015 | 404 | The task comment does not exist. |
| 4016 | 400 | Invalid task field. |
| 4017 | 400 | Invalid task filter comparator. |
| 4018 | 400 | Invalid task filter concatinator. |
| 4019 | 400 | Invalid task filter value. |
| 4020 | 400 | The provided attachment does not belong to that task. |
| 4021 | 400 | This user is already assigned to that task. |
| 4022 | 400 | The task has a relative reminder which does not specify relative to what. |
| 4023 | 409 | Tried to create a task relation which would create a cycle. |
| 4024 | 400 | The provided filter expression is invalid. |
## Team
@@ -106,6 +108,9 @@ This document describes the different errors Vikunja can return.
| 6005 | 409 | The user is already a member of that team. |
| 6006 | 400 | Cannot delete the last team member. |
| 6007 | 403 | The team does not have access to the project to perform that action. |
| 6008 | 400 | There are no teams found with that team name. |
| 6009 | 400 | There is no oidc team with that team name and oidcId. |
| 6010 | 400 | There are no oidc teams found for the user. |
## User Project Access
+73 -71
View File
@@ -13,7 +13,7 @@
},
"homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@8.15.1",
"packageManager": "pnpm@8.15.4",
"keywords": [
"todo",
"productivity",
@@ -28,13 +28,15 @@
"serve": "pnpm run dev",
"preview": "vite preview --port 4173",
"preview:dev": "vite preview --outDir dist-dev --mode development --port 4173",
"preview:test": "vite preview --port 4173 --outDir dist-test",
"build": "vite build && workbox copyLibraries dist/",
"build:test": "vite build --outDir dist-test && workbox copyLibraries dist-dev/",
"build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/",
"build:dev": "vite build --mode development --outDir dist-dev/",
"lint": "eslint 'src/**/*.{js,ts,vue}'",
"lint:fix": "pnpm run lint --fix",
"test:e2e": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome'",
"test:e2e-record": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome --record'",
"test:e2e-record-test": "start-server-and-test preview:test http://127.0.0.1:4173 'cypress run --e2e --browser chrome --record'",
"test:e2e-dev-dev": "start-server-and-test preview:dev http://127.0.0.1:4173 'cypress open --e2e'",
"test:e2e-dev": "start-server-and-test preview http://127.0.0.1:4173 'cypress open --e2e'",
"test:unit": "vitest --dir ./src",
@@ -55,53 +57,53 @@
"@github/hotkey": "3.1.0",
"@infectoone/vue-ganttastic": "2.2.0",
"@intlify/unplugin-vue-i18n": "2.0.0",
"@kyvg/vue3-notification": "3.1.4",
"@sentry/tracing": "7.100.1",
"@sentry/vue": "7.100.1",
"@tiptap/core": "2.2.1",
"@tiptap/extension-blockquote": "2.2.1",
"@tiptap/extension-bold": "2.2.1",
"@tiptap/extension-bullet-list": "2.2.1",
"@tiptap/extension-code": "2.2.1",
"@tiptap/extension-code-block-lowlight": "2.2.1",
"@tiptap/extension-document": "2.2.1",
"@tiptap/extension-dropcursor": "2.2.1",
"@tiptap/extension-gapcursor": "2.2.1",
"@tiptap/extension-hard-break": "2.2.1",
"@tiptap/extension-heading": "2.2.1",
"@tiptap/extension-history": "2.2.1",
"@tiptap/extension-horizontal-rule": "2.2.1",
"@tiptap/extension-image": "2.2.1",
"@tiptap/extension-italic": "2.2.1",
"@tiptap/extension-link": "2.2.1",
"@tiptap/extension-list-item": "2.2.1",
"@tiptap/extension-ordered-list": "2.2.1",
"@tiptap/extension-paragraph": "2.2.1",
"@tiptap/extension-placeholder": "2.2.1",
"@tiptap/extension-strike": "2.2.1",
"@tiptap/extension-table": "2.2.1",
"@tiptap/extension-table-cell": "2.2.1",
"@tiptap/extension-table-header": "2.2.1",
"@tiptap/extension-table-row": "2.2.1",
"@tiptap/extension-task-item": "2.2.1",
"@tiptap/extension-task-list": "2.2.1",
"@tiptap/extension-text": "2.2.1",
"@tiptap/extension-typography": "2.2.1",
"@tiptap/extension-underline": "2.2.1",
"@tiptap/pm": "2.2.1",
"@tiptap/suggestion": "2.2.1",
"@tiptap/vue-3": "2.2.1",
"@kyvg/vue3-notification": "3.2.0",
"@sentry/tracing": "7.105.0",
"@sentry/vue": "7.105.0",
"@tiptap/core": "2.2.4",
"@tiptap/extension-blockquote": "2.2.4",
"@tiptap/extension-bold": "2.2.4",
"@tiptap/extension-bullet-list": "2.2.4",
"@tiptap/extension-code": "2.2.4",
"@tiptap/extension-code-block-lowlight": "2.2.4",
"@tiptap/extension-document": "2.2.4",
"@tiptap/extension-dropcursor": "2.2.4",
"@tiptap/extension-gapcursor": "2.2.4",
"@tiptap/extension-hard-break": "2.2.4",
"@tiptap/extension-heading": "2.2.4",
"@tiptap/extension-history": "2.2.4",
"@tiptap/extension-horizontal-rule": "2.2.4",
"@tiptap/extension-image": "2.2.4",
"@tiptap/extension-italic": "2.2.4",
"@tiptap/extension-link": "2.2.4",
"@tiptap/extension-list-item": "2.2.4",
"@tiptap/extension-ordered-list": "2.2.4",
"@tiptap/extension-paragraph": "2.2.4",
"@tiptap/extension-placeholder": "2.2.4",
"@tiptap/extension-strike": "2.2.4",
"@tiptap/extension-table": "2.2.4",
"@tiptap/extension-table-cell": "2.2.4",
"@tiptap/extension-table-header": "2.2.4",
"@tiptap/extension-table-row": "2.2.4",
"@tiptap/extension-task-item": "2.2.4",
"@tiptap/extension-task-list": "2.2.4",
"@tiptap/extension-text": "2.2.4",
"@tiptap/extension-typography": "2.2.4",
"@tiptap/extension-underline": "2.2.4",
"@tiptap/pm": "2.2.4",
"@tiptap/suggestion": "2.2.4",
"@tiptap/vue-3": "2.2.4",
"@types/is-touch-device": "1.0.2",
"@types/lodash.clonedeep": "4.5.9",
"@vueuse/core": "10.7.2",
"@vueuse/router": "10.7.2",
"@vueuse/core": "10.9.0",
"@vueuse/router": "10.9.0",
"axios": "1.6.7",
"blurhash": "2.0.5",
"bulma-css-variables": "0.9.33",
"camel-case": "4.1.2",
"date-fns": "3.3.1",
"dayjs": "1.11.10",
"dompurify": "3.0.8",
"dompurify": "3.0.9",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
"flexsearch": "0.7.31",
@@ -116,11 +118,11 @@
"sortablejs": "1.15.2",
"tippy.js": "6.3.7",
"ufo": "1.4.0",
"vue": "3.4.15",
"vue": "3.4.21",
"vue-advanced-cropper": "2.8.8",
"vue-flatpickr-component": "11.0.3",
"vue-i18n": "9.9.1",
"vue-router": "4.2.5",
"vue-flatpickr-component": "11.0.5",
"vue-i18n": "9.10.1",
"vue-router": "4.3.0",
"workbox-precaching": "7.0.0",
"zhyswan-vuedraggable": "4.1.3"
},
@@ -128,9 +130,9 @@
"@4tw/cypress-drag-drop": "2.2.5",
"@cypress/vite-dev-server": "5.0.7",
"@cypress/vue": "6.0.0",
"@faker-js/faker": "8.4.0",
"@faker-js/faker": "8.4.1",
"@histoire/plugin-screenshot": "0.17.8",
"@histoire/plugin-vue": "0.17.9",
"@histoire/plugin-vue": "0.17.12",
"@rushstack/eslint-patch": "1.7.2",
"@tsconfig/node18": "18.2.2",
"@types/codemirror": "5.60.15",
@@ -139,44 +141,44 @@
"@types/is-touch-device": "1.0.2",
"@types/lodash.debounce": "4.0.9",
"@types/marked": "5.0.2",
"@types/node": "20.11.10",
"@types/node": "20.11.24",
"@types/postcss-preset-env": "7.7.0",
"@types/sortablejs": "1.15.7",
"@typescript-eslint/eslint-plugin": "6.20.0",
"@typescript-eslint/parser": "6.20.0",
"@vitejs/plugin-legacy": "5.3.0",
"@vitejs/plugin-vue": "5.0.3",
"@types/sortablejs": "1.15.8",
"@typescript-eslint/eslint-plugin": "7.1.1",
"@typescript-eslint/parser": "7.1.1",
"@vitejs/plugin-legacy": "5.3.1",
"@vitejs/plugin-vue": "5.0.4",
"@vue/eslint-config-typescript": "12.0.0",
"@vue/test-utils": "2.4.4",
"@vue/tsconfig": "0.5.1",
"autoprefixer": "10.4.17",
"browserslist": "4.22.3",
"caniuse-lite": "1.0.30001581",
"css-has-pseudo": "6.0.1",
"autoprefixer": "10.4.18",
"browserslist": "4.23.0",
"caniuse-lite": "1.0.30001593",
"css-has-pseudo": "6.0.2",
"csstype": "3.1.3",
"cypress": "13.6.3",
"esbuild": "0.20.0",
"eslint": "8.56.0",
"eslint-plugin-vue": "9.20.1",
"happy-dom": "13.3.5",
"cypress": "13.6.6",
"esbuild": "0.20.1",
"eslint": "8.57.0",
"eslint-plugin-vue": "9.22.0",
"happy-dom": "13.6.2",
"histoire": "0.17.9",
"postcss": "8.4.33",
"postcss": "8.4.35",
"postcss-easing-gradients": "3.0.1",
"postcss-easings": "4.0.0",
"postcss-focus-within": "8.0.1",
"postcss-preset-env": "9.3.0",
"rollup": "4.9.6",
"postcss-preset-env": "9.4.0",
"rollup": "4.12.0",
"rollup-plugin-visualizer": "5.12.0",
"sass": "1.70.0",
"sass": "1.71.1",
"start-server-and-test": "2.0.3",
"typescript": "5.3.3",
"vite": "5.0.12",
"vite": "5.1.5",
"vite-plugin-inject-preload": "1.3.3",
"vite-plugin-pwa": "0.17.5",
"vite-plugin-sentry": "1.3.0",
"vite-plugin-pwa": "0.19.2",
"vite-plugin-sentry": "1.4.0",
"vite-svg-loader": "5.1.0",
"vitest": "1.2.2",
"vue-tsc": "1.8.27",
"vitest": "1.3.1",
"vue-tsc": "2.0.4",
"wait-on": "7.2.0",
"workbox-cli": "7.0.0"
},
+1420 -1278
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -51,10 +51,12 @@ Hi ${process.env.DRONE_COMMIT_AUTHOR}!
Thank you for creating a PR!
I've deployed the changes of this PR on a preview environment under this URL: ${fullPreviewUrl}
I've deployed the frontend changes of this PR on a preview environment under this URL: ${fullPreviewUrl}
You can use this url to view the changes live and test them out.
You will need to manually connect this to an api running somehwere. The easiest to use is https://try.vikunja.io/.
You will need to manually connect this to an api running somewhere. The easiest to use is https://try.vikunja.io/.
This preview does not contain any changes made to the api, only the frontend.
Have a nice day!
@@ -1 +1 @@
d58cd9ebc135407aa29d093b046d84b72ec7073b3f08cedfdbb936318a0ad3e272fab921d3ff91a82c1a7059fcdecd7b ./scripts/deploy-preview-netlify.mjs
2ba5ae4c831fd749296d92f92c5f89339030e22b80be62b1253dc26982e8fd0082e354f884a3ba15293e0b96317ec758 ./scripts/deploy-preview-netlify.mjs
+3 -1
View File
@@ -26,7 +26,7 @@
class="base-button"
:href="href"
rel="noreferrer noopener nofollow"
target="_blank"
:target="openExternalInNewTab ? '_blank' : undefined"
>
<slot />
</a>
@@ -69,6 +69,7 @@ export interface BaseButtonProps extends /* @vue-ignore */ HTMLAttributes {
disabled?: boolean
to?: RouteLocationRaw
href?: string
openExternalInNewTab?: boolean
}
export interface BaseButtonEmits {
@@ -78,6 +79,7 @@ export interface BaseButtonEmits {
const {
type = BASE_BUTTON_TYPES_MAP.BUTTON,
disabled = false,
openExternalInNewTab = true,
} = defineProps<BaseButtonProps>()
const emit = defineEmits<BaseButtonEmits>()
@@ -19,3 +19,28 @@ export const DATE_RANGES = {
'thisYear': ['now/y', 'now/y+1y'],
'restOfThisYear': ['now', 'now/y+1y'],
}
export const DATE_VALUES = {
'now': 'now',
'startOfToday': 'now/d',
'endOfToday': 'now/d+1d',
'beginningOflastWeek': 'now/w-1w',
'endOfLastWeek': 'now/w-2w',
'beginningOfThisWeek': 'now/w',
'endOfThisWeek': 'now/w+1w',
'startOfNextWeek': 'now/w+1w',
'endOfNextWeek': 'now/w+2w',
'in7Days': 'now+7d',
'beginningOfLastMonth': 'now/M-1M',
'endOfLastMonth': 'now/M-2M',
'startOfThisMonth': 'now/M',
'endOfThisMonth': 'now/M+1M',
'startOfNextMonth': 'now/M+1M',
'endOfNextMonth': 'now/M+2M',
'in30Days': 'now+30d',
'startOfThisYear': 'now/y',
'endOfThisYear': 'now/y+1y',
}
@@ -75,14 +75,15 @@
<p>
{{ $t('input.datemathHelp.canuse') }}
<BaseButton
class="has-text-primary"
@click="showHowItWorks = true"
>
{{ $t('input.datemathHelp.learnhow') }}
</BaseButton>
</p>
<BaseButton
class="has-text-primary"
@click="showHowItWorks = true"
>
{{ $t('input.datemathHelp.learnhow') }}
</BaseButton>
<modal
:enabled="showHowItWorks"
transition-name="fade"
@@ -90,7 +91,7 @@
variant="hint-modal"
@close="() => showHowItWorks = false"
>
<DatemathHelp />
<DatemathHelp/>
</modal>
</div>
</div>
@@ -111,7 +112,7 @@ import Popup from '@/components/misc/popup.vue'
import {DATE_RANGES} from '@/components/date/dateRanges'
import BaseButton from '@/components/base/BaseButton.vue'
import DatemathHelp from '@/components/date/datemathHelp.vue'
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
import {getFlatpickrLanguage} from '@/helpers/flatpickrLanguage'
const props = defineProps({
modelValue: {
@@ -0,0 +1,255 @@
<template>
<div class="datepicker-with-range-container">
<Popup
:open="open"
@close="() => emit('close')"
>
<template #content="{isOpen}">
<div
class="datepicker-with-range"
:class="{'is-open': isOpen}"
>
<div class="selections">
<BaseButton
:class="{'is-active': customRangeActive}"
@click="setDate(null)"
>
{{ $t('misc.custom') }}
</BaseButton>
<BaseButton
v-for="(value, text) in DATE_VALUES"
:key="text"
:class="{'is-active': date === value}"
@click="setDate(value)"
>
{{ $t(`input.datepickerRange.values.${text}`) }}
</BaseButton>
</div>
<div class="flatpickr-container input-group">
<label class="label">
{{ $t('input.datepickerRange.date') }}
<div class="field has-addons">
<div class="control is-fullwidth">
<input
v-model="date"
class="input"
type="text"
>
</div>
<div class="control">
<x-button
icon="calendar"
variant="secondary"
data-toggle
/>
</div>
</div>
</label>
<flat-pickr
v-model="flatpickrDate"
:config="flatPickerConfig"
/>
<p>
{{ $t('input.datemathHelp.canuse') }}
</p>
<BaseButton
class="has-text-primary"
@click="showHowItWorks = true"
>
{{ $t('input.datemathHelp.learnhow') }}
</BaseButton>
<modal
:enabled="showHowItWorks"
transition-name="fade"
:overflow="true"
variant="hint-modal"
@close="() => showHowItWorks = false"
>
<DatemathHelp/>
</modal>
</div>
</div>
</template>
</Popup>
</div>
</template>
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import Popup from '@/components/misc/popup.vue'
import {DATE_VALUES} from '@/components/date/dateRanges'
import BaseButton from '@/components/base/BaseButton.vue'
import DatemathHelp from '@/components/date/datemathHelp.vue'
import {getFlatpickrLanguage} from '@/helpers/flatpickrLanguage'
const props = defineProps({
modelValue: {
required: false,
},
open: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue', 'close'])
const {t} = useI18n({useScope: 'global'})
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: false,
wrap: true,
locale: getFlatpickrLanguage(),
}))
const showHowItWorks = ref(false)
const flatpickrDate = ref('')
const date = ref<string|Date>('')
watch(
() => props.modelValue,
newValue => {
date.value = newValue
// Only set the date back to flatpickr when it's an actual date.
// Otherwise flatpickr runs in an endless loop and slows down the browser.
const parsed = parseDateOrString(date.value, false)
if (parsed instanceof Date) {
flatpickrDate.value = date.value
}
},
)
function emitChanged() {
emit('update:modelValue', date.value === '' ? null : date.value)
}
watch(
() => flatpickrDate.value,
(newVal: string | null) => {
if (newVal === null) {
return
}
date.value = newVal
emitChanged()
},
)
watch(() => date.value, emitChanged)
function setDate(range: string | null) {
if (range === null) {
date.value = ''
return
}
date.value = range
}
const customRangeActive = computed<boolean>(() => {
return !Object.values(DATE_VALUES).some(d => date.value === d)
})
</script>
<style lang="scss" scoped>
.datepicker-with-range-container {
position: relative;
}
:deep(.popup) {
z-index: 10;
margin-top: 1rem;
border-radius: $radius;
border: 1px solid var(--grey-200);
background-color: var(--white);
box-shadow: $shadow;
&.is-open {
width: 500px;
height: 320px;
}
}
.datepicker-with-range {
display: flex;
width: 100%;
height: 100%;
position: absolute;
}
:deep(.flatpickr-calendar) {
margin: 0 auto 8px;
box-shadow: none;
}
.flatpickr-container {
width: 70%;
border-left: 1px solid var(--grey-200);
padding: 1rem;
font-size: .9rem;
// Flatpickr has no option to use it without an input field so we're hiding it instead
:deep(input.form-control.input) {
height: 0;
padding: 0;
border: 0;
}
.field .control :deep(.button) {
border: 1px solid var(--input-border-color);
height: 2.25rem;
&:hover {
border: 1px solid var(--input-hover-border-color);
}
}
.label, .input, :deep(.button) {
font-size: .9rem;
}
}
.selections {
width: 30%;
display: flex;
flex-direction: column;
padding-top: .5rem;
overflow-y: scroll;
button {
display: block;
width: 100%;
text-align: left;
padding: .5rem 1rem;
transition: $transition;
font-size: .9rem;
color: var(--text);
background: transparent;
border: 0;
cursor: pointer;
&.is-active {
color: var(--primary);
}
&:hover, &.is-active {
background-color: var(--grey-100);
}
}
}
</style>
@@ -84,9 +84,10 @@
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import {computed} from 'vue'
import {useProjectStore} from '@/stores/projects'
import {useBaseStore} from '@/stores/base'
import {useStorage} from '@vueuse/core'
import type {IProject} from '@/modelTypes/IProject'
@@ -112,7 +113,18 @@ const projectStore = useProjectStore()
const baseStore = useBaseStore()
const currentProject = computed(() => baseStore.currentProject)
const childProjectsOpen = ref(true)
// Persist open state across browser reloads. Using a seperate ref for the state
// allows us to use only one entry in local storage instead of one for every project id.
type openState = { [key: number]: boolean }
const childProjectsOpenState = useStorage<openState>('navigation-child-projects-open', {})
const childProjectsOpen = computed({
get() {
return childProjectsOpenState.value[project.id] ?? true
},
set(open) {
childProjectsOpenState.value[project.id] = open
},
})
const childProjects = computed(() => {
return projectStore.getChildProjects(project.id)
+1 -1
View File
@@ -122,7 +122,7 @@ const labelStore = useLabelStore()
labelStore.loadAllLabels()
const projectStore = useProjectStore()
projectStore.loadProjects()
projectStore.loadAllProjects()
</script>
<style lang="scss" scoped>
@@ -0,0 +1,316 @@
<script setup lang="ts">
import {computed, nextTick, ref, watch} from 'vue'
const TAB = 9,
ENTER = 13,
ESCAPE = 27,
ARROW_UP = 38,
ARROW_DOWN = 40
type state = 'unfocused' | 'focused'
const selectedIndex = ref(0)
const state = ref<state>('unfocused')
const val = ref<string>('')
const isResizing = ref(false)
const model = defineModel<string>()
const suggestionScrollerRef = ref<HTMLInputElement | null>(null)
const containerRef = ref<HTMLInputElement | null>(null)
const editorRef = ref<HTMLInputElement | null>(null)
watch(
() => model.value,
newValue => {
val.value = newValue
},
)
const placeholderText = computed(() => {
const value = (model.value || '').replace(/[\n\r\t]/gi, ' ')
if (state.value === 'unfocused') {
return value ? '' : props.suggestion
}
if (!value || !value.trim()) {
return props.suggestion
}
return lookahead()
})
const spacerText = computed(() => {
const value = (model.value || '').replace(/[\n\r\t]/gi, ' ')
if (!value || !value.trim()) {
return props.suggestion
}
return value
})
const props = withDefaults(defineProps<{
options: string[],
suggestion?: string,
maxHeight?: number,
}>(), {
maxHeight: 200,
})
function addSelectedIndex(offset: number) {
let nextIndex = Math.max(
0,
Math.min(selectedIndex.value + offset, props.options.length - 1),
)
if (!isFinite(nextIndex)) {
nextIndex = 0
}
selectedIndex.value = nextIndex
updateSuggestionScroll()
}
function highlight(words: string, query: string) {
return (words || '').replace(new RegExp(query, 'i'), '<mark class="scroll-term">' + query + '</mark>')
}
function lookahead() {
if (!props.options.length) {
return model.value
}
const index = Math.max(0, Math.min(selectedIndex.value, props.options.length - 1))
const match = props.options[index]
return model.value + (match ? match.substring(model.value?.length) : '')
}
function updateSuggestionScroll() {
nextTick(() => {
const scroller = suggestionScrollerRef.value
const selectedItem = scroller?.querySelector('.selected')
scroller.scrollTop = selectedItem ? selectedItem.offsetTop : 0
})
}
function updateScrollWindowSize() {
if (isResizing.value) {
return
}
isResizing.value = true
nextTick(() => {
isResizing.value = false
const scroller = suggestionScrollerRef.value
const parent = containerRef.value
if (scroller) {
const rect = parent.getBoundingClientRect()
const pxTop = rect.top
const pxBottom = window.innerHeight - rect.bottom
const maxHeight = Math.max(pxTop, pxBottom, props.maxHeight)
const isReversed = pxBottom < props.maxHeight && pxTop > pxBottom
scroller.style.maxHeight = Math.min(isReversed ? pxTop : pxBottom, props.maxHeight) + 'px'
scroller.parentNode.style.transform =
isReversed ? 'translateY(-100%) translateY(-1.4rem)'
: 'translateY(.4rem)'
}
})
}
function setState(stateName: state) {
state.value = stateName
if (stateName === 'unfocused') {
editorRef.value.blur()
} else {
updateScrollWindowSize()
}
}
function onFocusField(e) {
setState('focused')
}
function onKeydown(e) {
switch (e.keyCode || e.which) {
case ESCAPE:
e.preventDefault()
setState('unfocused')
break
case ARROW_UP:
e.preventDefault()
addSelectedIndex(-1)
break
case ARROW_DOWN:
e.preventDefault()
addSelectedIndex(1)
break
case ENTER:
case TAB:
e.preventDefault()
onSelectValue(lookahead() || model.value)
break
}
}
function onSelectValue(value) {
model.value = value
selectedIndex.value = 0
setState('unfocused')
}
function onUpdateField(e) {
setState('focused')
model.value = e.currentTarget.value
}
</script>
<template>
<div class="autocomplete" ref="containerRef">
<div class="entry-box">
<div class="spacer">{{ spacerText }}</div>
<div class="placeholder">{{ placeholderText }}</div>
<textarea class="field"
@input="onUpdateField"
@focus="onFocusField"
@keydown="onKeydown"
:class="state"
:value="val"
ref="editorRef"></textarea>
</div>
<div class="suggestion-list" v-if="state === 'focused' && options.length">
<div v-if="options && options.length" class="scroll-list">
<div class="items" ref="suggestionScrollerRef" @keydown="onKeydown">
<button
v-for="(item, index) in options"
class="item"
@click="onSelectValue(item)"
:class="{ selected: index === selectedIndex }"
:key="item"
v-html="highlight(item, val)"></button>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.autocomplete {
position: relative;
* {
font-size: 1rem;
font-family: Consolas, Lucida Console, Courier New, monospace;
}
.entry-box {
position: relative;
width: 180px;
}
.spacer,
.placeholder,
.field {
border: none;
height: 100%;
padding: .1rem .2rem;
width: 100%;
}
.spacer {
min-height: 1rem;
visibility: hidden;
}
.placeholder {
user-select: none;
pointer-events: none;
opacity: 0.4;
z-index: 2;
}
.field {
z-index: 1;
&.focused {
color: blue;
}
}
.placeholder,
.field {
left: 0;
outline: none;
overflow: hidden;
position: absolute;
resize: none;
top: 0;
}
.suggestion-list {
position: absolute;
width: 100%;
}
.scroll-list {
position: absolute;
width: 100%;
border: solid 1px lightgray;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
}
.items {
margin: 0;
max-height: 150px;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
&::-webkit-scrollbar {
width: 10px;
}
&::-webkit-scrollbar-thumb {
background: #045068;
border-radius: 20px;
}
&::-webkit-scrollbar-track {
background: #dfe1e5;
}
}
.item {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: solid 1px transparent;
background-color: white;
display: block;
width: 100%;
text-align: left;
&:hover {
cursor: pointer;
}
&:not(.selected):hover {
background-color: #c1dae2;
color: black;
}
&.selected {
background-color: #00aee6;
color: white;
}
}
.scroll-term {
font-weight: bold;
background-color: unset;
color: unset;
}
}
</style>
+27 -18
View File
@@ -594,27 +594,21 @@ function clickTasklistCheckbox(event) {
watch(
() => isEditing.value,
editing => {
nextTick(() => {
const checkboxes = tiptapInstanceRef.value?.querySelectorAll('[data-checked]')
async editing => {
await nextTick()
let checkboxes = tiptapInstanceRef.value?.querySelectorAll('[data-checked]')
if (typeof checkboxes === 'undefined' || checkboxes.length === 0) {
// For some reason, this works when we check a second time.
await nextTick()
checkboxes = tiptapInstanceRef.value?.querySelectorAll('[data-checked]')
if (typeof checkboxes === 'undefined' || checkboxes.length === 0) {
return
}
}
if (editing) {
checkboxes.forEach(check => {
if (check.children.length < 2) {
return
}
// We assume the first child contains the label element with the checkbox and the second child the actual label
// When the actual label is clicked, we forward that click to the checkbox.
check.children[1].removeEventListener('click', clickTasklistCheckbox)
})
return
}
if (editing) {
checkboxes.forEach(check => {
if (check.children.length < 2) {
return
@@ -622,8 +616,21 @@ watch(
// We assume the first child contains the label element with the checkbox and the second child the actual label
// When the actual label is clicked, we forward that click to the checkbox.
check.children[1].addEventListener('click', clickTasklistCheckbox)
check.children[1].removeEventListener('click', clickTasklistCheckbox)
})
return
}
checkboxes.forEach(check => {
if (check.children.length < 2) {
return
}
// We assume the first child contains the label element with the checkbox and the second child the actual label
// When the actual label is clicked, we forward that click to the checkbox.
check.children[1].removeEventListener('click', clickTasklistCheckbox)
check.children[1].addEventListener('click', clickTasklistCheckbox)
})
},
{immediate: true},
@@ -781,6 +788,7 @@ watch(
.ProseMirror {
/* Table-specific styling */
table {
border-collapse: collapse;
table-layout: fixed;
@@ -836,6 +844,7 @@ watch(
}
// Lists
ul {
margin-left: .5rem;
margin-top: 0 !important;
+28 -7
View File
@@ -10,7 +10,8 @@
autocomplete="current-password"
:tabindex="props.tabindex"
@keyup.enter="e => $emit('submit', e)"
@focusout="validate"
@focusout="() => {validate(); validateAfterFirst = true}"
@keyup="() => {validateAfterFirst ? validate() : null}"
@input="handleInput"
>
<BaseButton
@@ -23,16 +24,17 @@
</BaseButton>
</div>
<p
v-if="!isValid"
v-if="isValid !== true"
class="help is-danger"
>
{{ $t('user.auth.passwordRequired') }}
{{ isValid }}
</p>
</template>
<script lang="ts" setup>
import {ref, watch} from 'vue'
import {useDebounceFn} from '@vueuse/core'
import {useI18n} from 'vue-i18n'
import BaseButton from '@/components/base/BaseButton.vue'
const props = defineProps({
@@ -40,13 +42,17 @@ const props = defineProps({
modelValue: String,
// This prop is a workaround to trigger validation from the outside when the user never had focus in the input.
validateInitially: Boolean,
validateMinLength: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['submit', 'update:modelValue'])
const {t} = useI18n()
const passwordFieldType = ref('password')
const password = ref('')
const isValid = ref(!props.validateInitially)
const isValid = ref<true | string>(props.validateInitially === true ? true : '')
const validateAfterFirst = ref(false)
watch(
() => props.validateInitially,
@@ -55,7 +61,22 @@ watch(
)
const validate = useDebounceFn(() => {
isValid.value = password.value !== ''
if (password.value === '') {
isValid.value = t('user.auth.passwordRequired')
return
}
if (props.validateMinLength && password.value.length < 8) {
isValid.value = t('user.auth.passwordNotMin')
return
}
if (props.validateMinLength && password.value.length > 250) {
isValid.value = t('user.auth.passwordNotMax')
return
}
isValid.value = true
}, 100)
function togglePasswordFieldType() {
@@ -1,5 +1,8 @@
<template>
<BaseButton class="dropdown-item">
<BaseButton
class="dropdown-item"
:class="{'is-disabled': disabled}"
>
<span
v-if="icon"
class="icon is-small"
@@ -21,6 +24,7 @@ import type {IconProp} from '@fortawesome/fontawesome-svg-core'
export interface DropDownItemProps extends /* @vue-ignore */ BaseButtonProps {
icon?: IconProp,
iconClass?: object | string,
disabled?: boolean,
}
defineProps<DropDownItemProps>()
+12 -1
View File
@@ -23,7 +23,7 @@
</template>
<script setup lang="ts">
import {ref} from 'vue'
import {ref, watch} from 'vue'
import {onClickOutside} from '@vueuse/core'
const props = defineProps({
@@ -31,8 +31,19 @@ const props = defineProps({
type: Boolean,
default: false,
},
open: {
type: Boolean,
default: false,
},
})
watch(
() => props.open,
nowOpen => {
open.value = nowOpen
},
)
const emit = defineEmits(['close'])
const open = ref(false)
@@ -0,0 +1,22 @@
<script setup lang="ts">
import FilterInput from '@/components/project/partials/FilterInput.vue'
function initState(value: string) {
return {
value,
}
}
</script>
<template>
<Story title="Filter Input">
<Variant
title="With date values"
:init-state="initState('dueDate < now && done = false && dueDate > now/w+1w')"
>
<template #default="{state}">
<FilterInput v-model="state.value"/>
</template>
</Variant>
</Story>
</template>
@@ -0,0 +1,376 @@
<script setup lang="ts">
import {nextTick, ref, watch} from 'vue'
import {useAutoHeightTextarea} from '@/composables/useAutoHeightTextarea'
import DatepickerWithValues from '@/components/date/datepickerWithValues.vue'
import UserService from '@/services/user'
import {getAvatarUrl, getDisplayName} from '@/models/user'
import {createRandomID} from '@/helpers/randomId'
const {
modelValue,
} = defineProps<{
modelValue: string,
}>()
const filterQuery = ref('')
const {
textarea: filterInput,
height,
} = useAutoHeightTextarea(filterQuery)
watch(
() => modelValue,
() => {
filterQuery.value = modelValue
},
{immediate: true},
)
const userService = new UserService()
const dateFields = [
'dueDate',
'startDate',
'endDate',
'doneAt',
'reminders',
]
const dateFieldsRegex = '(' + dateFields.join('|') + ')'
const assigneeFields = [
'assignees',
]
const availableFilterFields = [
'done',
'priority',
'usePriority',
'percentDone',
'labels',
...dateFields,
...assigneeFields,
]
const filterOperators = [
'!=',
'=',
'>',
'>=',
'<',
'<=',
'like',
'in',
'?=',
]
const filterJoinOperators = [
'&&',
'||',
'(',
')',
]
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function unEscapeHtml(unsafe: string): string {
return unsafe
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot/g, '"')
.replace(/&#039;/g, '\'')
}
const TOKEN_REGEX = '(&lt;|&gt;|&lt;=|&gt;=|=|!=)'
function getHighlightedFilterQuery() {
let highlighted = escapeHtml(filterQuery.value)
dateFields
.forEach(o => {
const pattern = new RegExp(o + '\\s*' + TOKEN_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
highlighted = highlighted.replaceAll(pattern, (match, token, start, value, position) => {
if (typeof value === 'undefined') {
value = ' '
}
return `${o} ${token} <button class="button is-primary filter-query__date_value" data-position="${position}">${value}</button>`
})
})
assigneeFields
.forEach(f => {
const pattern = new RegExp(f + '\\s*' + TOKEN_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
highlighted = highlighted.replaceAll(pattern, (match, token, start, value) => {
if (typeof value === 'undefined') {
value = ''
}
const id = createRandomID(32)
userService.getAll({}, {s: value}).then(users => {
if (users.length > 0) {
const displayName = getDisplayName(users[0])
const nameTag = document.createElement('span')
nameTag.innerText = displayName
const avatar = document.createElement('img')
avatar.src = getAvatarUrl(users[0], 20)
avatar.height = 20
avatar.width = 20
avatar.alt = displayName
// TODO: caching
nextTick(() => {
const assigneeValue = document.getElementById(id)
assigneeValue.innerText = ''
assigneeValue?.appendChild(avatar)
assigneeValue?.appendChild(nameTag)
})
}
})
return `${f} ${token} <span class="filter-query__assignee_value" id="${id}">${value}<span>`
})
})
filterOperators
.map(o => ` ${escapeHtml(o)} `)
.forEach(o => {
highlighted = highlighted.replaceAll(o, `<span class="filter-query__operator">${o}</span>`)
})
filterJoinOperators
.map(o => escapeHtml(o))
.forEach(o => {
highlighted = highlighted.replaceAll(o, `<span class="filter-query__join-operator">${o}</span>`)
})
availableFilterFields.forEach(f => {
highlighted = highlighted.replaceAll(f, `<span class="filter-query__field">${f}</span>`)
})
return highlighted
}
const currentOldDatepickerValue = ref('')
const currentDatepickerValue = ref('')
const currentDatepickerPos = ref()
const datePickerPopupOpen = ref(false)
function updateDateInQuery(newDate: string) {
// Need to escape and unescape the query because the positions are based on the escaped query
let escaped = escapeHtml(filterQuery.value)
escaped = escaped
.substring(0, currentDatepickerPos.value)
+ escaped
.substring(currentDatepickerPos.value)
.replace(currentOldDatepickerValue.value, newDate)
currentOldDatepickerValue.value = newDate
filterQuery.value = unEscapeHtml(escaped)
updateQueryHighlight()
}
function getCharacterOffsetWithin(element: HTMLInputElement, isStart: boolean): number {
let range = document.createRange()
let sel = window.getSelection()
if (sel.rangeCount > 0) {
let originalRange = sel.getRangeAt(0)
range.selectNodeContents(element)
range.setEnd(
isStart ? originalRange.startContainer : originalRange.endContainer,
isStart ? originalRange.startOffset : originalRange.endOffset,
)
const rangeLength = range.toString().length
const originalLength = originalRange.toString().length
return rangeLength - (isStart ? 0 : originalLength)
}
return 0 // No selection
}
function saveSelectionOffsets(element: HTMLInputElement) {
return {
start: getCharacterOffsetWithin(element, true),
end: getCharacterOffsetWithin(element, false),
}
}
function setSelectionByCharacterOffsets(element: HTMLElement, startOffset: number, endOffset: number) {
let charIndex = 0, range = document.createRange()
const sel = window.getSelection()
console.log({startOffset, endOffset})
range.setStart(element, 0)
range.collapse(true)
let foundStart = false
const allTextNodes: ChildNode[] = []
element.childNodes.forEach(n => {
if (n.nodeType === Node.TEXT_NODE) {
allTextNodes.push(n)
}
n.childNodes.forEach(child => {
if (child.nodeType === Node.TEXT_NODE) {
allTextNodes.push(child)
}
})
})
allTextNodes.forEach(node => {
const nextCharIndex = charIndex + node.textContent.length
let addition = node.textContent === ' ' ? 1 : 0
if (!foundStart && startOffset >= charIndex && startOffset <= nextCharIndex) {
range.setStart(node, startOffset - charIndex + addition)
foundStart = true // Start position found
}
if (foundStart && endOffset >= charIndex && endOffset <= nextCharIndex) {
if (node.parentNode?.nodeName === 'BUTTON') {
node.parentNode?.focus()
range.setStartAfter(node.parentNode)
range.setEndAfter(node.parentNode)
return
}
range.setEnd(node, endOffset - charIndex + addition)
}
charIndex = nextCharIndex // Update charIndex to the next position
})
// FIXME: This kind of works for the first literal but breaks as soon as you type another query after the first it breaks
sel.removeAllRanges()
sel.addRange(range)
}
function updateQueryStringFromInput(e) {
filterQuery.value = e.target.innerText
const element = e.target
const offsets = saveSelectionOffsets(element)
if (offsets) {
updateQueryHighlight()
setSelectionByCharacterOffsets(element, offsets.start, offsets.end)
} else {
updateQueryHighlight()
}
}
const queryInputRef = ref<HTMLInputElement | null>(null)
function updateQueryHighlight() {
// Updating the query value in a function instead of a computed gives us more control about the timing
queryInputRef.value.innerHTML = getHighlightedFilterQuery()
nextTick(() => {
document.querySelectorAll('button.filter-query__date_value')
.forEach(b => {
b.addEventListener('click', event => {
event.preventDefault()
event.stopPropagation()
const button = event.target
currentOldDatepickerValue.value = button?.innerText
currentDatepickerValue.value = button?.innerText
currentDatepickerPos.value = parseInt(button?.dataset.position)
datePickerPopupOpen.value = true
})
})
})
}
</script>
<template>
<div class="field">
<label class="label">{{ $t('filters.query.title') }}</label>
<div class="control filter-input">
<div
class="input filter-input-highlight"
:style="{'height': height}"
contenteditable="true"
@input="updateQueryStringFromInput"
ref="queryInputRef"
></div>
<DatepickerWithValues
v-model="currentDatepickerValue"
:open="datePickerPopupOpen"
@close="() => datePickerPopupOpen = false"
@update:model-value="updateDateInQuery"
/>
</div>
{{ filterQuery }}
</div>
</template>
<style lang="scss">
.filter-input-highlight {
span {
&.filter-query__field {
color: var(--code-literal);
}
&.filter-query__operator {
color: var(--code-keyword);
}
&.filter-query__join-operator {
color: var(--code-section);
}
&.filter-query__date_value_placeholder {
padding: .125rem .25rem;
display: inline-block;
}
&.filter-query__assignee_value {
padding: .125rem .25rem;
border-radius: $radius;
background-color: var(--grey-200);
color: var(--grey-700);
display: inline-flex;
align-items: center;
> img {
margin-right: .25rem;
}
}
}
button.filter-query__date_value {
padding: .125rem .25rem;
border-radius: $radius;
margin-top: calc((0.25em - 0.125rem) * -1);
height: 1.75rem;
}
}
</style>
<style lang="scss" scoped>
.filter-input {
//position: relative;
textarea {
//position: absolute;
//text-fill-color: transparent;
//-webkit-text-fill-color: transparent;
//background: transparent !important;
//resize: none;
}
.filter-input-highlight {
height: 2.5em;
line-height: 1.5;
padding: .5em .75em;
word-break: break-word;
}
}
</style>
@@ -30,6 +30,15 @@
{{ $t('filters.attributes.sortAlphabetically') }}
</Fancycheckbox>
</div>
<FilterInput v-model="filterQuery"/>
<Autocomplete
:options="filteredFruits"
suggestion="Type: Blueberry"
v-model="selectedValue"
/>
<div class="field">
<label class="label">{{ $t('misc.search') }}</label>
<div class="control">
@@ -227,6 +236,117 @@ import ProjectService from '@/services/project'
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
import {getDefaultParams} from '@/composables/useTaskList'
import FilterInput from '@/components/project/partials/FilterInput.vue'
import Autocomplete from '@/components/input/Autocomplete.vue'
const selectedValue = ref('')
const filteredFruits = computed(() => {
const vals = (selectedValue.value || '').toLowerCase().split(' ')
return FRUITS
.filter(f => f.toLowerCase().startsWith(vals[vals.length - 1]))
.sort()
})
const FRUITS = [
'Apple',
'Apricot',
'Avocado',
'Banana',
'Bilberry',
'Blackberry',
'Blackcurrant',
'Blueberry',
'Boysenberry',
'Buddha\'s hand (fingered citron)',
'Crab apples',
'Currant',
'Cherry',
'Cherimoya',
'Chico fruit',
'Cloudberry',
'Coconut',
'Cranberry',
'Cucumber',
'Custard apple',
'Damson',
'Date',
'Dragonfruit',
'Durian',
'Elderberry',
'Feijoa',
'Fig',
'Goji berry',
'Gooseberry',
'Grape',
'Raisin',
'Grapefruit',
'Guava',
'Honeyberry',
'Huckleberry',
'Jabuticaba',
'Jackfruit',
'Jambul',
'Jujube',
'Juniper berry',
'Kiwano',
'Kiwifruit',
'Kumquat',
'Lemon',
'Lime',
'Loquat',
'Longan',
'Lychee',
'Mango',
'Mangosteen',
'Marionberry',
'Melon',
'Cantaloupe',
'Honeydew',
'Watermelon',
'Miracle fruit',
'Mulberry',
'Nectarine',
'Nance',
'Olive',
'Orange',
'Blood orange',
'Clementine',
'Mandarine',
'Tangerine',
'Papaya',
'Passionfruit',
'Peach',
'Pear',
'Persimmon',
'Plantain',
'Plum',
'Prune (dried plum)',
'Pineapple',
'Plumcot (or Pluot)',
'Pomegranate',
'Pomelo',
'Purple mangosteen',
'Quince',
'Raspberry',
'Salmonberry',
'Rambutan',
'Redcurrant',
'Salal berry',
'Salak',
'Satsuma',
'Soursop',
'Star fruit',
'gonzoberry',
'Strawberry',
'Tamarillo',
'Tamarind',
'Ugli fruit',
'Yuzu',
]
const props = defineProps({
modelValue: {
@@ -252,6 +372,9 @@ const DEFAULT_PARAMS = {
s: '',
} as const
// FIXME: use params
const filterQuery = ref('')
const DEFAULT_FILTERS = {
done: false,
dueDate: '',
@@ -67,8 +67,10 @@
{{ $t('menu.duplicate') }}
</DropdownItem>
<DropdownItem
v-tooltip="isDefaultProject ? $t('menu.cantArchiveIsDefault') : ''"
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
icon="archive"
:disabled="isDefaultProject"
>
{{ $t('menu.archive') }}
</DropdownItem>
@@ -95,9 +97,11 @@
{{ $t('menu.createProject') }}
</DropdownItem>
<DropdownItem
v-tooltip="isDefaultProject ? $t('menu.cantDeleteIsDefault') : ''"
:to="{ name: 'project.settings.delete', params: { projectId: project.id } }"
icon="trash-alt"
class="has-text-danger"
:disabled="isDefaultProject"
>
{{ $t('menu.delete') }}
</DropdownItem>
@@ -106,7 +110,7 @@
</template>
<script setup lang="ts">
import {ref, computed, watchEffect, type PropType} from 'vue'
import {computed, type PropType, ref, watchEffect} from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import Dropdown from '@/components/misc/dropdown.vue'
@@ -118,6 +122,7 @@ import type {ISubscription} from '@/modelTypes/ISubscription'
import {isSavedFilter} from '@/services/savedFilter'
import {useConfigStore} from '@/stores/config'
import {useProjectStore} from '@/stores/projects'
import {useAuthStore} from '@/stores/auth'
const props = defineProps({
project: {
@@ -146,4 +151,7 @@ function setSubscriptionInStore(sub: ISubscription) {
}
projectStore.setProject(updatedProject)
}
const authStore = useAuthStore()
const isDefaultProject = computed(() => props.project?.id === authStore.settings.defaultProjectId)
</script>
+12 -1
View File
@@ -17,6 +17,7 @@
bar-end="endDate"
:grid="true"
:width="ganttChartWidth"
:color-scheme="GANTT_COLOR_SCHEME"
@dragendBar="updateGanttTask"
@dblclickBar="openTask"
>
@@ -59,7 +60,7 @@ import {
extendDayjs,
GGanttChart,
GGanttRow,
type GanttBarObject,
type GanttBarObject, type ColorScheme,
} from '@infectoone/vue-ganttastic'
import Loading from '@/components/misc/loading.vue'
@@ -113,6 +114,16 @@ const ganttChartWidth = computed(() => {
const ganttBars = ref<GanttBarObject[][]>([])
const GANTT_COLOR_SCHEME: ColorScheme = {
primary: 'var(--grey-100)',
secondary: 'var(--grey-300)',
ternary: 'var(--grey-500)',
quartenary: 'var(--grey-600)',
hoverHighlight: 'var(--grey-700)',
text: 'var(--grey-800)',
background: 'var(--white)',
}
/**
* Update ganttBars when tasks change
*/
+1 -1
View File
@@ -77,7 +77,7 @@ const props = defineProps({
const emit = defineEmits(['taskAdded'])
const newTaskTitle = ref('')
const newTaskInput = useAutoHeightTextarea(newTaskTitle)
const {textarea: newTaskInput} = useAutoHeightTextarea(newTaskTitle)
const {t} = useI18n({useScope: 'global'})
const authStore = useAuthStore()
@@ -59,7 +59,7 @@ const hasDelete = computed(() => typeof remove !== 'undefined' && !disabled)
}
&:hover .assignee:not(:first-child) {
margin-left: -1rem;
margin-left: -0.5rem;
}
}
@@ -68,7 +68,7 @@ const hasDelete = computed(() => typeof remove !== 'undefined' && !disabled)
transition: all $transition;
&:not(:first-child) {
margin-left: -1.5rem;
margin-left: -1rem;
}
:deep(.user img) {
@@ -27,7 +27,8 @@ defineProps({
display: inline;
:deep(.tag) {
margin-bottom: .25rem;
margin-top: .125rem;
margin-bottom: .125rem;
}
}
</style>
@@ -261,13 +261,18 @@ const foundTasks = ref<ITask[]>([])
async function findTasks(newQuery: string) {
query.value = newQuery
foundTasks.value = await taskService.getAll({}, {s: newQuery})
const result = await taskService.getAll({}, {
s: newQuery,
sort_by: 'done',
})
foundTasks.value = mapRelatedTasks(result)
}
function mapRelatedTasks(tasks: ITask[]) {
return tasks.map(task => {
// by doing this here once we can save a lot of duplicate calls in the template
const project = projectStore.projects[task.ProjectId]
const project = projectStore.projects[task.projectId]
return {
...task,
@@ -103,7 +103,7 @@ import {getHexColor} from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
import Labels from '@/components/tasks/partials//labels.vue'
import Labels from '@/components/tasks/partials/labels.vue'
import ChecklistSummary from '@/components/tasks/partials/checklist-summary.vue'
import ColorBubble from '@/components/misc/colorBubble.vue'
@@ -197,6 +197,7 @@ const project = computed(() => projectStore.projects[task.projectId])
span.parent-tasks {
color: var(--grey-500);
width: auto;
margin-left: .25rem;
}
}
</style>
@@ -6,6 +6,7 @@ import {debouncedWatch, tryOnMounted, useWindowSize, type MaybeRef} from '@vueus
export function useAutoHeightTextarea(value: MaybeRef<string>) {
const textarea = ref<HTMLTextAreaElement | null>(null)
const minHeight = ref(0)
const height = ref('')
// adapted from https://github.com/LeaVerou/stretchy/blob/47f5f065c733029acccb755cae793009645809e2/src/stretchy.js#L34
function resize(textareaEl: HTMLTextAreaElement | null) {
@@ -23,14 +24,13 @@ export function useAutoHeightTextarea(value: MaybeRef<string>) {
textareaEl.style.minHeight = ''
textareaEl.style.height = '0'
const offset = textareaEl.offsetHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)
const height = textareaEl.scrollHeight + offset + 'px'
height.value = textareaEl.scrollHeight + 'px'
textareaEl.style.height = height
textareaEl.style.height = height.value
// calculate min-height for the first time
if (!minHeight.value) {
minHeight.value = parseFloat(height)
minHeight.value = parseFloat(height.value)
}
textareaEl.style.minHeight = minHeight.value.toString()
@@ -68,5 +68,8 @@ export function useAutoHeightTextarea(value: MaybeRef<string>) {
},
)
return textarea
return {
textarea,
height,
}
}
+1
View File
@@ -21,6 +21,7 @@ export interface SortBy {
percent_done?: Order
created?: Order
updated?: Order
done_at?: Order,
}
// FIXME: merge with DEFAULT_PARAMS in filters.vue
+6 -3
View File
@@ -11,14 +11,17 @@ export function getRedirectUrlFromCurrentFrontendPath(provider: IProvider): stri
export const redirectToProvider = (provider: IProvider) => {
console.log({provider})
const redirectUrl = getRedirectUrlFromCurrentFrontendPath(provider)
const state = createRandomID(24)
localStorage.setItem('state', state)
window.location.href = `${provider.authUrl}?client_id=${provider.clientId}&redirect_uri=${redirectUrl}&response_type=code&scope=openid email profile&state=${state}`
let scope = 'openid email profile'
if (provider.scope !== null){
scope = provider.scope
}
window.location.href = `${provider.authUrl}?client_id=${provider.clientId}&redirect_uri=${redirectUrl}&response_type=code&scope=${scope}&state=${state}`
}
export const redirectToProviderOnLogout = (provider: IProvider) => {
if (provider.logoutUrl.length > 0) {
window.location.href = `${provider.logoutUrl}`
+37 -2
View File
@@ -57,7 +57,11 @@
"logout": "Logout",
"emailInvalid": "Please enter a valid email address.",
"usernameRequired": "Please provide a username.",
"usernameMustNotContainSpace": "The username must not contain spaces.",
"usernameMustNotLookLikeUrl": "The username must not look like a URL.",
"passwordRequired": "Please provide a password.",
"passwordNotMin": "Password must have at least 8 characters.",
"passwordNotMax": "Password must have at most 250 characters.",
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
@@ -411,6 +415,9 @@
"edit": {
"title": "Edit This Saved Filter",
"success": "The filter was saved successfully."
},
"query": {
"title": "Query"
}
},
"migrate": {
@@ -581,6 +588,7 @@
"to": "To",
"from": "From",
"fromto": "{from} to {to}",
"date": "Date",
"ranges": {
"today": "Today",
@@ -598,6 +606,30 @@
"thisYear": "This Year",
"restOfThisYear": "The Rest of This Year"
},
"values": {
"now": "Now",
"startOfToday": "Start of today",
"endOfToday": "End of today",
"beginningOflastWeek": "Beginning of last week",
"endOfLastWeek": "End of last week",
"beginningOfThisWeek": "Beginning of this week",
"endOfThisWeek": "End of this week",
"startOfNextWeek": "Start of next week",
"endOfNextWeek": "End of next week",
"in7Days": "In 7 days",
"beginningOfLastMonth": "Beginning of last month",
"endOfLastMonth": "End of last month",
"startOfThisMonth": "Start of this month",
"endOfThisMonth": "End of this month",
"startOfNextMonth": "Start of next month",
"endOfNextMonth": "End of next month",
"in30Days": "In 30 days",
"startOfThisYear": "Beginning of this year",
"endOfThisYear": "End of this year"
}
},
"datemathHelp": {
@@ -712,7 +744,8 @@
"repeat": "Repeat",
"startDate": "Start Date",
"title": "Title",
"updated": "Updated"
"updated": "Updated",
"doneAt": "Done At"
},
"subscription": {
"subscribedTaskThroughParentProject": "You can't unsubscribe here because you are subscribed to this task through its project.",
@@ -973,7 +1006,9 @@
"setBackground": "Set background",
"share": "Share",
"newProject": "New project",
"createProject": "Create project"
"createProject": "Create project",
"cantArchiveIsDefault": "You cannot archive this because it is your default project.",
"cantDeleteIsDefault": "You cannot delete this because it is your default project."
},
"apiConfig": {
"url": "Vikunja URL",
+1
View File
@@ -9,6 +9,7 @@ export interface ITeam extends IAbstract {
description: string
members: ITeamMember[]
right: Right
oidcId: string
createdBy: IUser
created: Date
+1
View File
@@ -13,6 +13,7 @@ export default class TeamModel extends AbstractModel<ITeam> implements ITeam {
description = ''
members: ITeamMember[] = []
right: Right = RIGHTS.READ
oidcId = ''
createdBy: IUser = {} // FIXME: seems wrong
created: Date = null
+3 -3
View File
@@ -111,13 +111,13 @@ export function useSavedFilter(projectId?: MaybeRef<IProject['id']>) {
async function createFilter() {
filter.value = await filterService.create(filter.value)
await projectStore.loadProjects()
await projectStore.loadAllProjects()
router.push({name: 'project.index', params: {projectId: getProjectId(filter.value)}})
}
async function saveFilter() {
const response = await filterService.update(filter.value)
await projectStore.loadProjects()
await projectStore.loadAllProjects()
success({message: t('filters.edit.success')})
response.filters = objectToSnakeCase(response.filters)
filter.value = response
@@ -130,7 +130,7 @@ export function useSavedFilter(projectId?: MaybeRef<IProject['id']>) {
async function deleteFilter() {
await filterService.delete(filter.value)
await projectStore.loadProjects()
await projectStore.loadAllProjects()
success({message: t('filters.delete.success')})
router.push({name: 'projects.index'})
}
+3 -2
View File
@@ -246,8 +246,9 @@ export const useKanbanStore = defineStore('kanban', () => {
}
async function loadNextTasksForBucket(
{projectId, ps = {}, bucketId} :
{projectId: IProject['id'], ps, bucketId: IBucket['id']},
projectId: IProject['id'],
ps,
bucketId: IBucket['id'],
) {
const isLoading = bucketLoading.value[bucketId] ?? false
if (isLoading) {
+16 -8
View File
@@ -175,20 +175,28 @@ export const useProjectStore = defineStore('project', () => {
}
}
async function loadProjects() {
async function loadAllProjects() {
const cancel = setModuleLoading(setIsLoading)
const projectService = new ProjectService()
const loadedProjects: IProject[] = []
let page = 1
try {
const loadedProjects = await projectService.getAll({}, {is_archived: true}) as IProject[]
projects.value = {}
setProjects(loadedProjects)
loadedProjects.forEach(p => add(p))
return loadedProjects
do {
const newProjects = await projectService.getAll({}, {is_archived: true}, page) as IProject[]
loadedProjects.push(...newProjects)
page++
} while (page <= projectService.totalPages)
} finally {
cancel()
}
projects.value = {}
setProjects(loadedProjects)
loadedProjects.forEach(p => add(p))
return loadedProjects
}
function getAncestors(project: IProject): IProject[] {
@@ -222,7 +230,7 @@ export const useProjectStore = defineStore('project', () => {
setProjects,
removeProjectById,
toggleProjectFavorite,
loadProjects,
loadAllProjects,
createProject,
updateProject,
deleteProject,
+1 -1
View File
@@ -473,7 +473,7 @@ export const useTaskStore = defineStore('task', () => {
task = await taskService.update(task)
// reloading the projects list so that the Favorites project shows up or is hidden when there are (or are not) favorite tasks
await projectStore.loadProjects()
await projectStore.loadAllProjects()
return task
}
+1
View File
@@ -4,4 +4,5 @@ export interface IProvider {
authUrl: string;
clientId: string;
logoutUrl: string;
scope: string;
}
+23 -17
View File
@@ -3,7 +3,7 @@
<h1>{{ $t('migrate.titleService', {name: migrator.name}) }}</h1>
<p>{{ $t('migrate.descriptionDo') }}</p>
<template v-if="message === '' && lastMigrationFinishedAt === null">
<template v-if="message === '' && lastMigrationStartedAt === null">
<template v-if="isMigrating === false">
<template v-if="migrator.isFileMigrator">
<p>{{ $t('migrate.importUpload', {name: migrator.name}) }}</p>
@@ -27,6 +27,7 @@
:loading="migrationService.loading"
:disabled="migrationService.loading || undefined"
:href="authUrl"
:open-external-in-new-tab="false"
>
{{ $t('migrate.getStarted') }}
</x-button>
@@ -53,10 +54,10 @@
<p>{{ $t('migrate.inProgress') }}</p>
</div>
</template>
<div v-else-if="lastMigrationStartedAt && lastMigrationFinishedAt === null">
<p>
<div v-else-if="!migrationJustStarted && lastMigrationStartedAt && lastMigrationFinishedAt === null">
<Message class="mb-4">
{{ $t('migrate.migrationInProgress') }}
</p>
</Message>
<x-button :to="{name: 'home'}">
{{ $t('home.goToOverview') }}
</x-button>
@@ -145,6 +146,7 @@ const lastMigrationFinishedAt = ref<Date | null>(null)
const lastMigrationStartedAt = ref<Date | null>(null)
const message = ref('')
const migratorAuthCode = ref('')
const migrationJustStarted = ref(false)
const migrator = computed<Migrator>(() => MIGRATORS[props.service])
@@ -170,19 +172,18 @@ async function initMigration() {
if (!migratorAuthCode.value) {
return
}
const {startedAt, finishedAt} = await migrationService.getStatus()
if (startedAt) {
lastMigrationStartedAt.value = parseDateOrNull(startedAt)
const {started_at, finished_at} = await migrationService.getStatus()
if (started_at) {
lastMigrationStartedAt.value = parseDateOrNull(started_at)
}
if (finishedAt) {
lastMigrationFinishedAt.value = parseDateOrNull(finishedAt)
if (finished_at) {
lastMigrationFinishedAt.value = parseDateOrNull(finished_at)
if (lastMigrationFinishedAt.value) {
return
}
}
if (lastMigrationStartedAt.value && lastMigrationFinishedAt.value === null) {
// Migration already in progress
return
}
@@ -208,12 +209,17 @@ async function migrate() {
}
try {
const result = migrator.value.isFileMigrator
? await migrationFileService.migrate(migrationConfig as File)
: await migrationService.migrate(migrationConfig as MigrationConfig)
message.value = result.message
const projectStore = useProjectStore()
return projectStore.loadProjects()
if (migrator.value.isFileMigrator) {
const result = await migrationFileService.migrate(migrationConfig as File)
message.value = result.message
const projectStore = useProjectStore()
return projectStore.loadAllProjects()
}
await migrationService.migrate(migrationConfig as MigrationConfig)
migrationJustStarted.value = true
} catch (e) {
console.log(e)
} finally {
isMigrating.value = false
}
+27 -8
View File
@@ -82,14 +82,15 @@
>
<div class="control">
<input
ref="bucketLimitInputRef"
v-focus.always
:value="bucket.limit"
class="input"
type="number"
min="0"
@keyup.esc="() => showSetLimitInput = false"
@keyup.enter="() => showSetLimitInput = false"
@input="(event) => setBucketLimit(bucket.id, parseInt((event.target as HTMLInputElement).value))"
@keyup.enter="() => {setBucketLimit(bucket.id, true); showSetLimitInput = false}"
@input="setBucketLimit(bucket.id)"
>
</div>
<div class="control">
@@ -98,6 +99,7 @@
:disabled="bucket.limit < 0"
:icon="['far', 'save']"
:shadow="false"
@click="setBucketLimit(bucket.id, true)"
/>
</div>
</div>
@@ -320,6 +322,7 @@ const taskStore = useTaskStore()
const projectStore = useProjectStore()
const taskContainerRefs = ref<{[id: IBucket['id']]: HTMLElement}>({})
const bucketLimitInputRef = ref<HTMLInputElement | null>(null)
const drag = ref(false)
const dragBucket = ref(false)
@@ -413,11 +416,11 @@ function handleTaskContainerScroll(id: IBucket['id'], projectId: IProject['id'],
return
}
kanbanStore.loadNextTasksForBucket({
projectId: projectId,
params: params.value,
bucketId: id,
})
kanbanStore.loadNextTasksForBucket(
projectId,
params.value,
id,
)
}
function updateTasks(bucketId: IBucket['id'], tasks: IBucket['tasks']) {
@@ -615,7 +618,7 @@ function updateBucketPosition(e: {newIndex: number}) {
})
}
async function setBucketLimit(bucketId: IBucket['id'], limit: number) {
async function saveBucketLimit(bucketId: IBucket['id'], limit: number) {
if (limit < 0) {
return
}
@@ -627,6 +630,22 @@ async function setBucketLimit(bucketId: IBucket['id'], limit: number) {
success({message: t('project.kanban.bucketLimitSavedSuccess')})
}
const setBucketLimitCancel = ref<number|null>(null)
async function setBucketLimit(bucketId: IBucket['id'], now: boolean = false) {
const limit = parseInt(bucketLimitInputRef.value?.value || '')
if (setBucketLimitCancel.value !== null) {
clearTimeout(setBucketLimitCancel.value)
}
if (now) {
return saveBucketLimit(bucketId, limit)
}
setBucketLimitCancel.value = setTimeout(saveBucketLimit, 2500, bucketId, limit)
}
function shouldAcceptDrop(bucket: IBucket) {
return (
// When dragging from a bucket who has its limit reached, dragging should still be possible
@@ -52,6 +52,9 @@
<Fancycheckbox v-model="activeColumns.percentDone">
{{ $t('task.attributes.percentDone') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.doneAt">
{{ $t('task.attributes.doneAt') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.created">
{{ $t('task.attributes.created') }}
</Fancycheckbox>
@@ -144,6 +147,13 @@
@click="sort('percent_done')"
/>
</th>
<th v-if="activeColumns.doneAt">
{{ $t('task.attributes.doneAt') }}
<Sort
:order="sortBy.done_at"
@click="sort('done_at')"
/>
</th>
<th v-if="activeColumns.created">
{{ $t('task.attributes.created') }}
<Sort
@@ -223,6 +233,10 @@
<td v-if="activeColumns.percentDone">
{{ t.percentDone * 100 }}%
</td>
<DateTableCell
v-if="activeColumns.doneAt"
:date="t.doneAt"
/>
<DateTableCell
v-if="activeColumns.created"
:date="t.created"
@@ -297,6 +311,7 @@ const ACTIVE_COLUMNS_DEFAULT = {
created: false,
updated: false,
createdBy: false,
doneAt: false,
}
const SORT_BY_DEFAULT: SortBy = {
+2 -2
View File
@@ -4,7 +4,7 @@
:class="{ 'is-loading': teamService.loading }"
>
<card
v-if="userIsAdmin"
v-if="userIsAdmin && !team.oidcId"
class="is-fullwidth"
:title="title"
>
@@ -77,7 +77,7 @@
:padding="false"
>
<div
v-if="userIsAdmin"
v-if="userIsAdmin && !team.oidcId"
class="p-4"
>
<div class="field has-addons">
+7 -5
View File
@@ -17,11 +17,13 @@
class="teams box"
>
<li
v-for="team in teams"
:key="team.id"
v-for="t in teams"
:key="t.id"
>
<router-link :to="{name: 'teams.edit', params: {id: team.id}}">
{{ team.name }}
<router-link :to="{name: 'teams.edit', params: {id: t.id}}">
<p>
{{ t.name }}
</p>
</router-link>
</li>
</ul>
@@ -63,7 +65,7 @@ ul.teams {
li {
list-style: none;
margin: 0;
border-bottom: 1px solid $border;
border-bottom: 1px solid var(--grey-200);
a {
color: var(--text);
+1
View File
@@ -66,6 +66,7 @@
v-model="password"
tabindex="2"
:validate-initially="validatePasswordInitially"
:validate-min-length="false"
@submit="submit"
/>
</div>
+33 -10
View File
@@ -28,21 +28,24 @@
type="text"
autocomplete="username"
@keyup.enter="submit"
@focusout="validateUsername"
@focusout="() => {validateUsername(); validateUsernameAfterFirst = true}"
@keyup="() => {validateUsernameAfterFirst ? validateUsername() : null}"
>
</div>
<p
v-if="!usernameValid"
v-if="usernameValid !== true"
class="help is-danger"
>
{{ $t('user.auth.usernameRequired') }}
{{ usernameValid }}
</p>
</div>
<div class="field">
<label
class="label"
for="email"
>{{ $t('user.auth.email') }}</label>
>
{{ $t('user.auth.email') }}
</label>
<div class="control">
<input
id="email"
@@ -53,7 +56,8 @@
required
type="email"
@keyup.enter="submit"
@focusout="validateEmail"
@focusout="() => {validateEmail(); validateEmailAfterFirst = true}"
@keyup="() => {validateEmailAfterFirst ? validateEmail() : null}"
>
</div>
<p
@@ -84,7 +88,7 @@
>
{{ $t('user.auth.createAccount') }}
</x-button>
<Message
v-if="configStore.demoModeEnabled"
variant="warning"
@@ -94,7 +98,7 @@
{{ $t('demo.accountWillBeDeleted') }}<br>
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
</Message>
<p class="mt-2">
{{ $t('user.auth.alreadyHaveAnAccount') }}
<router-link :to="{ name: 'user.login' }">
@@ -107,7 +111,8 @@
<script setup lang="ts">
import {useDebounceFn} from '@vueuse/core'
import {ref, reactive, toRaw, computed, onBeforeMount} from 'vue'
import {computed, onBeforeMount, reactive, ref, toRaw} from 'vue'
import {useI18n} from 'vue-i18n'
import router from '@/router'
import Message from '@/components/misc/message.vue'
@@ -117,6 +122,7 @@ import Password from '@/components/input/password.vue'
import {useAuthStore} from '@/stores/auth'
import {useConfigStore} from '@/stores/config'
const {t} = useI18n()
const authStore = useAuthStore()
const configStore = useConfigStore()
@@ -142,13 +148,30 @@ const DEBOUNCE_TIME = 100
// debouncing to prevent error messages when clicking on the log in button
const emailValid = ref(true)
const validateEmailAfterFirst = ref(false)
const validateEmail = useDebounceFn(() => {
emailValid.value = isEmail(credentials.email)
}, DEBOUNCE_TIME)
const usernameValid = ref(true)
const usernameValid = ref<true | string>(true)
const validateUsernameAfterFirst = ref(false)
const validateUsername = useDebounceFn(() => {
usernameValid.value = credentials.username !== ''
if (credentials.username === '') {
usernameValid.value = t('user.auth.usernameRequired')
return
}
if(credentials.username.indexOf(' ') !== -1) {
usernameValid.value = t('user.auth.usernameMustNotContainSpace')
return
}
if(credentials.username.indexOf('://') !== -1) {
usernameValid.value = t('user.auth.usernameMustNotLookLikeUrl')
return
}
usernameValid.value = true
}, DEBOUNCE_TIME)
const everythingValid = computed(() => {
@@ -286,16 +286,15 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
:key="group"
class="mb-2"
>
<strong class="is-capitalized">{{ formatPermissionTitle(group) }}</strong><br>
<template
v-if="Object.keys(routes).length > 1"
v-if="Object.keys(routes).length >= 1"
>
<Fancycheckbox
v-model="newTokenPermissionsGroup[group]"
class="mr-2 is-italic"
class="mr-2 is-capitalized has-text-weight-bold"
@update:modelValue="checked => selectPermissionGroup(group, checked)"
>
{{ $t('user.settings.apiTokens.selectAll') }}
{{ formatPermissionTitle(group) }}
</Fancycheckbox>
<br>
</template>
@@ -305,7 +304,7 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
>
<Fancycheckbox
v-model="newTokenPermissions[group][route]"
class="mr-2 is-capitalized"
class="ml-4 mr-2 is-capitalized"
@update:modelValue="checked => toggleGroupPermissionsFromChild(group, checked)"
>
{{ formatPermissionTitle(route) }}
+24 -24
View File
@@ -21,7 +21,7 @@ require (
dario.cat/mergo v1.0.0
github.com/ThreeDotsLabs/watermill v1.3.5
github.com/adlio/trello v1.10.0
github.com/arran4/golang-ical v0.2.4
github.com/arran4/golang-ical v0.2.7
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
github.com/bbrks/go-blurhash v1.1.1
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500
@@ -31,14 +31,16 @@ require (
github.com/disintegration/imaging v1.6.2
github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2
github.com/gabriel-vasile/mimetype v1.4.3
github.com/getsentry/sentry-go v0.26.0
github.com/ganigeorgiev/fexpr v0.4.0
github.com/getsentry/sentry-go v0.27.0
github.com/go-sql-driver/mysql v1.7.1
github.com/go-testfixtures/testfixtures/v3 v3.9.0
github.com/go-testfixtures/testfixtures/v3 v3.10.0
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/google/uuid v1.6.0
github.com/hashicorp/go-version v1.6.0
github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346
github.com/iancoleman/strcase v0.3.0
github.com/jinzhu/copier v0.4.0
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6
@@ -51,26 +53,26 @@ require (
github.com/olekukonko/tablewriter v0.0.5
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/pquerna/otp v1.4.0
github.com/prometheus/client_golang v1.18.0
github.com/redis/go-redis/v9 v9.4.0
github.com/prometheus/client_golang v1.19.0
github.com/redis/go-redis/v9 v9.5.1
github.com/robfig/cron/v3 v3.0.1
github.com/samedi/caldav-go v3.0.0+incompatible
github.com/spf13/afero v1.11.0
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.2
github.com/stretchr/testify v1.8.4
github.com/stretchr/testify v1.9.0
github.com/swaggo/swag v1.16.3
github.com/tkuchiki/go-timezone v0.2.2
github.com/typesense/typesense-go v1.0.0
github.com/ulule/limiter/v3 v3.11.2
github.com/wneessen/go-mail v0.4.0
github.com/yuin/goldmark v1.7.0
golang.org/x/crypto v0.18.0
golang.org/x/crypto v0.21.0
golang.org/x/image v0.15.0
golang.org/x/oauth2 v0.16.0
golang.org/x/oauth2 v0.18.0
golang.org/x/sync v0.6.0
golang.org/x/sys v0.16.0
golang.org/x/term v0.16.0
golang.org/x/sys v0.18.0
golang.org/x/term v0.18.0
golang.org/x/text v0.14.0
gopkg.in/d4l3k/messagediff.v1 v1.2.1
gopkg.in/yaml.v3 v3.0.1
@@ -78,14 +80,14 @@ require (
src.techknowlogick.com/xgo v1.7.1-0.20240206231429-45b9ea635e03
src.techknowlogick.com/xormigrate v1.7.1
xorm.io/builder v0.3.13
xorm.io/xorm v1.3.7
xorm.io/xorm v1.3.8
)
require (
github.com/ClickHouse/ch-go v0.55.0 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.9.1 // indirect
github.com/ClickHouse/ch-go v0.58.2 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.18.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/beevik/etree v1.1.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -120,7 +122,6 @@ require (
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -134,21 +135,19 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/onsi/ginkgo v1.16.4 // indirect
github.com/onsi/gomega v1.16.0 // indirect
github.com/paulmach/orb v0.9.0 // indirect
github.com/paulmach/orb v0.11.1 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pierrec/lz4/v4 v4.1.17 // indirect
github.com/pierrec/lz4/v4 v4.1.18 // 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.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
@@ -162,23 +161,24 @@ require (
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/syndtr/goleveldb v1.0.0 // indirect
github.com/tj/assert v0.0.3 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 // indirect
go.opentelemetry.io/otel v1.15.0 // indirect
go.opentelemetry.io/otel/trace v1.15.0 // indirect
go.opentelemetry.io/otel v1.22.0 // indirect
go.opentelemetry.io/otel/trace v1.22.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.4.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.13.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.31.0 // indirect
google.golang.org/protobuf v1.32.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
+52 -105
View File
@@ -9,10 +9,10 @@ github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbL
github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0=
github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/ClickHouse/ch-go v0.55.0 h1:jw4Tpx887YXrkyL5DfgUome/po8MLz92nz2heOQ6RjQ=
github.com/ClickHouse/ch-go v0.55.0/go.mod h1:kQT2f+yp2p+sagQA/7kS6G3ukym+GQ5KAu1kuFAFDiU=
github.com/ClickHouse/clickhouse-go/v2 v2.9.1 h1:IeE2bwVvAba7Yw5ZKu98bKI4NpDmykEy6jUaQdJJCk8=
github.com/ClickHouse/clickhouse-go/v2 v2.9.1/go.mod h1:teXfZNM90iQ99Jnuht+dxQXCuhDZ8nvvMoTJOFrcmcg=
github.com/ClickHouse/ch-go v0.58.2 h1:jSm2szHbT9MCAB1rJ3WuCJqmGLi5UTjlNu+f530UTS0=
github.com/ClickHouse/ch-go v0.58.2/go.mod h1:Ap/0bEmiLa14gYjCiRkYGbXvbe8vwdrfTYWhsuQ99aw=
github.com/ClickHouse/clickhouse-go/v2 v2.18.0 h1:O1LicIeg2JS2V29fKRH4+yT3f6jvvcJBm506dpVQ4mQ=
github.com/ClickHouse/clickhouse-go/v2 v2.18.0/go.mod h1:ztQvX6wm7kAbhJslS87EXEhOVNY/TObXwyURnGju5FQ=
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,15 +24,13 @@ github.com/ThreeDotsLabs/watermill v1.3.5 h1:50JEPEhMGZQMh08ct0tfO1PsgMOAOhV3zxK
github.com/ThreeDotsLabs/watermill v1.3.5/go.mod h1:O/u/Ptyrk5MPTxSeWM5vzTtZcZfxXfO9PK9eXTYiFZY=
github.com/adlio/trello v1.10.0 h1:ia/rzoBwJJKr4IqnMlrU6n09CVqeyaahSkEVcV5/gPc=
github.com/adlio/trello v1.10.0/go.mod h1:I4Lti4jf2KxjTNgTqs5W3lLuE78QZZdYbbPnQQGwjOo=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/arran4/golang-ical v0.2.3 h1:C4Vj7+BjJBIrAJhHgi6Ku+XUkQVugRq4re5Cqj5QVdE=
github.com/arran4/golang-ical v0.2.3/go.mod h1:RqMuPGmwRRwjkb07hmm+JBqcWa1vF1LvVmPtSZN2OhQ=
github.com/arran4/golang-ical v0.2.4 h1:0/rTXn2qqEekLKec3SzRRy+z7pCLtniMb0KD/dPogUo=
github.com/arran4/golang-ical v0.2.4/go.mod h1:RqMuPGmwRRwjkb07hmm+JBqcWa1vF1LvVmPtSZN2OhQ=
github.com/arran4/golang-ical v0.2.7 h1:VO7YlVaGupZE15aj6NhUhte/MIfZuoIzkoI71VsG6Gg=
github.com/arran4/golang-ical v0.2.7/go.mod h1:RqMuPGmwRRwjkb07hmm+JBqcWa1vF1LvVmPtSZN2OhQ=
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/bbrks/go-blurhash v1.1.1 h1:uoXOxRPDca9zHYabUTwvS4KnY++KKUbwFo+Yxb8ME4M=
@@ -109,10 +107,10 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/getsentry/sentry-go v0.25.0 h1:q6Eo+hS+yoJlTO3uu/azhQadsD8V+jQn2D8VvX1eOyI=
github.com/getsentry/sentry-go v0.25.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/getsentry/sentry-go v0.26.0 h1:IX3++sF6/4B5JcevhdZfdKIHfyvMmAq/UnqcyT2H6mA=
github.com/getsentry/sentry-go v0.26.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/ganigeorgiev/fexpr v0.4.0 h1:ojitI+VMNZX/odeNL1x3RzTTE8qAIVvnSSYPNAnQFDI=
github.com/ganigeorgiev/fexpr v0.4.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
@@ -156,8 +154,8 @@ github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrt
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
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.9.0 h1:938g5V+GWLVejm3Hc+nWCuEXRlcglZDDlN/t1gWzcSY=
github.com/go-testfixtures/testfixtures/v3 v3.9.0/go.mod h1:cdsKD2ApFBjdog9jRsz6EJqF+LClq/hrwE9K/1Dzo4s=
github.com/go-testfixtures/testfixtures/v3 v3.10.0 h1:BrBwN7AuC+74g5qtk9D59TLGOaEa8Bw1WmIsf+SyzWc=
github.com/go-testfixtures/testfixtures/v3 v3.10.0/go.mod h1:z8RoleoNtibi6Ar8ziCW7e6PQ+jWiqbUWvuv8AMe4lo=
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a h1:RYfmiM0zluBJOiPDJseKLEN4BapJ42uSi9SZBQ2YyiA=
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI=
github.com/goccy/go-json v0.8.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
@@ -169,8 +167,9 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
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=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
@@ -199,15 +198,14 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -315,13 +313,9 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo-jwt/v4 v4.2.0 h1:odSISV9JgcSCuhgQSV/6Io3i7nUmfM/QkBeR5GVJj5c=
github.com/labstack/echo-jwt/v4 v4.2.0/go.mod h1:MA2RqdXdEn4/uEglx0HcUOgQSyBaTh5JcaHIan3biwU=
github.com/labstack/echo/v4 v4.1.16/go.mod h1:awO+5TzAjvL8XpibdsfXxPgHr+orhtXZJZIQCVjogKI=
github.com/labstack/echo/v4 v4.11.3 h1:Upyu3olaqSHkCjs1EJJwQ3WId8b8b1hxbogyommKktM=
github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws=
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
github.com/labstack/gommon v0.4.1 h1:gqEff0p/hTENGMABzezPoPSRtIh1Cvw0ueMOe0/dfOk=
github.com/labstack/gommon v0.4.1/go.mod h1:TyTrpPqxR5KMk8LKVtLmfMjeQ5FEkBYdxLYPw/WfrOM=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/laurent22/ical-go v0.1.1-0.20181107184520-7e5d6ade8eef h1:RZnRnSID1skF35j/15KJ6hKZkdIC/teQClJK5wP5LU4=
@@ -369,18 +363,8 @@ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
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.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.20 h1:BAZ50Ns0OFBNxdAqFhbZqdPcht1Xlb16pDCqkq1spr0=
github.com/mattn/go-sqlite3 v1.14.20/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.21 h1:IXocQLOykluc3xPE0Lvy8FtggMz1G+U3mEjg+0zGizc=
github.com/mattn/go-sqlite3 v1.14.21/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -410,13 +394,13 @@ github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c=
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/paulmach/orb v0.9.0 h1:MwA1DqOKtvCgm7u9RZ/pnYejTeDJPnr0+0oFajBbJqk=
github.com/paulmach/orb v0.9.0/go.mod h1:SudmOk85SXtmXAB3sLGyJ6tZy/8pdfrV0o6ef98Xc30=
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc=
github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
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=
@@ -428,26 +412,16 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI=
github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY=
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/redis/go-redis/v9 v9.3.1 h1:KqdY8U+3X6z+iACvumCNxnoluToB+9Me+TvyFa21Mds=
github.com/redis/go-redis/v9 v9.3.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/redis/go-redis/v9 v9.4.0 h1:Yzoz33UZw9I/mFhx4MNrB6Fk+XHO1VukNcCa1+lwyKk=
github.com/redis/go-redis/v9 v9.4.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -508,23 +482,22 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/swaggo/swag v1.16.2 h1:28Pp+8DkQoV+HLzLx8RGJZXNGKbFqnuvSbAAtoxiY04=
github.com/swaggo/swag v1.16.2/go.mod h1:6YzXnDcpr0767iOejs318CwYkCQqyGer6BizOg03f+E=
github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg=
github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk=
github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk=
github.com/tkuchiki/go-timezone v0.2.2 h1:MdHR65KwgVTwWFQrota4SKzc4L5EfuH5SdZZGtk/P2Q=
github.com/tkuchiki/go-timezone v0.2.2/go.mod h1:oFweWxYl35C/s7HMVZXiA19Jr9Y0qJHMaG/J2TES4LY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/typesense/typesense-go v0.9.0 h1:V1sk0QN6jHevHHiV3GZyL6aIb6Oa8QsmyXRUYJj2Zfg=
github.com/typesense/typesense-go v0.9.0/go.mod h1:4mq4FYHzU7csU/KHaZoyG2bCSKl7GrCeyAr2YhXT1/0=
github.com/typesense/typesense-go v1.0.0 h1:/8Lr1yf9YjmUKdn/xbTNy+OhwOvBd0noBTRkcB22Uhw=
github.com/typesense/typesense-go v1.0.0/go.mod h1:4mq4FYHzU7csU/KHaZoyG2bCSKl7GrCeyAr2YhXT1/0=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
@@ -551,17 +524,15 @@ github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7Jul
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA=
github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
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.mongodb.org/mongo-driver v1.11.1/go.mod h1:s7p5vEtfbeR1gYi6pnj3c3/urpbLv2T5Sfd6Rp2HBB8=
go.opentelemetry.io/otel v1.15.0 h1:NIl24d4eiLJPM0vKn4HjLYM+UZf6gSfi9Z+NmCxkWbk=
go.opentelemetry.io/otel v1.15.0/go.mod h1:qfwLEbWhLPk5gyWrne4XnF0lC8wtywbuJbgfAE3zbek=
go.opentelemetry.io/otel/trace v1.15.0 h1:5Fwje4O2ooOxkfyqI/kJwxWotggDLix4BSAvpE1wlpo=
go.opentelemetry.io/otel/trace v1.15.0/go.mod h1:CUsmE2Ht1CRkvE8OsMESvraoZrrcgD1J2W8GV1ev0Y4=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.opentelemetry.io/otel v1.22.0 h1:xS7Ku+7yTFvDfDraDIJVpw7XPyuHlB9MCiqqX5mcJ6Y=
go.opentelemetry.io/otel v1.22.0/go.mod h1:eoV4iAi3Ea8LkAEI9+GFT44O6T/D0GWAVFyZVCC6pMI=
go.opentelemetry.io/otel/trace v1.22.0 h1:Hg6pPujv0XG9QaVbGOBVHunyuLcCC3jN7WEhPx83XD0=
go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40a21sPw2He1xo=
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=
@@ -595,15 +566,11 @@ 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.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -633,23 +600,16 @@ golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-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.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ=
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ=
golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/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-20190911185100-cd5d95a43a6e/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-20210220032951-036812b2e83c/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.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -680,18 +640,14 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
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=
@@ -739,8 +695,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -764,6 +720,7 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -813,12 +770,6 @@ nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYm
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
src.techknowlogick.com/xgo v1.7.1-0.20231205202227-c7ed78300ce9 h1:lcNlqzNPv7WBKVRqGXWjs+nt9r5WBf2FG+eBOCUcyLM=
src.techknowlogick.com/xgo v1.7.1-0.20231205202227-c7ed78300ce9/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU=
src.techknowlogick.com/xgo v1.7.1-0.20240124202215-77ac23f331fe h1:8t+5jXWFfMOxWi0OIBMpRSM5agX6xhwA5+em+P9nGTE=
src.techknowlogick.com/xgo v1.7.1-0.20240124202215-77ac23f331fe/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU=
src.techknowlogick.com/xgo v1.7.1-0.20240206191224-5aae65575674 h1:/uC4C2ANN3SsMZmsLSDWvfjJPP+nHisQIfD8ElkjBdI=
src.techknowlogick.com/xgo v1.7.1-0.20240206191224-5aae65575674/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU=
src.techknowlogick.com/xgo v1.7.1-0.20240206231429-45b9ea635e03 h1:GMq57lSFGhXrFuOJ/HuSf67Y/SfzWxlJRZus262YxXw=
src.techknowlogick.com/xgo v1.7.1-0.20240206231429-45b9ea635e03/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU=
src.techknowlogick.com/xormigrate v1.7.1 h1:RKGLLUAqJ+zO8iZ7eOc7oLH7f0cs2gfXSZSvBRBHnlY=
@@ -829,9 +780,5 @@ xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.0.5/go.mod h1:uF9EtbhODq5kNWxMbnBEj8hRRZnlcNSz2t2N7HW/+A4=
xorm.io/xorm v1.3.3/go.mod h1:qFJGFoVYbbIdnz2vaL5OxSQ2raleMpyRRalnq3n9OJo=
xorm.io/xorm v1.3.4 h1:vWFKzR3DhGUDl5b4srhUjhDwjxkZAc4C7BFszpu0swI=
xorm.io/xorm v1.3.4/go.mod h1:qFJGFoVYbbIdnz2vaL5OxSQ2raleMpyRRalnq3n9OJo=
xorm.io/xorm v1.3.6 h1:hfpWHkDIWWqUi8FRF2H2M9O8lO3Ov47rwFcS9gPzPkU=
xorm.io/xorm v1.3.6/go.mod h1:qFJGFoVYbbIdnz2vaL5OxSQ2raleMpyRRalnq3n9OJo=
xorm.io/xorm v1.3.7 h1:mLceAGu0b87r9pD4qXyxGHxifOXIIrAdVcA6k95/osw=
xorm.io/xorm v1.3.7/go.mod h1:LsCCffeeYp63ssk0pKumP6l96WZcHix7ChpurcLNuMw=
xorm.io/xorm v1.3.8 h1:CJmplmWqfSRpLWSPMmqz+so8toBp3m7ehuRehIWedZo=
xorm.io/xorm v1.3.8/go.mod h1:LsCCffeeYp63ssk0pKumP6l96WZcHix7ChpurcLNuMw=
+5 -3
View File
@@ -25,7 +25,6 @@ import (
"context"
"crypto/sha256"
"fmt"
"github.com/iancoleman/strcase"
"io"
"os"
"os/exec"
@@ -34,6 +33,8 @@ import (
"strings"
"time"
"github.com/iancoleman/strcase"
"github.com/magefile/mage/mg"
"golang.org/x/sync/errgroup"
"gopkg.in/yaml.v3"
@@ -71,7 +72,8 @@ var (
"dev:make-listener": Dev.MakeListener,
"dev:make-notification": Dev.MakeNotification,
"generate-docs": GenerateDocs,
"check:golangci-fix": Check.GolangciFix,
"lint": Check.Golangci,
"lint:fix": Check.GolangciFix,
}
)
@@ -157,7 +159,7 @@ func setRootPath() {
func setGoFiles() {
// GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go")
files, err := runCmdWithOutput("find", ".", "-name", "*.go", "-type", "f", "!", "-path", "*/bindata.go")
files, err := runCmdWithOutput("find", "./pkg", "-name", "*.go", "-type", "f", "!", "-path", "*/bindata.go")
if err != nil {
fmt.Printf("Error getting go files: %s\n", err)
os.Exit(1)
+4
View File
@@ -179,6 +179,10 @@ func initSqliteEngine() (engine *xorm.Engine, err error) {
path = "./db.db"
}
if path == "memory" {
return xorm.NewEngine("sqlite3", "file::memory:?cache=shared")
}
// Try opening the db file to return a better error message if that does not work
var exists = true
if _, err := os.Stat(path); err != nil {
+9 -1
View File
@@ -25,6 +25,7 @@
title: testbucket4 - other project
project_id: 2
created_by_id: 1
position: 1
created: 2020-04-18 21:13:52
updated: 2020-04-18 21:13:52
# The following are not or only partly owned by user 1
@@ -241,4 +242,11 @@
project_id: 38
created_by_id: 15
created: 2020-04-18 21:13:52
updated: 2020-04-18 21:13:52
updated: 2020-04-18 21:13:52
- id: 40
title: testbucket40
project_id: 2
created_by_id: 1
position: 10
created: 2020-04-18 21:13:52
updated: 2020-04-18 21:13:52
+3
View File
@@ -16,6 +16,7 @@
owner_id: 3
position: 2
done_bucket_id: 4
default_bucket_id: 40
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
-
@@ -234,6 +235,7 @@
title: Test25
owner_id: 6
parent_project_id: 12
position: 25
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
-
@@ -241,6 +243,7 @@
title: Test26
owner_id: 6
parent_project_id: 25
position: 26
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
-
+1 -1
View File
@@ -1,5 +1,5 @@
- id: 1
filters: '{"sort_by":null,"order_by":null,"filter_by":["start_date","end_date","due_date"],"filter_value":["2018-12-11T03:46:40+00:00","2018-12-13T11:20:01+00:00","2018-11-29T14:00:00+00:00"],"filter_comparator":["greater","less","greater"],"filter_concat":"","filter_include_nulls":false}'
filters: '{"sort_by":null,"order_by":null,"filter":"start_date > \u00272018-12-11T03:46:40+00:00\u0027 || end_date < \u00272018-12-13T11:20:01+00:00\u0027 || due_date > \u00272018-11-29T14:00:00+00:00\u0027","filter_include_nulls":false}'
title: testfilter1
owner_id: 1
updated: 2020-09-08 15:13:12
+4
View File
@@ -55,3 +55,7 @@
team_id: 13
user_id: 10
created: 2018-12-01 15:13:12
-
team_id: 14
user_id: 10
created: 2018-12-01 15:13:12
+6 -1
View File
@@ -28,4 +28,9 @@
created_by_id: 7
- id: 13
name: testteam13
created_by_id: 7
created_by_id: 7
- id: 14
name: testteam14
created_by_id: 7
oidc_id: 14
issuer: "https://some.issuer"
+9 -2
View File
@@ -17,7 +17,9 @@
package files
import (
"errors"
"io"
gofs "io/fs"
)
// Dump dumps all saved files
@@ -31,8 +33,13 @@ func Dump() (allFiles map[int64]io.ReadCloser, err error) {
allFiles = make(map[int64]io.ReadCloser, len(files))
for _, file := range files {
if err := file.LoadFileByID(); err != nil {
return nil, err
err = file.LoadFileByID()
if err != nil {
var pathError *gofs.PathError
if errors.As(err, &pathError) {
continue
}
return
}
allFiles[file.ID] = file.File
}
+11 -29
View File
@@ -184,9 +184,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("start and end date", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(
url.Values{
"filter_by": []string{"start_date", "end_date", "due_date"},
"filter_value": []string{"2018-12-11T03:46:40+00:00", "2018-12-13T11:20:01+00:00", "2018-11-29T14:00:00+00:00"},
"filter_comparator": []string{"greater", "less", "greater"},
"filter": []string{"start_date > '2018-12-11T03:46:40+00:00' || end_date < '2018-12-13T11:20:01+00:00' || due_date > '2018-11-29T14:00:00+00:00'"},
},
urlParams,
)
@@ -209,9 +207,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("start date only", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(
url.Values{
"filter_by": []string{"start_date"},
"filter_value": []string{"2018-10-20T01:46:40+00:00"},
"filter_comparator": []string{"greater"},
"filter": []string{"start_date > '2018-10-20T01:46:40+00:00'"},
},
urlParams,
)
@@ -234,9 +230,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("end date only", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(
url.Values{
"filter_by": []string{"end_date"},
"filter_value": []string{"2018-12-13T11:20:01+00:00"},
"filter_comparator": []string{"greater"},
"filter": []string{"end_date > '2018-12-13T11:20:01+00:00'"},
},
urlParams,
)
@@ -249,9 +243,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("unix timestamps", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(
url.Values{
"filter_by": []string{"start_date", "end_date", "due_date"},
"filter_value": []string{"1544500000", "1513164001", "1543500000"},
"filter_comparator": []string{"greater", "less", "greater"},
"filter": []string{"start_date > 1544500000 || end_date < 1513164001 || due_date > 1543500000"},
},
urlParams,
)
@@ -275,9 +267,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("invalid date", func(t *testing.T) {
_, err := testHandler.testReadAllWithUser(
url.Values{
"filter_by": []string{"due_date"},
"filter_value": []string{"invalid"},
"filter_comparator": []string{"greater"},
"filter": []string{"due_date > invalid"},
},
nil,
)
@@ -368,7 +358,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("by priority", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}}, nil)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":35,"title":"task #35","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":21,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":[{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"labels":[{"id":4,"title":"Label #4 - visible via other task","description":"","hex_color":"","created_by":{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"},"created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"hex_color":"","percent_done":0,"identifier":"test21-1","index":1,"related_tasks":{"related":[{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"created_by":null},{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"created_by":null}]},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":19,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":35,"title":"task #35","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":21,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":[{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"labels":[{"id":4,"title":"Label #4 - visible via other task","description":"","hex_color":"","created_by":{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"},"created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"hex_color":"","percent_done":0,"identifier":"test21-1","index":1,"related_tasks":{"related":[{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"created_by":null},{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"created_by":null}]},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":19,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":39,"title":"task #39","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":25,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"#0","index":0,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
})
t.Run("by priority desc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"desc"}}, nil)
@@ -378,7 +368,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("by priority asc", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"asc"}}, nil)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":35,"title":"task #35","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":21,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":[{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"labels":[{"id":4,"title":"Label #4 - visible via other task","description":"","hex_color":"","created_by":{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"},"created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"hex_color":"","percent_done":0,"identifier":"test21-1","index":1,"related_tasks":{"related":[{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"created_by":null},{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"created_by":null}]},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":19,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":35,"title":"task #35","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":21,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":[{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"labels":[{"id":4,"title":"Label #4 - visible via other task","description":"","hex_color":"","created_by":{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"},"created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"hex_color":"","percent_done":0,"identifier":"test21-1","index":1,"related_tasks":{"related":[{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"created_by":null},{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"created_by":null}]},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":19,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":39,"title":"task #39","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":25,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"#0","index":0,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
})
// should equal duedate asc
t.Run("by due_date", func(t *testing.T) {
@@ -411,9 +401,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("start and end date", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(
url.Values{
"filter_by": []string{"start_date", "end_date", "due_date"},
"filter_value": []string{"2018-12-11T03:46:40+00:00", "2018-12-13T11:20:01+00:00", "2018-11-29T14:00:00+00:00"},
"filter_comparator": []string{"greater", "less", "greater"},
"filter": []string{"start_date > '2018-12-11T03:46:40+00:00' || end_date < '2018-12-13T11:20:01+00:00' || due_date > '2018-11-29T14:00:00+00:00'"},
},
nil,
)
@@ -436,9 +424,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("start date only", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(
url.Values{
"filter_by": []string{"start_date"},
"filter_value": []string{"2018-10-20T01:46:40+00:00"},
"filter_comparator": []string{"greater"},
"filter": []string{"start_date > '2018-10-20T01:46:40+00:00'"},
},
nil,
)
@@ -461,9 +447,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("end date only", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(
url.Values{
"filter_by": []string{"end_date"},
"filter_value": []string{"2018-12-13T11:20:01+00:00"},
"filter_comparator": []string{"greater"},
"filter": []string{"end_date > '2018-12-13T11:20:01+00:00'"},
},
nil,
)
@@ -477,9 +461,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("invalid date", func(t *testing.T) {
_, err := testHandler.testReadAllWithUser(
url.Values{
"filter_by": []string{"due_date"},
"filter_value": []string{"invalid"},
"filter_comparator": []string{"greater"},
"filter": []string{"due_date > invalid"},
},
nil,
)
+1 -1
View File
@@ -69,7 +69,7 @@ func init() {
func setupActiveUsersMetric() {
err := registry.Register(promauto.NewGaugeFunc(prometheus.GaugeOpts{
Name: "vikunja_active_users",
Help: "The number of shares active within the last 30 seconds",
Help: "The number of users active within the last 30 seconds",
}, func() float64 {
allActiveUsers := activeUsersMap{}
_, err := keyvalue.GetWithValue(activeUsersKey, &allActiveUsers)
+43
View File
@@ -0,0 +1,43 @@
// 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 Licensee 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 Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type teams20230104152903 struct {
OidcID string `xorm:"varchar(250) null" maxLength:"250" json:"oidc_id"`
}
func (teams20230104152903) TableName() string {
return "teams"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20230104152903",
Description: "Adding OidcID to teams",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(teams20230104152903{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}
+107
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 Licensee 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 Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"strings"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type taskCollectionFilter20231121191822 struct {
SortBy []string `query:"sort_by" json:"sort_by"`
OrderBy []string `query:"order_by" json:"order_by"`
FilterBy []string `query:"filter_by" json:"filter_by,omitempty"`
FilterValue []string `query:"filter_value" json:"filter_value,omitempty"`
FilterComparator []string `query:"filter_comparator" json:"filter_comparator,omitempty"`
FilterConcat string `query:"filter_concat" json:"filter_concat,omitempty"`
Filter string `query:"filter" json:"filter"`
FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls"`
}
type savedFilter20231121191822 struct {
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"filter"`
Filters *taskCollectionFilter20231121191822 `xorm:"JSON not null" json:"filters" valid:"required"`
}
func (savedFilter20231121191822) TableName() string {
return "saved_filters"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20231121191822",
Description: "Migrate saved filter structure",
Migrate: func(tx *xorm.Engine) (err error) {
allFilters := []*savedFilter20231121191822{}
err = tx.Find(&allFilters)
if err != nil {
return
}
for _, filter := range allFilters {
var filterStrings []string
for i, f := range filter.Filters.FilterBy {
var comparator string
switch filter.Filters.FilterComparator[i] {
case "equals":
comparator = "="
case "greater":
comparator = ">"
case "greater_equals":
comparator = ">="
case "less":
comparator = "<"
case "less_equals":
comparator = "<="
case "not_equals":
comparator = "!="
case "like":
comparator = "~"
case "in":
comparator = "?="
}
filterStrings = append(filterStrings, f+" "+comparator+" "+filter.Filters.FilterValue[i])
}
filter.Filters.FilterConcat = " || "
if filter.Filters.FilterConcat == "and" {
filter.Filters.FilterConcat = " && "
}
filter.Filters.Filter = strings.Join(filterStrings, filter.Filters.FilterConcat)
filter.Filters.FilterBy = nil
filter.Filters.FilterComparator = nil
filter.Filters.FilterValue = nil
filter.Filters.FilterConcat = ""
_, err = tx.Where("id = ?", filter.ID).Update(filter)
if err != nil {
return
}
}
return nil
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}
+43
View File
@@ -0,0 +1,43 @@
// 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 Licensee 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 Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type teams20240304153738 struct {
Issuer string `xorm:"text null" json:"-"`
}
func (teams20240304153738) TableName() string {
return "teams"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20240304153738",
Description: "Add issuer column to teams to track the OIDC provider this issue has bene created by",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(teams20240304153738{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}
+16 -1
View File
@@ -69,7 +69,7 @@ func getRouteGroupName(path string) string {
// CollectRoutesForAPITokenUsage gets called for every added APITokenRoute and builds a list of all routes we can use for the api tokens.
func CollectRoutesForAPITokenUsage(route echo.Route) {
if !strings.Contains(route.Name, "(*WebHandler)") {
if !strings.Contains(route.Name, "(*WebHandler)") && !strings.Contains(route.Name, "Attachment") {
return
}
@@ -116,6 +116,21 @@ func CollectRoutesForAPITokenUsage(route echo.Route) {
Method: route.Method,
}
}
if routeGroupName == "tasks_attachments" {
if strings.Contains(route.Name, "UploadTaskAttachment") {
apiTokenRoutes[routeGroupName].Create = &RouteDetail{
Path: route.Path,
Method: route.Method,
}
}
if strings.Contains(route.Name, "GetTaskAttachment") {
apiTokenRoutes[routeGroupName].ReadOne = &RouteDetail{
Path: route.Path,
Method: route.Method,
}
}
}
}
// GetAvailableAPIRoutesForToken returns a list of all API routes which are available for token usage.
+77 -2
View File
@@ -1021,7 +1021,7 @@ func (err ErrTaskRelationCycle) Error() string {
}
// ErrCodeTaskRelationCycle holds the unique world-error code of this error
const ErrCodeTaskRelationCycle = 4022
const ErrCodeTaskRelationCycle = 4023
// HTTPError holds the http error description
func (err ErrTaskRelationCycle) HTTPError() web.HTTPError {
@@ -1032,6 +1032,34 @@ func (err ErrTaskRelationCycle) HTTPError() web.HTTPError {
}
}
// ErrInvalidFilterExpression represents an error where the task filter expression was invalid
type ErrInvalidFilterExpression struct {
Expression string
ExpressionError error
}
// IsErrInvalidFilterExpression checks if an error is ErrInvalidFilterExpression.
func IsErrInvalidFilterExpression(err error) bool {
_, ok := err.(ErrInvalidFilterExpression)
return ok
}
func (err ErrInvalidFilterExpression) Error() string {
return fmt.Sprintf("Task filter expression '%s' is invalid [ExpressionError: %v]", err.Expression, err.ExpressionError)
}
// ErrCodeInvalidFilterExpression holds the unique world-error code of this error
const ErrCodeInvalidFilterExpression = 4024
// HTTPError holds the http error description
func (err ErrInvalidFilterExpression) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusBadRequest,
Code: ErrCodeInvalidFilterExpression,
Message: fmt.Sprintf("The filter expression '%s' is invalid: %v", err.Expression, err.ExpressionError),
}
}
// ============
// Team errors
// ============
@@ -1059,7 +1087,6 @@ func (err ErrTeamNameCannotBeEmpty) HTTPError() web.HTTPError {
return web.HTTPError{HTTPCode: http.StatusBadRequest, Code: ErrCodeTeamNameCannotBeEmpty, Message: "The team name cannot be empty"}
}
// ErrTeamDoesNotExist represents an error where a team does not exist
type ErrTeamDoesNotExist struct {
TeamID int64
}
@@ -1178,6 +1205,54 @@ func (err ErrTeamDoesNotHaveAccessToProject) HTTPError() web.HTTPError {
return web.HTTPError{HTTPCode: http.StatusForbidden, Code: ErrCodeTeamDoesNotHaveAccessToProject, Message: "This team does not have access to the project."}
}
// ErrOIDCTeamDoesNotExist represents an error where a team with specified oidcId property does not exist for a given issuer
type ErrOIDCTeamDoesNotExist struct {
OidcID string
Issuer string
}
// IsErrOIDCTeamDoesNotExist checks if an error is ErrOIDCTeamDoesNotExist.
func IsErrOIDCTeamDoesNotExist(err error) bool {
_, ok := err.(ErrOIDCTeamDoesNotExist)
return ok
}
// ErrTeamDoesNotExist represents an error where a team does not exist
func (err ErrOIDCTeamDoesNotExist) Error() string {
return fmt.Sprintf("No team could be found for the given oidcId and issuer. [OIDC ID : %v] [Issuer: %v] ", err.OidcID, err.Issuer)
}
// ErrCodeTeamDoesNotExist holds the unique world-error code of this error
const ErrCodeOIDCTeamDoesNotExist = 6008
// HTTPError holds the http error description
func (err ErrOIDCTeamDoesNotExist) HTTPError() web.HTTPError {
return web.HTTPError{HTTPCode: http.StatusNotFound, Code: ErrCodeTeamDoesNotExist, Message: "No team could be found for the given OIDC ID and issuer."}
}
// ErrOIDCTeamsDoNotExistForUser represents an error where an oidcTeam does not exist for the user
type ErrOIDCTeamsDoNotExistForUser struct {
UserID int64
}
// IsErrOIDCTeamsDoNotExistForUser checks if an error is ErrOIDCTeamsDoNotExistForUser.
func IsErrOIDCTeamsDoNotExistForUser(err error) bool {
_, ok := err.(ErrOIDCTeamsDoNotExistForUser)
return ok
}
func (err ErrOIDCTeamsDoNotExistForUser) Error() string {
return fmt.Sprintf("No teams with property oidcId could be found for user [User ID: %d]", err.UserID)
}
// ErrCodeTeamDoesNotExist holds the unique world-error code of this error
const ErrCodeOIDCTeamsDoNotExistForUser = 6009
// HTTPError holds the http error description
func (err ErrOIDCTeamsDoNotExistForUser) HTTPError() web.HTTPError {
return web.HTTPError{HTTPCode: http.StatusNotFound, Code: ErrCodeTeamDoesNotExist, Message: "No Teams with property oidcId could be found for User."}
}
// ====================
// User <-> Project errors
// ====================
+24 -14
View File
@@ -17,6 +17,8 @@
package models
import (
"strconv"
"strings"
"time"
"code.vikunja.io/api/pkg/log"
@@ -173,28 +175,36 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int
opts.page = page
opts.perPage = perPage
opts.search = search
opts.filterConcat = filterConcatAnd
var bucketFilterIndex int
for i, filter := range opts.filters {
for _, filter := range opts.parsedFilters {
if filter.field == taskPropertyBucketID {
bucketFilterIndex = i
// Limiting the map to the one filter we're looking for is the easiest way to ensure we only
// get tasks in this bucket
bucketID := filter.value.(int64)
bucket := bucketMap[bucketID]
bucketMap = make(map[int64]*Bucket, 1)
bucketMap[bucketID] = bucket
break
}
}
if bucketFilterIndex == 0 {
opts.filters = append(opts.filters, &taskFilter{
field: taskPropertyBucketID,
value: 0,
comparator: taskFilterComparatorEquals,
})
bucketFilterIndex = len(opts.filters) - 1
}
originalFilter := opts.filter
for id, bucket := range bucketMap {
opts.filters[bucketFilterIndex].value = id
if !strings.Contains(originalFilter, "bucket_id") {
var filterString string
if originalFilter == "" {
filterString = "bucket_id = " + strconv.FormatInt(id, 10)
} else {
filterString = "(" + originalFilter + ") && bucket_id = " + strconv.FormatInt(id, 10)
}
opts.parsedFilters, err = getTaskFiltersFromFilterString(filterString)
if err != nil {
return
}
}
ts, _, total, err := getRawTasksForProjects(s, []*Project{{ID: bucket.ProjectID}}, auth, opts)
if err != nil {
+25 -3
View File
@@ -81,9 +81,7 @@ func TestBucket_ReadAll(t *testing.T) {
b := &Bucket{
ProjectID: 1,
TaskCollection: TaskCollection{
FilterBy: []string{"title"},
FilterComparator: []string{"like"},
FilterValue: []string{"done"},
Filter: "title ~ 'done'",
},
}
bucketsInterface, _, _, err := b.ReadAll(s, testuser, "", -1, 0)
@@ -94,6 +92,30 @@ func TestBucket_ReadAll(t *testing.T) {
assert.Equal(t, int64(2), buckets[0].Tasks[0].ID)
assert.Equal(t, int64(33), buckets[0].Tasks[1].ID)
})
t.Run("filtered by bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
testuser := &user.User{ID: 1}
b := &Bucket{
ProjectID: 1,
TaskCollection: TaskCollection{
Filter: "title ~ 'task' && bucket_id = 2",
},
}
bucketsInterface, _, _, err := b.ReadAll(s, testuser, "", -1, 0)
assert.NoError(t, err)
buckets := bucketsInterface.([]*Bucket)
assert.Len(t, buckets, 3)
assert.Len(t, buckets[0].Tasks, 0)
assert.Len(t, buckets[1].Tasks, 3)
assert.Len(t, buckets[2].Tasks, 0)
assert.Equal(t, int64(3), buckets[1].Tasks[0].ID)
assert.Equal(t, int64(4), buckets[1].Tasks[1].ID)
assert.Equal(t, int64(5), buckets[1].Tasks[2].ID)
})
t.Run("accessed by link share", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
+75 -70
View File
@@ -17,6 +17,7 @@
package models
import (
"fmt"
"math"
"strconv"
"strings"
@@ -315,8 +316,8 @@ func GetProjectSimplByTaskID(s *xorm.Session, taskID int64) (l *Project, err err
return &project, nil
}
// GetProjectsSimplByTaskIDs gets a list of projects by a task ids
func GetProjectsSimplByTaskIDs(s *xorm.Session, taskIDs []int64) (ps map[int64]*Project, err error) {
// GetProjectsMapSimplByTaskIDs gets a list of projects by a task ids
func GetProjectsMapSimplByTaskIDs(s *xorm.Session, taskIDs []int64) (ps map[int64]*Project, err error) {
ps = make(map[int64]*Project)
err = s.
Select("projects.*").
@@ -327,8 +328,18 @@ func GetProjectsSimplByTaskIDs(s *xorm.Session, taskIDs []int64) (ps map[int64]*
return
}
// GetProjectsByIDs returns a map of projects from a slice with project ids
func GetProjectsByIDs(s *xorm.Session, projectIDs []int64) (projects map[int64]*Project, err error) {
func GetProjectsSimplByTaskIDs(s *xorm.Session, taskIDs []int64) (ps []*Project, err error) {
err = s.
Select("projects.*").
Table(Project{}).
Join("INNER", "tasks", "projects.id = tasks.project_id").
In("tasks.id", taskIDs).
Find(&ps)
return
}
// GetProjectsMapByIDs returns a map of projects from a slice with project ids
func GetProjectsMapByIDs(s *xorm.Session, projectIDs []int64) (projects map[int64]*Project, err error) {
projects = make(map[int64]*Project, len(projectIDs))
if len(projectIDs) == 0 {
@@ -339,6 +350,17 @@ func GetProjectsByIDs(s *xorm.Session, projectIDs []int64) (projects map[int64]*
return
}
func GetProjectsByIDs(s *xorm.Session, projectIDs []int64) (projects []*Project, err error) {
projects = make([]*Project, 0, len(projectIDs))
if len(projectIDs) == 0 {
return
}
err = s.In("id", projectIDs).Find(&projects)
return
}
type projectOptions struct {
search string
user *user.User
@@ -418,67 +440,49 @@ func getUserProjectsStatement(parentProjectIDs []int64, userID int64, search str
parentCondition,
builder.NotIn("l.id", parentProjectIDs),
)).
OrderBy("position").
GroupBy("l.id")
}
func getAllProjectsForUser(s *xorm.Session, userID int64, parentProjectIDs []int64, opts *projectOptions, projects *[]*Project, oldTotalCount int64, archivedProjects map[int64]bool) (resultCount int, totalCount int64, err error) {
func getAllProjectsForUser(s *xorm.Session, userID int64, opts *projectOptions) (projects []*Project, totalCount int64, err error) {
limit, start := getLimitFromPageIndex(opts.page, opts.perPage)
query := getUserProjectsStatement(parentProjectIDs, userID, opts.search, opts.getArchived)
if limit > 0 {
query = query.Limit(limit, start)
query := getUserProjectsStatement(nil, userID, opts.search, opts.getArchived)
querySQLString, args, err := query.ToSQL()
if err != nil {
return nil, 0, err
}
var limitSQL string
if limit > 0 {
limitSQL = fmt.Sprintf("LIMIT %d OFFSET %d", limit, start)
}
baseQuery := querySQLString + `
UNION ALL
SELECT p.* FROM projects p
INNER JOIN all_projects ap ON p.parent_project_id = ap.id`
currentProjects := []*Project{}
err = s.SQL(query).Find(&currentProjects)
err = s.SQL(`WITH RECURSIVE all_projects as (`+baseQuery+`)
SELECT DISTINCT * FROM all_projects ORDER BY position `+limitSQL, args...).Find(&currentProjects)
if err != nil {
return 0, 0, err
return
}
if len(currentProjects) == 0 {
return 0, oldTotalCount, err
return nil, 0, err
}
query = getUserProjectsStatement(parentProjectIDs, userID, opts.search, opts.getArchived)
totalCount, err = s.
SQL(query.Select("count(*)")).
SQL(`WITH RECURSIVE all_projects as (`+baseQuery+`)
SELECT COUNT(DISTINCT all_projects.id) FROM all_projects`, args...).
Count(&Project{})
if err != nil {
return 0, 0, err
return nil, 0, err
}
parentIDsMap := make(map[int64]bool, len(parentProjectIDs))
for _, id := range parentProjectIDs {
parentIDsMap[id] = true
}
for _, project := range currentProjects {
parentIDsMap[project.ID] = true
}
newParentIDs := []int64{}
for _, project := range currentProjects {
if project.IsArchived {
archivedProjects[project.ID] = true
}
if archivedProjects[project.ParentProjectID] {
project.IsArchived = true
}
// Filter out parent project ids which we're not looking for to avoid leaking
// information about parent projects
if !parentIDsMap[project.ParentProjectID] {
project.ParentProjectID = 0
}
newParentIDs = append(newParentIDs, project.ID)
}
*projects = append(*projects, currentProjects...)
// If we don't reset the limit for subprojects, it will be impossible to fetch all subprojects.
opts.page = -1
return getAllProjectsForUser(s, userID, newParentIDs, opts, projects, oldTotalCount+totalCount, archivedProjects)
return currentProjects, totalCount, err
}
// Gets the projects with their children without any tasks
@@ -488,9 +492,7 @@ func getRawProjectsForUser(s *xorm.Session, opts *projectOptions) (projects []*P
return nil, 0, 0, err
}
allProjects := []*Project{}
archivedProjects := make(map[int64]bool)
resultCount, totalItems, err = getAllProjectsForUser(s, fullUser.ID, nil, opts, &allProjects, 0, archivedProjects)
allProjects, totalItems, err := getAllProjectsForUser(s, fullUser.ID, opts)
if err != nil {
return
}
@@ -538,19 +540,24 @@ func getSavedFilterProjects(s *xorm.Session, doer *user.User) (savedFiltersProje
}
// GetAllParentProjects returns all parents of a given project
func (p *Project) GetAllParentProjects(s *xorm.Session) (err error) {
if p.ParentProjectID == 0 {
return
}
parent, err := GetProjectSimpleByID(s, p.ParentProjectID)
if err != nil {
return err
}
p.ParentProject = parent
return parent.GetAllParentProjects(s)
func GetAllParentProjects(s *xorm.Session, projectID int64) (allProjects map[int64]*Project, err error) {
allProjects = make(map[int64]*Project)
err = s.SQL(`WITH RECURSIVE all_projects AS (
SELECT
p.*
FROM
projects p
WHERE
p.id = ?
UNION ALL
SELECT
p.*
FROM
projects p
INNER JOIN all_projects pc ON p.ID = pc.parent_project_id
)
SELECT DISTINCT * FROM all_projects`, projectID).Find(&allProjects)
return
}
// addProjectDetails adds owner user objects and project tasks to all projects in the slice
@@ -578,7 +585,7 @@ func addProjectDetails(s *xorm.Session, projects []*Project, a web.Auth) (err er
return err
}
subscriptions, err := GetSubscriptions(s, SubscriptionEntityProject, projectIDs, a)
subscriptions, err := GetSubscriptionsForProjects(s, projects, a)
if err != nil {
log.Errorf("An error occurred while getting project subscriptions for a project: %s", err.Error())
subscriptions = make(map[int64][]*Subscription)
@@ -665,12 +672,14 @@ func checkProjectBeforeUpdateOrDelete(s *xorm.Session, project *Project) (err er
}
}
var parent *Project
parent, err = GetProjectSimpleByID(s, project.ParentProjectID)
allProjects, err := GetAllParentProjects(s, project.ParentProjectID)
if err != nil {
return err
}
var parent *Project
parent = allProjects[project.ParentProjectID]
// Check if there's a cycle in the parent relation
parentsVisited := make(map[int64]bool)
parentsVisited[project.ID] = true
@@ -679,11 +688,7 @@ func checkProjectBeforeUpdateOrDelete(s *xorm.Session, project *Project) (err er
break
}
// FIXME: Can we do this with better performance?
parent, err = GetProjectSimpleByID(s, parent.ParentProjectID)
if err != nil {
return err
}
parent = allProjects[parent.ParentProjectID]
if parentsVisited[parent.ID] {
return &ErrProjectCannotHaveACyclicRelationship{
+5 -7
View File
@@ -349,11 +349,9 @@ func TestProject_ReadAll(t *testing.T) {
t.Run("all", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
projects := []*Project{}
archivedProjects := make(map[int64]bool)
_, _, err := getAllProjectsForUser(s, 1, nil, &projectOptions{}, &projects, 0, archivedProjects)
projects, _, err := getAllProjectsForUser(s, 6, &projectOptions{})
require.NoError(t, err)
assert.Len(t, projects, 24)
assert.Len(t, projects, 25)
_ = s.Close()
})
t.Run("only child projects for one project", func(t *testing.T) {
@@ -369,12 +367,12 @@ func TestProject_ReadAll(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, reflect.Slice, reflect.TypeOf(projects3).Kind())
ls := projects3.([]*Project)
assert.Len(t, ls, 26)
assert.Len(t, ls, 28)
assert.Equal(t, int64(3), ls[0].ID) // Project 3 has a position of 1 and should be sorted first
assert.Equal(t, int64(1), ls[1].ID)
assert.Equal(t, int64(6), ls[2].ID)
assert.Equal(t, int64(-1), ls[24].ID)
assert.Equal(t, int64(-2), ls[25].ID)
assert.Equal(t, int64(-1), ls[26].ID)
assert.Equal(t, int64(-2), ls[27].ID)
_ = s.Close()
})
t.Run("projects for nonexistant user", func(t *testing.T) {
+1 -1
View File
@@ -65,7 +65,7 @@ func TestSavedFilter_Create(t *testing.T) {
vals := map[string]interface{}{
"title": "'test'",
"description": "'Lorem Ipsum dolor sit amet'",
"filters": "'{\"sort_by\":null,\"order_by\":null,\"filter_by\":null,\"filter_value\":null,\"filter_comparator\":null,\"filter_concat\":\"\",\"filter_include_nulls\":false}'",
"filters": "'{\"sort_by\":null,\"order_by\":null,\"filter\":\"\",\"filter_include_nulls\":false}'",
"owner_id": 1,
}
// Postgres can't compare json values directly, see https://dba.stackexchange.com/a/106290/210721
+59 -34
View File
@@ -223,7 +223,11 @@ func GetSubscriptions(s *xorm.Session, entityType SubscriptionEntityType, entity
switch entityType {
case SubscriptionEntityProject:
return getSubscriptionsForProjects(s, entityIDs, u)
projects, err := GetProjectsByIDs(s, entityIDs)
if err != nil {
return nil, err
}
return GetSubscriptionsForProjects(s, projects, u)
case SubscriptionEntityTask:
subs, err := getSubscriptionsForTasks(s, entityIDs, u)
if err != nil {
@@ -232,22 +236,34 @@ func GetSubscriptions(s *xorm.Session, entityType SubscriptionEntityType, entity
// If the task does not have a subscription directly or from its project, get the one
// from the parent and return it instead.
var taskIDsWithoutSubscription []int64
for _, eID := range entityIDs {
if _, has := subs[eID]; has {
continue
}
task, err := GetTaskByIDSimple(s, eID)
if err != nil {
return nil, err
}
projectSubscriptions, err := getSubscriptionsForProjects(s, []int64{task.ProjectID}, u)
if err != nil {
return nil, err
}
for _, subscription := range projectSubscriptions {
subs[eID] = subscription // The first project subscription is the subscription we're looking for
break
taskIDsWithoutSubscription = append(taskIDsWithoutSubscription, eID)
}
projects, err := GetProjectsSimplByTaskIDs(s, taskIDsWithoutSubscription)
if err != nil {
return nil, err
}
tasks, err := GetTasksSimpleByIDs(s, taskIDsWithoutSubscription)
if err != nil {
return nil, err
}
projectSubscriptions, err := GetSubscriptionsForProjects(s, projects, u)
if err != nil {
return nil, err
}
for _, task := range tasks {
sub, has := projectSubscriptions[task.ProjectID]
if has {
subs[task.ID] = sub
}
}
@@ -257,48 +273,57 @@ func GetSubscriptions(s *xorm.Session, entityType SubscriptionEntityType, entity
return
}
func getSubscriptionsForProjects(s *xorm.Session, projectIDs []int64, u *user.User) (projectsToSubscriptions map[int64][]*Subscription, err error) {
origEntityIDs := projectIDs
func GetSubscriptionsForProjects(s *xorm.Session, projects []*Project, a web.Auth) (projectsToSubscriptions map[int64][]*Subscription, err error) {
u, is := a.(*user.User)
if u != nil && !is {
return
}
var ps = make(map[int64]*Project)
origProjectIDs := make([]int64, 0, len(projects))
allProjectIDs := make([]int64, 0, len(projects))
for _, eID := range projectIDs {
if eID < 1 {
for _, p := range projects {
ps[p.ID] = p
origProjectIDs = append(origProjectIDs, p.ID)
allProjectIDs = append(allProjectIDs, p.ID)
}
// We can't just use the projects we have, we need to fetch the parents
// because they may not be loaded in the same object
for _, p := range projects {
if p.ParentProjectID == 0 {
continue
}
ps[eID], err = GetProjectSimpleByID(s, eID)
if err != nil && IsErrProjectDoesNotExist(err) {
// If the project does not exist, it might got deleted. There could still be subscribers though.
delete(ps, eID)
if _, has := ps[p.ParentProjectID]; has {
continue
}
if err != nil {
return nil, err
}
err = ps[eID].GetAllParentProjects(s)
parents, err := GetAllParentProjects(s, p.ID)
if err != nil {
return nil, err
}
parentIDs := []int64{}
var parent = ps[eID].ParentProject
// Walk the tree up until we reach the top
var parent = parents[p.ParentProjectID] // parent now has a pointer…
ps[p.ID].ParentProject = parents[p.ParentProjectID]
for parent != nil {
parentIDs = append(parentIDs, parent.ID)
parent = parent.ParentProject
allProjectIDs = append(allProjectIDs, parent.ID)
parent = parents[parent.ParentProjectID] // … which means we can update it here and then update the pointer in the map
}
// Now we have all parent ids
projectIDs = append(projectIDs, parentIDs...) // the child project id is already in there
}
var subscriptions []*Subscription
if u != nil {
err = s.
Where("user_id = ?", u.ID).
And(getSubscriberCondForEntities(SubscriptionEntityProject, projectIDs)).
And(getSubscriberCondForEntities(SubscriptionEntityProject, allProjectIDs)).
Find(&subscriptions)
} else {
err = s.
And(getSubscriberCondForEntities(SubscriptionEntityProject, projectIDs)).
And(getSubscriberCondForEntities(SubscriptionEntityProject, allProjectIDs)).
Find(&subscriptions)
}
if err != nil {
@@ -313,7 +338,7 @@ func getSubscriptionsForProjects(s *xorm.Session, projectIDs []int64, u *user.Us
// Rearrange so that subscriptions trickle down
for _, eID := range origEntityIDs {
for _, eID := range origProjectIDs {
// If the current project does not have a subscription, climb up the tree until a project has one,
// then use that subscription for all child projects
_, has := projectsToSubscriptions[eID]
+4 -13
View File
@@ -33,17 +33,8 @@ type TaskCollection struct {
OrderBy []string `query:"order_by" json:"order_by"`
OrderByArr []string `query:"order_by[]" json:"-"`
// The field name of the field to filter by
FilterBy []string `query:"filter_by" json:"filter_by"`
FilterByArr []string `query:"filter_by[]" json:"-"`
// The value of the field name to filter by
FilterValue []string `query:"filter_value" json:"filter_value"`
FilterValueArr []string `query:"filter_value[]" json:"-"`
// The comparator for field and value
FilterComparator []string `query:"filter_comparator" json:"filter_comparator"`
FilterComparatorArr []string `query:"filter_comparator[]" json:"-"`
// The way all filter conditions are concatenated together, can be either "and" or "or".,
FilterConcat string `query:"filter_concat" json:"filter_concat"`
Filter string `query:"filter" json:"filter"`
// If set to true, the result will also include null values
FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls"`
@@ -110,11 +101,11 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection) (opts *taskSearchOption
opts = &taskSearchOptions{
sortby: sort,
filterConcat: taskFilterConcatinator(tf.FilterConcat),
filterIncludeNulls: tf.FilterIncludeNulls,
filter: tf.Filter,
}
opts.filters, err = getTaskFiltersByCollections(tf)
opts.parsedFilters, err = getTaskFiltersFromFilterString(tf.Filter)
return opts, err
}
+83 -56
View File
@@ -23,6 +23,8 @@ import (
"strings"
"time"
"github.com/ganigeorgiev/fexpr"
"code.vikunja.io/api/pkg/config"
"github.com/iancoleman/strcase"
@@ -54,6 +56,7 @@ type taskFilter struct {
value interface{} // Needs to be an interface to be able to hold the field's native value
comparator taskFilterComparator
isNumeric bool
join taskFilterConcatinator
}
func parseTimeFromUserInput(timeString string) (value time.Time, err error) {
@@ -88,61 +91,83 @@ func parseTimeFromUserInput(timeString string) (value time.Time, err error) {
return value.In(config.GetTimeZone()), err
}
func getTaskFiltersByCollections(c *TaskCollection) (filters []*taskFilter, err error) {
if len(c.FilterByArr) > 0 {
c.FilterBy = append(c.FilterBy, c.FilterByArr...)
func parseFilterFromExpression(f fexpr.ExprGroup) (filter *taskFilter, err error) {
filter = &taskFilter{
join: filterConcatAnd,
}
if f.Join == fexpr.JoinOr {
filter.join = filterConcatOr
}
if len(c.FilterValueArr) > 0 {
c.FilterValue = append(c.FilterValue, c.FilterValueArr...)
}
if len(c.FilterComparatorArr) > 0 {
c.FilterComparator = append(c.FilterComparator, c.FilterComparatorArr...)
}
if c.FilterConcat != "" && c.FilterConcat != filterConcatAnd && c.FilterConcat != filterConcatOr {
return nil, ErrInvalidTaskFilterConcatinator{
Concatinator: taskFilterConcatinator(c.FilterConcat),
}
}
filters = make([]*taskFilter, 0, len(c.FilterBy))
for i, f := range c.FilterBy {
filter := &taskFilter{
field: f,
comparator: taskFilterComparatorEquals,
}
if len(c.FilterComparator) > i {
filter.comparator, err = getFilterComparatorFromString(c.FilterComparator[i])
if err != nil {
return
}
}
err = validateTaskFieldComparator(filter.comparator)
var value string
switch v := f.Item.(type) {
case fexpr.Expr:
filter.field = v.Left.Literal
value = v.Right.Literal
filter.comparator, err = getFilterComparatorFromOp(v.Op)
if err != nil {
return
}
// Cast the field value to its native type
var reflectValue *reflect.StructField
if len(c.FilterValue) > i {
reflectValue, filter.value, err = getNativeValueForTaskField(filter.field, filter.comparator, c.FilterValue[i])
case []fexpr.ExprGroup:
values := make([]*taskFilter, 0, len(v))
for _, expression := range v {
subfilter, err := parseFilterFromExpression(expression)
if err != nil {
return nil, ErrInvalidTaskFilterValue{
Value: filter.field,
Field: c.FilterValue[i],
}
return nil, err
}
values = append(values, subfilter)
}
if reflectValue != nil {
filter.isNumeric = reflectValue.Type.Kind() == reflect.Int64
}
filter.value = values
return
}
filters = append(filters, filter)
err = validateTaskFieldComparator(filter.comparator)
if err != nil {
return
}
// Cast the field value to its native type
var reflectValue *reflect.StructField
if filter.field == "project" {
filter.field = "project_id"
}
reflectValue, filter.value, err = getNativeValueForTaskField(filter.field, filter.comparator, value)
if err != nil {
return nil, ErrInvalidTaskFilterValue{
Value: filter.field,
Field: value,
}
}
if reflectValue != nil {
filter.isNumeric = reflectValue.Type.Kind() == reflect.Int64
}
return filter, nil
}
func getTaskFiltersFromFilterString(filter string) (filters []*taskFilter, err error) {
if filter == "" {
return
}
filter = strings.ReplaceAll(filter, " in ", " ?= ")
parsedFilter, err := fexpr.Parse(filter)
if err != nil {
return nil, &ErrInvalidFilterExpression{
Expression: filter,
ExpressionError: err,
}
}
filters = make([]*taskFilter, 0, len(parsedFilter))
for _, f := range parsedFilter {
parsedFilter, err := parseFilterFromExpression(f)
if err != nil {
return nil, err
}
filters = append(filters, parsedFilter)
}
return
@@ -167,26 +192,28 @@ func validateTaskFieldComparator(comparator taskFilterComparator) error {
}
}
func getFilterComparatorFromString(comparator string) (taskFilterComparator, error) {
switch comparator {
case "equals":
func getFilterComparatorFromOp(op fexpr.SignOp) (taskFilterComparator, error) {
switch op {
case fexpr.SignEq:
return taskFilterComparatorEquals, nil
case "greater":
case fexpr.SignGt:
return taskFilterComparatorGreater, nil
case "greater_equals":
case fexpr.SignGte:
return taskFilterComparatorGreateEquals, nil
case "less":
case fexpr.SignLt:
return taskFilterComparatorLess, nil
case "less_equals":
case fexpr.SignLte:
return taskFilterComparatorLessEquals, nil
case "not_equals":
case fexpr.SignNeq:
return taskFilterComparatorNotEquals, nil
case "like":
case fexpr.SignLike:
return taskFilterComparatorLike, nil
case fexpr.SignAnyEq:
fallthrough
case "in":
return taskFilterComparatorIn, nil
default:
return taskFilterComparatorInvalid, ErrInvalidTaskFilterComparator{Comparator: taskFilterComparator(comparator)}
return taskFilterComparatorInvalid, ErrInvalidTaskFilterComparator{Comparator: taskFilterComparator(op)}
}
}
+160 -76
View File
@@ -29,6 +29,8 @@ import (
"gopkg.in/d4l3k/messagediff.v1"
)
// To only run a selected tests: ^\QTestTaskCollection_ReadAll\E$/^\QReadAll_Tasks_with_range\E$
func TestTaskCollection_ReadAll(t *testing.T) {
// Dummy users
user1 := &user.User{
@@ -656,6 +658,18 @@ func TestTaskCollection_ReadAll(t *testing.T) {
Created: time.Unix(1543626724, 0).In(loc),
Updated: time.Unix(1543626724, 0).In(loc),
}
task39 := &Task{
ID: 39,
Title: "task #39",
Identifier: "#0",
CreatedByID: 1,
CreatedBy: user1,
ProjectID: 25,
RelatedTasks: map[RelationKind][]*Task{},
BucketID: 0,
Created: time.Unix(1543626724, 0).In(loc),
Updated: time.Unix(1543626724, 0).In(loc),
}
type fields struct {
ProjectID int64
@@ -663,10 +677,8 @@ func TestTaskCollection_ReadAll(t *testing.T) {
SortBy []string // Is a string, since this is the place where a query string comes from the user
OrderBy []string
FilterBy []string
FilterValue []string
FilterComparator []string
FilterIncludeNulls bool
Filter string
CRUDable web.CRUDable
Rights web.Rights
@@ -728,6 +740,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
task32,
task33,
task35,
task39,
},
wantErr: false,
},
@@ -772,15 +785,14 @@ func TestTaskCollection_ReadAll(t *testing.T) {
task3,
task1,
task2,
task39,
},
wantErr: false,
},
{
name: "ReadAll Tasks with range",
fields: fields{
FilterBy: []string{"start_date", "end_date"},
FilterValue: []string{"2018-12-11T03:46:40+00:00", "2018-12-13T11:20:01+00:00"},
FilterComparator: []string{"greater", "less"},
Filter: "start_date > '2018-12-11T03:46:40+00:00' || end_date < '2018-12-13T11:20:01+00:00'",
},
args: defaultArgs,
want: []*Task{
@@ -793,9 +805,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "ReadAll Tasks with different range",
fields: fields{
FilterBy: []string{"start_date", "end_date"},
FilterValue: []string{"2018-12-13T11:20:00+00:00", "2018-12-16T22:40:00+00:00"},
FilterComparator: []string{"greater", "less"},
Filter: "start_date > '2018-12-13T11:20:00+00:00' || end_date < '2018-12-16T22:40:00+00:00'",
},
args: defaultArgs,
want: []*Task{
@@ -807,20 +817,16 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "ReadAll Tasks with range with start date only",
fields: fields{
FilterBy: []string{"start_date"},
FilterValue: []string{"2018-12-12T07:33:20+00:00"},
FilterComparator: []string{"greater"},
Filter: "start_date > '2018-12-12T07:33:20+00:00'",
},
args: defaultArgs,
want: []*Task{},
wantErr: false,
},
{
name: "ReadAll Tasks with range with start date only and greater equals",
name: "ReadAll Tasks with range with start date only between",
fields: fields{
FilterBy: []string{"start_date"},
FilterValue: []string{"2018-12-12T07:33:20+00:00"},
FilterComparator: []string{"greater_equals"},
Filter: "start_date > '2018-12-12T00:00:00+00:00' && start_date < '2018-12-13T00:00:00+00:00'",
},
args: defaultArgs,
want: []*Task{
@@ -829,12 +835,35 @@ func TestTaskCollection_ReadAll(t *testing.T) {
},
wantErr: false,
},
{
name: "ReadAll Tasks with range with start date only and greater equals",
fields: fields{
Filter: "start_date >= '2018-12-12T07:33:20+00:00'",
},
args: defaultArgs,
want: []*Task{
task7,
task9,
},
wantErr: false,
},
{
name: "range and nesting",
fields: fields{
Filter: "(start_date > '2018-12-12T00:00:00+00:00' && start_date < '2018-12-13T00:00:00+00:00') || end_date > '2018-12-13T00:00:00+00:00'",
},
args: defaultArgs,
want: []*Task{
task7,
task8,
task9,
},
wantErr: false,
},
{
name: "undone tasks only",
fields: fields{
FilterBy: []string{"done"},
FilterValue: []string{"false"},
FilterComparator: []string{"equals"},
Filter: "done = false",
},
args: defaultArgs,
want: []*Task{
@@ -878,9 +907,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "done tasks only",
fields: fields{
FilterBy: []string{"done"},
FilterValue: []string{"true"},
FilterComparator: []string{"equals"},
Filter: "done = true",
},
args: defaultArgs,
want: []*Task{
@@ -891,9 +918,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "done tasks only - not equals done",
fields: fields{
FilterBy: []string{"done"},
FilterValue: []string{"false"},
FilterComparator: []string{"not_equals"},
Filter: "done != false",
},
args: defaultArgs,
want: []*Task{
@@ -904,10 +929,8 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "range with nulls",
fields: fields{
FilterBy: []string{"start_date", "end_date"},
FilterValue: []string{"2018-12-11T03:46:40+00:00", "2018-12-13T11:20:01+00:00"},
FilterComparator: []string{"greater", "less"},
FilterIncludeNulls: true,
Filter: "start_date > '2018-12-11T03:46:40+00:00' || end_date < '2018-12-13T11:20:01+00:00'",
},
args: defaultArgs,
want: []*Task{
@@ -943,6 +966,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
task32, // has nil dates
task33, // has nil dates
task35, // has nil dates
task39, // has nil dates
},
wantErr: false,
},
@@ -961,9 +985,26 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filtered with like",
fields: fields{
FilterBy: []string{"title"},
FilterValue: []string{"with"},
FilterComparator: []string{"like"},
Filter: "title ~ with",
},
args: defaultArgs,
want: []*Task{
task7,
task8,
task9,
task27,
task28,
task29,
task30,
task31,
task33,
},
wantErr: false,
},
{
name: "filtered with like and '",
fields: fields{
Filter: "title ~ 'with'",
},
args: defaultArgs,
want: []*Task{
@@ -982,9 +1023,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filtered reminder dates",
fields: fields{
FilterBy: []string{"reminders", "reminders"},
FilterValue: []string{"2018-10-01T00:00:00+00:00", "2018-12-10T00:00:00+00:00"},
FilterComparator: []string{"greater", "less"},
Filter: "reminders > '2018-10-01T00:00:00+00:00' && reminders < '2018-12-10T00:00:00+00:00'",
},
args: defaultArgs,
want: []*Task{
@@ -993,12 +1032,22 @@ func TestTaskCollection_ReadAll(t *testing.T) {
},
wantErr: false,
},
{
name: "filter in keyword",
fields: fields{
Filter: "id in '1,2,34'", // user does not have permission to access task 34
},
args: defaultArgs,
want: []*Task{
task1,
task2,
},
wantErr: false,
},
{
name: "filter in",
fields: fields{
FilterBy: []string{"id"},
FilterValue: []string{"1,2,34"}, // Task 34 is forbidden for user 1
FilterComparator: []string{"in"},
Filter: "id ?= '1,2,34'", // user does not have permission to access task 34
},
args: defaultArgs,
want: []*Task{
@@ -1010,9 +1059,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filter assignees by username",
fields: fields{
FilterBy: []string{"assignees"},
FilterValue: []string{"user1"},
FilterComparator: []string{"equals"},
Filter: "assignees = 'user1'",
},
args: defaultArgs,
want: []*Task{
@@ -1023,9 +1070,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filter assignees by username with users field name",
fields: fields{
FilterBy: []string{"users"},
FilterValue: []string{"user1"},
FilterComparator: []string{"equals"},
Filter: "users = 'user1'",
},
args: defaultArgs,
want: nil,
@@ -1034,9 +1079,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filter assignees by username with user_id field name",
fields: fields{
FilterBy: []string{"user_id"},
FilterValue: []string{"user1"},
FilterComparator: []string{"equals"},
Filter: "user_id = 'user1'",
},
args: defaultArgs,
want: nil,
@@ -1045,9 +1088,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filter assignees by multiple username",
fields: fields{
FilterBy: []string{"assignees", "assignees"},
FilterValue: []string{"user1", "user2"},
FilterComparator: []string{"equals", "equals"},
Filter: "assignees = 'user1' || assignees = 'user2'",
},
args: defaultArgs,
want: []*Task{
@@ -1059,9 +1100,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filter assignees by numbers",
fields: fields{
FilterBy: []string{"assignees"},
FilterValue: []string{"1"},
FilterComparator: []string{"equals"},
Filter: "assignees = 1",
},
args: defaultArgs,
want: []*Task{},
@@ -1070,20 +1109,50 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filter assignees by name with like",
fields: fields{
FilterBy: []string{"assignees"},
FilterValue: []string{"user"},
FilterComparator: []string{"like"},
Filter: "assignees ~ 'user'",
},
args: defaultArgs,
want: []*Task{
// Same as without any filter since the filter is ignored
task1,
task2,
task3,
task4,
task5,
task6,
task7,
task8,
task9,
task10,
task11,
task12,
task15,
task16,
task17,
task18,
task19,
task20,
task21,
task22,
task23,
task24,
task25,
task26,
task27,
task28,
task29,
task30,
task31,
task32,
task33,
task35,
},
args: defaultArgs,
want: []*Task{},
wantErr: false,
},
{
name: "filter assignees in by id",
fields: fields{
FilterBy: []string{"assignees"},
FilterValue: []string{"1,2"},
FilterComparator: []string{"in"},
Filter: "assignees ?= '1,2'",
},
args: defaultArgs,
want: []*Task{},
@@ -1092,9 +1161,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filter assignees in by username",
fields: fields{
FilterBy: []string{"assignees"},
FilterValue: []string{"user1,user2"},
FilterComparator: []string{"in"},
Filter: "assignees ?= 'user1,user2'",
},
args: defaultArgs,
want: []*Task{
@@ -1106,9 +1173,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filter labels",
fields: fields{
FilterBy: []string{"labels"},
FilterValue: []string{"4"},
FilterComparator: []string{"equals"},
Filter: "labels = 4",
},
args: defaultArgs,
want: []*Task{
@@ -1119,11 +1184,9 @@ func TestTaskCollection_ReadAll(t *testing.T) {
wantErr: false,
},
{
name: "filter project",
name: "filter project_id",
fields: fields{
FilterBy: []string{"project_id"},
FilterValue: []string{"6"},
FilterComparator: []string{"equals"},
Filter: "project_id = 6",
},
args: defaultArgs,
want: []*Task{
@@ -1131,13 +1194,31 @@ func TestTaskCollection_ReadAll(t *testing.T) {
},
wantErr: false,
},
{
name: "filter project",
fields: fields{
Filter: "project = 6",
},
args: defaultArgs,
want: []*Task{
task15,
},
wantErr: false,
},
{
name: "filter project forbidden",
fields: fields{
Filter: "project_id = 20", // user1 has no access to project 20
},
args: defaultArgs,
want: []*Task{},
wantErr: false,
},
// TODO filter parent project?
{
name: "filter by index",
fields: fields{
FilterBy: []string{"index"},
FilterValue: []string{"5"},
FilterComparator: []string{"equals"},
Filter: "index = 5",
},
args: defaultArgs,
want: []*Task{
@@ -1202,6 +1283,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
task32,
task33,
task35,
task39,
},
},
{
@@ -1218,6 +1300,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
task6,
task5,
// The other ones don't have a due date
task39,
task35,
task33,
task32,
@@ -1304,6 +1387,8 @@ func TestTaskCollection_ReadAll(t *testing.T) {
task9,
},
},
// TODO unix dates
// TODO date magic
}
for _, tt := range tests {
@@ -1317,11 +1402,10 @@ func TestTaskCollection_ReadAll(t *testing.T) {
SortBy: tt.fields.SortBy,
OrderBy: tt.fields.OrderBy,
FilterBy: tt.fields.FilterBy,
FilterValue: tt.fields.FilterValue,
FilterComparator: tt.fields.FilterComparator,
FilterIncludeNulls: tt.fields.FilterIncludeNulls,
Filter: tt.fields.Filter,
CRUDable: tt.fields.CRUDable,
Rights: tt.fields.Rights,
}
+1 -1
View File
@@ -139,7 +139,7 @@ func RegisterOverdueReminderCron() {
}
}
projects, err := GetProjectsSimplByTaskIDs(s, taskIDs)
projects, err := GetProjectsMapSimplByTaskIDs(s, taskIDs)
if err != nil {
log.Errorf("[Undone Overdue Tasks Reminder] Could not get projects for tasks: %s", err)
return
+1 -1
View File
@@ -173,7 +173,7 @@ func getTasksWithRemindersDueAndTheirUsers(s *xorm.Session, now time.Time) (remi
seen := make(map[int64]map[int64]bool)
projects, err := GetProjectsSimplByTaskIDs(s, taskIDs)
projects, err := GetProjectsMapSimplByTaskIDs(s, taskIDs)
if err != nil {
return
}
+156 -111
View File
@@ -76,23 +76,21 @@ func getOrderByDBStatement(opts *taskSearchOptions) (orderby string, err error)
return
}
//nolint:gocyclo
func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCount int64, err error) {
func convertFiltersToDBFilterCond(rawFilters []*taskFilter, includeNulls bool) (filterCond builder.Cond, err error) {
orderby, err := getOrderByDBStatement(opts)
if err != nil {
return nil, 0, err
}
// Some filters need a special treatment since they are in a separate table
reminderFilters := []builder.Cond{}
assigneeFilters := []builder.Cond{}
labelFilters := []builder.Cond{}
projectFilters := []builder.Cond{}
var filters = make([]builder.Cond, 0, len(opts.filters))
var dbFilters = make([]builder.Cond, 0, len(rawFilters))
// To still find tasks with nil values, we exclude 0s when comparing with >/< values.
for _, f := range opts.filters {
for _, f := range rawFilters {
if nested, is := f.value.([]*taskFilter); is {
nestedDBFilters, err := convertFiltersToDBFilterCond(nested, includeNulls)
if err != nil {
return nil, err
}
dbFilters = append(dbFilters, nestedDBFilters)
continue
}
if f.field == "reminders" {
filter, err := getFilterCond(&taskFilter{
// recreating the struct here to avoid modifying it when reusing the opts struct
@@ -100,17 +98,17 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
value: f.value,
comparator: f.comparator,
isNumeric: f.isNumeric,
}, opts.filterIncludeNulls)
}, includeNulls)
if err != nil {
return nil, totalCount, err
return nil, err
}
reminderFilters = append(reminderFilters, filter)
dbFilters = append(dbFilters, getFilterCondForSeparateTable("task_reminders", filter))
continue
}
if f.field == "assignees" {
if f.comparator == taskFilterComparatorLike {
return nil, totalCount, err
return
}
filter, err := getFilterCond(&taskFilter{
// recreating the struct here to avoid modifying it when reusing the opts struct
@@ -118,11 +116,17 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
value: f.value,
comparator: f.comparator,
isNumeric: f.isNumeric,
}, opts.filterIncludeNulls)
}, includeNulls)
if err != nil {
return nil, totalCount, err
return nil, err
}
assigneeFilters = append(assigneeFilters, filter)
assigneeFilter := builder.In("user_id",
builder.Select("id").
From("users").
Where(filter),
)
dbFilters = append(dbFilters, getFilterCondForSeparateTable("task_assignees", assigneeFilter))
continue
}
@@ -133,11 +137,12 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
value: f.value,
comparator: f.comparator,
isNumeric: f.isNumeric,
}, opts.filterIncludeNulls)
}, includeNulls)
if err != nil {
return nil, totalCount, err
return nil, err
}
labelFilters = append(labelFilters, filter)
dbFilters = append(dbFilters, getFilterCondForSeparateTable("label_tasks", filter))
continue
}
@@ -148,19 +153,60 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
value: f.value,
comparator: f.comparator,
isNumeric: f.isNumeric,
}, opts.filterIncludeNulls)
}, includeNulls)
if err != nil {
return nil, totalCount, err
return nil, err
}
projectFilters = append(projectFilters, filter)
cond := builder.In(
"project_id",
builder.
Select("id").
From("projects").
Where(filter),
)
dbFilters = append(dbFilters, cond)
continue
}
filter, err := getFilterCond(f, opts.filterIncludeNulls)
filter, err := getFilterCond(f, includeNulls)
if err != nil {
return nil, totalCount, err
return nil, err
}
filters = append(filters, filter)
dbFilters = append(dbFilters, filter)
}
if len(dbFilters) > 0 {
if len(dbFilters) == 1 {
filterCond = dbFilters[0]
} else {
for i, f := range dbFilters {
if len(dbFilters) > i+1 {
switch rawFilters[i+1].join {
case filterConcatOr:
filterCond = builder.Or(filterCond, f, dbFilters[i+1])
case filterConcatAnd:
filterCond = builder.And(filterCond, f, dbFilters[i+1])
}
}
}
}
}
return filterCond, nil
}
//nolint:gocyclo
func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCount int64, err error) {
orderby, err := getOrderByDBStatement(opts)
if err != nil {
return nil, 0, err
}
filterCond, err := convertFiltersToDBFilterCond(opts.parsedFilters, opts.filterIncludeNulls)
if err != nil {
return nil, 0, err
}
// Then return all tasks for that projects
@@ -199,53 +245,6 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
favoritesCond = builder.In("id", favCond)
}
if len(reminderFilters) > 0 {
filters = append(filters, getFilterCondForSeparateTable("task_reminders", opts.filterConcat, reminderFilters))
}
if len(assigneeFilters) > 0 {
assigneeFilter := []builder.Cond{
builder.In("user_id",
builder.Select("id").
From("users").
Where(builder.Or(assigneeFilters...)),
)}
filters = append(filters, getFilterCondForSeparateTable("task_assignees", opts.filterConcat, assigneeFilter))
}
if len(labelFilters) > 0 {
filters = append(filters, getFilterCondForSeparateTable("label_tasks", opts.filterConcat, labelFilters))
}
if len(projectFilters) > 0 {
var filtercond builder.Cond
if opts.filterConcat == filterConcatOr {
filtercond = builder.Or(projectFilters...)
}
if opts.filterConcat == filterConcatAnd {
filtercond = builder.And(projectFilters...)
}
cond := builder.In(
"project_id",
builder.
Select("id").
From("projects").
Where(filtercond),
)
filters = append(filters, cond)
}
var filterCond builder.Cond
if len(filters) > 0 {
if opts.filterConcat == filterConcatOr {
filterCond = builder.Or(filters...)
}
if opts.filterConcat == filterConcatAnd {
filterCond = builder.And(filters...)
}
}
limit, start := getLimitFromPageIndex(opts.page, opts.perPage)
cond := builder.And(builder.Or(projectIDCond, favoritesCond), where, filterCond)
@@ -316,41 +315,23 @@ func convertFilterValues(value interface{}) string {
return ""
}
func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCount int64, err error) {
// Parsing and rebuilding the filter for Typesense has the advantage that we have more control over
// what Typesense finally gets to see.
func convertParsedFilterToTypesense(rawFilters []*taskFilter) (filterBy string, err error) {
var sortbyFields []string
for i, param := range opts.sortby {
// Validate the params
if err := param.validate(); err != nil {
return nil, totalCount, err
filters := []string{}
for _, f := range rawFilters {
if nested, is := f.value.([]*taskFilter); is {
nestedDBFilters, err := convertParsedFilterToTypesense(nested)
if err != nil {
return "", err
}
filters = append(filters, "("+nestedDBFilters+")")
continue
}
// Typesense does not allow sorting by ID, so we sort by created timestamp instead
if param.sortBy == "id" {
param.sortBy = "created"
}
sortbyFields = append(sortbyFields, param.sortBy+"(missing_values:last):"+param.orderBy.String())
if i == 2 {
// Typesense supports up to 3 sorting parameters
// https://typesense.org/docs/0.25.0/api/search.html#ranking-and-sorting-parameters
break
}
}
sortby := strings.Join(sortbyFields, ",")
projectIDStrings := []string{}
for _, id := range opts.projectIDs {
projectIDStrings = append(projectIDStrings, strconv.FormatInt(id, 10))
}
filterBy := []string{
"project_id: [" + strings.Join(projectIDStrings, ", ") + "]",
}
for _, f := range opts.filters {
if f.field == "reminders" {
f.field = "reminders.reminder"
}
@@ -363,6 +344,10 @@ func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task,
f.field = "labels.id"
}
if f.field == "project" {
f.field = "project_id"
}
filter := f.field
switch f.comparator {
@@ -394,7 +379,67 @@ func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task,
filter += "]"
}
filterBy = append(filterBy, filter)
filters = append(filters, filter)
}
if len(filters) > 0 {
if len(filters) == 1 {
filterBy = filters[0]
} else {
for i, f := range filters {
if len(filters) > i+1 {
switch rawFilters[i+1].join {
case filterConcatOr:
filterBy = f + " || " + filters[i+1]
case filterConcatAnd:
filterBy = f + " && " + filters[i+1]
}
}
}
}
}
return
}
func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCount int64, err error) {
var sortbyFields []string
for i, param := range opts.sortby {
// Validate the params
if err := param.validate(); err != nil {
return nil, totalCount, err
}
// Typesense does not allow sorting by ID, so we sort by created timestamp instead
if param.sortBy == "id" {
param.sortBy = "created"
}
sortbyFields = append(sortbyFields, param.sortBy+"(missing_values:last):"+param.orderBy.String())
if i == 2 {
// Typesense supports up to 3 sorting parameters
// https://typesense.org/docs/0.25.0/api/search.html#ranking-and-sorting-parameters
break
}
}
sortby := strings.Join(sortbyFields, ",")
projectIDStrings := []string{}
for _, id := range opts.projectIDs {
projectIDStrings = append(projectIDStrings, strconv.FormatInt(id, 10))
}
filter, err := convertParsedFilterToTypesense(opts.parsedFilters)
if err != nil {
return nil, 0, err
}
filterBy := []string{
"project_id: [" + strings.Join(projectIDStrings, ", ") + "]",
"(" + filter + ")",
}
////////////////
+24 -27
View File
@@ -162,8 +162,8 @@ func (t *Task) GetFrontendURL() string {
type taskFilterConcatinator string
const (
filterConcatAnd = "and"
filterConcatOr = "or"
filterConcatAnd taskFilterConcatinator = "and"
filterConcatOr taskFilterConcatinator = "or"
)
type taskSearchOptions struct {
@@ -171,9 +171,9 @@ type taskSearchOptions struct {
page int
perPage int
sortby []*sortParam
filters []*taskFilter
filterConcat taskFilterConcatinator
parsedFilters []*taskFilter
filterIncludeNulls bool
filter string
projectIDs []int64
}
@@ -238,21 +238,13 @@ func getFilterCond(f *taskFilter, includeNulls bool) (cond builder.Cond, err err
return
}
func getFilterCondForSeparateTable(table string, concat taskFilterConcatinator, conds []builder.Cond) builder.Cond {
var filtercond builder.Cond
if concat == filterConcatOr {
filtercond = builder.Or(conds...)
}
if concat == filterConcatAnd {
filtercond = builder.And(conds...)
}
func getFilterCondForSeparateTable(table string, cond builder.Cond) builder.Cond {
return builder.In(
"id",
builder.
Select("task_id").
From(table).
Where(filtercond),
Where(cond),
)
}
@@ -273,11 +265,6 @@ func getRawTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, op
return nil, 0, 0, nil
}
// Set the default concatinator of filter variables to or if none was provided
if opts.filterConcat == "" {
opts.filterConcat = filterConcatOr
}
// Get all project IDs and get the tasks
opts.projectIDs = []int64{}
var hasFavoritesProject bool
@@ -356,6 +343,11 @@ func GetTaskSimple(s *xorm.Session, t *Task) (task Task, err error) {
return
}
func GetTasksSimpleByIDs(s *xorm.Session, ids []int64) (tasks []*Task, err error) {
err = s.In("id", ids).Find(&tasks)
return
}
// GetTasksByIDs returns all tasks for a project of ids
func (bt *BulkTask) GetTasksByIDs(s *xorm.Session) (err error) {
for _, id := range bt.IDs {
@@ -586,7 +578,7 @@ func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth) (e
}
// Get all identifiers
projects, err := GetProjectsByIDs(s, projectIDs)
projects, err := GetProjectsMapByIDs(s, projectIDs)
if err != nil {
return err
}
@@ -652,7 +644,8 @@ func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, doCheckBucke
}
var bucket *Bucket
if task.Done && originalTask != nil && !originalTask.Done {
if task.Done && originalTask != nil &&
(!originalTask.Done || task.ProjectID != originalTask.ProjectID) {
task.BucketID = project.DoneBucketID
}
@@ -661,7 +654,10 @@ func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, doCheckBucke
}
// Either no bucket was provided or the task was moved between projects
if task.BucketID == 0 || (originalTask != nil && task.ProjectID != 0 && originalTask.ProjectID != task.ProjectID) {
// But if the task was moved between projects, don't update the done bucket
// because then we have it already updated to the done bucket.
if task.BucketID == 0 ||
(originalTask != nil && task.ProjectID != 0 && originalTask.ProjectID != task.ProjectID && !task.Done) {
task.BucketID, err = getDefaultBucketID(s, project)
if err != nil {
return
@@ -855,17 +851,18 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) {
// Old task has the stored reminders
ot.Reminders = reminders
targetBucket, err := setTaskBucket(s, t, &ot, t.BucketID != 0 && t.BucketID != ot.BucketID, nil)
project, err := GetProjectSimpleByID(s, t.ProjectID)
if err != nil {
return err
}
targetBucket, err := setTaskBucket(s, t, &ot, t.BucketID != 0 && t.BucketID != ot.BucketID, project)
if err != nil {
return err
}
// If the task was moved into the done bucket and the task has a repeating cycle we should not update
// the bucket.
project, err := GetProjectSimpleByID(s, t.ProjectID)
if err != nil {
return err
}
if targetBucket.ID == project.DoneBucketID && t.RepeatAfter > 0 {
t.Done = true // This will trigger the correct re-scheduling of the task (happening in updateDone later)
t.BucketID = ot.BucketID
+24 -3
View File
@@ -345,8 +345,7 @@ func TestTask_Update(t *testing.T) {
err = s.Commit()
require.NoError(t, err)
assert.Equal(t, int64(4), task.BucketID) // bucket 4 is the default bucket on project 2
assert.True(t, task.Done) // bucket 4 is the done bucket, so the task should be marked as done as well
assert.Equal(t, int64(40), task.BucketID) // bucket 40 is the default bucket on project 2
})
t.Run("marking a task as done should move it to the done bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@@ -387,7 +386,29 @@ func TestTask_Update(t *testing.T) {
db.AssertExists(t, "tasks", map[string]interface{}{
"id": 1,
"project_id": 2,
"bucket_id": 4,
"bucket_id": 40,
}, false)
})
t.Run("move done task to another project with a done bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
task := &Task{
ID: 2,
Done: true,
ProjectID: 2,
}
err := task.Update(s, u)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
db.AssertExists(t, "tasks", map[string]interface{}{
"id": 2,
"project_id": 2,
"bucket_id": 4, // 4 is the done bucket
"done": true,
}, false)
})
t.Run("repeating tasks should not be moved to the done bucket", func(t *testing.T) {
+6 -1
View File
@@ -44,7 +44,6 @@ func (tm *TeamMember) Create(s *xorm.Session, a web.Auth) (err error) {
if err != nil {
return err
}
// Check if the user exists
member, err := user2.GetUserByUsername(s, tm.Username)
if err != nil {
@@ -109,6 +108,12 @@ func (tm *TeamMember) Delete(s *xorm.Session, _ web.Auth) (err error) {
return
}
func (tm *TeamMember) MembershipExists(s *xorm.Session) (exists bool, err error) {
return s.
Where("team_id = ? AND user_id = ?", tm.TeamID, tm.UserID).
Exist(&TeamMember{})
}
// Update toggles a team member's admin status
// @Summary Toggle a team member's admin status
// @Description If a user is team admin, this will make them member and vise-versa.
+40 -2
View File
@@ -38,6 +38,10 @@ type Team struct {
// The team's description.
Description string `xorm:"longtext null" json:"description"`
CreatedByID int64 `xorm:"bigint not null INDEX" json:"-"`
// The team's oidc id delivered by the oidc provider
OidcID string `xorm:"varchar(250) null" maxLength:"250" json:"oidc_id"`
// Contains the issuer extracted from the vikunja_groups claim if this team was created through oidc
Issuer string `xorm:"text null" json:"-"`
// The user who created this team.
CreatedBy *user.User `xorm:"-" json:"created_by"`
@@ -86,11 +90,18 @@ func (*TeamMember) TableName() string {
// TeamUser is the team member type
type TeamUser struct {
user.User `xorm:"extends"`
// Whether or not the member is an admin of the team. See the docs for more about what a team admin can do
// Whether the member is an admin of the team. See the docs for more about what a team admin can do
Admin bool `json:"admin"`
TeamID int64 `json:"-"`
}
// OIDCTeam is the relevant data for a team and is delivered by oidc token
type OIDCTeam struct {
Name string
OidcID string
Description string
}
// GetTeamByID gets a team by its ID
func GetTeamByID(s *xorm.Session, id int64) (team *Team, err error) {
if id < 1 {
@@ -120,6 +131,34 @@ func GetTeamByID(s *xorm.Session, id int64) (team *Team, err error) {
return
}
// GetTeamByOidcID returns a team matching the given oidc_id
// For oidc team creation oidcID and Name need to be set
func GetTeamByOidcIDAndIssuer(s *xorm.Session, oidcID string, issuer string) (*Team, error) {
team := &Team{}
has, err := s.
Table("teams").
Where("oidc_id = ? AND issuer = ?", oidcID, issuer).
Get(team)
if !has || err != nil {
return nil, ErrOIDCTeamDoesNotExist{issuer, oidcID}
}
return team, nil
}
func FindAllOidcTeamIDsForUser(s *xorm.Session, userID int64) (ts []int64, err error) {
err = s.
Table("team_members").
Where("user_id = ? ", userID).
Join("RIGHT", "teams", "teams.id = team_members.team_id").
Where("teams.oidc_id != ? AND teams.oidc_id IS NOT NULL", "").
Cols("teams.id").
Find(&ts)
if ts == nil || err != nil {
return ts, err
}
return ts, nil
}
func addMoreInfoToTeams(s *xorm.Session, teams []*Team) (err error) {
if len(teams) == 0 {
@@ -270,7 +309,6 @@ func (t *Team) Create(s *xorm.Session, a web.Auth) (err error) {
return
}
// Insert the current user as member and admin
tm := TeamMember{TeamID: t.ID, Username: doer.Username, Admin: true}
if err = tm.Create(s, doer); err != nil {
return err
+179 -8
View File
@@ -21,21 +21,22 @@ import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"code.vikunja.io/web/handler"
"code.vikunja.io/api/pkg/db"
"xorm.io/xorm"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
"github.com/coreos/go-oidc/v3/oidc"
petname "github.com/dustinkirkland/golang-petname"
"github.com/labstack/echo/v4"
"golang.org/x/oauth2"
"xorm.io/xorm"
)
// Callback contains the callback after an auth request was made and redirected
@@ -53,16 +54,17 @@ type Provider struct {
AuthURL string `json:"auth_url"`
LogoutURL string `json:"logout_url"`
ClientID string `json:"client_id"`
Scope string `json:"scope"`
ClientSecret string `json:"-"`
openIDProvider *oidc.Provider
Oauth2Config *oauth2.Config `json:"-"`
}
type claims struct {
Email string `json:"email"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Nickname string `json:"nickname"`
Email string `json:"email"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Nickname string `json:"nickname"`
VikunjaGroups []map[string]interface{} `json:"vikunja_groups"`
}
func init() {
@@ -96,6 +98,7 @@ func HandleCallback(c echo.Context) error {
// Check if the provider exists
providerKey := c.Param("provider")
provider, err := GetProvider(providerKey)
log.Debugf("Provider: %v", provider)
if err != nil {
log.Error(err)
return handler.HandleHTTPError(err, c)
@@ -145,6 +148,7 @@ func HandleCallback(c echo.Context) error {
// Extract custom claims
cl := &claims{}
err = idToken.Claims(cl)
if err != nil {
log.Errorf("Error getting token claims for provider %s: %v", provider.Name, err)
@@ -198,16 +202,183 @@ func HandleCallback(c echo.Context) error {
return handler.HandleHTTPError(err, c)
}
// does the oidc token contain well formed "vikunja_groups" through vikunja_scope
log.Debugf("Checking for vikunja_groups in token %v", cl.VikunjaGroups)
teamData, errs := getTeamDataFromToken(cl.VikunjaGroups, provider)
if len(teamData) > 0 {
for _, err := range errs {
log.Errorf("Error creating teams for user and vikunja groups %s: %v", cl.VikunjaGroups, err)
}
// find old teams for user through oidc
oldOidcTeams, err := models.FindAllOidcTeamIDsForUser(s, u.ID)
if err != nil {
log.Debugf("No oidc teams found for user %v", err)
}
oidcTeams, err := AssignOrCreateUserToTeams(s, u, teamData, idToken.Issuer)
if err != nil {
log.Errorf("Could not proceed with group routine %v", err)
}
teamIDsToLeave := utils.NotIn(oldOidcTeams, oidcTeams)
err = RemoveUserFromTeamsByIds(s, u, teamIDsToLeave)
if err != nil {
log.Errorf("Found error while leaving teams %v", err)
}
errs := RemoveEmptySSOTeams(s, teamIDsToLeave)
if len(errs) > 0 {
for _, err := range errs {
log.Errorf("Found error while removing empty teams %v", err)
}
}
}
err = s.Commit()
if err != nil {
_ = s.Rollback()
log.Errorf("Error creating new team for provider %s: %v", provider.Name, err)
return handler.HandleHTTPError(err, c)
}
// Create token
return auth.NewUserAuthTokenResponse(u, c, false)
}
func AssignOrCreateUserToTeams(s *xorm.Session, u *user.User, teamData []*models.OIDCTeam, issuer string) (oidcTeams []int64, err error) {
if len(teamData) == 0 {
return
}
// check if we have seen these teams before.
// find or create Teams and assign user as teammember.
teams, err := GetOrCreateTeamsByOIDC(s, teamData, u, issuer)
if err != nil {
log.Errorf("Error verifying team for %v, got %v. Error: %v", u.Name, teams, err)
return nil, err
}
for _, team := range teams {
tm := models.TeamMember{TeamID: team.ID, UserID: u.ID, Username: u.Username}
exists, _ := tm.MembershipExists(s)
if !exists {
err = tm.Create(s, u)
if err != nil {
log.Errorf("Could not assign user %s to team %s: %v", u.Username, team.Name, err)
}
}
oidcTeams = append(oidcTeams, team.ID)
}
return oidcTeams, err
}
func RemoveEmptySSOTeams(s *xorm.Session, teamIDs []int64) (errs []error) {
for _, teamID := range teamIDs {
count, err := s.Where("team_id = ?", teamID).Count(&models.TeamMember{})
if count == 0 && err == nil {
log.Debugf("SSO team with id %v has no members. It will be deleted", teamID)
_, _err := s.Where("id = ?", teamID).Delete(&models.Team{})
if _err != nil {
errs = append(errs, _err)
}
}
}
return errs
}
func RemoveUserFromTeamsByIds(s *xorm.Session, u *user.User, teamIDs []int64) (err error) {
if len(teamIDs) < 1 {
return nil
}
log.Debugf("Removing team_member with user_id %v from team_ids %v", u.ID, teamIDs)
_, err = s.In("team_id", teamIDs).And("user_id = ?", u.ID).Delete(&models.TeamMember{})
return err
}
func getTeamDataFromToken(groups []map[string]interface{}, provider *Provider) (teamData []*models.OIDCTeam, errs []error) {
teamData = []*models.OIDCTeam{}
errs = []error{}
for _, team := range groups {
var name string
var description string
var oidcID string
_, exists := team["name"]
if exists {
name = team["name"].(string)
}
_, exists = team["description"]
if exists {
description = team["description"].(string)
}
_, exists = team["oidcID"]
if exists {
switch t := team["oidcID"].(type) {
case string:
oidcID = team["oidcID"].(string)
case int64:
oidcID = strconv.FormatInt(team["oidcID"].(int64), 10)
case float64:
oidcID = strconv.FormatFloat(team["oidcID"].(float64), 'f', -1, 64)
default:
log.Errorf("No oidcID assigned for %v or type %v not supported", team, t)
}
}
if name == "" || oidcID == "" {
log.Errorf("Claim of your custom scope does not hold name or oidcID for automatic group assignment through oidc provider. Please check %s", provider.Name)
errs = append(errs, &user.ErrOpenIDCustomScopeMalformed{})
continue
}
teamData = append(teamData, &models.OIDCTeam{Name: name, OidcID: oidcID, Description: description})
}
return teamData, errs
}
func getOIDCTeamName(name string) string {
return name + " (OIDC)"
}
func CreateOIDCTeam(s *xorm.Session, teamData *models.OIDCTeam, u *user.User, issuer string) (team *models.Team, err error) {
team = &models.Team{
Name: getOIDCTeamName(teamData.Name),
Description: teamData.Description,
OidcID: teamData.OidcID,
Issuer: issuer,
}
err = team.Create(s, u)
return team, err
}
// GetOrCreateTeamsByOIDC returns a slice of teams which were generated from the oidc data. If a team did not exist previously it is automatically created.
func GetOrCreateTeamsByOIDC(s *xorm.Session, teamData []*models.OIDCTeam, u *user.User, issuer string) (te []*models.Team, err error) {
te = []*models.Team{}
// Procedure can only be successful if oidcID is set
for _, oidcTeam := range teamData {
team, err := models.GetTeamByOidcIDAndIssuer(s, oidcTeam.OidcID, issuer)
if err != nil && !models.IsErrOIDCTeamDoesNotExist(err) {
return nil, err
}
if err != nil && models.IsErrOIDCTeamDoesNotExist(err) {
log.Debugf("Team with oidc_id %v and name %v does not exist. Creating team… ", oidcTeam.OidcID, oidcTeam.Name)
newTeam, err := CreateOIDCTeam(s, oidcTeam, u, issuer)
if err != nil {
return te, err
}
te = append(te, newTeam)
continue
}
if team.Name != getOIDCTeamName(oidcTeam.Name) {
team.Name = getOIDCTeamName(oidcTeam.Name)
err = team.Update(s, u)
if err != nil {
return nil, err
}
}
log.Debugf("Team with oidc_id %v and name %v already exists.", team.OidcID, team.Name)
te = append(te, team)
}
return te, err
}
func getOrCreateUser(s *xorm.Session, cl *claims, issuer, subject string) (u *user.User, err error) {
// Check if the user exists for that issuer and subject
u, err = user.GetUserWithEmail(s, &user.User{
Issuer: issuer,
+144 -1
View File
@@ -20,7 +20,9 @@ import (
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -95,4 +97,145 @@ func TestGetOrCreateUser(t *testing.T) {
"email": cl.Email,
}, false)
})
t.Run("existing user, non existing team", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
team := "new sso team"
oidcID := "47404"
cl := &claims{
Email: "other-email-address@some.service.com",
VikunjaGroups: []map[string]interface{}{
{"name": team, "oidcID": oidcID},
},
}
u, err := getOrCreateUser(s, cl, "https://some.service.com", "12345")
require.NoError(t, err)
teamData, errs := getTeamDataFromToken(cl.VikunjaGroups, nil)
for _, err := range errs {
require.NoError(t, err)
}
require.NoError(t, err)
oidcTeams, err := AssignOrCreateUserToTeams(s, u, teamData, "https://some.issuer")
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
db.AssertExists(t, "users", map[string]interface{}{
"id": u.ID,
"email": cl.Email,
}, false)
db.AssertExists(t, "teams", map[string]interface{}{
"id": oidcTeams,
"name": team + " (OIDC)",
}, false)
})
t.Run("existing user, assign to existing team", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
team := "testteam14"
oidcID := "14"
cl := &claims{
Email: "other-email-address@some.service.com",
VikunjaGroups: []map[string]interface{}{
{"name": team, "oidcID": oidcID},
},
}
u := &user.User{ID: 10}
teamData, errs := getTeamDataFromToken(cl.VikunjaGroups, nil)
for _, err := range errs {
require.NoError(t, err)
}
oidcTeams, err := AssignOrCreateUserToTeams(s, u, teamData, "https://some.issuer")
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
db.AssertExists(t, "team_members", map[string]interface{}{
"team_id": oidcTeams,
"user_id": u.ID,
}, false)
})
t.Run("existing user, remove from existing team", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
cl := &claims{
Email: "other-email-address@some.service.com",
VikunjaGroups: []map[string]interface{}{},
}
u := &user.User{ID: 10}
teamData, errs := getTeamDataFromToken(cl.VikunjaGroups, nil)
if len(errs) > 0 {
for _, err := range errs {
require.NoError(t, err)
}
}
oldOidcTeams, err := models.FindAllOidcTeamIDsForUser(s, u.ID)
require.NoError(t, err)
oidcTeams, err := AssignOrCreateUserToTeams(s, u, teamData, "https://some.issuer")
require.NoError(t, err)
teamIDsToLeave := utils.NotIn(oldOidcTeams, oidcTeams)
require.NoError(t, err)
err = RemoveUserFromTeamsByIds(s, u, teamIDsToLeave)
require.NoError(t, err)
errs = RemoveEmptySSOTeams(s, teamIDsToLeave)
for _, err = range errs {
require.NoError(t, err)
}
errs = RemoveEmptySSOTeams(s, teamIDsToLeave)
for _, err = range errs {
require.NoError(t, err)
}
err = s.Commit()
require.NoError(t, err)
db.AssertMissing(t, "team_members", map[string]interface{}{
"team_id": oidcTeams,
"user_id": u.ID,
})
})
t.Run("existing user, remove from existing team and delete team", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
cl := &claims{
Email: "other-email-address@some.service.com",
VikunjaGroups: []map[string]interface{}{},
}
u := &user.User{ID: 10}
teamData, errs := getTeamDataFromToken(cl.VikunjaGroups, nil)
if len(errs) > 0 {
for _, err := range errs {
require.NoError(t, err)
}
}
oldOidcTeams, err := models.FindAllOidcTeamIDsForUser(s, u.ID)
require.NoError(t, err)
oidcTeams, err := AssignOrCreateUserToTeams(s, u, teamData, "https://some.issuer")
require.NoError(t, err)
teamIDsToLeave := utils.NotIn(oldOidcTeams, oidcTeams)
require.NoError(t, err)
err = RemoveUserFromTeamsByIds(s, u, teamIDsToLeave)
require.NoError(t, err)
errs = RemoveEmptySSOTeams(s, teamIDsToLeave)
for _, err := range errs {
require.NoError(t, err)
}
err = s.Commit()
require.NoError(t, err)
db.AssertMissing(t, "teams", map[string]interface{}{
"id": oidcTeams,
})
})
}
+5
View File
@@ -125,6 +125,10 @@ func getProviderFromMap(pi map[string]interface{}) (provider *Provider, err erro
logoutURL = ""
}
scope, _ := pi["scope"].(string)
if scope == "" {
scope = "openid profile email"
}
provider = &Provider{
Name: pi["name"].(string),
Key: k,
@@ -132,6 +136,7 @@ func getProviderFromMap(pi map[string]interface{}) (provider *Provider, err erro
OriginalAuthURL: pi["authurl"].(string),
ClientSecret: pi["clientsecret"].(string),
LogoutURL: logoutURL,
Scope: scope,
}
cl, is := pi["clientid"].(int)
+50
View File
@@ -30,11 +30,14 @@ import (
"strconv"
"strings"
"github.com/hashicorp/go-version"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/initialize"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/migration"
vversion "code.vikunja.io/api/pkg/version"
"src.techknowlogick.com/xormigrate"
)
@@ -63,6 +66,7 @@ func Restore(filename string) error {
// Find the configFile, database and files files
var configFile *zip.File
var dotEnvFile *zip.File
var versionFile *zip.File
dbfiles := make(map[string]*zip.File)
filesFiles := make(map[string]*zip.File)
for _, file := range r.File {
@@ -81,7 +85,18 @@ func Restore(filename string) error {
}
if strings.HasPrefix(file.Name, "files/") {
filesFiles[strings.ReplaceAll(file.Name, "files/", "")] = file
continue
}
if file.Name == "VERSION" {
versionFile = file
}
}
///////
// Check if we're restoring to the same version as the dump
err = checkVikunjaVersion(versionFile)
if err != nil {
return err
}
///////
@@ -278,3 +293,38 @@ func restoreConfig(configFile, dotEnvFile *zip.File) error {
return nil
}
func checkVikunjaVersion(versionFile *zip.File) error {
if versionFile == nil {
return fmt.Errorf("dump does not contain VERSION file, refusing to continue")
}
vf, err := versionFile.Open()
if err != nil {
return fmt.Errorf("could not open version file: %w", err)
}
var bufVersion bytes.Buffer
if _, err := bufVersion.ReadFrom(vf); err != nil {
return fmt.Errorf("could not read version file: %w", err)
}
versionString := bufVersion.String()
if versionString == "dev" && vversion.Version == "dev" {
log.Debugf("Importing from dev version")
} else {
dumpedVersion, err := version.NewVersion(bufVersion.String())
if err != nil {
return err
}
currentVersion, err := version.NewVersion(vversion.Version)
if err != nil {
return err
}
if !dumpedVersion.Equal(currentVersion) {
return fmt.Errorf("export was created with version %s but this is %s - please make sure you are running the same Vikunja version before restoring", dumpedVersion, currentVersion)
}
}
return nil
}
@@ -159,7 +159,7 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
err = handler.SaveBackgroundFile(s, user, &project.Project, backgroundFile, "", uint64(backgroundFile.Len()))
if err != nil {
return err
log.Errorf("[creating structure] Could not create background for project %d, error was %v", project.ID, err)
}
log.Debugf("[creating structure] Created a background file for project %d", project.ID)
+3 -3
View File
@@ -73,8 +73,8 @@ func (mw *MigrationWeb) Migrate(c echo.Context) error {
return handler.HandleHTTPError(err, c)
}
if stats.FinishedAt.IsZero() {
return c.JSON(http.StatusOK, map[string]string{
if !stats.StartedAt.IsZero() && stats.FinishedAt.IsZero() {
return c.JSON(http.StatusPreconditionFailed, map[string]string{
"message": "Migration already running",
"running_since": stats.StartedAt.String(),
})
@@ -95,7 +95,7 @@ func (mw *MigrationWeb) Migrate(c echo.Context) error {
return handler.HandleHTTPError(err, c)
}
return c.JSON(http.StatusOK, models.Message{Message: "Everything was migrated successfully."})
return c.JSON(http.StatusOK, models.Message{Message: "Migration was started successfully."})
}
// Status returns whether or not a user has already done this migration

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