Compare commits
55 Commits
7ea8de2872
...
aa71601763
Author | SHA1 | Date |
---|---|---|
kolaente | aa71601763 | |
renovate | 8a3dfe4fea | |
renovate | da8cee0ba5 | |
renovate | 352381f377 | |
renovate | 61455b8795 | |
treysullivent | aceaccbf11 | |
kolaente | 392ce66edb | |
kolaente | ecbefdb921 | |
kolaente | d8ca1a2de1 | |
kolaente | 2d084c091e | |
kolaente | 5a84d37fca | |
kolaente | fd520dab0a | |
kolaente | 144a6e4140 | |
kolaente | a7aa74227a | |
kolaente | d2adbc53c6 | |
kolaente | 422e4371f8 | |
kolaente | 6e5b31f1e0 | |
kolaente | 5756da412b | |
kolaente | 4e05b8e97c | |
kolaente | 5177f516c4 | |
kolaente | 637c8f6ba5 | |
kolaente | 1460d212ee | |
kolaente | e9de7d8a24 | |
kolaente | ce1d7778c7 | |
kolaente | 9a16f6f817 | |
kolaente | 7d755fcb89 | |
kolaente | 77e95642a9 | |
kolaente | a5d02380a3 | |
kolaente | 3519b8b2fe | |
kolaente | cb648e5ad8 | |
kolaente | 75f830457b | |
kolaente | 6e2b540394 | |
kolaente | bf3c8ac9da | |
kolaente | 3e7225ebee | |
kolaente | 9eb19e0362 | |
kolaente | 73bf119409 | |
kolaente | 500b761fe6 | |
kolaente | 0bc9a670d7 | |
renovate | a3e5e98c64 | |
Elscrux | a3a4d05e89 | |
renovate | 72c3e1a03f | |
Elscrux | 61ee0bd5e2 | |
kolaente | 423558f58a | |
kolaente | 75fd17c750 | |
kolaente | 4e49ec9e16 | |
renovate | 58e0ec3d35 | |
kolaente | ed4be389ab | |
renovate | cb2c2eeae8 | |
renovate | e19ac57130 | |
kolaente | 0557d4b5bb | |
kolaente | bc19a2fb78 | |
kolaente | 994aaeb920 | |
kolaente | ee3d20e1d2 | |
Elscrux | 8458e77341 | |
kolaente | af3b0bbea1 |
|
@ -104,3 +104,7 @@ issues:
|
|||
text: "parameter 'tx' seems to be unused, consider removing or renaming it as"
|
||||
linters:
|
||||
- revive
|
||||
- path: pkg/models/typesense.go
|
||||
text: 'structtag: struct field Position repeats json tag "position" also at'
|
||||
linters:
|
||||
- govet
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
"eslint.packageManager": "pnpm",
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": true
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"eslint.format.enable": true,
|
||||
"[javascript]": {
|
||||
|
|
|
@ -29,7 +29,7 @@ If you find any security-related issues you don't want to disclose publicly, ple
|
|||
|
||||
## Features
|
||||
|
||||
See [the features page](https://vikunja.io/features/) on our website for a more exaustive list or
|
||||
See [the features page](https://vikunja.io/features/) on our website for a more exhaustive list or
|
||||
try it on [try.vikunja.io](https://try.vikunja.io)!
|
||||
|
||||
## Docs
|
||||
|
|
|
@ -51,7 +51,7 @@
|
|||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "29.2.0",
|
||||
"electron": "29.3.0",
|
||||
"electron-builder": "24.13.3"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
@ -769,10 +769,10 @@ electron-publish@24.13.1:
|
|||
lazy-val "^1.0.5"
|
||||
mime "^2.5.2"
|
||||
|
||||
electron@29.2.0:
|
||||
version "29.2.0"
|
||||
resolved "https://registry.yarnpkg.com/electron/-/electron-29.2.0.tgz#98e9d45dcebda124fb0bd1ff20fc509ec692101c"
|
||||
integrity sha512-ALKrCN52RG4g9prx4DriXSPnY5WoiyRUCNp7zEVQuoiNOpHTNqMMpRidQAHzntV4hajF1LMWHVoBkwqIs1jHhg==
|
||||
electron@29.3.0:
|
||||
version "29.3.0"
|
||||
resolved "https://registry.yarnpkg.com/electron/-/electron-29.3.0.tgz#8e65cb08e9c0952c66d3196e1b5c811c43b8c5b0"
|
||||
integrity sha512-ZxFKm0/v48GSoBuO3DdnMlCYXefEUKUHLMsKxyXY4nZGgzbBKpF/X8haZa2paNj23CLfsCKBOtfc2vsEQiOOsA==
|
||||
dependencies:
|
||||
"@electron/get" "^2.0.0"
|
||||
"@types/node" "^20.9.0"
|
||||
|
|
|
@ -18,6 +18,7 @@ To fully build Vikunja from source files, you need to build the api and frontend
|
|||
|
||||
1. Make sure you have git installed
|
||||
2. Clone the repo with `git clone https://code.vikunja.io/vikunja` and switch into the directory.
|
||||
3. Check out the version you want to build with `git checkout VERSION` - replace `VERSION` with the version want to use. If you don't do this, you'll build the [latest unstable build]({{< ref "versions.md">}}), which might contain bugs.
|
||||
|
||||
## Frontend
|
||||
|
||||
|
|
|
@ -225,10 +225,13 @@ go install github.com/magefile/mage
|
|||
```
|
||||
mkdir /mnt/GO/code.vikunja.io
|
||||
cd /mnt/GO/code.vikunja.io
|
||||
git clone https://code.vikunja.io/api
|
||||
cd /mnt/GO/code.vikunja.io/api
|
||||
git clone https://code.vikunja.io/vikunja
|
||||
cd vikunja
|
||||
```
|
||||
|
||||
**Note:** Ceck out the version you want to build with `git checkout VERSION` - replace `VERSION` with the version want to use.
|
||||
If you don't do this, you'll build the [latest unstable build]({{< ref "versions.md">}}), which might contain bugs.
|
||||
|
||||
### Compile binaries
|
||||
|
||||
```
|
||||
|
|
|
@ -103,7 +103,7 @@ auth:
|
|||
|
||||
## Automatically assign users to teams
|
||||
|
||||
Vikunja is capable of automatically adding users to a team based on OIDC claims added by the identity provider.
|
||||
Starting with version 0.24.0, Vikunja is capable of automatically adding users to a team based on OIDC claims added by the identity provider.
|
||||
If configured, Vikunja will sync teams, automatically create new ones and make sure the members are part of the configured teams.
|
||||
Teams which exist only because they were created from oidc attributes are not editable in Vikunja.
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ menu:
|
|||
|
||||
# Vikunja Versions
|
||||
|
||||
The Vikunja api and frontend are available in two different release flavors.
|
||||
Vikunja api is available in two different release flavors.
|
||||
|
||||
{{< table_of_contents >}}
|
||||
|
||||
|
@ -30,6 +30,9 @@ There might be multiple new such builds a day.
|
|||
Versions contain the last stable version, the number of commits since then and the commit the currently running binary was built from.
|
||||
They look like this: `v0.18.1+269-5cc4927b9e`
|
||||
|
||||
Since a release is also cut from the main branch at some point, features from unstable will eventually become available in stable releases.
|
||||
At the point in time of a new version release, the unstable build is the same exact thing.
|
||||
|
||||
The demo instance at [try.vikunja.io](https://try.vikunja.io) automatically updates and always runs the last unstable build.
|
||||
|
||||
## Switching between versions
|
||||
|
|
|
@ -28,13 +28,21 @@ All commands use the same standard [config file]({{< ref "../setup/config.md">}}
|
|||
|
||||
## Using the cli in docker
|
||||
|
||||
When running Vikunja in docker, you'll need to execute all commands in the `api` container.
|
||||
When running Vikunja in docker, you'll need to execute all commands in the `vikunja` container.
|
||||
Instead of running the `vikunja` binary directly, run it like this:
|
||||
|
||||
```sh
|
||||
docker exec <name of the vikunja api container> /app/vikunja/vikunja <subcommand>
|
||||
docker exec <name of the vikunja container> /app/vikunja/vikunja <subcommand>
|
||||
```
|
||||
|
||||
If you need to run a bunch of Vikunja commands, you can also create a shell alias for it:
|
||||
|
||||
```sh
|
||||
alias vikunja-docker='docker exec <name of the vikunja container> /app/vikunja/vikunja'
|
||||
```
|
||||
|
||||
Then use it as `vikunja-docker <subcommand>`.
|
||||
|
||||
### `dump`
|
||||
|
||||
Creates a zip file with all vikunja-related files.
|
||||
|
|
|
@ -13,6 +13,7 @@ import {BucketFactory} from '../../factories/bucket'
|
|||
import {TaskAttachmentFactory} from '../../factories/task_attachments'
|
||||
import {TaskReminderFactory} from '../../factories/task_reminders'
|
||||
import {createDefaultViews} from "../project/prepareProjects";
|
||||
import { TaskBucketFactory } from '../../factories/task_buckets'
|
||||
|
||||
function addLabelToTaskAndVerify(labelTitle: string) {
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
|
@ -48,7 +49,7 @@ function uploadAttachmentAndVerify(taskId: number) {
|
|||
describe('Task', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
let projects
|
||||
let projects: {}[]
|
||||
let buckets
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -470,6 +471,10 @@ describe('Task', () => {
|
|||
})
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.truncate()
|
||||
TaskBucketFactory.create(1, {
|
||||
task_id: tasks[0].id,
|
||||
bucket_id: buckets[0].id,
|
||||
})
|
||||
|
||||
cy.visit(`/projects/${projects[0].id}/4`)
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
},
|
||||
"homepage": "https://vikunja.io/",
|
||||
"funding": "https://opencollective.com/vikunja",
|
||||
"packageManager": "pnpm@8.15.6",
|
||||
"packageManager": "pnpm@8.15.7",
|
||||
"keywords": [
|
||||
"todo",
|
||||
"productivity",
|
||||
|
@ -58,41 +58,41 @@
|
|||
"@infectoone/vue-ganttastic": "2.3.2",
|
||||
"@intlify/unplugin-vue-i18n": "3.0.1",
|
||||
"@kyvg/vue3-notification": "3.2.1",
|
||||
"@sentry/tracing": "7.109.0",
|
||||
"@sentry/vue": "7.109.0",
|
||||
"@tiptap/core": "2.2.6",
|
||||
"@tiptap/extension-blockquote": "2.2.6",
|
||||
"@tiptap/extension-bold": "2.2.6",
|
||||
"@tiptap/extension-bullet-list": "2.2.6",
|
||||
"@tiptap/extension-code": "2.2.6",
|
||||
"@tiptap/extension-code-block-lowlight": "2.2.6",
|
||||
"@tiptap/extension-document": "2.2.6",
|
||||
"@tiptap/extension-dropcursor": "2.2.6",
|
||||
"@tiptap/extension-gapcursor": "2.2.6",
|
||||
"@tiptap/extension-hard-break": "2.2.6",
|
||||
"@tiptap/extension-heading": "2.2.6",
|
||||
"@tiptap/extension-history": "2.2.6",
|
||||
"@tiptap/extension-horizontal-rule": "2.2.6",
|
||||
"@tiptap/extension-image": "2.2.6",
|
||||
"@tiptap/extension-italic": "2.2.6",
|
||||
"@tiptap/extension-link": "2.2.6",
|
||||
"@tiptap/extension-list-item": "2.2.6",
|
||||
"@tiptap/extension-ordered-list": "2.2.6",
|
||||
"@tiptap/extension-paragraph": "2.2.6",
|
||||
"@tiptap/extension-placeholder": "2.2.6",
|
||||
"@tiptap/extension-strike": "2.2.6",
|
||||
"@tiptap/extension-table": "2.2.6",
|
||||
"@tiptap/extension-table-cell": "2.2.6",
|
||||
"@tiptap/extension-table-header": "2.2.6",
|
||||
"@tiptap/extension-table-row": "2.2.6",
|
||||
"@tiptap/extension-task-item": "2.2.6",
|
||||
"@tiptap/extension-task-list": "2.2.6",
|
||||
"@tiptap/extension-text": "2.2.6",
|
||||
"@tiptap/extension-typography": "2.2.6",
|
||||
"@tiptap/extension-underline": "2.2.6",
|
||||
"@tiptap/pm": "2.2.6",
|
||||
"@tiptap/suggestion": "2.2.6",
|
||||
"@tiptap/vue-3": "2.2.6",
|
||||
"@sentry/tracing": "7.110.1",
|
||||
"@sentry/vue": "7.110.1",
|
||||
"@tiptap/core": "2.3.0",
|
||||
"@tiptap/extension-blockquote": "2.3.0",
|
||||
"@tiptap/extension-bold": "2.3.0",
|
||||
"@tiptap/extension-bullet-list": "2.3.0",
|
||||
"@tiptap/extension-code": "2.3.0",
|
||||
"@tiptap/extension-code-block-lowlight": "2.3.0",
|
||||
"@tiptap/extension-document": "2.3.0",
|
||||
"@tiptap/extension-dropcursor": "2.3.0",
|
||||
"@tiptap/extension-gapcursor": "2.3.0",
|
||||
"@tiptap/extension-hard-break": "2.3.0",
|
||||
"@tiptap/extension-heading": "2.3.0",
|
||||
"@tiptap/extension-history": "2.3.0",
|
||||
"@tiptap/extension-horizontal-rule": "2.3.0",
|
||||
"@tiptap/extension-image": "2.3.0",
|
||||
"@tiptap/extension-italic": "2.3.0",
|
||||
"@tiptap/extension-link": "2.3.0",
|
||||
"@tiptap/extension-list-item": "2.3.0",
|
||||
"@tiptap/extension-ordered-list": "2.3.0",
|
||||
"@tiptap/extension-paragraph": "2.3.0",
|
||||
"@tiptap/extension-placeholder": "2.3.0",
|
||||
"@tiptap/extension-strike": "2.3.0",
|
||||
"@tiptap/extension-table": "2.3.0",
|
||||
"@tiptap/extension-table-cell": "2.3.0",
|
||||
"@tiptap/extension-table-header": "2.3.0",
|
||||
"@tiptap/extension-table-row": "2.3.0",
|
||||
"@tiptap/extension-task-item": "2.3.0",
|
||||
"@tiptap/extension-task-list": "2.3.0",
|
||||
"@tiptap/extension-text": "2.3.0",
|
||||
"@tiptap/extension-typography": "2.3.0",
|
||||
"@tiptap/extension-underline": "2.3.0",
|
||||
"@tiptap/pm": "2.3.0",
|
||||
"@tiptap/suggestion": "2.3.0",
|
||||
"@tiptap/vue-3": "2.3.0",
|
||||
"@types/is-touch-device": "1.0.2",
|
||||
"@types/lodash.clonedeep": "4.5.9",
|
||||
"@vueuse/core": "10.9.0",
|
||||
|
@ -118,10 +118,10 @@
|
|||
"sortablejs": "1.15.2",
|
||||
"tippy.js": "6.3.7",
|
||||
"ufo": "1.5.3",
|
||||
"vue": "3.4.21",
|
||||
"vue": "3.4.23",
|
||||
"vue-advanced-cropper": "2.8.8",
|
||||
"vue-flatpickr-component": "11.0.5",
|
||||
"vue-i18n": "9.11.0",
|
||||
"vue-i18n": "9.11.1",
|
||||
"vue-router": "4.3.0",
|
||||
"vuemoji-picker": "0.2.1",
|
||||
"workbox-precaching": "7.0.0",
|
||||
|
@ -132,9 +132,9 @@
|
|||
"@cypress/vite-dev-server": "5.0.7",
|
||||
"@cypress/vue": "6.0.0",
|
||||
"@faker-js/faker": "8.4.1",
|
||||
"@histoire/plugin-screenshot": "0.17.15",
|
||||
"@histoire/plugin-vue": "0.17.15",
|
||||
"@rushstack/eslint-patch": "1.10.1",
|
||||
"@histoire/plugin-screenshot": "0.17.17",
|
||||
"@histoire/plugin-vue": "0.17.17",
|
||||
"@rushstack/eslint-patch": "1.10.2",
|
||||
"@tsconfig/node18": "18.2.4",
|
||||
"@types/codemirror": "5.60.15",
|
||||
"@types/dompurify": "3.0.5",
|
||||
|
@ -142,11 +142,11 @@
|
|||
"@types/is-touch-device": "1.0.2",
|
||||
"@types/lodash.debounce": "4.0.9",
|
||||
"@types/marked": "5.0.2",
|
||||
"@types/node": "20.12.5",
|
||||
"@types/node": "20.12.7",
|
||||
"@types/postcss-preset-env": "7.7.0",
|
||||
"@types/sortablejs": "1.15.8",
|
||||
"@typescript-eslint/eslint-plugin": "7.5.0",
|
||||
"@typescript-eslint/parser": "7.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.6.0",
|
||||
"@typescript-eslint/parser": "7.6.0",
|
||||
"@vitejs/plugin-legacy": "5.3.2",
|
||||
"@vitejs/plugin-vue": "5.0.4",
|
||||
"@vue/eslint-config-typescript": "13.0.0",
|
||||
|
@ -154,15 +154,15 @@
|
|||
"@vue/tsconfig": "0.5.1",
|
||||
"autoprefixer": "10.4.19",
|
||||
"browserslist": "4.23.0",
|
||||
"caniuse-lite": "1.0.30001607",
|
||||
"caniuse-lite": "1.0.30001608",
|
||||
"css-has-pseudo": "6.0.3",
|
||||
"csstype": "3.1.3",
|
||||
"cypress": "13.7.2",
|
||||
"esbuild": "0.20.2",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-vue": "9.24.0",
|
||||
"eslint-plugin-vue": "9.24.1",
|
||||
"happy-dom": "14.7.1",
|
||||
"histoire": "0.17.15",
|
||||
"histoire": "0.17.17",
|
||||
"postcss": "8.4.38",
|
||||
"postcss-easing-gradients": "3.0.1",
|
||||
"postcss-easings": "4.0.0",
|
||||
|
@ -172,14 +172,14 @@
|
|||
"rollup-plugin-visualizer": "5.12.0",
|
||||
"sass": "1.74.1",
|
||||
"start-server-and-test": "2.0.3",
|
||||
"typescript": "5.4.4",
|
||||
"typescript": "5.4.5",
|
||||
"vite": "5.2.8",
|
||||
"vite-plugin-inject-preload": "1.3.3",
|
||||
"vite-plugin-pwa": "0.19.8",
|
||||
"vite-plugin-sentry": "1.4.0",
|
||||
"vite-svg-loader": "5.1.0",
|
||||
"vitest": "1.4.0",
|
||||
"vue-tsc": "2.0.11",
|
||||
"vue-tsc": "2.0.12",
|
||||
"wait-on": "7.2.0",
|
||||
"workbox-cli": "7.0.0"
|
||||
},
|
||||
|
|
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 218 KiB |
|
@ -162,7 +162,7 @@ projectStore.loadAllProjects()
|
|||
.app-content {
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
padding: 1.5rem 0.5rem 1rem;
|
||||
padding: 1.5rem 0.5rem 0;
|
||||
// TODO refactor: DRY `transition-timing-function` with `./navigation.vue`.
|
||||
transition: margin-left $transition-duration;
|
||||
|
||||
|
@ -172,7 +172,7 @@ projectStore.loadAllProjects()
|
|||
}
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
|
||||
padding: $navbar-height + 1.5rem 1.5rem 0 1.5rem;
|
||||
}
|
||||
|
||||
&.is-menu-enabled {
|
||||
|
|
|
@ -155,8 +155,8 @@ const savedFilterProjects = computed(() => projectStore.savedFilterProjects)
|
|||
bottom: 0;
|
||||
left: 0;
|
||||
transform: translateX(-100%);
|
||||
overflow-x: auto;
|
||||
width: $navbar-width;
|
||||
overflow-y: auto;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
top: 0;
|
||||
|
|
|
@ -338,10 +338,12 @@ const editor = useEditor({
|
|||
HardBreak.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Shift-Enter': () => this.editor.commands.setHardBreak(),
|
||||
'Mod-Enter': () => {
|
||||
if (contentHasChanged.value) {
|
||||
bubbleSave()
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
},
|
||||
|
@ -891,8 +893,14 @@ ul[data-type='taskList'] {
|
|||
padding: 0;
|
||||
margin-left: 0;
|
||||
|
||||
li[data-checked='true'] {
|
||||
color: var(--grey-500);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
margin-top: 0.25rem;
|
||||
|
||||
> label {
|
||||
flex: 0 0 auto;
|
||||
|
|
|
@ -88,7 +88,7 @@ const currentProject = computed<IProject>(() => {
|
|||
})
|
||||
useTitle(() => currentProject.value?.id ? getProjectTitle(currentProject.value) : '')
|
||||
|
||||
const views = computed(() => currentProject.value?.views)
|
||||
const views = computed(() => projectStore.projects[projectId]?.views)
|
||||
|
||||
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
|
||||
// This resulted in loading and setting the project multiple times, even when navigating away from it.
|
||||
|
@ -161,6 +161,7 @@ function getViewTitle(view: IProjectView) {
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
justify-content: center;
|
||||
|
|
|
@ -30,45 +30,27 @@ import {computed, ref, watch} from 'vue'
|
|||
|
||||
import Filters from '@/components/project/partials/filters.vue'
|
||||
|
||||
import {getDefaultTaskFilterParams, type TaskFilterParams} from '@/services/taskCollection'
|
||||
import {useRouteQuery} from '@vueuse/router'
|
||||
import {type TaskFilterParams} from '@/services/taskCollection'
|
||||
|
||||
const modelValue = defineModel<TaskFilterParams>({})
|
||||
const props = defineProps(['modelValue'])
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const value = ref<TaskFilterParams>({})
|
||||
const filter = useRouteQuery('filter')
|
||||
|
||||
watch(
|
||||
() => modelValue.value,
|
||||
() => props.modelValue,
|
||||
(modelValue: TaskFilterParams) => {
|
||||
value.value = modelValue
|
||||
if (value.value.filter !== '' && value.value.filter !== getDefaultTaskFilterParams().filter) {
|
||||
filter.value = value.value.filter
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filter.value,
|
||||
val => {
|
||||
if (modelValue.value?.filter === val || typeof val === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
modelValue.value.filter = val
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
function emitChanges(newValue: TaskFilterParams) {
|
||||
filter.value = newValue.filter
|
||||
if (modelValue.value?.filter === newValue.filter && modelValue.value?.s === newValue.s) {
|
||||
return
|
||||
}
|
||||
|
||||
modelValue.value.filter = newValue.filter
|
||||
modelValue.value.s = newValue.s
|
||||
emit('update:modelValue', {
|
||||
...value.value,
|
||||
filter: newValue.filter,
|
||||
s: newValue.s,
|
||||
})
|
||||
}
|
||||
|
||||
const hasFilters = computed(() => {
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
role="search"
|
||||
>
|
||||
<FilterInput
|
||||
v-model="params.filter"
|
||||
v-model="filterQuery"
|
||||
:project-id="projectId"
|
||||
@blur="change()"
|
||||
/>
|
||||
|
@ -28,7 +28,7 @@
|
|||
<x-button
|
||||
variant="secondary"
|
||||
class="mr-2"
|
||||
:disabled="params.filter === ''"
|
||||
:disabled="filterQuery === ''"
|
||||
@click.prevent.stop="clearFiltersAndEmit"
|
||||
>
|
||||
{{ $t('filters.clear') }}
|
||||
|
@ -87,6 +87,16 @@ const params = ref<TaskFilterParams>({
|
|||
s: '',
|
||||
})
|
||||
|
||||
const filterQuery = ref('')
|
||||
watch(
|
||||
() => [params.value.filter, params.value.s],
|
||||
() => {
|
||||
const filter = params.value.filter || ''
|
||||
const s = params.value.s || ''
|
||||
filterQuery.value = filter || s
|
||||
},
|
||||
)
|
||||
|
||||
// Using watchDebounced to prevent the filter re-triggering itself.
|
||||
watch(
|
||||
() => modelValue,
|
||||
|
@ -107,7 +117,7 @@ const projectStore = useProjectStore()
|
|||
|
||||
function change() {
|
||||
const filter = transformFilterStringForApi(
|
||||
params.value.filter,
|
||||
filterQuery.value,
|
||||
labelTitle => labelStore.filterLabelsByQuery([], labelTitle)[0]?.id || null,
|
||||
projectTitle => {
|
||||
const found = projectStore.findProjectByExactname(projectTitle)
|
||||
|
@ -142,7 +152,7 @@ function changeAndEmitButton() {
|
|||
}
|
||||
|
||||
function clearFiltersAndEmit() {
|
||||
params.value.filter = ''
|
||||
filterQuery.value = ''
|
||||
changeAndEmitButton()
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -773,11 +773,6 @@ $crazy-height-calculation: '100vh - 4.5rem - 1.5rem - 1rem - 1.5rem - 11px';
|
|||
$crazy-height-calculation-tasks: '#{$crazy-height-calculation} - 1rem - 2.5rem - 2rem - #{$button-height} - 1rem';
|
||||
$filter-container-height: '1rem - #{$switch-view-height}';
|
||||
|
||||
// FIXME:
|
||||
.app-content.project\.kanban, .app-content.task\.detail {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.kanban {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
@ -785,6 +780,10 @@ $filter-container-height: '1rem - #{$switch-view-height}';
|
|||
margin: 0 -1.5rem;
|
||||
padding: 0 1.5rem;
|
||||
|
||||
&:focus, .bucket .tasks:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
height: calc(#{$crazy-height-calculation} - #{$filter-container-height});
|
||||
scroll-snap-type: x mandatory;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useRouteQuery} from '@vueuse/router'
|
||||
|
||||
import TaskCollectionService, {getDefaultTaskFilterParams, type TaskFilterParams} from '@/services/taskCollection'
|
||||
|
@ -66,7 +66,6 @@ export function useTaskList(
|
|||
|
||||
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
|
||||
|
||||
const search = ref('')
|
||||
const page = useRouteQuery('page', '1', { transform: Number })
|
||||
|
||||
const sortBy = ref({ ...sortByDefault })
|
||||
|
@ -74,10 +73,6 @@ export function useTaskList(
|
|||
const allParams = computed(() => {
|
||||
const loadParams = {...params.value}
|
||||
|
||||
if (search.value !== '') {
|
||||
loadParams.s = search.value
|
||||
}
|
||||
|
||||
return formatSortOrder(sortBy.value, loadParams)
|
||||
})
|
||||
|
||||
|
@ -122,16 +117,38 @@ export function useTaskList(
|
|||
|
||||
const route = useRoute()
|
||||
watch(() => route.query, (query) => {
|
||||
const { page: pageQueryValue, search: searchQuery } = query
|
||||
if (searchQuery !== undefined) {
|
||||
search.value = searchQuery as string
|
||||
const {
|
||||
page: pageQueryValue,
|
||||
s,
|
||||
filter,
|
||||
} = query
|
||||
if (s !== undefined) {
|
||||
params.value.s = s as string
|
||||
}
|
||||
if (pageQueryValue !== undefined) {
|
||||
page.value = Number(pageQueryValue)
|
||||
}
|
||||
|
||||
if (filter !== undefined) {
|
||||
params.value.filter = filter
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const router = useRouter()
|
||||
watch(
|
||||
() => [page.value, params.value.filter, params.value.s],
|
||||
() => {
|
||||
router.replace({
|
||||
name: route.name,
|
||||
params: route.params,
|
||||
query: {
|
||||
page: page.value,
|
||||
filter: params.value.filter || undefined,
|
||||
s: params.value.s || undefined,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// Only listen for query path changes
|
||||
watch(() => JSON.stringify(getAllTasksParams.value), (newParams, oldParams) => {
|
||||
|
@ -148,7 +165,6 @@ export function useTaskList(
|
|||
totalPages,
|
||||
currentPage: page,
|
||||
loadTasks,
|
||||
searchTerm: search,
|
||||
params,
|
||||
sortByParam: sortBy,
|
||||
}
|
||||
|
|
|
@ -19,6 +19,13 @@ export function secondsToPeriod(seconds: number): { unit: PeriodUnit, amount: nu
|
|||
}
|
||||
}
|
||||
|
||||
if (seconds % SECONDS_A_MINUTE === 0) {
|
||||
return {
|
||||
unit: 'minutes',
|
||||
amount: seconds / SECONDS_A_MINUTE,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
unit: 'hours',
|
||||
amount: seconds / SECONDS_A_HOUR,
|
||||
|
|
|
@ -2,7 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||
import type { RouteLocation } from 'vue-router'
|
||||
import {saveLastVisited} from '@/helpers/saveLastVisited'
|
||||
|
||||
import {saveProjectView, getProjectViewId} from '@/helpers/projectView'
|
||||
import {getProjectViewId} from '@/helpers/projectView'
|
||||
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||
import {getNextWeekDate} from '@/helpers/time/getNextWeekDate'
|
||||
import {LINK_SHARE_HASH_PREFIX} from '@/constants/linkShareHash'
|
||||
|
@ -366,7 +366,6 @@ const router = createRouter({
|
|||
path: '/projects/:projectId/:viewId',
|
||||
name: 'project.view',
|
||||
component: ProjectView,
|
||||
beforeEnter: (to) => saveProjectView(parseInt(to.params.projectId as string), parseInt(to.params.viewId as string)),
|
||||
props: route => ({
|
||||
projectId: parseInt(route.params.projectId as string),
|
||||
viewId: route.params.viewId ? parseInt(route.params.viewId as string): undefined,
|
||||
|
|
|
@ -207,13 +207,17 @@ export const useProjectStore = defineStore('project', () => {
|
|||
}
|
||||
|
||||
function getAncestors(project: IProject): IProject[] {
|
||||
if (typeof project === 'undefined') {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!project?.parentProjectId) {
|
||||
return [project]
|
||||
}
|
||||
|
||||
const parentProject = projects.value[project.parentProjectId]
|
||||
return [
|
||||
...getAncestors(parentProject),
|
||||
...(parentProject ? getAncestors(parentProject) : []),
|
||||
project,
|
||||
]
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import {computed, watch} from 'vue'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {saveProjectView} from '@/helpers/projectView'
|
||||
|
||||
import ProjectList from '@/components/project/views/ProjectList.vue'
|
||||
import ProjectGantt from '@/components/project/views/ProjectGantt.vue'
|
||||
|
@ -53,6 +54,13 @@ watch(
|
|||
redirectToFirstViewIfNecessary,
|
||||
)
|
||||
|
||||
// using a watcher instead of beforeEnter because beforeEnter is not called when only the viewId changes
|
||||
watch(
|
||||
() => [projectId, viewId],
|
||||
() => saveProjectView(projectId, viewId),
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
|
|
|
@ -17,11 +17,11 @@ import postcssEasings from 'postcss-easings'
|
|||
import postcssEasingGradients from 'postcss-easing-gradients'
|
||||
|
||||
|
||||
const pathSrc = fileURLToPath(new URL('./src', import.meta.url))
|
||||
const pathSrc = fileURLToPath(new URL('./src', import.meta.url)).replaceAll('\\', '/')
|
||||
|
||||
// the @use rules have to be the first in the compiled stylesheets
|
||||
const PREFIXED_SCSS_STYLES = `@use "sass:math";
|
||||
@import "${pathSrc}/styles/common-imports";`
|
||||
@import "${pathSrc}/styles/common-imports.scss";`
|
||||
|
||||
const isModernBuild = Boolean(process.env.BUILD_MODERN_ONLY)
|
||||
const legacy = isModernBuild
|
||||
|
|
4
go.mod
4
go.mod
|
@ -20,8 +20,8 @@ require (
|
|||
code.vikunja.io/web v0.0.0-20210706160506-d85def955bd3
|
||||
dario.cat/mergo v1.0.0
|
||||
github.com/ThreeDotsLabs/watermill v1.3.5
|
||||
github.com/adlio/trello v1.11.0
|
||||
github.com/arran4/golang-ical v0.2.7
|
||||
github.com/adlio/trello v1.12.0
|
||||
github.com/arran4/golang-ical v0.2.8
|
||||
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
|
||||
|
|
8
go.sum
8
go.sum
|
@ -24,15 +24,15 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko
|
|||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/ThreeDotsLabs/watermill v1.3.5 h1:50JEPEhMGZQMh08ct0tfO1PsgMOAOhV3zxK2WofkbXg=
|
||||
github.com/ThreeDotsLabs/watermill v1.3.5/go.mod h1:O/u/Ptyrk5MPTxSeWM5vzTtZcZfxXfO9PK9eXTYiFZY=
|
||||
github.com/adlio/trello v1.11.0 h1:PGpwpRZcRiVhsG7VEHb2GWKw4R2ZxB9nc6cMI/7mLD8=
|
||||
github.com/adlio/trello v1.11.0/go.mod h1:I4Lti4jf2KxjTNgTqs5W3lLuE78QZZdYbbPnQQGwjOo=
|
||||
github.com/adlio/trello v1.12.0 h1:JqOE2GFHQ9YtEviRRRSnicSxPbt4WFOxhqXzjMOw8lw=
|
||||
github.com/adlio/trello v1.12.0/go.mod h1:I4Lti4jf2KxjTNgTqs5W3lLuE78QZZdYbbPnQQGwjOo=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/arran4/golang-ical v0.2.7 h1:VO7YlVaGupZE15aj6NhUhte/MIfZuoIzkoI71VsG6Gg=
|
||||
github.com/arran4/golang-ical v0.2.7/go.mod h1:RqMuPGmwRRwjkb07hmm+JBqcWa1vF1LvVmPtSZN2OhQ=
|
||||
github.com/arran4/golang-ical v0.2.8 h1:8lsFcfQqzg0gBpIxq7fWr4RV+8SVENLMXpSic5xsFUs=
|
||||
github.com/arran4/golang-ical v0.2.8/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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
|
|
|
@ -32,9 +32,9 @@ The to-do app to organize your life.
|
|||
Also one of the two wild South American camelids which live in the high
|
||||
alpine areas of the Andes and a relative of the llama.
|
||||
|
||||
Vikunja is a self-hosted To-Do list application with a web app and mobile apps for all platforms. It is licensed under the GPLv3.
|
||||
Vikunja is a self-hosted To-Do list application with a web app and mobile apps for all platforms. It is licensed under the AGPLv3.
|
||||
|
||||
Find more info at vikunja.io.`,
|
||||
Find out more at vikunja.io.`,
|
||||
PreRun: webCmd.PreRun,
|
||||
Run: webCmd.Run,
|
||||
}
|
||||
|
|
|
@ -82,7 +82,6 @@ func init() {
|
|||
|
||||
if config.DatabaseType.GetString() == "sqlite" {
|
||||
_, err = tx.Exec(`
|
||||
|
||||
create table buckets_dg_tmp
|
||||
(
|
||||
id INTEGER not null
|
||||
|
@ -107,7 +106,7 @@ drop table buckets;
|
|||
alter table buckets_dg_tmp
|
||||
rename to buckets;
|
||||
|
||||
create unique index UQE_buckets_id
|
||||
create unique index if not exists UQE_buckets_id
|
||||
on buckets (id);
|
||||
`)
|
||||
return err
|
||||
|
|
|
@ -155,6 +155,21 @@ func exportProjectsAndTasks(s *xorm.Session, u *user.User, wr *zip.Writer) (task
|
|||
projectIDs = append(projectIDs, p.ID)
|
||||
}
|
||||
|
||||
views := map[int64]*ProjectView{}
|
||||
err = s.In("project_id", projectIDs).Find(&views)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
viewIDs := []int64{}
|
||||
for _, v := range views {
|
||||
if projectsMap[v.ProjectID].Views == nil {
|
||||
projectsMap[v.ProjectID].Views = []*ProjectView{}
|
||||
}
|
||||
projectsMap[v.ProjectID].Views = append(projectsMap[v.ProjectID].Views, v)
|
||||
viewIDs = append(viewIDs, v.ID)
|
||||
}
|
||||
|
||||
tasks, _, _, err := getTasksForProjects(s, rawProjects, u, &taskSearchOptions{
|
||||
page: 0,
|
||||
perPage: -1,
|
||||
|
@ -194,17 +209,75 @@ func exportProjectsAndTasks(s *xorm.Session, u *user.User, wr *zip.Writer) (task
|
|||
}
|
||||
|
||||
buckets := []*Bucket{}
|
||||
err = s.In("project_id", projectIDs).Find(&buckets)
|
||||
err = s.In("project_view_id", viewIDs).Find(&buckets)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
bucketIDs := []int64{}
|
||||
for _, b := range buckets {
|
||||
if _, exists := projectsMap[b.ProjectID]; !exists {
|
||||
log.Debugf("[User Data Export] Project %d does not exist for bucket %d, omitting", b.ProjectID, b.ID)
|
||||
view, exists := views[b.ProjectViewID]
|
||||
if !exists {
|
||||
log.Debugf("[User Data Export] Project view %d does not exist for bucket %d, omitting", b.ProjectViewID, b.ID)
|
||||
continue
|
||||
}
|
||||
projectsMap[b.ProjectID].Buckets = append(projectsMap[b.ProjectID].Buckets, b)
|
||||
_, exists = projectsMap[view.ProjectID]
|
||||
if !exists {
|
||||
log.Debugf("[User Data Export] Project %d does not exist for bucket %d, omitting", view.ProjectID, b.ID)
|
||||
continue
|
||||
}
|
||||
projectsMap[view.ProjectID].Buckets = append(projectsMap[view.ProjectID].Buckets, b)
|
||||
bucketIDs = append(bucketIDs, b.ID)
|
||||
}
|
||||
|
||||
taskBuckets := []*TaskBucket{}
|
||||
err = s.In("bucket_id", bucketIDs).Find(&taskBuckets)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, tb := range taskBuckets {
|
||||
view, exists := views[tb.ProjectViewID]
|
||||
if !exists {
|
||||
log.Debugf("[User Data Export] Project view %d does not exist, omitting", tb.ProjectViewID)
|
||||
continue
|
||||
}
|
||||
_, exists = projectsMap[view.ProjectID]
|
||||
if !exists {
|
||||
log.Debugf("[User Data Export] Project %d does not exist, omitting", view.ProjectID)
|
||||
continue
|
||||
}
|
||||
|
||||
if projectsMap[view.ProjectID].TaskBuckets == nil {
|
||||
projectsMap[view.ProjectID].TaskBuckets = []*TaskBucket{}
|
||||
}
|
||||
|
||||
projectsMap[view.ProjectID].TaskBuckets = append(projectsMap[view.ProjectID].TaskBuckets, tb)
|
||||
}
|
||||
|
||||
taskPositions := []*TaskPosition{}
|
||||
err = s.In("project_view_id", viewIDs).Find(&taskPositions)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, p := range taskPositions {
|
||||
view, exists := views[p.ProjectViewID]
|
||||
if !exists {
|
||||
log.Debugf("[User Data Export] Project view %d does not exist, omitting", p.ProjectViewID)
|
||||
continue
|
||||
}
|
||||
_, exists = projectsMap[view.ProjectID]
|
||||
if !exists {
|
||||
log.Debugf("[User Data Export] Project %d does not exist, omitting", view.ProjectID)
|
||||
continue
|
||||
}
|
||||
|
||||
if projectsMap[view.ProjectID].Positions == nil {
|
||||
projectsMap[view.ProjectID].Positions = []*TaskPosition{}
|
||||
}
|
||||
|
||||
projectsMap[view.ProjectID].Positions = append(projectsMap[view.ProjectID].Positions, p)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(projects)
|
||||
|
|
|
@ -529,7 +529,18 @@ func (l *AddTaskToTypesense) Handle(msg *message.Message) (err error) {
|
|||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
ttask, err := getTypesenseTaskForTask(s, event.Task, nil)
|
||||
|
||||
positionsMap, err := getPositionsForTask(s, event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bucketsMap, err := getBucketsForTask(s, event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ttask, err := getTypesenseTaskForTask(s, event.Task, nil, positionsMap, bucketsMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -540,6 +551,36 @@ func (l *AddTaskToTypesense) Handle(msg *message.Message) (err error) {
|
|||
return
|
||||
}
|
||||
|
||||
func getPositionsForTask(s *xorm.Session, event *TaskCreatedEvent) (positionsMap map[int64][]*TaskPositionWithView, err error) {
|
||||
positions := []*TaskPositionWithView{}
|
||||
err = s.
|
||||
Table("project_views").
|
||||
Where("project_views.project_id = ?", event.Task.ProjectID).
|
||||
Join("LEFT", "task_positions", "project_views.id = task_positions.project_view_id AND task_positions.task_id = ?", event.Task.ID).
|
||||
Find(&positions)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
positionsMap = make(map[int64][]*TaskPositionWithView, 1)
|
||||
positionsMap[event.Task.ID] = positions
|
||||
return
|
||||
}
|
||||
|
||||
func getBucketsForTask(s *xorm.Session, event *TaskCreatedEvent) (bucketsMap map[int64][]*TaskBucket, err error) {
|
||||
buckets := []*TaskBucket{}
|
||||
err = s.
|
||||
Where("task_id = ?", event.Task.ID).
|
||||
Find(&buckets)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
bucketsMap = make(map[int64][]*TaskBucket, 1)
|
||||
bucketsMap[event.Task.ID] = buckets
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateTaskInTypesense represents a listener
|
||||
type UpdateTaskInTypesense struct {
|
||||
}
|
||||
|
|
|
@ -93,8 +93,10 @@ type ProjectWithTasksAndBuckets struct {
|
|||
// An array of tasks which belong to the project.
|
||||
Tasks []*TaskWithComments `xorm:"-" json:"tasks"`
|
||||
// Only used for migration.
|
||||
Buckets []*Bucket `xorm:"-" json:"buckets"`
|
||||
BackgroundFileID int64 `xorm:"null" json:"background_file_id"`
|
||||
Buckets []*Bucket `xorm:"-" json:"buckets"`
|
||||
TaskBuckets []*TaskBucket `xorm:"-" json:"task_buckets"`
|
||||
Positions []*TaskPosition `xorm:"-" json:"positions"`
|
||||
BackgroundFileID int64 `xorm:"null" json:"background_file_id"`
|
||||
}
|
||||
|
||||
// TableName returns a better name for the projects table
|
||||
|
@ -110,15 +112,43 @@ type ProjectBackgroundType struct {
|
|||
// ProjectBackgroundUpload represents the project upload background type
|
||||
const ProjectBackgroundUpload string = "upload"
|
||||
|
||||
const FavoritesPseudoProjectID = -1
|
||||
|
||||
// FavoritesPseudoProject holds all tasks marked as favorites
|
||||
var FavoritesPseudoProject = Project{
|
||||
ID: -1,
|
||||
ID: FavoritesPseudoProjectID,
|
||||
Title: "Favorites",
|
||||
Description: "This project has all tasks marked as favorites.",
|
||||
IsFavorite: true,
|
||||
Position: -1,
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
|
||||
Views: []*ProjectView{
|
||||
{
|
||||
ID: -1,
|
||||
ProjectID: FavoritesPseudoProjectID,
|
||||
Title: "List",
|
||||
ViewKind: ProjectViewKindList,
|
||||
Position: 100,
|
||||
Filter: "done = false",
|
||||
},
|
||||
{
|
||||
ID: -2,
|
||||
ProjectID: FavoritesPseudoProjectID,
|
||||
Title: "Gantt",
|
||||
ViewKind: ProjectViewKindGantt,
|
||||
Position: 200,
|
||||
},
|
||||
{
|
||||
ID: -3,
|
||||
ProjectID: FavoritesPseudoProjectID,
|
||||
Title: "Table",
|
||||
ViewKind: ProjectViewKindTable,
|
||||
Position: 300,
|
||||
},
|
||||
},
|
||||
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
}
|
||||
|
||||
// ReadAll gets all projects a user has access to
|
||||
|
@ -146,6 +176,9 @@ func (p *Project) ReadAll(s *xorm.Session, a web.Auth, search string, page int,
|
|||
}
|
||||
projects := []*Project{project}
|
||||
err = addProjectDetails(s, projects, a)
|
||||
if err == nil && len(projects) > 0 {
|
||||
projects[0].ParentProjectID = 0
|
||||
}
|
||||
return projects, 0, 0, err
|
||||
}
|
||||
|
||||
|
@ -207,6 +240,7 @@ func (p *Project) ReadAll(s *xorm.Session, a web.Auth, search string, page int,
|
|||
func (p *Project) ReadOne(s *xorm.Session, a web.Auth) (err error) {
|
||||
|
||||
if p.ID == FavoritesPseudoProject.ID {
|
||||
p.Views = FavoritesPseudoProject.Views
|
||||
// Already "built" the project in CanRead
|
||||
return nil
|
||||
}
|
||||
|
@ -226,6 +260,11 @@ func (p *Project) ReadOne(s *xorm.Session, a web.Auth) (err error) {
|
|||
p.OwnerID = sf.OwnerID
|
||||
}
|
||||
|
||||
_, isShareAuth := a.(*LinkSharing)
|
||||
if isShareAuth {
|
||||
p.ParentProjectID = 0
|
||||
}
|
||||
|
||||
// Get project owner
|
||||
p.Owner, err = user.GetUserByID(s, p.OwnerID)
|
||||
if err != nil {
|
||||
|
@ -1073,6 +1112,46 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Delete related project entities
|
||||
views, err := getViewsForProject(s, p.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
viewIDs := []int64{}
|
||||
for _, v := range views {
|
||||
viewIDs = append(viewIDs, v.ID)
|
||||
}
|
||||
|
||||
_, err = s.In("project_view_id", viewIDs).Delete(&Bucket{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.In("id", viewIDs).Delete(&ProjectView{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = removeFromFavorite(s, p.ID, a, FavoriteKindProject)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.Where("project_id = ?", p.ID).Delete(&LinkSharing{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.Where("project_id = ?", p.ID).Delete(&ProjectUser{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.Where("project_id = ?", p.ID).Delete(&TeamProject{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the project
|
||||
_, err = s.ID(p.ID).Delete(&Project{})
|
||||
if err != nil {
|
||||
|
|
|
@ -159,6 +159,7 @@ func (pd *ProjectDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) {
|
|||
|
||||
log.Debugf("Duplicated all link shares from project %d into %d", pd.ProjectID, pd.Project.ID)
|
||||
|
||||
err = pd.Project.ReadOne(s, doer)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -226,9 +227,11 @@ func duplicateViews(s *xorm.Session, pd *ProjectDuplicate, doer web.Auth, taskMa
|
|||
})
|
||||
}
|
||||
|
||||
_, err = s.Insert(&taskBuckets)
|
||||
if err != nil {
|
||||
return err
|
||||
if len(taskBuckets) > 0 {
|
||||
_, err = s.Insert(&taskBuckets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
oldTaskPositions := []*TaskPosition{}
|
||||
|
@ -246,7 +249,9 @@ func duplicateViews(s *xorm.Session, pd *ProjectDuplicate, doer web.Auth, taskMa
|
|||
})
|
||||
}
|
||||
|
||||
_, err = s.Insert(&taskPositions)
|
||||
if len(taskPositions) > 0 {
|
||||
_, err = s.Insert(&taskPositions)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -347,10 +347,22 @@ func (p *ProjectView) Update(s *xorm.Session, _ web.Auth) (err error) {
|
|||
return
|
||||
}
|
||||
|
||||
func GetProjectViewByIDAndProject(s *xorm.Session, id, projectID int64) (view *ProjectView, err error) {
|
||||
func GetProjectViewByIDAndProject(s *xorm.Session, viewID, projectID int64) (view *ProjectView, err error) {
|
||||
if projectID == FavoritesPseudoProjectID && viewID < 0 {
|
||||
for _, v := range FavoritesPseudoProject.Views {
|
||||
if v.ID == viewID {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, &ErrProjectViewDoesNotExist{
|
||||
ProjectViewID: viewID,
|
||||
}
|
||||
}
|
||||
|
||||
view = &ProjectView{}
|
||||
exists, err := s.
|
||||
Where("id = ? AND project_id = ?", id, projectID).
|
||||
Where("id = ? AND project_id = ?", viewID, projectID).
|
||||
NoAutoCondition().
|
||||
Get(view)
|
||||
if err != nil {
|
||||
|
@ -359,7 +371,7 @@ func GetProjectViewByIDAndProject(s *xorm.Session, id, projectID int64) (view *P
|
|||
|
||||
if !exists {
|
||||
return nil, &ErrProjectViewDoesNotExist{
|
||||
ProjectViewID: id,
|
||||
ProjectViewID: viewID,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -100,6 +100,10 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection, projectView *ProjectVie
|
|||
param.orderBy = getSortOrderFromString(tf.OrderBy[i])
|
||||
}
|
||||
|
||||
if s == taskPropertyPosition && projectView != nil && projectView.ID < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if s == taskPropertyPosition && projectView != nil {
|
||||
param.projectViewID = projectView.ID
|
||||
}
|
||||
|
@ -249,11 +253,20 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa
|
|||
opts.perPage = perPage
|
||||
|
||||
if view != nil {
|
||||
opts.sortby = append(opts.sortby, &sortParam{
|
||||
projectViewID: view.ID,
|
||||
sortBy: taskPropertyPosition,
|
||||
orderBy: orderAscending,
|
||||
})
|
||||
var hasOrderByPosition bool
|
||||
for _, param := range opts.sortby {
|
||||
if param.sortBy == taskPropertyPosition {
|
||||
hasOrderByPosition = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasOrderByPosition {
|
||||
opts.sortby = append(opts.sortby, &sortParam{
|
||||
projectViewID: view.ID,
|
||||
sortBy: taskPropertyPosition,
|
||||
orderBy: orderAscending,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
shareAuth, is := a.(*LinkSharing)
|
||||
|
|
|
@ -65,6 +65,14 @@ func (tc *TaskComment) TableName() string {
|
|||
// @Failure 500 {object} models.Message "Internal error"
|
||||
// @Router /tasks/{taskID}/comments [put]
|
||||
func (tc *TaskComment) Create(s *xorm.Session, a web.Auth) (err error) {
|
||||
|
||||
tc.Created = time.Time{}
|
||||
tc.Updated = time.Time{}
|
||||
|
||||
return tc.CreateWithTimestamps(s, a)
|
||||
}
|
||||
|
||||
func (tc *TaskComment) CreateWithTimestamps(s *xorm.Session, a web.Auth) (err error) {
|
||||
// Check if the task exists
|
||||
task, err := GetTaskSimple(s, &Task{ID: tc.TaskID})
|
||||
if err != nil {
|
||||
|
@ -77,9 +85,16 @@ func (tc *TaskComment) Create(s *xorm.Session, a web.Auth) (err error) {
|
|||
}
|
||||
tc.AuthorID = tc.Author.ID
|
||||
|
||||
_, err = s.Insert(tc)
|
||||
if err != nil {
|
||||
return
|
||||
if !tc.Created.IsZero() && !tc.Updated.IsZero() {
|
||||
_, err = s.NoAutoTime().Insert(tc)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
_, err = s.Insert(tc)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return events.Dispatch(&TaskCommentCreatedEvent{
|
||||
|
@ -255,7 +270,7 @@ func (tc *TaskComment) ReadAll(s *xorm.Session, auth web.Auth, search string, pa
|
|||
query := s.
|
||||
Where(builder.And(where...)).
|
||||
Join("LEFT", "users", "users.id = task_comments.author_id").
|
||||
OrderBy("task_comments.id asc")
|
||||
OrderBy("task_comments.created asc")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit, start)
|
||||
}
|
||||
|
|
|
@ -297,6 +297,7 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
|
|||
queryCount = queryCount.Join("LEFT", "task_buckets", "task_buckets.task_id = tasks.id")
|
||||
}
|
||||
totalCount, err = queryCount.
|
||||
Select("count(DISTINCT tasks.id)").
|
||||
Count(&Task{})
|
||||
return
|
||||
}
|
||||
|
@ -379,6 +380,10 @@ func convertParsedFilterToTypesense(rawFilters []*taskFilter) (filterBy string,
|
|||
f.field = "project_id"
|
||||
}
|
||||
|
||||
if f.field == "bucket_id" {
|
||||
f.field = "buckets"
|
||||
}
|
||||
|
||||
filter := f.field
|
||||
|
||||
switch f.comparator {
|
||||
|
@ -450,6 +455,7 @@ func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task,
|
|||
"(" + filter + ")",
|
||||
}
|
||||
|
||||
var projectViewIDForPosition int64
|
||||
var sortbyFields []string
|
||||
for i, param := range opts.sortby {
|
||||
// Validate the params
|
||||
|
@ -457,17 +463,19 @@ func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task,
|
|||
return nil, totalCount, err
|
||||
}
|
||||
|
||||
sortBy := param.sortBy
|
||||
|
||||
// Typesense does not allow sorting by ID, so we sort by created timestamp instead
|
||||
if param.sortBy == taskPropertyID {
|
||||
param.sortBy = taskPropertyCreated
|
||||
sortBy = taskPropertyCreated
|
||||
}
|
||||
|
||||
if param.sortBy == taskPropertyPosition {
|
||||
param.sortBy = "positions.view_" + strconv.FormatInt(param.projectViewID, 10)
|
||||
continue
|
||||
sortBy = "positions.view_" + strconv.FormatInt(param.projectViewID, 10)
|
||||
projectViewIDForPosition = param.projectViewID
|
||||
}
|
||||
|
||||
sortbyFields = append(sortbyFields, param.sortBy+"(missing_values:last):"+param.orderBy.String())
|
||||
sortbyFields = append(sortbyFields, sortBy+"(missing_values:last):"+param.orderBy.String())
|
||||
|
||||
if i == 2 {
|
||||
// Typesense supports up to 3 sorting parameters
|
||||
|
@ -525,9 +533,14 @@ func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task,
|
|||
return nil, 0, err
|
||||
}
|
||||
|
||||
err = t.s.
|
||||
query := t.s.
|
||||
In("id", taskIDs).
|
||||
OrderBy(orderby).
|
||||
Find(&tasks)
|
||||
OrderBy(orderby)
|
||||
|
||||
if projectViewIDForPosition != 0 {
|
||||
query = query.Join("LEFT", "task_positions", "task_positions.task_id = tasks.id AND task_positions.project_view_id = ?", projectViewIDForPosition)
|
||||
}
|
||||
|
||||
err = query.Find(&tasks)
|
||||
return tasks, int64(*result.Found), err
|
||||
}
|
||||
|
|
|
@ -1486,6 +1486,18 @@ func (t *Task) Delete(s *xorm.Session, a web.Auth) (err error) {
|
|||
return
|
||||
}
|
||||
|
||||
// Delete all positions
|
||||
_, err = s.Where("task_id = ?", t.ID).Delete(&TaskPosition{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Delete all bucket relations
|
||||
_, err = s.Where("task_id = ?", t.ID).Delete(&TaskBucket{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Actually delete the task
|
||||
_, err = s.ID(t.ID).Delete(Task{})
|
||||
if err != nil {
|
||||
|
|
|
@ -146,22 +146,10 @@ func CreateTypesenseCollections() error {
|
|||
Name: "updated",
|
||||
Type: "int64", // unix timestamp
|
||||
},
|
||||
{
|
||||
Name: "bucket_id",
|
||||
Type: "int64",
|
||||
},
|
||||
{
|
||||
Name: "position",
|
||||
Type: "float",
|
||||
},
|
||||
{
|
||||
Name: "created_by_id",
|
||||
Type: "int64",
|
||||
},
|
||||
{
|
||||
Name: "project_view_id",
|
||||
Type: "int64",
|
||||
},
|
||||
{
|
||||
Name: "reminders",
|
||||
Type: "object[]", // TODO
|
||||
|
@ -192,6 +180,14 @@ func CreateTypesenseCollections() error {
|
|||
Type: "object[]", // TODO
|
||||
Optional: pointer.True(),
|
||||
},
|
||||
{
|
||||
Name: "positions",
|
||||
Type: "object",
|
||||
},
|
||||
{
|
||||
Name: "buckets",
|
||||
Type: "int64[]",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -248,14 +244,8 @@ func ReindexAllTasks() (err error) {
|
|||
return
|
||||
}
|
||||
|
||||
func getTypesenseTaskForTask(s *xorm.Session, task *Task, projectsCache map[int64]*Project) (ttask *typesenseTask, err error) {
|
||||
positions := []*TaskPosition{}
|
||||
err = s.Where("task_id = ?", task.ID).Find(&positions)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ttask = convertTaskToTypesenseTask(task, positions)
|
||||
func getTypesenseTaskForTask(s *xorm.Session, task *Task, projectsCache map[int64]*Project, taskPositionCache map[int64][]*TaskPositionWithView, taskBucketCache map[int64][]*TaskBucket) (ttask *typesenseTask, err error) {
|
||||
ttask = convertTaskToTypesenseTask(task, taskPositionCache[task.ID], taskBucketCache[task.ID])
|
||||
|
||||
var p *Project
|
||||
if projectsCache == nil {
|
||||
|
@ -299,9 +289,19 @@ func reindexTasksInTypesense(s *xorm.Session, tasks map[int64]*Task) (err error)
|
|||
projects := make(map[int64]*Project)
|
||||
typesenseTasks := []interface{}{}
|
||||
|
||||
positionsByTask, err := getPositionsByTask(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bucketsByTask, err := getBucketsByTask(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
|
||||
ttask, err := getTypesenseTaskForTask(s, task, projects)
|
||||
ttask, err := getTypesenseTaskForTask(s, task, projects, positionsByTask, bucketsByTask)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -320,11 +320,55 @@ func reindexTasksInTypesense(s *xorm.Session, tasks map[int64]*Task) (err error)
|
|||
return err
|
||||
}
|
||||
|
||||
log.Debugf("Indexed tasks %v into Typesense", tasks)
|
||||
log.Debugf("Indexed %d tasks into Typesense", len(tasks))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type TaskPositionWithView struct {
|
||||
ProjectView `xorm:"extends"`
|
||||
TaskPosition `xorm:"extends"`
|
||||
}
|
||||
|
||||
func getPositionsByTask(s *xorm.Session) (positionsByTask map[int64][]*TaskPositionWithView, err error) {
|
||||
rawPositions := []*TaskPositionWithView{}
|
||||
err = s.
|
||||
Table("project_views").
|
||||
Join("LEFT", "task_positions", "project_views.id = task_positions.project_view_id").
|
||||
Find(&rawPositions)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
positionsByTask = make(map[int64][]*TaskPositionWithView, len(rawPositions))
|
||||
for _, p := range rawPositions {
|
||||
_, has := positionsByTask[p.TaskID]
|
||||
if !has {
|
||||
positionsByTask[p.TaskID] = []*TaskPositionWithView{}
|
||||
}
|
||||
positionsByTask[p.TaskID] = append(positionsByTask[p.TaskID], p)
|
||||
}
|
||||
return positionsByTask, nil
|
||||
}
|
||||
|
||||
func getBucketsByTask(s *xorm.Session) (positionsByTask map[int64][]*TaskBucket, err error) {
|
||||
rawBuckets := []*TaskBucket{}
|
||||
err = s.Find(&rawBuckets)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
positionsByTask = make(map[int64][]*TaskBucket, len(rawBuckets))
|
||||
for _, p := range rawBuckets {
|
||||
_, has := positionsByTask[p.TaskID]
|
||||
if !has {
|
||||
positionsByTask[p.TaskID] = []*TaskBucket{}
|
||||
}
|
||||
positionsByTask[p.TaskID] = append(positionsByTask[p.TaskID], p)
|
||||
}
|
||||
return positionsByTask, nil
|
||||
}
|
||||
|
||||
func indexDummyTask() (err error) {
|
||||
// The initial sync should contain one dummy task with all related fields populated so that typesense
|
||||
// creates the indexes properly. A little hacky, but gets the job done.
|
||||
|
@ -386,6 +430,13 @@ func indexDummyTask() (err error) {
|
|||
},
|
||||
},
|
||||
},
|
||||
Positions: map[string]float64{
|
||||
"view_1": 10,
|
||||
"view_2": 30,
|
||||
"view_3": 5450,
|
||||
"view_4": 42,
|
||||
},
|
||||
Buckets: []int64{42},
|
||||
}
|
||||
|
||||
_, err = typesenseClient.Collection("tasks").
|
||||
|
@ -430,9 +481,10 @@ type typesenseTask struct {
|
|||
Attachments interface{} `json:"attachments"`
|
||||
Comments interface{} `json:"comments"`
|
||||
Positions map[string]float64 `json:"positions"`
|
||||
Buckets []int64 `json:"buckets"`
|
||||
}
|
||||
|
||||
func convertTaskToTypesenseTask(task *Task, positions []*TaskPosition) *typesenseTask {
|
||||
func convertTaskToTypesenseTask(task *Task, positions []*TaskPositionWithView, buckets []*TaskBucket) *typesenseTask {
|
||||
|
||||
tt := &typesenseTask{
|
||||
ID: fmt.Sprintf("%d", task.ID),
|
||||
|
@ -461,6 +513,8 @@ func convertTaskToTypesenseTask(task *Task, positions []*TaskPosition) *typesens
|
|||
Labels: task.Labels,
|
||||
//RelatedTasks: task.RelatedTasks,
|
||||
Attachments: task.Attachments,
|
||||
Positions: make(map[string]float64, len(positions)),
|
||||
Buckets: make([]int64, 0, len(buckets)),
|
||||
}
|
||||
|
||||
if task.DoneAt.IsZero() {
|
||||
|
@ -477,7 +531,15 @@ func convertTaskToTypesenseTask(task *Task, positions []*TaskPosition) *typesens
|
|||
}
|
||||
|
||||
for _, position := range positions {
|
||||
tt.Positions["view_"+strconv.FormatInt(position.ProjectViewID, 10)] = position.Position
|
||||
pos := position.TaskPosition.Position
|
||||
if pos == 0 {
|
||||
pos = float64(task.ID)
|
||||
}
|
||||
tt.Positions["view_"+strconv.FormatInt(position.ProjectView.ID, 10)] = pos
|
||||
}
|
||||
|
||||
for _, bucket := range buckets {
|
||||
tt.Buckets = append(tt.Buckets, bucket.BucketID)
|
||||
}
|
||||
|
||||
return tt
|
||||
|
|
|
@ -64,6 +64,11 @@ func insertFromStructure(s *xorm.Session, str []*models.ProjectWithTasksAndBucke
|
|||
}
|
||||
|
||||
p.ID = 0
|
||||
|
||||
for _, view := range p.Views {
|
||||
view.ProjectID = 0
|
||||
}
|
||||
|
||||
err = createProject(s, p, &archivedProjects, labels, user)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -167,7 +172,7 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
|
|||
}
|
||||
|
||||
// Create all buckets
|
||||
buckets := make(map[int64]*models.Bucket) // old bucket id is the key
|
||||
bucketsByOldID := make(map[int64]*models.Bucket) // old bucket id is the key
|
||||
if len(project.Buckets) > 0 {
|
||||
log.Debugf("[creating structure] Creating %d buckets", len(project.Buckets))
|
||||
}
|
||||
|
@ -179,40 +184,45 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
|
|||
if err != nil {
|
||||
return
|
||||
}
|
||||
buckets[oldID] = bucket
|
||||
bucketsByOldID[oldID] = bucket
|
||||
log.Debugf("[creating structure] Created bucket %d, old ID was %d", bucket.ID, oldID)
|
||||
}
|
||||
|
||||
// Create all views, create default views if we don't have any
|
||||
viewsByOldIDs := make(map[int64]*models.ProjectView, len(oldViews))
|
||||
if len(oldViews) > 0 {
|
||||
for _, view := range oldViews {
|
||||
oldID := view.ID
|
||||
view.ID = 0
|
||||
|
||||
if view.DefaultBucketID != 0 {
|
||||
bucket, has := buckets[view.DefaultBucketID]
|
||||
bucket, has := bucketsByOldID[view.DefaultBucketID]
|
||||
if has {
|
||||
view.DefaultBucketID = bucket.ID
|
||||
}
|
||||
}
|
||||
|
||||
if view.DoneBucketID != 0 {
|
||||
bucket, has := buckets[view.DoneBucketID]
|
||||
bucket, has := bucketsByOldID[view.DoneBucketID]
|
||||
if has {
|
||||
view.DoneBucketID = bucket.ID
|
||||
}
|
||||
}
|
||||
|
||||
view.ProjectID = project.ID
|
||||
|
||||
err = view.Create(s, user)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
viewsByOldIDs[oldID] = view
|
||||
}
|
||||
} else {
|
||||
// Only using the default views
|
||||
// Add all buckets to the default kanban view
|
||||
for _, view := range project.Views {
|
||||
if view.ViewKind == models.ProjectViewKindKanban {
|
||||
for _, b := range buckets {
|
||||
for _, b := range bucketsByOldID {
|
||||
b.ProjectViewID = view.ID
|
||||
err = b.Update(s, user)
|
||||
if err != nil {
|
||||
|
@ -227,7 +237,7 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
|
|||
log.Debugf("[creating structure] Creating %d tasks", len(tasks))
|
||||
|
||||
setBucketOrDefault := func(task *models.Task) {
|
||||
bucket, exists := buckets[task.BucketID]
|
||||
bucket, exists := bucketsByOldID[task.BucketID]
|
||||
if exists {
|
||||
task.BucketID = bucket.ID
|
||||
} else if task.BucketID > 0 {
|
||||
|
@ -240,6 +250,7 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
|
|||
}
|
||||
|
||||
tasksByOldID := make(map[int64]*models.TaskWithComments, len(tasks))
|
||||
newTaskIDs := []int64{}
|
||||
// Create all tasks
|
||||
for i, t := range tasks {
|
||||
setBucketOrDefault(&tasks[i].Task)
|
||||
|
@ -251,6 +262,8 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
|
|||
continue
|
||||
}
|
||||
|
||||
newTaskIDs = append(newTaskIDs, t.ID)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
@ -359,10 +372,11 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
|
|||
log.Debugf("[creating structure] Associated task %d with label %d", t.ID, lb.ID)
|
||||
}
|
||||
|
||||
// Comments
|
||||
for _, comment := range t.Comments {
|
||||
comment.TaskID = t.ID
|
||||
comment.ID = 0
|
||||
err = comment.Create(s, user)
|
||||
err = comment.CreateWithTimestamps(s, user)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
@ -400,6 +414,58 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
|
|||
}
|
||||
}
|
||||
|
||||
if len(viewsByOldIDs) > 0 {
|
||||
newPositions := []*models.TaskPosition{}
|
||||
for _, pos := range project.Positions {
|
||||
_, hasTask := tasksByOldID[pos.TaskID]
|
||||
_, hasView := viewsByOldIDs[pos.ProjectViewID]
|
||||
if !hasTask || !hasView {
|
||||
continue
|
||||
}
|
||||
newPositions = append(newPositions, &models.TaskPosition{
|
||||
TaskID: tasksByOldID[pos.TaskID].ID,
|
||||
ProjectViewID: viewsByOldIDs[pos.ProjectViewID].ID,
|
||||
Position: pos.Position,
|
||||
})
|
||||
}
|
||||
|
||||
if len(newPositions) > 0 {
|
||||
_, err = s.In("task_id", newTaskIDs).Delete(&models.TaskPosition{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = s.Insert(newPositions)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
newTaskBuckets := make([]*models.TaskBucket, 0, len(project.TaskBuckets))
|
||||
for _, tb := range project.TaskBuckets {
|
||||
_, hasTask := tasksByOldID[tb.TaskID]
|
||||
_, hasBucket := bucketsByOldID[tb.BucketID]
|
||||
if !hasTask || !hasBucket {
|
||||
continue
|
||||
}
|
||||
newTaskBuckets = append(newTaskBuckets, &models.TaskBucket{
|
||||
TaskID: tasksByOldID[tb.TaskID].ID,
|
||||
BucketID: bucketsByOldID[tb.BucketID].ID,
|
||||
ProjectViewID: bucketsByOldID[tb.BucketID].ProjectViewID,
|
||||
})
|
||||
}
|
||||
|
||||
if len(newTaskBuckets) > 0 {
|
||||
_, err = s.In("task_id", newTaskIDs).Delete(&models.TaskBucket{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = s.Insert(newTaskBuckets)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
project.Tasks = tasks
|
||||
project.Buckets = originalBuckets
|
||||
|
||||
|
|
|
@ -17,16 +17,17 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/events"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/modules/migration"
|
||||
"code.vikunja.io/api/pkg/notifications"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/getsentry/sentry-go"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/getsentry/sentry-go"
|
||||
)
|
||||
|
||||
func RegisterListeners() {
|
||||
|
|
|
@ -107,80 +107,109 @@ func (m *Migration) AuthURL() string {
|
|||
"&return_url=" + config.MigrationTrelloRedirectURL.GetString()
|
||||
}
|
||||
|
||||
func getTrelloData(token string) (trelloData []*trello.Board, err error) {
|
||||
allArg := trello.Arguments{"fields": "all"}
|
||||
|
||||
client := trello.NewClient(config.MigrationTrelloKey.GetString(), token)
|
||||
client.Logger = log.GetLogger()
|
||||
|
||||
func getTrelloBoards(client *trello.Client) (trelloData []*trello.Board, err error) {
|
||||
log.Debugf("[Trello Migration] Getting boards...")
|
||||
|
||||
trelloData, err = client.GetMyBoards(trello.Defaults())
|
||||
if err != nil {
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Got %d trello boards", len(trelloData))
|
||||
|
||||
for _, board := range trelloData {
|
||||
log.Debugf("[Trello Migration] Getting projects for board %s", board.ID)
|
||||
return
|
||||
}
|
||||
|
||||
board.Lists, err = board.GetLists(trello.Defaults())
|
||||
if err != nil {
|
||||
return
|
||||
func getTrelloOrganizationsWithBoards(boards []*trello.Board) (boardsByOrg map[string][]*trello.Board) {
|
||||
|
||||
boardsByOrg = make(map[string][]*trello.Board)
|
||||
|
||||
for _, board := range boards {
|
||||
// Trello boards without an organization are considered personal boards
|
||||
if board.IDOrganization == "" {
|
||||
board.IDOrganization = "Personal"
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Got %d projects for board %s", len(board.Lists), board.ID)
|
||||
|
||||
listMap := make(map[string]*trello.List, len(board.Lists))
|
||||
for _, list := range board.Lists {
|
||||
listMap[list.ID] = list
|
||||
_, has := boardsByOrg[board.IDOrganization]
|
||||
if !has {
|
||||
boardsByOrg[board.IDOrganization] = []*trello.Board{}
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Getting cards for board %s", board.ID)
|
||||
boardsByOrg[board.IDOrganization] = append(boardsByOrg[board.IDOrganization], board)
|
||||
}
|
||||
|
||||
cards, err := board.GetCards(allArg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return
|
||||
}
|
||||
|
||||
func fillCardData(client *trello.Client, board *trello.Board) (err error) {
|
||||
allArg := trello.Arguments{"fields": "all"}
|
||||
|
||||
log.Debugf("[Trello Migration] Getting projects for board %s", board.ID)
|
||||
|
||||
board.Lists, err = board.GetLists(trello.Defaults())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Got %d projects for board %s", len(board.Lists), board.ID)
|
||||
|
||||
listMap := make(map[string]*trello.List, len(board.Lists))
|
||||
for _, list := range board.Lists {
|
||||
listMap[list.ID] = list
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Getting cards for board %s", board.ID)
|
||||
|
||||
cards, err := board.GetCards(allArg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Got %d cards for board %s", len(cards), board.ID)
|
||||
|
||||
for _, card := range cards {
|
||||
list, exists := listMap[card.IDList]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Got %d cards for board %s", len(cards), board.ID)
|
||||
|
||||
for _, card := range cards {
|
||||
list, exists := listMap[card.IDList]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
if card.Badges.Attachments > 0 {
|
||||
card.Attachments, err = card.GetAttachments(allArg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return
|
||||
}
|
||||
|
||||
if len(card.IDCheckLists) > 0 {
|
||||
for _, checkListID := range card.IDCheckLists {
|
||||
checklist, err := client.GetChecklist(checkListID, allArg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
checklist.CheckItems = []trello.CheckItem{}
|
||||
err = client.Get("checklists/"+checkListID+"/checkItems", allArg, &checklist.CheckItems)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
card.Checklists = append(card.Checklists, checklist)
|
||||
log.Debugf("Retrieved checklist %s for card %s", checkListID, card.ID)
|
||||
}
|
||||
}
|
||||
|
||||
list.Cards = append(list.Cards, card)
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Looked for attachements on all cards of board %s", board.ID)
|
||||
if card.Badges.Comments > 0 {
|
||||
card.Actions, err = card.GetCommentActions()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(card.IDCheckLists) > 0 {
|
||||
for _, checkListID := range card.IDCheckLists {
|
||||
checklist, err := client.GetChecklist(checkListID, allArg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checklist.CheckItems = []trello.CheckItem{}
|
||||
err = client.Get("checklists/"+checkListID+"/checkItems", allArg, &checklist.CheckItems)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
card.Checklists = append(card.Checklists, checklist)
|
||||
log.Debugf("Retrieved checklist %s for card %s", checkListID, card.ID)
|
||||
}
|
||||
}
|
||||
|
||||
list.Cards = append(list.Cards, card)
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Looked for attachements on all cards of board %s", board.ID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -196,7 +225,7 @@ func convertMarkdownToHTML(input string) (output string, err error) {
|
|||
|
||||
// Converts all previously obtained data from trello into the vikunja format.
|
||||
// `trelloData` should contain all boards with their projects and cards respectively.
|
||||
func convertTrelloDataToVikunja(trelloData []*trello.Board, token string) (fullVikunjaHierachie []*models.ProjectWithTasksAndBuckets, err error) {
|
||||
func convertTrelloDataToVikunja(organizationName string, trelloData []*trello.Board, token string, currentMember *trello.Member) (fullVikunjaHierachie []*models.ProjectWithTasksAndBuckets, err error) {
|
||||
|
||||
log.Debugf("[Trello Migration] ")
|
||||
|
||||
|
@ -205,7 +234,7 @@ func convertTrelloDataToVikunja(trelloData []*trello.Board, token string) (fullV
|
|||
{
|
||||
Project: models.Project{
|
||||
ID: pseudoParentID,
|
||||
Title: "Imported from Trello",
|
||||
Title: organizationName,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -252,9 +281,11 @@ func convertTrelloDataToVikunja(trelloData []*trello.Board, token string) (fullV
|
|||
log.Debugf("[Trello Migration] Converting card %s", card.ID)
|
||||
|
||||
// The usual stuff: Title, description, position, bucket id
|
||||
task := &models.Task{
|
||||
Title: card.Name,
|
||||
BucketID: bucketID,
|
||||
task := &models.TaskWithComments{
|
||||
Task: models.Task{
|
||||
Title: card.Name,
|
||||
BucketID: bucketID,
|
||||
},
|
||||
}
|
||||
|
||||
task.Description, err = convertMarkdownToHTML(card.Desc)
|
||||
|
@ -305,37 +336,39 @@ func convertTrelloDataToVikunja(trelloData []*trello.Board, token string) (fullV
|
|||
log.Debugf("[Trello Migration] Downloading %d card attachments from card %s", len(card.Attachments), card.ID)
|
||||
}
|
||||
for _, attachment := range card.Attachments {
|
||||
if !attachment.IsUpload { // There are other types of attachments which are not files. We can only handle files.
|
||||
log.Debugf("[Trello Migration] Attachment %s does not have a mime type, not downloading", attachment.ID)
|
||||
if attachment.IsUpload {
|
||||
// Download file and add it as attachment
|
||||
log.Debugf("[Trello Migration] Downloading card attachment %s", attachment.ID)
|
||||
|
||||
buf, err := migration.DownloadFileWithHeaders(attachment.URL, map[string][]string{
|
||||
"Authorization": {`OAuth oauth_consumer_key="` + config.MigrationTrelloKey.GetString() + `", oauth_token="` + token + `"`},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vikunjaAttachment := &models.TaskAttachment{
|
||||
File: &files.File{
|
||||
Name: attachment.Name,
|
||||
Mime: attachment.MimeType,
|
||||
Size: uint64(buf.Len()),
|
||||
FileContent: buf.Bytes(),
|
||||
},
|
||||
}
|
||||
|
||||
if card.IDAttachmentCover != "" && card.IDAttachmentCover == attachment.ID {
|
||||
vikunjaAttachment.ID = 42
|
||||
task.CoverImageAttachmentID = 42
|
||||
}
|
||||
|
||||
task.Attachments = append(task.Attachments, vikunjaAttachment)
|
||||
|
||||
log.Debugf("[Trello Migration] Downloaded card attachment %s", attachment.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Downloading card attachment %s", attachment.ID)
|
||||
|
||||
buf, err := migration.DownloadFileWithHeaders(attachment.URL, map[string][]string{
|
||||
"Authorization": {`OAuth oauth_consumer_key="` + config.MigrationTrelloKey.GetString() + `", oauth_token="` + token + `"`},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vikunjaAttachment := &models.TaskAttachment{
|
||||
File: &files.File{
|
||||
Name: attachment.Name,
|
||||
Mime: attachment.MimeType,
|
||||
Size: uint64(buf.Len()),
|
||||
FileContent: buf.Bytes(),
|
||||
},
|
||||
}
|
||||
|
||||
if card.IDAttachmentCover != "" && card.IDAttachmentCover == attachment.ID {
|
||||
vikunjaAttachment.ID = 42
|
||||
task.CoverImageAttachmentID = 42
|
||||
}
|
||||
|
||||
task.Attachments = append(task.Attachments, vikunjaAttachment)
|
||||
|
||||
log.Debugf("[Trello Migration] Downloaded card attachment %s", attachment.ID)
|
||||
// Other links are not attachments in Vikunja, but we can add them to the description
|
||||
task.Description += `<p><a href="` + attachment.URL + `">` + attachment.Name + "</a></p>\n"
|
||||
}
|
||||
|
||||
// When the cover image was set manually, we need to add it as an attachment
|
||||
|
@ -362,7 +395,32 @@ func convertTrelloDataToVikunja(trelloData []*trello.Board, token string) (fullV
|
|||
task.CoverImageAttachmentID = coverAttachment.ID
|
||||
}
|
||||
|
||||
project.Tasks = append(project.Tasks, &models.TaskWithComments{Task: *task})
|
||||
for _, action := range card.Actions {
|
||||
if action.DidCommentCard() {
|
||||
if task.Comments == nil {
|
||||
task.Comments = []*models.TaskComment{}
|
||||
}
|
||||
|
||||
comment := &models.TaskComment{
|
||||
Comment: action.Data.Text,
|
||||
Created: action.Date,
|
||||
Updated: action.Date,
|
||||
}
|
||||
|
||||
if currentMember == nil || action.IDMemberCreator != currentMember.ID {
|
||||
comment.Comment = "*" + action.MemberCreator.FullName + "*:\n\n" + comment.Comment
|
||||
}
|
||||
|
||||
comment.Comment, err = convertMarkdownToHTML(comment.Comment)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
task.Comments = append(task.Comments, comment)
|
||||
}
|
||||
}
|
||||
|
||||
project.Tasks = append(project.Tasks, task)
|
||||
}
|
||||
|
||||
project.Buckets = append(project.Buckets, bucket)
|
||||
|
@ -392,29 +450,62 @@ func (m *Migration) Migrate(u *user.User) (err error) {
|
|||
log.Debugf("[Trello Migration] Starting migration for user %d", u.ID)
|
||||
log.Debugf("[Trello Migration] Getting all trello data for user %d", u.ID)
|
||||
|
||||
trelloData, err := getTrelloData(m.Token)
|
||||
client := trello.NewClient(config.MigrationTrelloKey.GetString(), m.Token)
|
||||
client.Logger = log.GetLogger()
|
||||
|
||||
boards, err := getTrelloBoards(client)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Got all trello data for user %d", u.ID)
|
||||
log.Debugf("[Trello Migration] Start converting trello data for user %d", u.ID)
|
||||
|
||||
fullVikunjaHierachie, err := convertTrelloDataToVikunja(trelloData, m.Token)
|
||||
if err != nil {
|
||||
return
|
||||
organizationMap := getTrelloOrganizationsWithBoards(boards)
|
||||
for organizationID, boards := range organizationMap {
|
||||
log.Debugf("[Trello Migration] Getting organization with id %s for user %d", organizationID, u.ID)
|
||||
orgName := organizationID
|
||||
if organizationID != "Personal" {
|
||||
organization, err := client.GetOrganization(organizationID, trello.Defaults())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
orgName = organization.DisplayName
|
||||
}
|
||||
|
||||
for _, board := range boards {
|
||||
log.Debugf("[Trello Migration] Getting card data for board %s for user %d for organization %s", board.ID, u.ID, organizationID)
|
||||
|
||||
err = fillCardData(client, board)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Got card data for board %s for user %d for organization %s", board.ID, u.ID, organizationID)
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Start converting trello data for user %d for organization %s", u.ID, organizationID)
|
||||
|
||||
currentMember, err := client.GetMyMember(trello.Defaults())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hierarchy, err := convertTrelloDataToVikunja(orgName, boards, client.Token, currentMember)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Done migrating trello data for user %d for organization %s", u.ID, organizationID)
|
||||
log.Debugf("[Trello Migration] Start inserting trello data for user %d for organization %s", u.ID, organizationID)
|
||||
|
||||
err = migration.InsertFromStructure(hierarchy, u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Done inserting trello data for user %d for organization %s", u.ID, organizationID)
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Done migrating trello data for user %d", u.ID)
|
||||
log.Debugf("[Trello Migration] Start inserting trello data for user %d", u.ID)
|
||||
log.Debugf("[Trello Migration] Done migrating all trello data for user %d", u.ID)
|
||||
|
||||
err = migration.InsertFromStructure(fullVikunjaHierachie, u)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("[Trello Migration] Done inserting trello data for user %d", u.ID)
|
||||
log.Debugf("[Trello Migration] Migration done for user %d", u.ID)
|
||||
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
|
|
@ -32,20 +32,23 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConvertTrelloToVikunja(t *testing.T) {
|
||||
func getTestBoard(t *testing.T) ([]*trello.Board, time.Time) {
|
||||
|
||||
config.InitConfig()
|
||||
|
||||
time1, err := time.Parse(time.RFC3339Nano, "2014-09-26T08:25:05Z")
|
||||
require.NoError(t, err)
|
||||
exampleFile, err := os.ReadFile(config.ServiceRootpath.GetString() + "/pkg/modules/migration/testimage.jpg")
|
||||
require.NoError(t, err)
|
||||
|
||||
trelloData := []*trello.Board{
|
||||
{
|
||||
Name: "TestBoard",
|
||||
Desc: "This is a description",
|
||||
Closed: false,
|
||||
Name: "TestBoard",
|
||||
Organization: trello.Organization{
|
||||
ID: "orgid",
|
||||
DisplayName: "TestOrg",
|
||||
},
|
||||
IDOrganization: "orgid",
|
||||
Desc: "This is a description",
|
||||
Closed: false,
|
||||
Lists: []*trello.List{
|
||||
{
|
||||
Name: "Test Project 1",
|
||||
|
@ -75,6 +78,13 @@ func TestConvertTrelloToVikunja(t *testing.T) {
|
|||
MimeType: "image/jpg",
|
||||
URL: "https://vikunja.io/testimage.jpg",
|
||||
},
|
||||
{
|
||||
ID: "7cc71b16f0c7a57bed3c94e9",
|
||||
Name: "Website",
|
||||
IsUpload: false,
|
||||
MimeType: "",
|
||||
URL: "https://vikunja.io",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -168,8 +178,13 @@ func TestConvertTrelloToVikunja(t *testing.T) {
|
|||
},
|
||||
},
|
||||
{
|
||||
Name: "TestBoard 2",
|
||||
Closed: false,
|
||||
Organization: trello.Organization{
|
||||
ID: "orgid2",
|
||||
DisplayName: "TestOrg2",
|
||||
},
|
||||
IDOrganization: "orgid2",
|
||||
Name: "TestBoard 2",
|
||||
Closed: false,
|
||||
Lists: []*trello.List{
|
||||
{
|
||||
Name: "Test Project 4",
|
||||
|
@ -183,8 +198,13 @@ func TestConvertTrelloToVikunja(t *testing.T) {
|
|||
},
|
||||
},
|
||||
{
|
||||
Name: "TestBoard Archived",
|
||||
Closed: true,
|
||||
Organization: trello.Organization{
|
||||
ID: "orgid",
|
||||
DisplayName: "TestOrg",
|
||||
},
|
||||
IDOrganization: "orgid",
|
||||
Name: "TestBoard Archived",
|
||||
Closed: true,
|
||||
Lists: []*trello.List{
|
||||
{
|
||||
Name: "Test Project 5",
|
||||
|
@ -197,67 +217,92 @@ func TestConvertTrelloToVikunja(t *testing.T) {
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Personal Board",
|
||||
Lists: []*trello.List{
|
||||
{
|
||||
Name: "Test Project 6",
|
||||
Cards: []*trello.Card{
|
||||
{
|
||||
Name: "Test Card 5659",
|
||||
Pos: 123,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
trelloData[0].Prefs.BackgroundImage = "https://vikunja.io/testimage.jpg" // Using an image which we are hosting, so it'll still be up
|
||||
|
||||
expectedHierachie := []*models.ProjectWithTasksAndBuckets{
|
||||
{
|
||||
Project: models.Project{
|
||||
ID: 1,
|
||||
Title: "Imported from Trello",
|
||||
},
|
||||
},
|
||||
{
|
||||
Project: models.Project{
|
||||
ID: 2,
|
||||
ParentProjectID: 1,
|
||||
Title: "TestBoard",
|
||||
Description: "This is a description",
|
||||
BackgroundInformation: bytes.NewBuffer(exampleFile),
|
||||
},
|
||||
Buckets: []*models.Bucket{
|
||||
{
|
||||
return trelloData, time1
|
||||
}
|
||||
|
||||
func TestConvertTrelloToVikunja(t *testing.T) {
|
||||
trelloData, time1 := getTestBoard(t)
|
||||
|
||||
exampleFile, err := os.ReadFile(config.ServiceRootpath.GetString() + "/pkg/modules/migration/testimage.jpg")
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedHierarchyOrg := map[string][]*models.ProjectWithTasksAndBuckets{
|
||||
"orgid": {
|
||||
{
|
||||
Project: models.Project{
|
||||
ID: 1,
|
||||
Title: "Test Project 1",
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Title: "Test Project 2",
|
||||
Title: "orgid",
|
||||
},
|
||||
},
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 1",
|
||||
Description: "<p>Card Description <strong>bold</strong></p>\n",
|
||||
BucketID: 1,
|
||||
DueDate: time1,
|
||||
Labels: []*models.Label{
|
||||
{
|
||||
Title: "Label 1",
|
||||
HexColor: trelloColorMap["green"],
|
||||
{
|
||||
Project: models.Project{
|
||||
ID: 2,
|
||||
ParentProjectID: 1,
|
||||
Title: "TestBoard",
|
||||
Description: "This is a description",
|
||||
BackgroundInformation: bytes.NewBuffer(exampleFile),
|
||||
},
|
||||
Buckets: []*models.Bucket{
|
||||
{
|
||||
ID: 1,
|
||||
Title: "Test Project 1",
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Title: "Test Project 2",
|
||||
},
|
||||
},
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 1",
|
||||
Description: "<p>Card Description <strong>bold</strong></p>\n" +
|
||||
"<p><a href=\"https://vikunja.io\">Website</a></p>\n",
|
||||
BucketID: 1,
|
||||
DueDate: time1,
|
||||
Labels: []*models.Label{
|
||||
{
|
||||
Title: "Label 1",
|
||||
HexColor: trelloColorMap["green"],
|
||||
},
|
||||
{
|
||||
Title: "Label 2",
|
||||
HexColor: trelloColorMap["orange"],
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Label 2",
|
||||
HexColor: trelloColorMap["orange"],
|
||||
},
|
||||
},
|
||||
Attachments: []*models.TaskAttachment{
|
||||
{
|
||||
File: &files.File{
|
||||
Name: "Testimage.jpg",
|
||||
Mime: "image/jpg",
|
||||
Size: uint64(len(exampleFile)),
|
||||
FileContent: exampleFile,
|
||||
Attachments: []*models.TaskAttachment{
|
||||
{
|
||||
File: &files.File{
|
||||
Name: "Testimage.jpg",
|
||||
Mime: "image/jpg",
|
||||
Size: uint64(len(exampleFile)),
|
||||
FileContent: exampleFile,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 2",
|
||||
Description: `
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 2",
|
||||
Description: `
|
||||
|
||||
<h2> Checkproject 1</h2>
|
||||
|
||||
|
@ -270,117 +315,180 @@ func TestConvertTrelloToVikunja(t *testing.T) {
|
|||
<ul data-type="taskList">
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Pending Task</p></div></li>
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Another Pending Task</p></div></li></ul>`,
|
||||
BucketID: 1,
|
||||
BucketID: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 3",
|
||||
BucketID: 1,
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 3",
|
||||
BucketID: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 4",
|
||||
BucketID: 1,
|
||||
Labels: []*models.Label{
|
||||
{
|
||||
Title: "Label 2",
|
||||
HexColor: trelloColorMap["orange"],
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 4",
|
||||
BucketID: 1,
|
||||
Labels: []*models.Label{
|
||||
{
|
||||
Title: "Label 2",
|
||||
HexColor: trelloColorMap["orange"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 5",
|
||||
BucketID: 2,
|
||||
Labels: []*models.Label{
|
||||
{
|
||||
Title: "Label 3",
|
||||
HexColor: trelloColorMap["blue"],
|
||||
},
|
||||
{
|
||||
Title: "Label 4",
|
||||
HexColor: trelloColorMap["green_dark"],
|
||||
},
|
||||
{
|
||||
Title: "Label 5",
|
||||
HexColor: trelloColorMap["transparent"],
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 5",
|
||||
BucketID: 2,
|
||||
Labels: []*models.Label{
|
||||
{
|
||||
Title: "Label 3",
|
||||
HexColor: trelloColorMap["blue"],
|
||||
},
|
||||
{
|
||||
Title: "Label 4",
|
||||
HexColor: trelloColorMap["green_dark"],
|
||||
},
|
||||
{
|
||||
Title: "Label 5",
|
||||
HexColor: trelloColorMap["transparent"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 6",
|
||||
BucketID: 2,
|
||||
DueDate: time1,
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 6",
|
||||
BucketID: 2,
|
||||
DueDate: time1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 7",
|
||||
BucketID: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 8",
|
||||
BucketID: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 7",
|
||||
BucketID: 2,
|
||||
},
|
||||
{
|
||||
Project: models.Project{
|
||||
ID: 3,
|
||||
ParentProjectID: 1,
|
||||
Title: "TestBoard Archived",
|
||||
IsArchived: true,
|
||||
},
|
||||
Buckets: []*models.Bucket{
|
||||
{
|
||||
ID: 3,
|
||||
Title: "Test Project 5",
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 8",
|
||||
BucketID: 2,
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 63423",
|
||||
BucketID: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Project: models.Project{
|
||||
ID: 3,
|
||||
ParentProjectID: 1,
|
||||
Title: "TestBoard 2",
|
||||
},
|
||||
Buckets: []*models.Bucket{
|
||||
{
|
||||
ID: 3,
|
||||
Title: "Test Project 4",
|
||||
"orgid2": {
|
||||
{
|
||||
Project: models.Project{
|
||||
ID: 1,
|
||||
Title: "orgid2",
|
||||
},
|
||||
},
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 634",
|
||||
BucketID: 3,
|
||||
{
|
||||
Project: models.Project{
|
||||
ID: 2,
|
||||
ParentProjectID: 1,
|
||||
Title: "TestBoard 2",
|
||||
},
|
||||
Buckets: []*models.Bucket{
|
||||
{
|
||||
ID: 1,
|
||||
Title: "Test Project 4",
|
||||
},
|
||||
},
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 634",
|
||||
BucketID: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Project: models.Project{
|
||||
ID: 4,
|
||||
ParentProjectID: 1,
|
||||
Title: "TestBoard Archived",
|
||||
IsArchived: true,
|
||||
},
|
||||
Buckets: []*models.Bucket{
|
||||
{
|
||||
ID: 4,
|
||||
Title: "Test Project 5",
|
||||
"Personal": {
|
||||
{
|
||||
Project: models.Project{
|
||||
ID: 1,
|
||||
Title: "Personal",
|
||||
},
|
||||
},
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 63423",
|
||||
BucketID: 4,
|
||||
{
|
||||
Project: models.Project{
|
||||
ID: 2,
|
||||
ParentProjectID: 1,
|
||||
Title: "Personal Board",
|
||||
},
|
||||
Buckets: []*models.Bucket{
|
||||
{
|
||||
ID: 1,
|
||||
Title: "Test Project 6",
|
||||
},
|
||||
},
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Test Card 5659",
|
||||
BucketID: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
hierachie, err := convertTrelloDataToVikunja(trelloData, "")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, hierachie)
|
||||
if diff, equal := messagediff.PrettyDiff(hierachie, expectedHierachie); !equal {
|
||||
t.Errorf("converted trello data = %v, want %v, diff: %v", hierachie, expectedHierachie, diff)
|
||||
organizationMap := getTrelloOrganizationsWithBoards(trelloData)
|
||||
for organizationID, boards := range organizationMap {
|
||||
hierarchy, err := convertTrelloDataToVikunja(organizationID, boards, "", nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, hierarchy)
|
||||
if diff, equal := messagediff.PrettyDiff(hierarchy, expectedHierarchyOrg[organizationID]); !equal {
|
||||
t.Errorf("converted trello data = %v,\nwant %v,\ndiff: %v", hierarchy, expectedHierarchyOrg[organizationID], diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrganizationMap(t *testing.T) {
|
||||
trelloData, _ := getTestBoard(t)
|
||||
|
||||
organizationMap := getTrelloOrganizationsWithBoards(trelloData)
|
||||
expectedMap := map[string][]*trello.Board{
|
||||
"orgid": {
|
||||
trelloData[0],
|
||||
trelloData[2],
|
||||
},
|
||||
"orgid2": {
|
||||
trelloData[1],
|
||||
},
|
||||
"Personal": {
|
||||
trelloData[3],
|
||||
},
|
||||
}
|
||||
if diff, equal := messagediff.PrettyDiff(organizationMap, expectedMap); !equal {
|
||||
t.Errorf("converted organization map = %v,\nwant %v,\ndiff: %v", organizationMap, expectedMap, diff)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -156,10 +156,18 @@ func setupSentry(e *echo.Echo) {
|
|||
if hub != nil {
|
||||
hub.WithScope(func(scope *sentry.Scope) {
|
||||
scope.SetExtra("url", c.Request().URL)
|
||||
hub.CaptureException(herr.Internal)
|
||||
if herr.Internal == nil {
|
||||
hub.CaptureException(err)
|
||||
} else {
|
||||
hub.CaptureException(herr.Internal)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
sentry.CaptureException(herr.Internal)
|
||||
if herr.Internal == nil {
|
||||
sentry.CaptureException(err)
|
||||
} else {
|
||||
sentry.CaptureException(herr.Internal)
|
||||
}
|
||||
log.Debugf("Could not add context for sending error '%s' to sentry", err.Error())
|
||||
}
|
||||
log.Debugf("Error '%s' sent to sentry", err.Error())
|
||||
|
|
Loading…
Reference in New Issue