Compare commits

...

88 Commits

Author SHA1 Message Date
Elscrux be50d3d7b3 Extract img to FilePreview component
Now images will also be displayed when they are uploaded initially
2024-04-26 19:05:10 +02:00
Elscrux 2c847c3fa9 Proof of concept for image preview 2024-04-26 17:29:18 +02:00
renovate fd126fa234 fix(deps): update dependency dompurify to v3.1.1 2024-04-26 12:06:39 +00:00
renovate 0c39a3dd38 chore(deps): update dependency vitest to v1.5.2 2024-04-26 00:12:26 +00:00
kolaente 66e96322ea
fix: do not remove empty openid teams when none are present
Maybe resolves https://community.vikunja.io/t/empty-openid-team-cleanup-cron-error-removing-empty-openid-team-database-is-locked-error-when-exporting-data/2306/3
2024-04-25 14:21:31 +02:00
kolaente 00a96663ba
fix(caldav): check if vtodo contains any components
Resolves https://vikunja.sentry.io/share/issue/1ae2fd1601aa40dea4aee41927cfcf78/
2024-04-25 13:40:23 +02:00
kolaente 741370b613
fix(caldav): return more than 1000 tasks
Resolves https://kolaente.dev/vikunja/vikunja/issues/2302
2024-04-25 13:37:04 +02:00
renovate 70183dd7c6 chore(deps): update pnpm to v9.0.6 2024-04-25 06:59:45 +00:00
renovate 760bec5e76 chore(deps): update dependency vitest to v1.5.1 2024-04-25 01:10:55 +00:00
renovate 78f03373b8 fix(deps): update dependency vue to v3.4.25 2024-04-24 13:09:36 +00:00
renovate 09c6d095df fix(deps): update sentry-javascript monorepo to v7.112.2 2024-04-24 12:06:21 +00:00
renovate b102fe8188 chore(deps): update dev-dependencies 2024-04-24 00:11:24 +00:00
renovate f7c367b5bb fix(deps): update dependency workbox-precaching to v7.1.0 2024-04-23 21:08:15 +00:00
renovate b94053e42e fix(deps): update sentry-javascript monorepo to v7.112.1 2024-04-23 14:06:37 +00:00
renovate 6e98a6d7ff fix(deps): update sentry-javascript monorepo to v7.112.0 2024-04-23 10:18:38 +00:00
renovate 42bfe107ae chore(deps): update dev-dependencies 2024-04-23 06:10:30 +00:00
Frederick [Bot] a1892ea10b chore(i18n): update translations via Crowdin 2024-04-23 00:07:19 +00:00
renovate 899f8f9bc1 fix(deps): update github.com/dustinkirkland/golang-petname digest to 76c06c4 2024-04-22 22:11:33 +00:00
renovate 40f0ca6670 fix(deps): update dependency vue-i18n to v9.13.1 2024-04-22 17:06:27 +00:00
renovate f8d35396dc fix(deps): update dependency vue to v3.4.24 2024-04-22 16:36:18 +00:00
kolaente 409822442b
fix(backgrounds): return full project after uploading image 2024-04-22 18:33:43 +02:00
kolaente aec60f3591
feat(backgrounds): resize images to a maximum of 4K
Resolves https://kolaente.dev/vikunja/vikunja/issues/1373#issuecomment-43491
2024-04-22 18:29:58 +02:00
renovate 9b5ae38784 chore(deps): update dev-dependencies 2024-04-22 02:08:31 +00:00
renovate 3e40a43d56 chore(deps): update pnpm to v9.0.5 2024-04-21 22:07:37 +00:00
kolaente 15e0c716ad
fix(reminders): do not show relative reminders as minutes when they round to hours
Regression from fd520dab0a
2024-04-22 00:05:12 +02:00
kolaente 26ada628a2
fix(editor): use colors from color scheme to render table cells
Resolves https://github.com/go-vikunja/vikunja/issues/253
2024-04-21 23:57:07 +02:00
kolaente d86fdcb756
fix(table view): do not sort table column fields when the field in question is hidden
Resolves https://kolaente.dev/vikunja/vikunja/issues/2272
2024-04-21 23:48:40 +02:00
kolaente 84197dd9c1
fix: correctly return error and bubble up when the api could not be reached 2024-04-21 23:33:50 +02:00
kolaente 324df991ce
chore(desktop): switch from yarn to pnpm 2024-04-21 21:04:07 +02:00
kolaente 1f6a1f8ad4
fix(kanban): fetch project and view when checking permissions 2024-04-21 19:44:47 +02:00
kolaente ea7527a3cf
fix(test): cast result before comparing 2024-04-21 19:43:57 +02:00
kolaente 574c7f218e
fix(labels): allow link shares to add existing labels to a task
Resolves https://github.com/go-vikunja/vikunja/issues/252
2024-04-21 15:12:27 +02:00
kolaente 1074a8d916
fix(views): only allow project admins to manage views
Resolves https://community.vikunja.io/t/manage-views-only-for-project-admins/2279
2024-04-21 14:36:09 +02:00
kolaente e88f95e501
fix(migration): remove buckets table name when dropping index
Related to https://kolaente.dev/vikunja/vikunja/issues/2243
2024-04-21 13:50:03 +02:00
kolaente 0962aa4262
fix(restore): transform json fields during restore
Resolves https://community.vikunja.io/t/unable-to-restore-after-dump-and-export-also-not-working/2263/5
2024-04-21 13:45:49 +02:00
renovate a48ad6c9e1 chore(deps): update dev-dependencies 2024-04-21 05:09:05 +00:00
renovate bc8fe05e9e chore(deps): update pnpm to v9.0.4 2024-04-19 06:59:31 +00:00
renovate b4c12273af chore(deps): update dev-dependencies 2024-04-19 00:07:11 +00:00
renovate be004793aa chore(deps): update dependency node to v20.12.2 (#2238)
Reviewed-on: https://kolaente.dev/vikunja/vikunja/pulls/2238
Reviewed-by: konrad <k@knt.li>
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-04-18 20:46:48 +00:00
renovate a080400d3e chore(deps): update pnpm to v9 2024-04-18 19:05:45 +00:00
renovate d4ec0978ee fix(deps): update dependency vue-i18n to v9.13.0 2024-04-18 17:20:39 +00:00
renovate c37e08635a fix(deps): update module github.com/labstack/echo/v4 to v4.12.0 2024-04-18 17:20:18 +00:00
renovate 31f448e50f fix(deps): update sentry-javascript monorepo to v7.111.0 2024-04-18 17:06:02 +00:00
renovate 00c323891a fix(deps): update module golang.org/x/oauth2 to v0.19.0 2024-04-18 15:56:46 +00:00
renovate ff06bb202b fix(deps): update module github.com/tkuchiki/go-timezone to v0.2.3 2024-04-18 15:56:02 +00:00
renovate e806cbaf22 fix(deps): update dependency vue-router to v4.3.2 2024-04-18 10:09:09 +00:00
Frederick [Bot] d35ff0b380 chore(i18n): update translations via Crowdin 2024-04-18 00:06:32 +00:00
renovate 982884ee05 fix(deps): update module golang.org/x/sync to v0.7.0 (#2258)
Reviewed-on: https://kolaente.dev/vikunja/vikunja/pulls/2258
Reviewed-by: konrad <k@knt.li>
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-04-17 20:37:47 +00:00
renovate e43d9e9bbd fix(deps): update dependency @intlify/unplugin-vue-i18n to v4 2024-04-17 20:06:11 +00:00
renovate da8cee0ba5 fix(deps): update dependency vue to v3.4.23 2024-04-17 10:07:15 +00:00
renovate 352381f377 chore(deps): update pnpm to v8.15.7 2024-04-17 07:06:42 +00:00
renovate 61455b8795 fix(deps): update sentry-javascript monorepo to v7.110.1 2024-04-17 06:21:21 +00:00
treysullivent aceaccbf11 docs: fix typo in README.md (#2271)
Fixed "exausted" to "exhausted"

Reviewed-on: https://kolaente.dev/vikunja/vikunja/pulls/2271
Reviewed-by: konrad <k@knt.li>
Co-authored-by: treysullivent <trey.sullivent@gmail.com>
Co-committed-by: treysullivent <trey.sullivent@gmail.com>
2024-04-17 06:20:44 +00:00
kolaente 392ce66edb
chore(deps): update github.com/adlio/trello to v1.12.0 2024-04-16 23:14:51 +02:00
kolaente ecbefdb921
fix(buckets): return correct task count for tasks in buckets 2024-04-14 17:21:53 +02:00
kolaente d8ca1a2de1
fix(favorites): make favorites work with configurable views 2024-04-14 17:12:16 +02:00
kolaente 2d084c091e
feat: new login image 2024-04-14 12:43:22 +02:00
kolaente 5a84d37fca
fix(kanban): do not focus on task list in bucket when clicking on a task 2024-04-14 11:21:59 +02:00
kolaente fd520dab0a
fix(reminders): do not fall back to hours when the reminder interval is minutes
Resolves https://github.com/go-vikunja/vikunja/issues/225
2024-04-14 11:20:20 +02:00
kolaente 144a6e4140
fix(kanban): do not add bottom spacing to view 2024-04-14 11:15:53 +02:00
kolaente a7aa74227a
fix(kanban): do not focus kanban board 2024-04-14 11:12:26 +02:00
kolaente d2adbc53c6
fix(test): add task to bucket in test 2024-04-14 11:00:41 +02:00
kolaente 422e4371f8
fix(project): add more spacing between filter button and view switcher on mobile 2024-04-14 00:06:26 +02:00
kolaente 6e5b31f1e0
fix(filters): always persist filter or search in query path and load it correctly into filter query input when loading the page
Previously, when using the filter query as a search input, it would load the search as requested but the filter query parameter in the url would be empty, which meant the search would not be loaded correctly when reloading (or otherwise newly accessing) the page. We're now persisting the filter and search in the task loading logic, to make sure they are always populated correctly.
2024-04-13 23:34:25 +02:00
kolaente 5756da412b
fix(project): return full project after duplicating it 2024-04-13 22:39:40 +02:00
kolaente 4e05b8e97c
fix(project): do not crash when duplicating a project with no tasks 2024-04-13 22:36:41 +02:00
kolaente 5177f516c4
fix(views): make sure view changes are reflected in switcher 2024-04-13 22:24:12 +02:00
kolaente 637c8f6ba5
fix(views): make sure the view is saved properly in localStorage 2024-04-13 22:15:41 +02:00
kolaente 1460d212ee
fix: do not push nil errors to sentry 2024-04-13 21:46:07 +02:00
kolaente e9de7d8a24
fix(project): delete all related entities when deleting a project 2024-04-13 21:43:44 +02:00
kolaente ce1d7778c7
fix(export): make export work with project views and new task positions 2024-04-13 21:07:06 +02:00
kolaente 9a16f6f817
fix: license in cmd help text 2024-04-13 20:13:24 +02:00
kolaente 7d755fcb89
fix: lint 2024-04-13 17:58:53 +02:00
kolaente 77e95642a9
fix(tasks): make fetching tasks in buckets via typesense work 2024-04-13 17:52:47 +02:00
kolaente a5d02380a3
fix(typesense): make fetching task positions per view more efficient 2024-04-13 17:26:38 +02:00
kolaente 3519b8b2fe
fix(tasks): index and order by task position when using typesense 2024-04-13 17:19:27 +02:00
kolaente cb648e5ad8
fix(typesense): fix reindexing views and positions in typesense 2024-04-13 16:38:45 +02:00
kolaente 75f830457b
fix(comments): order comments by created timestamp instead of id
Partially resolves https://community.vikunja.io/t/trello-import-comments-and-assignments/2174/14
2024-04-13 14:45:12 +02:00
kolaente 6e2b540394
fix(migration): import task comments with original timestamps
Partially resolves https://community.vikunja.io/t/trello-import-comments-and-assignments/2174/14
2024-04-13 14:44:55 +02:00
kolaente bf3c8ac9da
fix(views): check if bucket index already exists before creating new index
Resolves https://kolaente.dev/vikunja/vikunja/issues/2243
2024-04-13 14:20:27 +02:00
kolaente 3e7225ebee
fix(editor): do not prevent shift+enter to add a line break in text
Resolves https://github.com/go-vikunja/vikunja/issues/250
2024-04-13 14:08:27 +02:00
kolaente 9eb19e0362
fix(project): do not crash when views were not loaded yet
The project view crashed when accessing a task from /projects because the currentProject in store was not set, hence the views weren't set either. This change adds a fallback to it.

Related to https://kolaente.dev/vikunja/vikunja/issues/2246
Related to https://community.vikunja.io/t/vikunja-freezes/2246/5
2024-04-13 13:18:14 +02:00
kolaente 73bf119409
docs: clarify version checkout when building from source
Related to https://kolaente.dev/vikunja/vikunja/issues/2270#issuecomment-62038
2024-04-12 23:39:27 +02:00
kolaente 500b761fe6
fix(projects): do not return parent project id when authenticating as link share
Related to https://community.vikunja.io/t/vikunja-freezes/2246
Related to https://github.com/go-vikunja/vikunja/issues/233
2024-04-12 18:02:39 +02:00
kolaente 0bc9a670d7
fix(task): do not crash when loading a task if parent projects are not loaded
Related to https://community.vikunja.io/t/vikunja-freezes/2246
Related to https://github.com/go-vikunja/vikunja/issues/233
2024-04-12 17:56:19 +02:00
renovate a3e5e98c64 fix(deps): update module github.com/arran4/golang-ical to v0.2.8 2024-04-11 16:07:49 +00:00
Elscrux a3a4d05e89 feat(editor): checklist visual improvements (#2264)
This makes task lists (especially big ones) easier to read. I've set a margin so there is a distance between task items which makes them easier to stand out.
I've also changed the visuals of the checked elements (strike through + grey font color) so the unchecked ones stand out more. Note that this currently seems to be a big bugged outside of edit mode as `data-checked` doesn't seem to be updating correctly in this state which seems to be an issue that is already noted for the TipTap editor.

Co-authored-by: Elscrux <nickposer2102@gmail.com>
Reviewed-on: https://kolaente.dev/vikunja/vikunja/pulls/2264
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Elscrux <elscrux@gmail.com>
Co-committed-by: Elscrux <elscrux@gmail.com>
2024-04-11 15:46:10 +00:00
renovate 72c3e1a03f chore(deps): update dev-dependencies 2024-04-11 00:09:10 +00:00
95 changed files with 11846 additions and 8594 deletions

View File

@ -364,7 +364,7 @@ steps:
- api-build
- name: frontend-dependencies
image: node:20.11.1-alpine
image: node:20.12.2-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -378,7 +378,7 @@ steps:
# - restore-cache
- name: frontend-lint
image: node:20.11.1-alpine
image: node:20.12.2-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -390,7 +390,7 @@ steps:
- frontend-dependencies
- name: frontend-build-prod
image: node:20.11.1-alpine
image: node:20.12.2-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -402,7 +402,7 @@ steps:
- frontend-dependencies
- name: frontend-test-unit
image: node:20.11.1-alpine
image: node:20.12.2-alpine
pull: always
commands:
- cd frontend
@ -413,7 +413,7 @@ steps:
- name: frontend-typecheck
failure: ignore
image: node:20.11.1-alpine
image: node:20.12.2-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -544,7 +544,7 @@ steps:
- git fetch --tags
- name: frontend-dependencies
image: node:20.11.1-alpine
image: node:20.12.2-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -556,7 +556,7 @@ steps:
- pnpm install --fetch-timeout 100000
- name: frontend-build
image: node:20.11.1-alpine
image: node:20.12.2-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -901,7 +901,7 @@ steps:
- git fetch --tags
- name: build
image: node:20.11.1-alpine
image: node:20.12.2-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -962,7 +962,7 @@ steps:
- git fetch --tags
- name: build
image: node:20.11.1-alpine
image: node:20.12.2-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -1400,6 +1400,6 @@ steps:
- failure
---
kind: signature
hmac: 2c9cb0483fb346988188515f6423929f46eefb9e14eb26b0f312a0b694d5fe8c
hmac: f2753482faf9e2a3d34a9111587a75dfb4519cb77002cc64a51266540fd2478e
...

View File

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

View File

@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM node:20.11.1-alpine AS frontendbuilder
FROM --platform=$BUILDPLATFORM node:20.12.2-alpine AS frontendbuilder
WORKDIR /build

View File

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

View File

@ -1,9 +0,0 @@
{ pkgs ? import <nixpkgs> {}
}:
pkgs.mkShell {
name="electron-dev";
buildInputs = [
pkgs.electron
];
}

View File

@ -51,7 +51,7 @@
}
},
"devDependencies": {
"electron": "29.2.0",
"electron": "29.3.1",
"electron-builder": "24.13.3"
},
"dependencies": {

2375
desktop/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -12,6 +12,8 @@
nodePackages.pnpm cypress
# API tools
go golangci-lint mage
# Desktop
electron
];
};
};

View File

@ -1 +1 @@
20.11.1
20.12.2

View File

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

View File

@ -13,7 +13,7 @@
},
"homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@8.15.6",
"packageManager": "pnpm@9.0.6",
"keywords": [
"todo",
"productivity",
@ -56,10 +56,10 @@
"@fortawesome/vue-fontawesome": "3.0.6",
"@github/hotkey": "3.1.0",
"@infectoone/vue-ganttastic": "2.3.2",
"@intlify/unplugin-vue-i18n": "3.0.1",
"@intlify/unplugin-vue-i18n": "4.0.0",
"@kyvg/vue3-notification": "3.2.1",
"@sentry/tracing": "7.109.0",
"@sentry/vue": "7.109.0",
"@sentry/tracing": "7.112.2",
"@sentry/vue": "7.112.2",
"@tiptap/core": "2.3.0",
"@tiptap/extension-blockquote": "2.3.0",
"@tiptap/extension-bold": "2.3.0",
@ -103,7 +103,7 @@
"camel-case": "4.1.2",
"date-fns": "3.6.0",
"dayjs": "1.11.10",
"dompurify": "3.1.0",
"dompurify": "3.1.1",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
"flexsearch": "0.7.31",
@ -118,13 +118,13 @@
"sortablejs": "1.15.2",
"tippy.js": "6.3.7",
"ufo": "1.5.3",
"vue": "3.4.21",
"vue": "3.4.25",
"vue-advanced-cropper": "2.8.8",
"vue-flatpickr-component": "11.0.5",
"vue-i18n": "9.11.1",
"vue-router": "4.3.0",
"vue-i18n": "9.13.1",
"vue-router": "4.3.2",
"vuemoji-picker": "0.2.1",
"workbox-precaching": "7.0.0",
"workbox-precaching": "7.1.0",
"zhyswan-vuedraggable": "4.1.3"
},
"devDependencies": {
@ -134,7 +134,7 @@
"@faker-js/faker": "8.4.1",
"@histoire/plugin-screenshot": "0.17.17",
"@histoire/plugin-vue": "0.17.17",
"@rushstack/eslint-patch": "1.10.1",
"@rushstack/eslint-patch": "1.10.2",
"@tsconfig/node18": "18.2.4",
"@types/codemirror": "5.60.15",
"@types/dompurify": "3.0.5",
@ -145,8 +145,8 @@
"@types/node": "20.12.7",
"@types/postcss-preset-env": "7.7.0",
"@types/sortablejs": "1.15.8",
"@typescript-eslint/eslint-plugin": "7.6.0",
"@typescript-eslint/parser": "7.6.0",
"@typescript-eslint/eslint-plugin": "7.7.1",
"@typescript-eslint/parser": "7.7.1",
"@vitejs/plugin-legacy": "5.3.2",
"@vitejs/plugin-vue": "5.0.4",
"@vue/eslint-config-typescript": "13.0.0",
@ -154,34 +154,34 @@
"@vue/tsconfig": "0.5.1",
"autoprefixer": "10.4.19",
"browserslist": "4.23.0",
"caniuse-lite": "1.0.30001608",
"caniuse-lite": "1.0.30001612",
"css-has-pseudo": "6.0.3",
"csstype": "3.1.3",
"cypress": "13.7.2",
"cypress": "13.8.1",
"esbuild": "0.20.2",
"eslint": "8.57.0",
"eslint-plugin-vue": "9.24.1",
"eslint-plugin-vue": "9.25.0",
"happy-dom": "14.7.1",
"histoire": "0.17.17",
"postcss": "8.4.38",
"postcss-easing-gradients": "3.0.1",
"postcss-easings": "4.0.0",
"postcss-focus-within": "8.0.1",
"postcss-preset-env": "9.5.4",
"rollup": "4.14.1",
"postcss-preset-env": "9.5.9",
"rollup": "4.16.4",
"rollup-plugin-visualizer": "5.12.0",
"sass": "1.74.1",
"sass": "1.75.0",
"start-server-and-test": "2.0.3",
"typescript": "5.4.5",
"vite": "5.2.8",
"vite": "5.2.10",
"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.12",
"vitest": "1.5.2",
"vue-tsc": "2.0.14",
"wait-on": "7.2.0",
"workbox-cli": "7.0.0"
"workbox-cli": "7.1.0"
},
"pnpm": {
"patchedDependencies": {

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

View File

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

View File

@ -34,6 +34,7 @@ import {useBaseStore} from '@/stores/base'
import Logo from '@/components/home/Logo.vue'
import PoweredByLink from './PoweredByLink.vue'
import {useProjectStore} from '@/stores/projects'
import {useLabelStore} from '@/stores/labels'
const baseStore = useBaseStore()
const currentProject = computed(() => baseStore.currentProject)
@ -42,6 +43,9 @@ const logoVisible = computed(() => baseStore.logoVisible)
const projectStore = useProjectStore()
projectStore.loadAllProjects()
const labelStore = useLabelStore()
labelStore.loadAllLabels()
</script>
<style lang="scss" scoped>

View File

@ -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
},
}
},
@ -816,7 +818,7 @@ watch(
td,
th {
min-width: 1em;
border: 2px solid #ced4da;
border: 2px solid var(--grey-300) !important;
padding: 3px 5px;
vertical-align: top;
box-sizing: border-box;
@ -830,7 +832,7 @@ watch(
th {
font-weight: bold;
text-align: left;
background-color: #f1f3f5;
background-color: var(--grey-200);
}
.selectedCell:after {
@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@ -267,7 +267,7 @@
</template>
<script setup lang="ts">
import {computed, type Ref} from 'vue'
import {computed, type Ref, watch} from 'vue'
import {useStorage} from '@vueuse/core'
@ -337,6 +337,12 @@ Object.assign(params.value, {
filter: '',
})
watch(
() => activeColumns.value,
() => setActiveColumnsSortParam(),
{deep: true},
)
// FIXME: by doing this we can have multiple sort orders
function sort(property: keyof SortBy) {
const order = sortBy.value[property]
@ -347,7 +353,16 @@ function sort(property: keyof SortBy) {
} else {
delete sortBy.value[property]
}
sortByParam.value = sortBy.value
setActiveColumnsSortParam()
}
function setActiveColumnsSortParam() {
sortByParam.value = Object.keys(sortBy.value)
.filter(prop => activeColumns.value[prop])
.reduce((obj, key) => {
obj[key] = sortBy.value[key]
return obj
}, {})
}
// TODO: re-enable opening task detail in modal

View File

@ -27,83 +27,91 @@
v-if="attachments.length > 0"
class="files"
>
<!-- FIXME: don't use a for element that wraps other links / buttons
Instead: overlay element with button that is inside.
-->
<a
v-for="a in attachments"
:key="a.id"
class="attachment"
@click="viewOrDownload(a)"
>
<div class="filename">
{{ a.file.name }}
<span
v-if="task.coverImageAttachmentId === a.id"
class="is-task-cover"
>
{{ $t('task.attachment.usedAsCover') }}
</span>
</div>
<div class="info">
<p class="attachment-info-meta">
<i18n-t
keypath="task.attachment.createdBy"
scope="global"
>
<span v-tooltip="formatDateLong(a.created)">
{{ formatDateSince(a.created) }}
<table class="table table-striped">
<tr
v-for="a in attachments"
:key="a.id"
class="clickable"
@click="viewOrDownload(a)"
>
<td class="preview-column">
<FilePreview
v-if="canPreview(a)"
class="attachment-preview"
:model-value="a"
/>
</td>
<td>
<div class="filename">
{{ a.file.name }}
<span
v-if="task.coverImageAttachmentId === a.id"
class="is-task-cover"
>
{{ $t('task.attachment.usedAsCover') }}
</span>
<User
:avatar-size="24"
:user="a.createdBy"
:is-inline="true"
/>
</i18n-t>
<span>
{{ getHumanSize(a.file.size) }}
</span>
<span v-if="a.file.mime">
{{ a.file.mime }}
</span>
</p>
<p>
<BaseButton
v-tooltip="$t('task.attachment.downloadTooltip')"
class="attachment-info-meta-button"
@click.prevent.stop="downloadAttachment(a)"
>
{{ $t('misc.download') }}
</BaseButton>
<BaseButton
v-tooltip="$t('task.attachment.copyUrlTooltip')"
class="attachment-info-meta-button"
@click.stop="copyUrl(a)"
>
{{ $t('task.attachment.copyUrl') }}
</BaseButton>
<BaseButton
v-if="editEnabled"
v-tooltip="$t('task.attachment.deleteTooltip')"
class="attachment-info-meta-button"
@click.prevent.stop="setAttachmentToDelete(a)"
>
{{ $t('misc.delete') }}
</BaseButton>
<BaseButton
v-if="editEnabled"
class="attachment-info-meta-button"
@click.prevent.stop="setCoverImage(task.coverImageAttachmentId === a.id ? null : a)"
>
{{
task.coverImageAttachmentId === a.id
? $t('task.attachment.unsetAsCover')
: $t('task.attachment.setAsCover')
}}
</BaseButton>
</p>
</div>
</a>
</div>
<div class="info">
<p class="attachment-info-meta">
<i18n-t
keypath="task.attachment.createdBy"
scope="global"
>
<span v-tooltip="formatDateLong(a.created)">
{{ formatDateSince(a.created) }}
</span>
<User
:avatar-size="24"
:user="a.createdBy"
:is-inline="true"
/>
</i18n-t>
<span>
{{ getHumanSize(a.file.size) }}
</span>
<span v-if="a.file.mime">
{{ a.file.mime }}
</span>
</p>
<p>
<BaseButton
v-tooltip="$t('task.attachment.downloadTooltip')"
class="attachment-info-meta-button"
@click.prevent.stop="downloadAttachment(a)"
>
{{ $t('misc.download') }}
</BaseButton>
<BaseButton
v-tooltip="$t('task.attachment.copyUrlTooltip')"
class="attachment-info-meta-button"
@click.stop="copyUrl(a)"
>
{{ $t('task.attachment.copyUrl') }}
</BaseButton>
<BaseButton
v-if="editEnabled"
v-tooltip="$t('task.attachment.deleteTooltip')"
class="attachment-info-meta-button"
@click.prevent.stop="setAttachmentToDelete(a)"
>
{{ $t('misc.delete') }}
</BaseButton>
<BaseButton
v-if="editEnabled"
class="attachment-info-meta-button"
@click.prevent.stop="setCoverImage(task.coverImageAttachmentId === a.id ? null : a)"
>
{{
task.coverImageAttachmentId === a.id
? $t('task.attachment.unsetAsCover')
: $t('task.attachment.setAsCover')
}}
</BaseButton>
</p>
</div>
</td>
</tr>
</table>
</div>
<x-button
@ -188,6 +196,7 @@ import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {error, success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
import {useI18n} from 'vue-i18n'
import FilePreview from '@/components/tasks/partials/file-preview.vue'
const {
task,
@ -260,13 +269,17 @@ async function deleteAttachment() {
const attachmentImageBlobUrl = ref<string | null>(null)
async function viewOrDownload(attachment: IAttachment) {
if (SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))) {
if (canPreview(attachment)) {
attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
} else {
downloadAttachment(attachment)
}
}
function canPreview(attachment: IAttachment): boolean {
return SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))
}
const copy = useCopyToClipboard()
function copyUrl(attachment: IAttachment) {
@ -435,6 +448,18 @@ async function setCoverImage(attachment: IAttachment | null) {
}
}
.preview-column {
max-width: 75px;
}
.attachment-preview {
max-height: 75px;
}
.clickable {
cursor: pointer;
}
.is-task-cover {
background: var(--primary);
color: var(--white);

View File

@ -0,0 +1,34 @@
<template>
<img
:src="blobUrl"
alt="Attachment preview"
>
</template>
<script setup lang="ts">
import {type PropType, ref, shallowReactive, watchEffect} from 'vue'
import AttachmentService from '@/services/attachment'
import type { IAttachment } from '@/modelTypes/IAttachment'
const props = defineProps({
modelValue: {
type: Object as PropType<IAttachment>,
default: undefined,
},
})
const attachmentService = shallowReactive(new AttachmentService())
const blobUrl = ref<string | undefined>(undefined)
watchEffect(async () => {
if (props.modelValue) {
blobUrl.value = await attachmentService.getBlobUrl(props.modelValue)
}
})
</script>
<style scoped lang="scss">
img {
border-radius: 0.5rem;
}
</style>

View File

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

View File

@ -19,9 +19,16 @@ export function secondsToPeriod(seconds: number): { unit: PeriodUnit, amount: nu
}
}
if (seconds % SECONDS_A_HOUR === 0) {
return {
unit: 'hours',
amount: seconds / SECONDS_A_HOUR,
}
}
return {
unit: 'hours',
amount: seconds / SECONDS_A_HOUR,
unit: 'minutes',
amount: seconds / SECONDS_A_MINUTE,
}
}

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Nur Projektadministrator:innen können Ansichten bearbeiten."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Nur Projektadministrator:innen können Ansichten bearbeiten."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Kérjük, adjon meg egy címet.",
"delete": "Törölje ezt a nézetet",
"deleteText": "Biztosan eltávolítja ezt a nézetet? A továbbiakban nem lesz használható a projektben szereplő feladatok megtekintésére. Ez a művelet nem töröl semmilyen feladatot. Ezt nem lehet visszacsinálni!",
"deleteSuccess": "A nézet sikeresen törölve"
"deleteSuccess": "A nézet sikeresen törölve",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Proszę podać tytuł.",
"delete": "Usuń ten widok",
"deleteText": "Czy na pewno chcesz usunąć ten widok? Nie będzie już możliwe wyświetlanie zadań w tym projekcie. Ta akcja nie usunie żadnych zadań. Tej operacji nie można cofnąć!",
"deleteSuccess": "Widok został pomyślnie usunięty"
"deleteSuccess": "Widok został pomyślnie usunięty",
"onlyAdminsCanEdit": "Tylko administratorzy projektu mogą edytować widoki."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Prosim navedite naslov.",
"delete": "Izbriši pogled",
"deleteText": "Ali ste prepričani, da želite odstraniti ta pogled? Ne bo ga več mogoče uporabljati za ogled nalog v tem projektu. To dejanje ne bo izbrisalo nobenih opravil. Tega ni mogoče razveljaviti!",
"deleteSuccess": "Pogled je bil uspešno izbrisan"
"deleteSuccess": "Pogled je bil uspešno izbrisan",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -385,7 +385,7 @@
"views": {
"header": "Edit views",
"title": "Title",
"actions": "Actions",
"actions": "Åtgärder",
"kind": "Kind",
"bucketConfigMode": "Bucket configuration mode",
"bucketConfig": "Bucket configuration",
@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {
@ -455,7 +456,7 @@
},
"operators": {
"intro": "The available operators for filtering include:",
"notEqual": "Not equal to",
"notEqual": "Inte lika med",
"equal": "Lika med",
"greaterThan": "Större än",
"greaterThanOrEqual": "Större än eller lika med",
@ -777,7 +778,7 @@
"moveProject": "Flytta",
"color": "Set Color",
"delete": "Radera",
"favorite": "Add to Favorites",
"favorite": "Lägg till i favoriter",
"unfavorite": "Remove from Favorites"
}
},

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "请提供标题。",
"delete": "删除此视图",
"deleteText": "您确定要删除此视图吗?它将不再可能使用它来查看此项目中的任务。 此操作不会删除任何任务。此操作不能撤销!",
"deleteSuccess": "视图已成功删除"
"deleteSuccess": "视图已成功删除",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

@ -396,7 +396,8 @@
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
"deleteSuccess": "The view was successfully deleted",
"onlyAdminsCanEdit": "Only project admins can edit views."
}
},
"filters": {

View File

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

View File

@ -1,5 +1,5 @@
import {computed, reactive, toRefs} from 'vue'
import {defineStore, acceptHMRUpdate} from 'pinia'
import {acceptHMRUpdate, defineStore} from 'pinia'
import {parseURL} from 'ufo'
import {HTTPFactory} from '@/helpers/fetcher'
@ -7,6 +7,7 @@ import {objectToCamelCase} from '@/helpers/case'
import type {IProvider} from '@/types/IProvider'
import type {MIGRATORS} from '@/views/migrate/migrators'
import {InvalidApiUrlProvidedError} from '@/helpers/checkAndSetApiUrl'
export interface ConfigState {
version: string,
@ -83,15 +84,17 @@ export const useConfigStore = defineStore('config', () => {
function setConfig(config: ConfigState) {
Object.assign(state, config)
}
async function update(): Promise<boolean> {
const HTTP = HTTPFactory()
const {data: config} = await HTTP.get('info')
if (typeof config.version === 'undefined') {
return false
throw new InvalidApiUrlProvidedError()
}
setConfig(objectToCamelCase(config))
const success = !!config
return success
return !!config
}
return {

View File

@ -160,7 +160,6 @@ export const useLabelStore = defineStore('label', () => {
deleteLabel,
updateLabel,
createLabel,
}
})

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import CreateEdit from '@/components/misc/create-edit.vue'
import {computed, ref} from 'vue'
import {watch, ref, computed} from 'vue'
import {useProjectStore} from '@/stores/projects'
import ProjectViewModel from '@/models/projectView'
import type {IProjectView} from '@/modelTypes/IProjectView'
@ -9,6 +9,10 @@ import ProjectViewService from '@/services/projectViews'
import XButton from '@/components/input/button.vue'
import {error, success} from '@/message'
import {useI18n} from 'vue-i18n'
import ProjectService from '@/services/project'
import {RIGHTS} from '@/constants/rights'
import ProjectModel from '@/models/project'
import Message from '@/components/misc/message.vue'
const {
projectId,
@ -28,6 +32,17 @@ const viewIdToDelete = ref<number | null>(null)
const showDeleteModal = ref(false)
const viewToEdit = ref<IProjectView | null>(null)
const isAdmin = ref<boolean>(false)
watch(
() => projectId,
async () => {
const projectService = new ProjectService()
const project = await projectService.get(new ProjectModel({id: projectId}))
isAdmin.value = project.maxRight === RIGHTS.ADMIN
},
{immediate: true},
)
async function createView() {
if (!showCreateForm.value) {
showCreateForm.value = true
@ -83,13 +98,17 @@ async function saveView() {
<CreateEdit
:title="$t('project.views.header')"
:primary-label="$t('misc.save')"
:has-primary-action="false"
>
<ViewEditForm
v-if="showCreateForm"
v-model="newView"
class="mb-4"
/>
<div class="is-flex is-justify-content-end mb-4">
<div
v-if="isAdmin"
class="is-flex is-justify-content-end mb-4"
>
<XButton
:loading="projectViewService.loading"
@click="createView"
@ -97,6 +116,10 @@ async function saveView() {
{{ $t('project.views.create') }}
</XButton>
</div>
<Message v-if="!isAdmin">
{{ $t('project.views.onlyAdminsCanEdit') }}
</Message>
<table
v-if="views?.length > 0"
@ -144,6 +167,7 @@ async function saveView() {
<td>{{ v.viewKind }}</td>
<td class="has-text-right">
<XButton
v-if="isAdmin"
class="is-danger mr-2"
icon="trash-alt"
@click="() => {
@ -152,6 +176,7 @@ async function saveView() {
}"
/>
<XButton
v-if="isAdmin"
icon="pen"
@click="viewToEdit = {...v}"
/>

View File

@ -309,6 +309,7 @@
v-model="task.labels"
:disabled="!canWrite"
:task-id="taskId"
:creatable="!authStore.isLinkShareAuth"
/>
</div>
@ -653,6 +654,7 @@ const projectStore = useProjectStore()
const attachmentStore = useAttachmentStore()
const taskStore = useTaskStore()
const kanbanStore = useKanbanStore()
const authStore = useAuthStore()
const task = ref<ITask>(new TaskModel())
const taskTitle = computed(() => task.value.title)
@ -877,7 +879,7 @@ function toggleTaskDone() {
done: !task.value.done,
}
if (newTask.done && useAuthStore().settings.frontendSettings.playSoundWhenDone) {
if (newTask.done && authStore.settings.frontendSettings.playSoundWhenDone) {
playPopSound()
}

View File

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

18
go.mod
View File

@ -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
@ -29,7 +29,7 @@ require (
github.com/cweill/gotests v1.6.0
github.com/d4l3k/messagediff v1.2.1
github.com/disintegration/imaging v1.6.2
github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2
github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b
github.com/gabriel-vasile/mimetype v1.4.3
github.com/ganigeorgiev/fexpr v0.4.0
github.com/getsentry/sentry-go v0.27.0
@ -45,7 +45,7 @@ require (
github.com/jinzhu/copier v0.4.0
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6
github.com/labstack/echo-jwt/v4 v4.2.0
github.com/labstack/echo/v4 v4.11.4
github.com/labstack/echo/v4 v4.12.0
github.com/labstack/gommon v0.4.2
github.com/lib/pq v1.10.9
github.com/magefile/mage v1.15.0
@ -63,15 +63,15 @@ require (
github.com/spf13/viper v1.18.2
github.com/stretchr/testify v1.9.0
github.com/swaggo/swag v1.16.3
github.com/tkuchiki/go-timezone v0.2.2
github.com/tkuchiki/go-timezone v0.2.3
github.com/typesense/typesense-go v1.0.0
github.com/ulule/limiter/v3 v3.11.2
github.com/wneessen/go-mail v0.4.0
github.com/yuin/goldmark v1.7.1
golang.org/x/crypto v0.22.0
golang.org/x/image v0.15.0
golang.org/x/oauth2 v0.18.0
golang.org/x/sync v0.6.0
golang.org/x/oauth2 v0.19.0
golang.org/x/sync v0.7.0
golang.org/x/sys v0.19.0
golang.org/x/term v0.19.0
golang.org/x/text v0.14.0
@ -120,7 +120,6 @@ require (
github.com/go-playground/validator/v10 v10.15.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@ -181,7 +180,6 @@ require (
golang.org/x/net v0.24.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.13.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
@ -190,8 +188,6 @@ require (
replace github.com/samedi/caldav-go => github.com/kolaente/caldav-go v3.0.1-0.20190610114120-2a4eb8b5dcc9+incompatible // Branch: feature/dynamic-supported-components, PR: https://github.com/samedi/caldav-go/pull/6 and https://github.com/samedi/caldav-go/pull/7
replace github.com/adlio/trello v1.11.0 => github.com/kolaente/trello v1.8.1-0.20240410214605-9314fa638eab // https://github.com/adlio/trello/pull/95
go 1.21
toolchain go1.21.2

31
go.sum
View File

@ -24,13 +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.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=
@ -101,6 +103,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2 h1:S6Dco8FtAhEI/qkg/00H6RdEGC+MCy5GPiQ+xweNRFE=
github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b h1:+0Xqob+onh+4l9TSWmFyZ4JHqGUiCy5P1muyH8Evfpw=
github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
@ -187,8 +191,6 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
@ -303,8 +305,6 @@ github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZY
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kolaente/caldav-go v3.0.1-0.20190610114120-2a4eb8b5dcc9+incompatible h1:q7DbyV+sFjEoTuuUdRDNl2nlyfztkZgxVVCV7JhzIkY=
github.com/kolaente/caldav-go v3.0.1-0.20190610114120-2a4eb8b5dcc9+incompatible/go.mod h1:y1UhTNI4g0hVymJrI6yJ5/ohy09hNBeU8iJEZjgdDOw=
github.com/kolaente/trello v1.8.1-0.20240410214605-9314fa638eab h1:KAEuOtvjNohQzONw0NpOlR4TlWgsPawY/PLyOiMHlFw=
github.com/kolaente/trello v1.8.1-0.20240410214605-9314fa638eab/go.mod h1:I4Lti4jf2KxjTNgTqs5W3lLuE78QZZdYbbPnQQGwjOo=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@ -318,8 +318,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo-jwt/v4 v4.2.0 h1:odSISV9JgcSCuhgQSV/6Io3i7nUmfM/QkBeR5GVJj5c=
github.com/labstack/echo-jwt/v4 v4.2.0/go.mod h1:MA2RqdXdEn4/uEglx0HcUOgQSyBaTh5JcaHIan3biwU=
github.com/labstack/echo/v4 v4.1.16/go.mod h1:awO+5TzAjvL8XpibdsfXxPgHr+orhtXZJZIQCVjogKI=
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
@ -501,8 +501,8 @@ github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpP
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk=
github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk=
github.com/tkuchiki/go-timezone v0.2.2 h1:MdHR65KwgVTwWFQrota4SKzc4L5EfuH5SdZZGtk/P2Q=
github.com/tkuchiki/go-timezone v0.2.2/go.mod h1:oFweWxYl35C/s7HMVZXiA19Jr9Y0qJHMaG/J2TES4LY=
github.com/tkuchiki/go-timezone v0.2.3 h1:D3TVdIPrFsu9lxGxqNX2wsZwn1MZtTqTW0mdevMozHc=
github.com/tkuchiki/go-timezone v0.2.3/go.mod h1:oFweWxYl35C/s7HMVZXiA19Jr9Y0qJHMaG/J2TES4LY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/typesense/typesense-go v1.0.0 h1:/8Lr1yf9YjmUKdn/xbTNy+OhwOvBd0noBTRkcB22Uhw=
@ -608,16 +608,16 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg=
golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -660,7 +660,6 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
@ -690,8 +689,6 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=

View File

@ -95,6 +95,9 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) {
if err != nil {
return nil, err
}
if len(parsed.Components) == 0 {
return nil, errors.New("VTODO element does seem not contain any components")
}
vTodo, ok := parsed.Components[0].(*ics.VTodo)
if !ok {
return nil, errors.New("VTODO element not found")

View File

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

View File

@ -349,82 +349,135 @@ func TestBucket(t *testing.T) {
})
t.Run("Create", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "1"}, `{"title":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{
"project": "1",
"view": "3",
}, `{"title":"Lorem Ipsum"}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Nonexisting", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "9999"}, `{"title":"Lorem Ipsum"}`)
t.Run("Nonexistent project", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{
"project": "9999",
"view": "1",
}, `{"title":"Lorem Ipsum"}`)
require.Error(t, err)
assertHandlerErrorCode(t, err, models.ErrCodeProjectDoesNotExist)
assertHandlerErrorCode(t, err, models.ErrCodeProjectViewDoesNotExist)
})
t.Run("Nonexistent view", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{
"project": "1",
"view": "9999",
}, `{"title":"Lorem Ipsum"}`)
require.Error(t, err)
assertHandlerErrorCode(t, err, models.ErrCodeProjectViewDoesNotExist)
})
t.Run("Rights check", func(t *testing.T) {
t.Run("Forbidden", func(t *testing.T) {
// Owned by user13
_, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "20"}, `{"title":"Lorem Ipsum"}`)
_, err := testHandler.testCreateWithUser(nil, map[string]string{
"project": "20",
"view": "80",
}, `{"title":"Lorem Ipsum"}`)
require.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via Team readonly", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "6"}, `{"title":"Lorem Ipsum"}`)
_, err := testHandler.testCreateWithUser(nil, map[string]string{
"project": "6",
"view": "24",
}, `{"title":"Lorem Ipsum"}`)
require.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via Team write", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "7"}, `{"title":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{
"project": "7",
"view": "28",
}, `{"title":"Lorem Ipsum"}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via Team admin", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "8"}, `{"title":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{
"project": "8",
"view": "32",
}, `{"title":"Lorem Ipsum"}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via User readonly", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "9"}, `{"title":"Lorem Ipsum"}`)
_, err := testHandler.testCreateWithUser(nil, map[string]string{
"project": "9",
"view": "36",
}, `{"title":"Lorem Ipsum"}`)
require.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via User write", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "10"}, `{"title":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{
"project": "10",
"view": "40",
}, `{"title":"Lorem Ipsum"}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via User admin", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "11"}, `{"title":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{
"project": "11",
"view": "44",
}, `{"title":"Lorem Ipsum"}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via Parent Project Team readonly", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "12"}, `{"title":"Lorem Ipsum"}`)
_, err := testHandler.testCreateWithUser(nil, map[string]string{
"project": "12",
"view": "48",
}, `{"title":"Lorem Ipsum"}`)
require.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via Parent Project Team write", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "13"}, `{"title":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{
"project": "13",
"view": "52",
}, `{"title":"Lorem Ipsum"}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via Parent Project Team admin", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "14"}, `{"title":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{
"project": "14",
"view": "56",
}, `{"title":"Lorem Ipsum"}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via Parent Project User readonly", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "15"}, `{"title":"Lorem Ipsum"}`)
_, err := testHandler.testCreateWithUser(nil, map[string]string{
"project": "15",
"view": "60",
}, `{"title":"Lorem Ipsum"}`)
require.Error(t, err)
assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`)
})
t.Run("Shared Via Parent Project User write", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "16"}, `{"title":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{
"project": "16",
"view": "64",
}, `{"title":"Lorem Ipsum"}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
t.Run("Shared Via Parent Project User admin", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "17"}, `{"title":"Lorem Ipsum"}`)
rec, err := testHandler.testCreateWithUser(nil, map[string]string{
"project": "17",
"view": "68",
}, `{"title":"Lorem Ipsum"}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})

View File

@ -82,7 +82,6 @@ func init() {
if config.DatabaseType.GetString() == "sqlite" {
_, err = tx.Exec(`
create table buckets_dg_tmp
(
id INTEGER not null
@ -100,14 +99,14 @@ insert into buckets_dg_tmp(id, title, "limit", position, created, updated, creat
select id, title, "limit", position, created, updated, created_by_id, project_view_id
from buckets;
drop index if exists buckets.UQE_buckets_id;
drop index if exists UQE_buckets_id;
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

View File

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

View File

@ -23,11 +23,13 @@ import (
// CanCreate checks if a user can create a new bucket
func (b *Bucket) CanCreate(s *xorm.Session, a web.Auth) (bool, error) {
pv := &ProjectView{
ID: b.ProjectViewID,
ProjectID: b.ProjectID,
pv, err := GetProjectViewByIDAndProject(s, b.ProjectViewID, b.ProjectID)
if err != nil {
return false, err
}
return pv.CanUpdate(s, a)
p := &Project{ID: pv.ProjectID}
return p.CanUpdate(s, a)
}
// CanUpdate checks if a user can update an existing bucket
@ -46,9 +48,11 @@ func (b *Bucket) canDoBucket(s *xorm.Session, a web.Auth) (bool, error) {
if err != nil {
return false, err
}
pv := &ProjectView{
ID: bb.ProjectViewID,
ProjectID: b.ProjectID,
pv, err := GetProjectViewByIDAndProject(s, bb.ProjectViewID, b.ProjectID)
if err != nil {
return false, err
}
return pv.CanUpdate(s, a)
p := &Project{ID: pv.ProjectID}
return p.CanUpdate(s, a)
}

View File

@ -148,23 +148,14 @@ func (l *Label) Delete(s *xorm.Session, _ web.Auth) (err error) {
// @Failure 500 {object} models.Message "Internal error"
// @Router /labels [get]
func (l *Label) ReadAll(s *xorm.Session, a web.Auth, search string, page int, perPage int) (ls interface{}, resultCount int, numberOfEntries int64, err error) {
if _, is := a.(*LinkSharing); is {
return nil, 0, 0, ErrGenericForbidden{}
}
u, err := user.GetUserByID(s, a.GetID())
if err != nil {
return nil, 0, 0, err
}
return GetLabelsByTaskIDs(s, &LabelByTaskIDsOptions{
Search: []string{search},
User: u,
GetForUser: u.ID,
User: a,
Page: page,
PerPage: perPage,
GetUnusedLabels: true,
GroupByLabelIDsOnly: true,
GetForUser: true,
})
}

View File

@ -17,7 +17,6 @@
package models
import (
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"xorm.io/builder"
"xorm.io/xorm"
@ -64,27 +63,29 @@ func (l *Label) isLabelOwner(s *xorm.Session, a web.Auth) (bool, error) {
// Helper method to check if a user can see a specific label
func (l *Label) hasAccessToLabel(s *xorm.Session, a web.Auth) (has bool, maxRight int, err error) {
if _, is := a.(*LinkSharing); is {
return false, 0, nil
}
linkShare, isLinkShare := a.(*LinkSharing)
u, err := user.GetUserByID(s, a.GetID())
if err != nil {
return false, 0, err
var where builder.Cond
var createdByID int64
if isLinkShare {
where = builder.Eq{"project_id": linkShare.ProjectID}
} else {
where = builder.In("project_id", getUserProjectsStatement(a.GetID(), "", false).Select("l.id"))
createdByID = a.GetID()
}
cond := builder.In("label_tasks.task_id",
builder.
Select("id").
From("tasks").
Where(builder.In("project_id", getUserProjectsStatement(u.ID, "", false).Select("l.id"))),
Where(where),
)
ll := &LabelTask{}
has, err = s.Table("labels").
Select("label_tasks.*").
Join("LEFT", "label_tasks", "label_tasks.label_id = labels.id").
Where("label_tasks.label_id is not null OR labels.created_by_id = ?", u.ID).
Where("label_tasks.label_id is not null OR labels.created_by_id = ?", createdByID).
Or(cond).
And("labels.id = ?", l.ID).
Exist(ll)

View File

@ -144,7 +144,7 @@ func (lt *LabelTask) ReadAll(s *xorm.Session, a web.Auth, search string, page in
}
return GetLabelsByTaskIDs(s, &LabelByTaskIDsOptions{
User: &user.User{ID: a.GetID()},
User: a,
Search: []string{search},
Page: page,
TaskIDs: []int64{lt.TaskID},
@ -159,23 +159,26 @@ type LabelWithTaskID struct {
// LabelByTaskIDsOptions is a struct to not clutter the function with too many optional parameters.
type LabelByTaskIDsOptions struct {
User *user.User
User web.Auth
Search []string
Page int
PerPage int
TaskIDs []int64
GetUnusedLabels bool
GroupByLabelIDsOnly bool
GetForUser int64
GetForUser bool
}
// GetLabelsByTaskIDs is a helper function to get all labels for a set of tasks
// Used when getting all labels for one task as well when getting all lables
func GetLabelsByTaskIDs(s *xorm.Session, opts *LabelByTaskIDsOptions) (ls []*LabelWithTaskID, resultCount int, totalEntries int64, err error) {
linkShare, isLinkShareAuth := opts.User.(*LinkSharing)
// We still need the task ID when we want to get all labels for a task, but because of this, we get the same label
// multiple times when it is associated to more than one task.
// Because of this whole thing, we need this extra switch here to only group by Task IDs if needed.
// Probably not the most ideal solution.
// Probably not the most ideataskdetaill solution.
var groupBy = "labels.id,label_tasks.task_id"
var selectStmt = "labels.*, label_tasks.task_id"
if opts.GroupByLabelIDsOnly {
@ -186,20 +189,25 @@ func GetLabelsByTaskIDs(s *xorm.Session, opts *LabelByTaskIDsOptions) (ls []*Lab
// Get all labels associated with these tasks
var labels []*LabelWithTaskID
cond := builder.And(builder.NotNull{"label_tasks.label_id"})
if len(opts.TaskIDs) > 0 && opts.GetForUser == 0 {
if len(opts.TaskIDs) > 0 && !opts.GetForUser {
cond = builder.And(builder.In("label_tasks.task_id", opts.TaskIDs), cond)
}
if opts.GetForUser != 0 {
if opts.GetForUser {
projects, _, _, err := getRawProjectsForUser(s, &projectOptions{
user: &user.User{ID: opts.GetForUser},
})
if err != nil {
return nil, 0, 0, err
}
projectIDs := make([]int64, 0, len(projects))
for _, project := range projects {
projectIDs = append(projectIDs, project.ID)
var projectIDs []int64
if isLinkShareAuth {
projectIDs = []int64{linkShare.ProjectID}
} else {
projects, _, _, err := getRawProjectsForUser(s, &projectOptions{
user: &user.User{ID: opts.User.GetID()},
})
if err != nil {
return nil, 0, 0, err
}
projectIDs = make([]int64, 0, len(projects))
for _, project := range projects {
projectIDs = append(projectIDs, project.ID)
}
}
cond = builder.And(builder.In("label_tasks.task_id",
@ -209,8 +217,8 @@ func GetLabelsByTaskIDs(s *xorm.Session, opts *LabelByTaskIDsOptions) (ls []*Lab
Where(builder.In("project_id", projectIDs)),
), cond)
}
if opts.GetUnusedLabels {
cond = builder.Or(cond, builder.Eq{"labels.created_by_id": opts.User.ID})
if opts.GetUnusedLabels && !isLinkShareAuth {
cond = builder.Or(cond, builder.Eq{"labels.created_by_id": opts.User.GetID()})
}
ids := []int64{}

View File

@ -62,7 +62,7 @@ func TestLabel_ReadAll(t *testing.T) {
name string
fields fields
args args
wantLs interface{}
wantLs []*LabelWithTaskID
wantErr bool
}{
{
@ -143,7 +143,10 @@ func TestLabel_ReadAll(t *testing.T) {
t.Errorf("Label.ReadAll() error = %v, wantErr %v", err, tt.wantErr)
return
}
if diff, equal := messagediff.PrettyDiff(gotLs, tt.wantLs); !equal {
got := gotLs.([]*LabelWithTaskID)
if diff, equal := messagediff.PrettyDiff(got, tt.wantLs); !equal {
t.Errorf("Label.ReadAll() = %v, want %v, diff: %v", gotLs, tt.wantLs, diff)
}
s.Close()

View File

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

View File

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

View File

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

View File

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

View File

@ -28,17 +28,17 @@ func (p *ProjectView) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) {
func (p *ProjectView) CanDelete(s *xorm.Session, a web.Auth) (bool, error) {
pp := p.getProject()
return pp.CanUpdate(s, a)
return pp.IsAdmin(s, a)
}
func (p *ProjectView) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) {
pp := p.getProject()
return pp.CanUpdate(s, a)
return pp.IsAdmin(s, a)
}
func (p *ProjectView) CanCreate(s *xorm.Session, a web.Auth) (bool, error) {
pp := p.getProject()
return pp.CanUpdate(s, a)
return pp.IsAdmin(s, a)
}
func (p *ProjectView) getProject() (pp *Project) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,6 +38,10 @@ func RemoveEmptySSOTeams(s *xorm.Session) (err error) {
return err
}
if len(teams) == 0 {
return nil
}
teamIDs := make([]int64, 0, len(teams))
for _, team := range teams {
teamIDs = append(teamIDs, team.ID)
@ -63,6 +67,6 @@ func RegisterEmptyOpenIDTeamCleanupCron() {
}
})
if err != nil {
log.Fatalf("Could not empty openid teams cleanup cron: %s", err)
log.Fatalf("Could not register empty openid teams cleanup cron: %s", err)
}
}

View File

@ -32,6 +32,8 @@ type Image struct {
Info interface{} `json:"info,omitempty"`
}
const MaxBackgroundImageHeight = 3840
// Provider represents something that is able to get a project of images and set one of them as background
type Provider interface {
// Search is used to either return a pre-defined project of Image or let the user search for an image

View File

@ -17,10 +17,13 @@
package handler
import (
"bytes"
_ "image/gif" // To make sure the decoder used for generating blurHashes recognizes gifs
_ "image/jpeg" // To make sure the decoder used for generating blurHashes recognizes jpgs
_ "image/png" // To make sure the decoder used for generating blurHashes recognizes pngs
"github.com/disintegration/imaging"
_ "golang.org/x/image/bmp" // To make sure the decoder used for generating blurHashes recognizes bmps
_ "golang.org/x/image/tiff" // To make sure the decoder used for generating blurHashes recognizes tiffs
_ "golang.org/x/image/webp" // To make sure the decoder used for generating blurHashes recognizes tiffs
@ -210,6 +213,12 @@ func (bp *BackgroundProvider) UploadBackground(c echo.Context) error {
return handler.HandleHTTPError(err, c)
}
err = project.ReadOne(s, auth)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
if err := s.Commit(); err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
@ -220,7 +229,30 @@ func (bp *BackgroundProvider) UploadBackground(c echo.Context) error {
func SaveBackgroundFile(s *xorm.Session, auth web.Auth, project *models.Project, srcf io.ReadSeeker, filename string, filesize uint64) (err error) {
_, _ = srcf.Seek(0, io.SeekStart)
f, err := files.Create(srcf, filename, filesize, auth)
src, err := imaging.Decode(srcf)
if err != nil {
return err
}
_, _ = srcf.Seek(0, io.SeekStart)
imgConfig, _, err := image.DecodeConfig(srcf)
if err != nil {
return err
}
height := imgConfig.Height
if imgConfig.Height > background.MaxBackgroundImageHeight {
height = background.MaxBackgroundImageHeight
}
buf := bytes.Buffer{}
dst := imaging.Resize(src, 0, height, imaging.Lanczos)
err = imaging.Encode(&buf, dst, imaging.JPEG, imaging.JPEGQuality(80))
if err != nil {
return err
}
f, err := files.Create(&buf, filename, filesize, auth)
if err != nil {
return err
}

View File

@ -256,7 +256,7 @@ func (p *Provider) Set(s *xorm.Session, image *background.Image, project *models
// Download the photo from unsplash
// The parameters crop the image to a max width of 2560 and a max height of 2048 to save bandwidth and storage.
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, photo.Urls.Raw+"&w=2560&h=2048&q=90", nil)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, photo.Urls.Raw+"&fm=jpg&h="+strconv.FormatInt(background.MaxBackgroundImageHeight, 10)+"&q=80", nil)
if err != nil {
return
}

View File

@ -150,36 +150,10 @@ func Restore(filename string) error {
delete(dbfiles, "migration")
// Restore all db data
for table, d := range dbfiles {
content, err := unmarshalFileToJSON(d)
if err != nil {
return fmt.Errorf("could not read table %s: %w", table, err)
}
// FIXME: There has to be a general way to do this but this works for now.
if table == "notifications" {
for i := range content {
var decoded []byte
decoded, err = base64.StdEncoding.DecodeString(content[i]["notification"].(string))
if err != nil && !errors.Is(err, base64.CorruptInputError(0)) {
return fmt.Errorf("could not decode notification %s: %w", content[i]["notification"], err)
}
if err != nil && errors.Is(err, base64.CorruptInputError(0)) {
decoded = []byte(content[i]["notification"].(string))
}
content[i]["notification"] = string(decoded)
}
}
if err := db.Restore(table, content); err != nil {
return fmt.Errorf("could not restore table data for table %s: %w", table, err)
}
log.Infof("Restored table %s", table)
err = restoreTableData(dbfiles)
if err != nil {
return err
}
log.Infof("Restored %d tables", len(dbfiles))
// Run migrations again to migrate a potentially outdated dump
migration.Migrate(nil)
@ -216,6 +190,53 @@ func Restore(filename string) error {
return nil
}
func restoreTableData(tables map[string]*zip.File) error {
jsonFields := map[string][]string{
"notifications": {"notification"},
"users": {"frontend_settings"},
}
// Restore all db data
for table, d := range tables {
content, err := unmarshalFileToJSON(d)
if err != nil {
return fmt.Errorf("could not read table %s: %w", table, err)
}
fields, hasJSONFields := jsonFields[table]
if hasJSONFields {
for i := range content {
for _, f := range fields {
if _, hasField := content[i][f]; !hasField {
continue
}
var decoded []byte
decoded, err = base64.StdEncoding.DecodeString(content[i][f].(string))
if err != nil && !errors.Is(err, base64.CorruptInputError(0)) {
return fmt.Errorf("could not decode field '%s' %s: %w", f, content[i][f], err)
}
if err != nil && errors.Is(err, base64.CorruptInputError(0)) {
decoded = []byte(content[i][f].(string))
}
content[i][f] = string(decoded)
}
}
}
if err := db.Restore(table, content); err != nil {
return fmt.Errorf("could not restore table data for table %s: %w", table, err)
}
log.Infof("Restored table %s", table)
}
log.Infof("Restored %d tables", len(tables))
return nil
}
func unmarshalFileToJSON(file *zip.File) (contents []map[string]interface{}, err error) {
rc, err := file.Open()
if err != nil {

View File

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

View File

@ -419,7 +419,7 @@ func persistLabels(s *xorm.Session, a web.Auth, task *models.Task, labels []*mod
existingLabels, _, _, err := models.GetLabelsByTaskIDs(s, &models.LabelByTaskIDsOptions{
Search: labelTitles,
User: u,
GetForUser: u.ID,
GetForUser: true,
GetUnusedLabels: true,
GroupByLabelIDsOnly: true,
})
@ -636,7 +636,7 @@ func (vcls *VikunjaCaldavProjectStorage) getProjectRessource(isCollection bool)
tk := models.TaskCollection{
ProjectID: vcls.project.ID,
}
iface, _, _, err := tk.ReadAll(s, vcls.user, "", 1, 1000)
iface, _, _, err := tk.ReadAll(s, vcls.user, "", 0, -1)
if err != nil {
_ = s.Rollback()
return rr, err

View File

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