Compare commits
30 Commits
ee835422f3
...
c6b65e688b
Author | SHA1 | Date | |
---|---|---|---|
c6b65e688b | |||
44c1f0d281 | |||
2dab2ccedd | |||
827c43fe12 | |||
415c6380a5 | |||
ebe25ee2d7 | |||
cc5f48eb74 | |||
3969f6ae66 | |||
5ab720d709 | |||
7ae38c5ac1 | |||
162741e940 | |||
205f330f8a | |||
a12c169ce8 | |||
2facbae0d7 | |||
77a779acea | |||
89e349f2fd | |||
77feae47d6 | |||
2fb263a109 | |||
641fec1215 | |||
18374c2e52 | |||
9921551f96 | |||
b26c359ce6 | |||
d7d3ffeebd | |||
0b61885e89 | |||
8d5cb335bd | |||
71e62541a1 | |||
ee065d9238 | |||
28ed754c66 | |||
390f71b0c6 | |||
8c6d98bb02 |
|
@ -134,7 +134,7 @@ steps:
|
||||||
event: [ push, tag, pull_request ]
|
event: [ push, tag, pull_request ]
|
||||||
|
|
||||||
- name: lint
|
- name: lint
|
||||||
image: golangci/golangci-lint:v1.56.1
|
image: golangci/golangci-lint:v1.56.2
|
||||||
pull: always
|
pull: always
|
||||||
environment:
|
environment:
|
||||||
GOPROXY: 'https://goproxy.kolaente.de'
|
GOPROXY: 'https://goproxy.kolaente.de'
|
||||||
|
@ -1220,7 +1220,7 @@ steps:
|
||||||
pull: true
|
pull: true
|
||||||
commands:
|
commands:
|
||||||
- cd desktop/dist
|
- cd desktop/dist
|
||||||
- for file in Vikunja*; do suffix=".${file##*.}"; if [[ ! -d $file ]]; then mv "$file" "Vikunja-Desktop-unstable${suffix}"; fi; done
|
- bash -c 'for file in Vikunja*; do suffix=".$${file##*.}"; if [[ ! -d $file ]]; then mv "$file" "Vikunja-Desktop-unstable$${suffix}"; fi; done'
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
when:
|
when:
|
||||||
|
@ -1389,6 +1389,6 @@ steps:
|
||||||
- failure
|
- failure
|
||||||
---
|
---
|
||||||
kind: signature
|
kind: signature
|
||||||
hmac: 701e3ef16ca217178380a0aacb14601828d9c0d43a7c3cc5033ccd3288927850
|
hmac: aa9bd51fc7d73686ee169060dcb4d6540214825a0d5134035f477a97f77dd24d
|
||||||
|
|
||||||
...
|
...
|
||||||
|
|
|
@ -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:
|
These tasks are automatically run in our CI every time someone pushes to main or you update a pull request:
|
||||||
|
|
||||||
* `mage check:lint`
|
* `mage lint`
|
||||||
* `mage check:fmt`
|
|
||||||
* `mage check:ineffassign`
|
|
||||||
* `mage check:misspell`
|
|
||||||
* `mage check:goconst`
|
|
||||||
* `mage build:generate`
|
|
||||||
* `mage build:build`
|
* `mage build:build`
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
### Build Vikunja
|
### Build Vikunja
|
||||||
|
|
||||||
```
|
|
||||||
mage build:build
|
|
||||||
```
|
|
||||||
|
|
||||||
or
|
|
||||||
|
|
||||||
```
|
```
|
||||||
mage build
|
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:
|
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:all`: Runs golangci and swagger documentation check
|
||||||
* `mage check:fmt`: Checks if the code is properly formatted with go fmt
|
* `mage lint`: Checks if the code follows the rules as defined in the `.golangci.yml` config file.
|
||||||
* `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 lint:fix`: Fixes all code style issues which are easily fixable.
|
||||||
* `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/)
|
|
||||||
|
|
||||||
## Release
|
## Release
|
||||||
|
|
||||||
|
|
|
@ -37,7 +37,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
VIKUNJA_SERVICE_PUBLICURL: http://<the public url where vikunja is reachable>
|
VIKUNJA_SERVICE_PUBLICURL: http://<the public url where vikunja is reachable>
|
||||||
VIKUNJA_DATABASE_HOST: db
|
VIKUNJA_DATABASE_HOST: db
|
||||||
VIKUNJA_DATABASE_PASSWORD: secret
|
VIKUNJA_DATABASE_PASSWORD: changeme
|
||||||
VIKUNJA_DATABASE_TYPE: mysql
|
VIKUNJA_DATABASE_TYPE: mysql
|
||||||
VIKUNJA_DATABASE_USER: vikunja
|
VIKUNJA_DATABASE_USER: vikunja
|
||||||
VIKUNJA_DATABASE_DATABASE: vikunja
|
VIKUNJA_DATABASE_DATABASE: vikunja
|
||||||
|
@ -54,16 +54,17 @@ services:
|
||||||
image: mariadb:10
|
image: mariadb:10
|
||||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: supersupersecret
|
MYSQL_ROOT_PASSWORD: supersecret
|
||||||
MYSQL_USER: vikunja
|
MYSQL_USER: vikunja
|
||||||
MYSQL_PASSWORD: supersecret
|
MYSQL_PASSWORD: changeme
|
||||||
MYSQL_DATABASE: vikunja
|
MYSQL_DATABASE: vikunja
|
||||||
volumes:
|
volumes:
|
||||||
- ./db:/var/lib/mysql
|
- ./db:/var/lib/mysql
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "--silent"]
|
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u $$MYSQL_USER --password=$$MYSQL_PASSWORD"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
|
start_period: 30s
|
||||||
```
|
```
|
||||||
|
|
||||||
This defines two services, each with their own container:
|
This defines two services, each with their own container:
|
||||||
|
|
|
@ -36,22 +36,18 @@ To use postgres as a database backend, change the `db` section of the examples t
|
||||||
db:
|
db:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: secret
|
POSTGRES_PASSWORD: changeme
|
||||||
POSTGRES_USER: vikunja
|
POSTGRES_USER: vikunja
|
||||||
volumes:
|
volumes:
|
||||||
- ./db:/var/lib/postgresql/data
|
- ./db:/var/lib/postgresql/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
test: ["CMD-SHELL", "pg_isready -h localhost -U $$POSTGRES_USER"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
```
|
```
|
||||||
|
|
||||||
You'll also need to change the `VIKUNJA_DATABASE_TYPE` to `postgres` on the api container declaration.
|
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
|
## Sqlite
|
||||||
|
|
||||||
Vikunja supports postgres, mysql and sqlite as a database backend. The examples on this page use mysql with a mariadb container.
|
Vikunja supports postgres, mysql and sqlite as a database backend. The examples on this page use mysql with a mariadb container.
|
||||||
|
@ -104,7 +100,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
VIKUNJA_SERVICE_PUBLICURL: http://<the public url where vikunja is reachable>
|
VIKUNJA_SERVICE_PUBLICURL: http://<the public url where vikunja is reachable>
|
||||||
VIKUNJA_DATABASE_HOST: db
|
VIKUNJA_DATABASE_HOST: db
|
||||||
VIKUNJA_DATABASE_PASSWORD: secret
|
VIKUNJA_DATABASE_PASSWORD: changeme
|
||||||
VIKUNJA_DATABASE_TYPE: mysql
|
VIKUNJA_DATABASE_TYPE: mysql
|
||||||
VIKUNJA_DATABASE_USER: vikunja
|
VIKUNJA_DATABASE_USER: vikunja
|
||||||
VIKUNJA_DATABASE_DATABASE: vikunja
|
VIKUNJA_DATABASE_DATABASE: vikunja
|
||||||
|
@ -121,16 +117,17 @@ services:
|
||||||
image: mariadb:10
|
image: mariadb:10
|
||||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: supersupersecret
|
MYSQL_ROOT_PASSWORD: supersecret
|
||||||
MYSQL_USER: vikunja
|
MYSQL_USER: vikunja
|
||||||
MYSQL_PASSWORD: supersecret
|
MYSQL_PASSWORD: changeme
|
||||||
MYSQL_DATABASE: vikunja
|
MYSQL_DATABASE: vikunja
|
||||||
volumes:
|
volumes:
|
||||||
- ./db:/var/lib/mysql
|
- ./db:/var/lib/mysql
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "--silent"]
|
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u $$MYSQL_USER --password=$$MYSQL_PASSWORD"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
|
start_period: 30s
|
||||||
```
|
```
|
||||||
|
|
||||||
## Example with Traefik 2
|
## Example with Traefik 2
|
||||||
|
@ -152,7 +149,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
VIKUNJA_SERVICE_PUBLICURL: http://<the public url where vikunja is reachable>
|
VIKUNJA_SERVICE_PUBLICURL: http://<the public url where vikunja is reachable>
|
||||||
VIKUNJA_DATABASE_HOST: db
|
VIKUNJA_DATABASE_HOST: db
|
||||||
VIKUNJA_DATABASE_PASSWORD: supersecret
|
VIKUNJA_DATABASE_PASSWORD: changeme
|
||||||
VIKUNJA_DATABASE_TYPE: mysql
|
VIKUNJA_DATABASE_TYPE: mysql
|
||||||
VIKUNJA_DATABASE_USER: vikunja
|
VIKUNJA_DATABASE_USER: vikunja
|
||||||
VIKUNJA_DATABASE_DATABASE: vikunja
|
VIKUNJA_DATABASE_DATABASE: vikunja
|
||||||
|
@ -178,14 +175,15 @@ services:
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: supersupersecret
|
MYSQL_ROOT_PASSWORD: supersupersecret
|
||||||
MYSQL_USER: vikunja
|
MYSQL_USER: vikunja
|
||||||
MYSQL_PASSWORD: supersecret
|
MYSQL_PASSWORD: changeme
|
||||||
MYSQL_DATABASE: vikunja
|
MYSQL_DATABASE: vikunja
|
||||||
volumes:
|
volumes:
|
||||||
- ./db:/var/lib/mysql
|
- ./db:/var/lib/mysql
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "--silent"]
|
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u $$MYSQL_USER --password=$$MYSQL_PASSWORD"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
web:
|
web:
|
||||||
|
@ -216,7 +214,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
VIKUNJA_SERVICE_PUBLICURL: http://<the public url where vikunja is reachable>
|
VIKUNJA_SERVICE_PUBLICURL: http://<the public url where vikunja is reachable>
|
||||||
VIKUNJA_DATABASE_HOST: db
|
VIKUNJA_DATABASE_HOST: db
|
||||||
VIKUNJA_DATABASE_PASSWORD: secret
|
VIKUNJA_DATABASE_PASSWORD: changeme
|
||||||
VIKUNJA_DATABASE_TYPE: mysql
|
VIKUNJA_DATABASE_TYPE: mysql
|
||||||
VIKUNJA_DATABASE_USER: vikunja
|
VIKUNJA_DATABASE_USER: vikunja
|
||||||
VIKUNJA_DATABASE_DATABASE: vikunja
|
VIKUNJA_DATABASE_DATABASE: vikunja
|
||||||
|
@ -233,16 +231,17 @@ services:
|
||||||
image: mariadb:10
|
image: mariadb:10
|
||||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: supersupersecret
|
MYSQL_ROOT_PASSWORD: supersecret
|
||||||
MYSQL_USER: vikunja
|
MYSQL_USER: vikunja
|
||||||
MYSQL_PASSWORD: supersecret
|
MYSQL_PASSWORD: changeme
|
||||||
MYSQL_DATABASE: vikunja
|
MYSQL_DATABASE: vikunja
|
||||||
volumes:
|
volumes:
|
||||||
- ./db:/var/lib/mysql
|
- ./db:/var/lib/mysql
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "--silent"]
|
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u $$MYSQL_USER --password=$$MYSQL_PASSWORD"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
|
start_period: 30s
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy
|
image: caddy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://vikunja.io/",
|
"homepage": "https://vikunja.io/",
|
||||||
"funding": "https://opencollective.com/vikunja",
|
"funding": "https://opencollective.com/vikunja",
|
||||||
"packageManager": "pnpm@8.15.1",
|
"packageManager": "pnpm@8.15.3",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"todo",
|
"todo",
|
||||||
"productivity",
|
"productivity",
|
||||||
|
@ -56,8 +56,8 @@
|
||||||
"@infectoone/vue-ganttastic": "2.2.0",
|
"@infectoone/vue-ganttastic": "2.2.0",
|
||||||
"@intlify/unplugin-vue-i18n": "2.0.0",
|
"@intlify/unplugin-vue-i18n": "2.0.0",
|
||||||
"@kyvg/vue3-notification": "3.1.4",
|
"@kyvg/vue3-notification": "3.1.4",
|
||||||
"@sentry/tracing": "7.100.1",
|
"@sentry/tracing": "7.101.0",
|
||||||
"@sentry/vue": "7.100.1",
|
"@sentry/vue": "7.101.0",
|
||||||
"@tiptap/core": "2.2.2",
|
"@tiptap/core": "2.2.2",
|
||||||
"@tiptap/extension-blockquote": "2.2.2",
|
"@tiptap/extension-blockquote": "2.2.2",
|
||||||
"@tiptap/extension-bold": "2.2.2",
|
"@tiptap/extension-bold": "2.2.2",
|
||||||
|
@ -116,7 +116,7 @@
|
||||||
"sortablejs": "1.15.2",
|
"sortablejs": "1.15.2",
|
||||||
"tippy.js": "6.3.7",
|
"tippy.js": "6.3.7",
|
||||||
"ufo": "1.4.0",
|
"ufo": "1.4.0",
|
||||||
"vue": "3.4.18",
|
"vue": "3.4.19",
|
||||||
"vue-advanced-cropper": "2.8.8",
|
"vue-advanced-cropper": "2.8.8",
|
||||||
"vue-flatpickr-component": "11.0.3",
|
"vue-flatpickr-component": "11.0.3",
|
||||||
"vue-i18n": "9.9.1",
|
"vue-i18n": "9.9.1",
|
||||||
|
@ -142,8 +142,8 @@
|
||||||
"@types/node": "20.11.10",
|
"@types/node": "20.11.10",
|
||||||
"@types/postcss-preset-env": "7.7.0",
|
"@types/postcss-preset-env": "7.7.0",
|
||||||
"@types/sortablejs": "1.15.7",
|
"@types/sortablejs": "1.15.7",
|
||||||
"@typescript-eslint/eslint-plugin": "6.20.0",
|
"@typescript-eslint/eslint-plugin": "7.0.1",
|
||||||
"@typescript-eslint/parser": "6.20.0",
|
"@typescript-eslint/parser": "7.0.1",
|
||||||
"@vitejs/plugin-legacy": "5.3.0",
|
"@vitejs/plugin-legacy": "5.3.0",
|
||||||
"@vitejs/plugin-vue": "5.0.3",
|
"@vitejs/plugin-vue": "5.0.3",
|
||||||
"@vue/eslint-config-typescript": "12.0.0",
|
"@vue/eslint-config-typescript": "12.0.0",
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -84,9 +84,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref} from 'vue'
|
import {computed} from 'vue'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
import {useStorage} from '@vueuse/core'
|
||||||
|
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
|
|
||||||
|
@ -112,7 +113,18 @@ const projectStore = useProjectStore()
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const currentProject = computed(() => baseStore.currentProject)
|
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(() => {
|
const childProjects = computed(() => {
|
||||||
return projectStore.getChildProjects(project.id)
|
return projectStore.getChildProjects(project.id)
|
||||||
|
|
|
@ -193,6 +193,7 @@ import {mergeAttributes} from '@tiptap/core'
|
||||||
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
|
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
|
||||||
import inputPrompt from '@/helpers/inputPrompt'
|
import inputPrompt from '@/helpers/inputPrompt'
|
||||||
import {setLinkInEditor} from '@/components/input/editor/setLinkInEditor'
|
import {setLinkInEditor} from '@/components/input/editor/setLinkInEditor'
|
||||||
|
import {createRandomID} from '@/helpers/randomId'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
modelValue,
|
modelValue,
|
||||||
|
@ -388,7 +389,20 @@ const editor = useEditor({
|
||||||
CustomImage,
|
CustomImage,
|
||||||
|
|
||||||
TaskList,
|
TaskList,
|
||||||
TaskItem.configure({
|
TaskItem.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
id: {
|
||||||
|
default: createRandomID,
|
||||||
|
parseHTML: element => element.getAttribute('data-id'),
|
||||||
|
renderHTML: attributes => ({
|
||||||
|
'data-id': attributes.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).configure({
|
||||||
nested: true,
|
nested: true,
|
||||||
onReadOnlyChecked: (node: Node, checked: boolean): boolean => {
|
onReadOnlyChecked: (node: Node, checked: boolean): boolean => {
|
||||||
if (!isEditEnabled) {
|
if (!isEditEnabled) {
|
||||||
|
@ -400,7 +414,7 @@ const editor = useEditor({
|
||||||
// https://github.com/ueberdosis/tiptap/issues/3676
|
// https://github.com/ueberdosis/tiptap/issues/3676
|
||||||
|
|
||||||
editor.value!.state.doc.descendants((subnode, pos) => {
|
editor.value!.state.doc.descendants((subnode, pos) => {
|
||||||
if (node.eq(subnode)) {
|
if (node.attrs.id === subnode.attrs.id) {
|
||||||
const {tr} = editor.value!.state
|
const {tr} = editor.value!.state
|
||||||
tr.setNodeMarkup(pos, undefined, {
|
tr.setNodeMarkup(pos, undefined, {
|
||||||
...node.attrs,
|
...node.attrs,
|
||||||
|
@ -408,10 +422,10 @@ const editor = useEditor({
|
||||||
})
|
})
|
||||||
editor.value!.view.dispatch(tr)
|
editor.value!.view.dispatch(tr)
|
||||||
bubbleSave()
|
bubbleSave()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
@ -594,27 +608,21 @@ function clickTasklistCheckbox(event) {
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => isEditing.value,
|
() => isEditing.value,
|
||||||
editing => {
|
async editing => {
|
||||||
nextTick(() => {
|
await nextTick()
|
||||||
const checkboxes = tiptapInstanceRef.value?.querySelectorAll('[data-checked]')
|
|
||||||
|
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) {
|
if (typeof checkboxes === 'undefined' || checkboxes.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (editing) {
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
checkboxes.forEach(check => {
|
checkboxes.forEach(check => {
|
||||||
if (check.children.length < 2) {
|
if (check.children.length < 2) {
|
||||||
return
|
return
|
||||||
|
@ -622,8 +630,21 @@ watch(
|
||||||
|
|
||||||
// We assume the first child contains the label element with the checkbox and the second child the actual label
|
// 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.
|
// 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},
|
{immediate: true},
|
||||||
|
@ -781,6 +802,7 @@ watch(
|
||||||
|
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
/* Table-specific styling */
|
/* Table-specific styling */
|
||||||
|
|
||||||
table {
|
table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
|
@ -836,6 +858,7 @@ watch(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lists
|
// Lists
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
margin-left: .5rem;
|
margin-left: .5rem;
|
||||||
margin-top: 0 !important;
|
margin-top: 0 !important;
|
||||||
|
|
|
@ -10,7 +10,8 @@
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
:tabindex="props.tabindex"
|
:tabindex="props.tabindex"
|
||||||
@keyup.enter="e => $emit('submit', e)"
|
@keyup.enter="e => $emit('submit', e)"
|
||||||
@focusout="validate"
|
@focusout="() => {validate(); validateAfterFirst = true}"
|
||||||
|
@keyup="() => {validateAfterFirst ? validate() : null}"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
>
|
>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
@ -23,16 +24,17 @@
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="!isValid"
|
v-if="isValid !== true"
|
||||||
class="help is-danger"
|
class="help is-danger"
|
||||||
>
|
>
|
||||||
{{ $t('user.auth.passwordRequired') }}
|
{{ isValid }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {ref, watch} from 'vue'
|
import {ref, watch} from 'vue'
|
||||||
import {useDebounceFn} from '@vueuse/core'
|
import {useDebounceFn} from '@vueuse/core'
|
||||||
|
import {useI18n} from 'vue-i18n'
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
@ -41,12 +43,12 @@ const props = defineProps({
|
||||||
// This prop is a workaround to trigger validation from the outside when the user never had focus in the input.
|
// This prop is a workaround to trigger validation from the outside when the user never had focus in the input.
|
||||||
validateInitially: Boolean,
|
validateInitially: Boolean,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['submit', 'update:modelValue'])
|
const emit = defineEmits(['submit', 'update:modelValue'])
|
||||||
|
const {t} = useI18n()
|
||||||
const passwordFieldType = ref('password')
|
const passwordFieldType = ref('password')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const isValid = ref(!props.validateInitially)
|
const isValid = ref<true | string>(props.validateInitially === true ? true : '')
|
||||||
|
const validateAfterFirst = ref(false)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.validateInitially,
|
() => props.validateInitially,
|
||||||
|
@ -55,7 +57,22 @@ watch(
|
||||||
)
|
)
|
||||||
|
|
||||||
const validate = useDebounceFn(() => {
|
const validate = useDebounceFn(() => {
|
||||||
isValid.value = password.value !== ''
|
if (password.value === '') {
|
||||||
|
isValid.value = t('user.auth.passwordRequired')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.value.length < 8) {
|
||||||
|
isValid.value = t('user.auth.passwordNotMin')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.value.length > 250) {
|
||||||
|
isValid.value = t('user.auth.passwordNotMax')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isValid.value = true
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|
||||||
function togglePasswordFieldType() {
|
function togglePasswordFieldType() {
|
||||||
|
|
|
@ -261,13 +261,18 @@ const foundTasks = ref<ITask[]>([])
|
||||||
|
|
||||||
async function findTasks(newQuery: string) {
|
async function findTasks(newQuery: string) {
|
||||||
query.value = newQuery
|
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[]) {
|
function mapRelatedTasks(tasks: ITask[]) {
|
||||||
return tasks.map(task => {
|
return tasks.map(task => {
|
||||||
// by doing this here once we can save a lot of duplicate calls in the template
|
// 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 {
|
return {
|
||||||
...task,
|
...task,
|
||||||
|
|
|
@ -21,6 +21,7 @@ export interface SortBy {
|
||||||
percent_done?: Order
|
percent_done?: Order
|
||||||
created?: Order
|
created?: Order
|
||||||
updated?: Order
|
updated?: Order
|
||||||
|
done_at?: Order,
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: merge with DEFAULT_PARAMS in filters.vue
|
// FIXME: merge with DEFAULT_PARAMS in filters.vue
|
||||||
|
|
|
@ -57,7 +57,11 @@
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"emailInvalid": "Please enter a valid email address.",
|
"emailInvalid": "Please enter a valid email address.",
|
||||||
"usernameRequired": "Please provide a username.",
|
"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.",
|
"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",
|
"showPassword": "Show the password",
|
||||||
"hidePassword": "Hide the password",
|
"hidePassword": "Hide the password",
|
||||||
"noAccountYet": "Don't have an account yet?",
|
"noAccountYet": "Don't have an account yet?",
|
||||||
|
@ -712,7 +716,8 @@
|
||||||
"repeat": "Repeat",
|
"repeat": "Repeat",
|
||||||
"startDate": "Start Date",
|
"startDate": "Start Date",
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
"updated": "Updated"
|
"updated": "Updated",
|
||||||
|
"doneAt": "Done At"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"subscribedTaskThroughParentProject": "You can't unsubscribe here because you are subscribed to this task through its project.",
|
"subscribedTaskThroughParentProject": "You can't unsubscribe here because you are subscribed to this task through its project.",
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
<h1>{{ $t('migrate.titleService', {name: migrator.name}) }}</h1>
|
<h1>{{ $t('migrate.titleService', {name: migrator.name}) }}</h1>
|
||||||
<p>{{ $t('migrate.descriptionDo') }}</p>
|
<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="isMigrating === false">
|
||||||
<template v-if="migrator.isFileMigrator">
|
<template v-if="migrator.isFileMigrator">
|
||||||
<p>{{ $t('migrate.importUpload', {name: migrator.name}) }}</p>
|
<p>{{ $t('migrate.importUpload', {name: migrator.name}) }}</p>
|
||||||
|
@ -54,9 +54,9 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else-if="lastMigrationStartedAt && lastMigrationFinishedAt === null">
|
<div v-else-if="lastMigrationStartedAt && lastMigrationFinishedAt === null">
|
||||||
<p>
|
<Message class="mb-4">
|
||||||
{{ $t('migrate.migrationInProgress') }}
|
{{ $t('migrate.migrationInProgress') }}
|
||||||
</p>
|
</Message>
|
||||||
<x-button :to="{name: 'home'}">
|
<x-button :to="{name: 'home'}">
|
||||||
{{ $t('home.goToOverview') }}
|
{{ $t('home.goToOverview') }}
|
||||||
</x-button>
|
</x-button>
|
||||||
|
@ -170,19 +170,18 @@ async function initMigration() {
|
||||||
if (!migratorAuthCode.value) {
|
if (!migratorAuthCode.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const {startedAt, finishedAt} = await migrationService.getStatus()
|
const {started_at, finished_at} = await migrationService.getStatus()
|
||||||
if (startedAt) {
|
if (started_at) {
|
||||||
lastMigrationStartedAt.value = parseDateOrNull(startedAt)
|
lastMigrationStartedAt.value = parseDateOrNull(started_at)
|
||||||
}
|
}
|
||||||
if (finishedAt) {
|
if (finished_at) {
|
||||||
lastMigrationFinishedAt.value = parseDateOrNull(finishedAt)
|
lastMigrationFinishedAt.value = parseDateOrNull(finished_at)
|
||||||
if (lastMigrationFinishedAt.value) {
|
if (lastMigrationFinishedAt.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastMigrationStartedAt.value && lastMigrationFinishedAt.value === null) {
|
if (lastMigrationStartedAt.value && lastMigrationFinishedAt.value === null) {
|
||||||
// Migration already in progress
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -214,6 +213,8 @@ async function migrate() {
|
||||||
message.value = result.message
|
message.value = result.message
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
return projectStore.loadProjects()
|
return projectStore.loadProjects()
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
} finally {
|
} finally {
|
||||||
isMigrating.value = false
|
isMigrating.value = false
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,14 +82,15 @@
|
||||||
>
|
>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input
|
<input
|
||||||
|
ref="bucketLimitInputRef"
|
||||||
v-focus.always
|
v-focus.always
|
||||||
:value="bucket.limit"
|
:value="bucket.limit"
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@keyup.esc="() => showSetLimitInput = false"
|
@keyup.esc="() => showSetLimitInput = false"
|
||||||
@keyup.enter="() => showSetLimitInput = false"
|
@keyup.enter="() => {setBucketLimit(bucket.id, true); showSetLimitInput = false}"
|
||||||
@input="(event) => setBucketLimit(bucket.id, parseInt((event.target as HTMLInputElement).value))"
|
@input="setBucketLimit(bucket.id)"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
|
@ -98,6 +99,7 @@
|
||||||
:disabled="bucket.limit < 0"
|
:disabled="bucket.limit < 0"
|
||||||
:icon="['far', 'save']"
|
:icon="['far', 'save']"
|
||||||
:shadow="false"
|
:shadow="false"
|
||||||
|
@click="setBucketLimit(bucket.id, true)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -320,6 +322,7 @@ const taskStore = useTaskStore()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
const taskContainerRefs = ref<{[id: IBucket['id']]: HTMLElement}>({})
|
const taskContainerRefs = ref<{[id: IBucket['id']]: HTMLElement}>({})
|
||||||
|
const bucketLimitInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const drag = ref(false)
|
const drag = ref(false)
|
||||||
const dragBucket = ref(false)
|
const dragBucket = ref(false)
|
||||||
|
@ -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) {
|
if (limit < 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -627,6 +630,22 @@ async function setBucketLimit(bucketId: IBucket['id'], limit: number) {
|
||||||
success({message: t('project.kanban.bucketLimitSavedSuccess')})
|
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) {
|
function shouldAcceptDrop(bucket: IBucket) {
|
||||||
return (
|
return (
|
||||||
// When dragging from a bucket who has its limit reached, dragging should still be possible
|
// When dragging from a bucket who has its limit reached, dragging should still be possible
|
||||||
|
|
|
@ -52,6 +52,9 @@
|
||||||
<Fancycheckbox v-model="activeColumns.percentDone">
|
<Fancycheckbox v-model="activeColumns.percentDone">
|
||||||
{{ $t('task.attributes.percentDone') }}
|
{{ $t('task.attributes.percentDone') }}
|
||||||
</Fancycheckbox>
|
</Fancycheckbox>
|
||||||
|
<Fancycheckbox v-model="activeColumns.doneAt">
|
||||||
|
{{ $t('task.attributes.doneAt') }}
|
||||||
|
</Fancycheckbox>
|
||||||
<Fancycheckbox v-model="activeColumns.created">
|
<Fancycheckbox v-model="activeColumns.created">
|
||||||
{{ $t('task.attributes.created') }}
|
{{ $t('task.attributes.created') }}
|
||||||
</Fancycheckbox>
|
</Fancycheckbox>
|
||||||
|
@ -144,6 +147,13 @@
|
||||||
@click="sort('percent_done')"
|
@click="sort('percent_done')"
|
||||||
/>
|
/>
|
||||||
</th>
|
</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">
|
<th v-if="activeColumns.created">
|
||||||
{{ $t('task.attributes.created') }}
|
{{ $t('task.attributes.created') }}
|
||||||
<Sort
|
<Sort
|
||||||
|
@ -223,6 +233,10 @@
|
||||||
<td v-if="activeColumns.percentDone">
|
<td v-if="activeColumns.percentDone">
|
||||||
{{ t.percentDone * 100 }}%
|
{{ t.percentDone * 100 }}%
|
||||||
</td>
|
</td>
|
||||||
|
<DateTableCell
|
||||||
|
v-if="activeColumns.doneAt"
|
||||||
|
:date="t.doneAt"
|
||||||
|
/>
|
||||||
<DateTableCell
|
<DateTableCell
|
||||||
v-if="activeColumns.created"
|
v-if="activeColumns.created"
|
||||||
:date="t.created"
|
:date="t.created"
|
||||||
|
@ -297,6 +311,7 @@ const ACTIVE_COLUMNS_DEFAULT = {
|
||||||
created: false,
|
created: false,
|
||||||
updated: false,
|
updated: false,
|
||||||
createdBy: false,
|
createdBy: false,
|
||||||
|
doneAt: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SORT_BY_DEFAULT: SortBy = {
|
const SORT_BY_DEFAULT: SortBy = {
|
||||||
|
|
|
@ -28,21 +28,24 @@
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
@keyup.enter="submit"
|
@keyup.enter="submit"
|
||||||
@focusout="validateUsername"
|
@focusout="() => {validateUsername(); validateUsernameAfterFirst = true}"
|
||||||
|
@keyup="() => {validateUsernameAfterFirst ? validateUsername() : null}"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="!usernameValid"
|
v-if="usernameValid !== true"
|
||||||
class="help is-danger"
|
class="help is-danger"
|
||||||
>
|
>
|
||||||
{{ $t('user.auth.usernameRequired') }}
|
{{ usernameValid }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label
|
<label
|
||||||
class="label"
|
class="label"
|
||||||
for="email"
|
for="email"
|
||||||
>{{ $t('user.auth.email') }}</label>
|
>
|
||||||
|
{{ $t('user.auth.email') }}
|
||||||
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
|
@ -53,7 +56,8 @@
|
||||||
required
|
required
|
||||||
type="email"
|
type="email"
|
||||||
@keyup.enter="submit"
|
@keyup.enter="submit"
|
||||||
@focusout="validateEmail"
|
@focusout="() => {validateEmail(); validateEmailAfterFirst = true}"
|
||||||
|
@keyup="() => {validateEmailAfterFirst ? validateEmail() : null}"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
|
@ -84,7 +88,7 @@
|
||||||
>
|
>
|
||||||
{{ $t('user.auth.createAccount') }}
|
{{ $t('user.auth.createAccount') }}
|
||||||
</x-button>
|
</x-button>
|
||||||
|
|
||||||
<Message
|
<Message
|
||||||
v-if="configStore.demoModeEnabled"
|
v-if="configStore.demoModeEnabled"
|
||||||
variant="warning"
|
variant="warning"
|
||||||
|
@ -94,7 +98,7 @@
|
||||||
{{ $t('demo.accountWillBeDeleted') }}<br>
|
{{ $t('demo.accountWillBeDeleted') }}<br>
|
||||||
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
|
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
|
||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
<p class="mt-2">
|
<p class="mt-2">
|
||||||
{{ $t('user.auth.alreadyHaveAnAccount') }}
|
{{ $t('user.auth.alreadyHaveAnAccount') }}
|
||||||
<router-link :to="{ name: 'user.login' }">
|
<router-link :to="{ name: 'user.login' }">
|
||||||
|
@ -107,7 +111,8 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {useDebounceFn} from '@vueuse/core'
|
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 router from '@/router'
|
||||||
import Message from '@/components/misc/message.vue'
|
import Message from '@/components/misc/message.vue'
|
||||||
|
@ -117,6 +122,7 @@ import Password from '@/components/input/password.vue'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
import {useConfigStore} from '@/stores/config'
|
import {useConfigStore} from '@/stores/config'
|
||||||
|
|
||||||
|
const {t} = useI18n()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
|
@ -142,13 +148,30 @@ const DEBOUNCE_TIME = 100
|
||||||
|
|
||||||
// debouncing to prevent error messages when clicking on the log in button
|
// debouncing to prevent error messages when clicking on the log in button
|
||||||
const emailValid = ref(true)
|
const emailValid = ref(true)
|
||||||
|
const validateEmailAfterFirst = ref(false)
|
||||||
const validateEmail = useDebounceFn(() => {
|
const validateEmail = useDebounceFn(() => {
|
||||||
emailValid.value = isEmail(credentials.email)
|
emailValid.value = isEmail(credentials.email)
|
||||||
}, DEBOUNCE_TIME)
|
}, DEBOUNCE_TIME)
|
||||||
|
|
||||||
const usernameValid = ref(true)
|
const usernameValid = ref<true | string>(true)
|
||||||
|
const validateUsernameAfterFirst = ref(false)
|
||||||
const validateUsername = useDebounceFn(() => {
|
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)
|
}, DEBOUNCE_TIME)
|
||||||
|
|
||||||
const everythingValid = computed(() => {
|
const everythingValid = computed(() => {
|
||||||
|
|
2
go.mod
2
go.mod
|
@ -21,7 +21,7 @@ require (
|
||||||
dario.cat/mergo v1.0.0
|
dario.cat/mergo v1.0.0
|
||||||
github.com/ThreeDotsLabs/watermill v1.3.5
|
github.com/ThreeDotsLabs/watermill v1.3.5
|
||||||
github.com/adlio/trello v1.10.0
|
github.com/adlio/trello v1.10.0
|
||||||
github.com/arran4/golang-ical v0.2.4
|
github.com/arran4/golang-ical v0.2.5
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
|
||||||
github.com/bbrks/go-blurhash v1.1.1
|
github.com/bbrks/go-blurhash v1.1.1
|
||||||
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500
|
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500
|
||||||
|
|
2
go.sum
2
go.sum
|
@ -33,6 +33,8 @@ github.com/arran4/golang-ical v0.2.3 h1:C4Vj7+BjJBIrAJhHgi6Ku+XUkQVugRq4re5Cqj5Q
|
||||||
github.com/arran4/golang-ical v0.2.3/go.mod h1:RqMuPGmwRRwjkb07hmm+JBqcWa1vF1LvVmPtSZN2OhQ=
|
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 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.4/go.mod h1:RqMuPGmwRRwjkb07hmm+JBqcWa1vF1LvVmPtSZN2OhQ=
|
||||||
|
github.com/arran4/golang-ical v0.2.5 h1:zaAdee/cOnOCeSuxUSgkWnF9jZl/oYq2ZgDk+LU3wGs=
|
||||||
|
github.com/arran4/golang-ical v0.2.5/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 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
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=
|
github.com/bbrks/go-blurhash v1.1.1 h1:uoXOxRPDca9zHYabUTwvS4KnY++KKUbwFo+Yxb8ME4M=
|
||||||
|
|
|
@ -71,7 +71,8 @@ var (
|
||||||
"dev:make-listener": Dev.MakeListener,
|
"dev:make-listener": Dev.MakeListener,
|
||||||
"dev:make-notification": Dev.MakeNotification,
|
"dev:make-notification": Dev.MakeNotification,
|
||||||
"generate-docs": GenerateDocs,
|
"generate-docs": GenerateDocs,
|
||||||
"check:golangci-fix": Check.GolangciFix,
|
"lint": Check.Golangci,
|
||||||
|
"lint:fix": Check.GolangciFix,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -179,6 +179,10 @@ func initSqliteEngine() (engine *xorm.Engine, err error) {
|
||||||
path = "./db.db"
|
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
|
// Try opening the db file to return a better error message if that does not work
|
||||||
var exists = true
|
var exists = true
|
||||||
if _, err := os.Stat(path); err != nil {
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
|
|
@ -17,7 +17,9 @@
|
||||||
package files
|
package files
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
gofs "io/fs"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dump dumps all saved files
|
// 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))
|
allFiles = make(map[int64]io.ReadCloser, len(files))
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if err := file.LoadFileByID(); err != nil {
|
err = file.LoadFileByID()
|
||||||
return nil, err
|
if err != nil {
|
||||||
|
var pathError *gofs.PathError
|
||||||
|
if errors.As(err, &pathError) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
allFiles[file.ID] = file.File
|
allFiles[file.ID] = file.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.
|
// 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) {
|
func CollectRoutesForAPITokenUsage(route echo.Route) {
|
||||||
|
|
||||||
if !strings.Contains(route.Name, "(*WebHandler)") {
|
if !strings.Contains(route.Name, "(*WebHandler)") && !strings.Contains(route.Name, "Attachment") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,6 +116,21 @@ func CollectRoutesForAPITokenUsage(route echo.Route) {
|
||||||
Method: route.Method,
|
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.
|
// GetAvailableAPIRoutesForToken returns a list of all API routes which are available for token usage.
|
||||||
|
|
|
@ -30,11 +30,14 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hashicorp/go-version"
|
||||||
|
|
||||||
"code.vikunja.io/api/pkg/db"
|
"code.vikunja.io/api/pkg/db"
|
||||||
"code.vikunja.io/api/pkg/files"
|
"code.vikunja.io/api/pkg/files"
|
||||||
"code.vikunja.io/api/pkg/initialize"
|
"code.vikunja.io/api/pkg/initialize"
|
||||||
"code.vikunja.io/api/pkg/log"
|
"code.vikunja.io/api/pkg/log"
|
||||||
"code.vikunja.io/api/pkg/migration"
|
"code.vikunja.io/api/pkg/migration"
|
||||||
|
vversion "code.vikunja.io/api/pkg/version"
|
||||||
|
|
||||||
"src.techknowlogick.com/xormigrate"
|
"src.techknowlogick.com/xormigrate"
|
||||||
)
|
)
|
||||||
|
@ -63,6 +66,7 @@ func Restore(filename string) error {
|
||||||
// Find the configFile, database and files files
|
// Find the configFile, database and files files
|
||||||
var configFile *zip.File
|
var configFile *zip.File
|
||||||
var dotEnvFile *zip.File
|
var dotEnvFile *zip.File
|
||||||
|
var versionFile *zip.File
|
||||||
dbfiles := make(map[string]*zip.File)
|
dbfiles := make(map[string]*zip.File)
|
||||||
filesFiles := make(map[string]*zip.File)
|
filesFiles := make(map[string]*zip.File)
|
||||||
for _, file := range r.File {
|
for _, file := range r.File {
|
||||||
|
@ -81,7 +85,18 @@ func Restore(filename string) error {
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(file.Name, "files/") {
|
if strings.HasPrefix(file.Name, "files/") {
|
||||||
filesFiles[strings.ReplaceAll(file.Name, "files/", "")] = file
|
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
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -73,8 +73,8 @@ func (mw *MigrationWeb) Migrate(c echo.Context) error {
|
||||||
return handler.HandleHTTPError(err, c)
|
return handler.HandleHTTPError(err, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
if stats.FinishedAt.IsZero() {
|
if !stats.StartedAt.IsZero() && stats.FinishedAt.IsZero() {
|
||||||
return c.JSON(http.StatusOK, map[string]string{
|
return c.JSON(http.StatusPreconditionFailed, map[string]string{
|
||||||
"message": "Migration already running",
|
"message": "Migration already running",
|
||||||
"running_since": stats.StartedAt.String(),
|
"running_since": stats.StartedAt.String(),
|
||||||
})
|
})
|
||||||
|
@ -95,7 +95,7 @@ func (mw *MigrationWeb) Migrate(c echo.Context) error {
|
||||||
return handler.HandleHTTPError(err, c)
|
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
|
// Status returns whether or not a user has already done this migration
|
||||||
|
|
|
@ -126,12 +126,11 @@ func serveIndexFile(c echo.Context, assetFs http.FileSystem) (err error) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
etag, err := generateEtag(index, info.Name())
|
//etag, err := generateEtag(index, info.Name())
|
||||||
if err != nil {
|
//if err != nil {
|
||||||
return err
|
// return err
|
||||||
}
|
//}
|
||||||
|
return serveFile(c, reader, info, "")
|
||||||
return serveFile(c, reader, info, etag)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copied from echo's middleware.StaticWithConfig simplified and adjusted for caching
|
// Copied from echo's middleware.StaticWithConfig simplified and adjusted for caching
|
||||||
|
@ -141,6 +140,9 @@ func static() echo.MiddlewareFunc {
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) (err error) {
|
return func(c echo.Context) (err error) {
|
||||||
p := c.Request().URL.Path
|
p := c.Request().URL.Path
|
||||||
|
if strings.HasPrefix(p, "/api/") {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
if strings.HasSuffix(c.Path(), "*") { // When serving from a group, e.g. `/static*`.
|
if strings.HasSuffix(c.Path(), "*") { // When serving from a group, e.g. `/static*`.
|
||||||
p = c.Param("*")
|
p = c.Param("*")
|
||||||
}
|
}
|
||||||
|
@ -268,7 +270,9 @@ func serveFile(c echo.Context, file io.ReadSeeker, info os.FileInfo, etag string
|
||||||
|
|
||||||
c.Response().Header().Set("Server", "Vikunja")
|
c.Response().Header().Set("Server", "Vikunja")
|
||||||
c.Response().Header().Set("Vary", "Accept-Encoding")
|
c.Response().Header().Set("Vary", "Accept-Encoding")
|
||||||
c.Response().Header().Set("Etag", etag)
|
if etag != "" {
|
||||||
|
c.Response().Header().Set("Etag", etag)
|
||||||
|
}
|
||||||
|
|
||||||
cacheControl, err := getCacheControlHeader(info, file)
|
cacheControl, err := getCacheControlHeader(info, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user