Compare commits

..

30 Commits

Author SHA1 Message Date
renovate c6b65e688b chore(deps): update golangci/golangci-lint docker tag to v1.56.2
continuous-integration/drone/pr Build is pending Details
2024-02-15 19:06:22 +00:00
renovate 44c1f0d281 chore(deps): update pnpm to v8.15.3
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-02-15 11:07:31 +00:00
kolaente 2dab2ccedd
feat: allow using sqlite in memory database
continuous-integration/drone/push Build is failing Details
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
continuous-integration/drone/push Build is passing Details
2024-02-14 15:05:11 +01:00
kolaente 415c6380a5
feat(api tokens): add task attachment to api scopes
continuous-integration/drone/push Build is failing Details
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
renovate ebe25ee2d7 fix(deps): update module github.com/arran4/golang-ical to v0.2.5
continuous-integration/drone/push Build is failing Details
2024-02-14 13:35:06 +00:00
kolaente cc5f48eb74
fix: lint
continuous-integration/drone/push Build is passing Details
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 #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
continuous-integration/drone/push Build is passing Details
2024-02-13 22:24:46 +01:00
kolaente 205f330f8a
fix(migration): make sure to correctly check if a migration was already running
continuous-integration/drone/push Build was killed Details
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
continuous-integration/drone/push Build is failing Details
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
continuous-integration/drone/push Build is failing Details
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
continuous-integration/drone/push Build is passing Details
2024-02-13 20:38:53 +01:00
renovate 77feae47d6 fix(deps): update sentry-javascript monorepo to v7.101.0
continuous-integration/drone/push Build encountered an error Details
continuous-integration/drone/pr Build was killed Details
2024-02-13 15:05:51 +00:00
renovate 2fb263a109 fix(deps): update dependency vue to v3.4.19
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-02-13 11:06:25 +00:00
kolaente 641fec1215
fix: never return frontend on routes starting with /api
continuous-integration/drone/push Build is passing Details
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 #2110
2024-02-13 10:05:15 +01:00
kolaente 18374c2e52
docs: fix healthcheck and mariadb password
continuous-integration/drone/push Build is passing Details
2024-02-13 09:55:12 +01:00
renovate 9921551f96 chore(deps): update pnpm to v8.15.2
continuous-integration/drone/push Build is passing Details
2024-02-13 08:24:39 +00:00
renovate b26c359ce6 chore(deps): update dev-dependencies to v7
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-02-13 00:06:22 +00:00
kolaente d7d3ffeebd
feat(navigation): persist project open state in navigation
continuous-integration/drone/push Build is passing Details
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 #2067
2024-02-12 22:22:55 +01:00
kolaente 0b61885e89
fix(tasks): correctly show different project in related tasks
continuous-integration/drone/push Build is passing Details
2024-02-12 22:05:33 +01:00
kolaente 8d5cb335bd
fix(tasks): sort done tasks last in relations
continuous-integration/drone/push Build is failing Details
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
continuous-integration/drone/push Build is failing Details
2024-02-12 21:56:25 +01:00
kolaente ee065d9238
feat(tasks): make done at column available for selection in table view
continuous-integration/drone/push Build is passing Details
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
continuous-integration/drone/push Build is passing Details
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
continuous-integration/drone/push Build is passing Details
2024-02-12 17:05:48 +01:00
25 changed files with 622 additions and 303 deletions

View File

@ -134,7 +134,7 @@ steps:
event: [ push, tag, pull_request ]
- name: lint
image: golangci/golangci-lint:v1.56.1
image: golangci/golangci-lint:v1.56.2
pull: always
environment:
GOPROXY: 'https://goproxy.kolaente.de'
@ -1220,7 +1220,7 @@ steps:
pull: true
commands:
- 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:
- build
when:
@ -1389,6 +1389,6 @@ steps:
- failure
---
kind: signature
hmac: 701e3ef16ca217178380a0aacb14601828d9c0d43a7c3cc5033ccd3288927850
hmac: aa9bd51fc7d73686ee169060dcb4d6540214825a0d5134035f477a97f77dd24d
...

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

View File

@ -37,7 +37,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
@ -54,16 +54,17 @@ services:
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", "mysqladmin", "ping", "--silent"]
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:

View File

@ -36,22 +36,18 @@ To use postgres as a database backend, change the `db` section of the examples t
db:
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 -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
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.
@ -104,7 +100,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
@ -121,16 +117,17 @@ services:
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", "mysqladmin", "ping", "--silent"]
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u $$MYSQL_USER --password=$$MYSQL_PASSWORD"]
interval: 2s
start_period: 30s
```
## Example with Traefik 2
@ -152,7 +149,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
@ -178,14 +175,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", "mysqladmin", "ping", "--silent"]
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u $$MYSQL_USER --password=$$MYSQL_PASSWORD"]
interval: 2s
start_period: 30s
networks:
web:
@ -216,7 +214,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
@ -233,16 +231,17 @@ services:
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", "mysqladmin", "ping", "--silent"]
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u $$MYSQL_USER --password=$$MYSQL_PASSWORD"]
interval: 2s
start_period: 30s
caddy:
image: caddy
restart: unless-stopped

View File

@ -13,7 +13,7 @@
},
"homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@8.15.1",
"packageManager": "pnpm@8.15.3",
"keywords": [
"todo",
"productivity",
@ -56,8 +56,8 @@
"@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",
"@sentry/tracing": "7.101.0",
"@sentry/vue": "7.101.0",
"@tiptap/core": "2.2.2",
"@tiptap/extension-blockquote": "2.2.2",
"@tiptap/extension-bold": "2.2.2",
@ -116,7 +116,7 @@
"sortablejs": "1.15.2",
"tippy.js": "6.3.7",
"ufo": "1.4.0",
"vue": "3.4.18",
"vue": "3.4.19",
"vue-advanced-cropper": "2.8.8",
"vue-flatpickr-component": "11.0.3",
"vue-i18n": "9.9.1",
@ -142,8 +142,8 @@
"@types/node": "20.11.10",
"@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",
"@typescript-eslint/eslint-plugin": "7.0.1",
"@typescript-eslint/parser": "7.0.1",
"@vitejs/plugin-legacy": "5.3.0",
"@vitejs/plugin-vue": "5.0.3",
"@vue/eslint-config-typescript": "12.0.0",

File diff suppressed because it is too large Load Diff

View File

@ -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)

View File

@ -193,6 +193,7 @@ import {mergeAttributes} from '@tiptap/core'
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
import inputPrompt from '@/helpers/inputPrompt'
import {setLinkInEditor} from '@/components/input/editor/setLinkInEditor'
import {createRandomID} from '@/helpers/randomId'
const {
modelValue,
@ -388,7 +389,20 @@ const editor = useEditor({
CustomImage,
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,
onReadOnlyChecked: (node: Node, checked: boolean): boolean => {
if (!isEditEnabled) {
@ -400,7 +414,7 @@ const editor = useEditor({
// https://github.com/ueberdosis/tiptap/issues/3676
editor.value!.state.doc.descendants((subnode, pos) => {
if (node.eq(subnode)) {
if (node.attrs.id === subnode.attrs.id) {
const {tr} = editor.value!.state
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
@ -408,10 +422,10 @@ const editor = useEditor({
})
editor.value!.view.dispatch(tr)
bubbleSave()
return true
}
})
return true
},
}),
@ -594,27 +608,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 +630,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 +802,7 @@ watch(
.ProseMirror {
/* Table-specific styling */
table {
border-collapse: collapse;
table-layout: fixed;
@ -836,6 +858,7 @@ watch(
}
// Lists
ul {
margin-left: .5rem;
margin-top: 0 !important;

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({
@ -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.
validateInitially: Boolean,
})
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 +57,22 @@ watch(
)
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)
function togglePasswordFieldType() {

View File

@ -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,

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

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?",
@ -712,7 +716,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.",

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>
@ -54,9 +54,9 @@
</div>
</template>
<div v-else-if="lastMigrationStartedAt && lastMigrationFinishedAt === null">
<p>
<Message class="mb-4">
{{ $t('migrate.migrationInProgress') }}
</p>
</Message>
<x-button :to="{name: 'home'}">
{{ $t('home.goToOverview') }}
</x-button>
@ -170,19 +170,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
}
@ -214,6 +213,8 @@ async function migrate() {
message.value = result.message
const projectStore = useProjectStore()
return projectStore.loadProjects()
} catch (e) {
console.log(e)
} finally {
isMigrating.value = false
}

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)
@ -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

View File

@ -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 = {

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(() => {

2
go.mod
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.5
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

2
go.sum
View File

@ -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.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.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/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/bbrks/go-blurhash v1.1.1 h1:uoXOxRPDca9zHYabUTwvS4KnY++KKUbwFo+Yxb8ME4M=

View File

@ -71,7 +71,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,
}
)

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 {

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
}

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.

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
}

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

View File

@ -126,12 +126,11 @@ func serveIndexFile(c echo.Context, assetFs http.FileSystem) (err error) {
return err
}
etag, err := generateEtag(index, info.Name())
if err != nil {
return err
}
return serveFile(c, reader, info, etag)
//etag, err := generateEtag(index, info.Name())
//if err != nil {
// return err
//}
return serveFile(c, reader, info, "")
}
// 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(c echo.Context) (err error) {
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*`.
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("Vary", "Accept-Encoding")
c.Response().Header().Set("Etag", etag)
if etag != "" {
c.Response().Header().Set("Etag", etag)
}
cacheControl, err := getCacheControlHeader(info, file)
if err != nil {