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 ]
|
||||
|
||||
- name: lint
|
||||
image: golangci/golangci-lint:v1.55.2
|
||||
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
|
||||
|
||||
...
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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
2
go.mod
|
@ -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
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.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=
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue
Block a user