Merge remote-tracking branch 'upstream/main' into landing-page

This commit is contained in:
surfingbytes 2026-04-08 08:54:49 +00:00
commit 290bf8b280
161 changed files with 12194 additions and 1139 deletions

View File

@ -67,18 +67,25 @@ jobs:
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
- name: Comment on PR
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
DOCKER_META_TAGS: ${{ steps.meta.outputs.tags }}
with:
script: |
const prNumber = context.payload.pull_request.number;
const fullSha = context.payload.pull_request.head.sha;
const shortSha = fullSha.substring(0, 7);
const base = 'preview.vikunja.dev';
const image = `ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}`;
const marker = '<!-- vikunja-preview-comment -->';
// Extract the SHA tag from docker meta output (the actual tag pushed to GHCR)
const metaTags = process.env.DOCKER_META_TAGS.split('\n').map(t => t.trim()).filter(Boolean);
const shaImageRef = metaTags.find(t => t.includes(':sha-'));
const shaTag = shaImageRef ? shaImageRef.split(':').pop() : null;
const shortSha = shaTag ? shaTag.replace('sha-', '').substring(0, 7) : context.payload.pull_request.head.sha.substring(0, 7);
const prTag = `pr-${prNumber}`;
const shaTag = `sha-${fullSha}`;
const newShaRow = `| https://${shaTag}.${base} | \`${image}:${shaTag}\` | \`${shortSha}\` |`;
const newShaRow = shaTag
? `| https://${shaTag}.${base} | \`${image}:${shaTag}\` | \`${shortSha}\` |`
: '';
// Collect previous SHA rows from existing comment
let previousShaRows = [];
@ -96,9 +103,11 @@ jobs:
}
// Remove duplicate if this SHA was already recorded
previousShaRows = previousShaRows.filter(r => !r.includes(shaTag));
if (shaTag) {
previousShaRows = previousShaRows.filter(r => !r.includes(shaTag));
}
const allShaRows = [newShaRow, ...previousShaRows].join('\n');
const allShaRows = [newShaRow, ...previousShaRows].filter(Boolean).join('\n');
const body = [
marker,

View File

@ -0,0 +1,29 @@
name: Close stale "waiting for reply" issues
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
permissions:
issues: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
only-labels: 'waiting for reply'
days-before-issue-stale: 30
days-before-issue-close: 0
stale-issue-label: 'waiting for reply'
remove-stale-when-updated: false
close-issue-message: >
Closing this for now since we haven't heard back on the follow-up
questions. If you're still seeing this on a recent version, just
drop a comment with the requested info and we'll reopen. Thanks
for the report!
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 100

View File

@ -178,16 +178,6 @@ jobs:
test:
- feature
- web
- e2e-api
exclude:
- db: sqlite
test: e2e-api
- db: postgres
test: e2e-api
- db: mysql
test: e2e-api
- db: paradedb
test: e2e-api
services:
db-mysql:
image: ${{ matrix.db == 'mysql' && 'mariadb:12@sha256:5b6a1eac15b85b981a61afb89aea2a22bf76b5f58809d05f0bcc13ab6ec44cb8' || '' }}
@ -256,6 +246,48 @@ jobs:
chmod +x mage-static
./mage-static test:${{ matrix.test }}
test-caldav:
runs-on: ubuntu-latest
needs:
- mage
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: test
run: |
mkdir -p frontend/dist
touch frontend/dist/index.html
chmod +x mage-static
./mage-static test:caldav
test-e2e-api:
runs-on: ubuntu-latest
needs:
- mage
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: test
run: |
mkdir -p frontend/dist
touch frontend/dist/index.html
chmod +x mage-static
./mage-static test:e2e-api
test-s3-integration:
runs-on: ubuntu-latest
needs:

View File

@ -941,8 +941,8 @@ packages:
lodash.union@4.6.0:
resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==}
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
log-symbols@4.1.0:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
@ -1723,7 +1723,7 @@ snapshots:
dependencies:
debug: 4.4.3
fs-extra: 9.1.0
lodash: 4.17.23
lodash: 4.18.1
tmp-promise: 3.0.3
transitivePeerDependencies:
- supports-color
@ -2806,7 +2806,7 @@ snapshots:
lodash.union@4.6.0: {}
lodash@4.17.23: {}
lodash@4.18.1: {}
log-symbols@4.1.0:
dependencies:

View File

@ -84,7 +84,6 @@
"dompurify": "3.3.2",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
"flexsearch": "0.8.212",
"floating-vue": "5.2.2",
"is-touch-device": "1.0.1",
"klona": "2.0.6",
@ -114,10 +113,11 @@
"@tsconfig/node24": "24.0.4",
"@types/codemirror": "5.60.17",
"@types/is-touch-device": "1.0.3",
"@types/node": "24.12.0",
"@types/node": "24.12.2",
"@types/sortablejs": "1.15.9",
"@typescript-eslint/eslint-plugin": "8.58.0",
"@typescript-eslint/parser": "8.58.0",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.58.1",
"@typescript-eslint/parser": "8.58.1",
"@vitejs/plugin-vue": "6.0.5",
"@vue/eslint-config-typescript": "14.7.0",
"@vue/test-utils": "2.4.6",
@ -125,20 +125,20 @@
"@vueuse/shared": "14.2.1",
"autoprefixer": "10.4.27",
"browserslist": "4.28.2",
"caniuse-lite": "1.0.30001784",
"caniuse-lite": "1.0.30001787",
"csstype": "3.2.3",
"esbuild": "0.27.5",
"esbuild": "0.28.0",
"eslint": "9.39.4",
"eslint-plugin-depend": "1.5.0",
"eslint-plugin-vue": "10.8.0",
"happy-dom": "20.8.9",
"histoire": "1.0.0-beta.1",
"postcss": "8.5.8",
"postcss": "8.5.9",
"postcss-easing-gradients": "3.0.1",
"postcss-preset-env": "11.2.0",
"rollup": "4.60.1",
"rollup-plugin-visualizer": "6.0.11",
"sass-embedded": "1.98.0",
"sass-embedded": "1.99.0",
"stylelint": "17.6.0",
"stylelint-config-property-sort-order-smacss": "10.0.0",
"stylelint-config-recommended-vue": "1.6.1",
@ -147,14 +147,15 @@
"tailwindcss": "4.2.2",
"typescript": "5.9.3",
"unplugin-inject-preload": "3.0.0",
"vite": "7.3.1",
"vite": "7.3.2",
"vite-plugin-pwa": "1.2.0",
"vite-plugin-vue-devtools": "8.1.1",
"vite-svg-loader": "5.1.1",
"vitest": "4.1.2",
"vitest": "4.1.3",
"vue-tsc": "3.2.6",
"wait-on": "9.0.4",
"workbox-cli": "7.4.0"
"workbox-cli": "7.4.0",
"ws": "8.20.0"
},
"pnpm": {
"onlyBuiltDependencies": [

File diff suppressed because it is too large Load Diff

View File

@ -751,6 +751,7 @@ onUnmounted(() => {
<style scoped lang="scss">
.gantt-container {
overflow-x: auto;
min-inline-size: 100%;
}
.gantt-chart-wrapper {

View File

@ -86,6 +86,7 @@ import {useProjectStore} from '@/stores/projects'
import {useRouteWithModal} from '@/composables/useRouteWithModal'
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
import {useSidebarResize} from '@/composables/useSidebarResize'
import {useWebSocket} from '@/composables/useWebSocket'
import {useAuthStore} from '@/stores/auth'
const authStore = useAuthStore()
@ -136,6 +137,9 @@ watch(() => route.name as string, (routeName) => {
useRenewTokenOnFocus()
const {connect} = useWebSocket()
connect()
const labelStore = useLabelStore()
labelStore.loadAllLabels()

View File

@ -18,15 +18,6 @@
:class="{ 'project-is-collapsed': !childProjectsOpen }"
/>
</BaseButton>
<span
v-if="canEditOrder && project.id > 0 && project.maxPermission !== null && project.maxPermission > PERMISSIONS.READ"
class="icon menu-item-icon handle drag-handle-standalone"
@mousedown.stop
@click.stop.prevent
@touchstart.stop
>
<Icon icon="grip-lines" />
</span>
<BaseButton
:to="{ name: 'project.index', params: { projectId: project.id} }"
class="list-menu-link"
@ -48,6 +39,15 @@
>
<Icon icon="filter" />
</span>
<span
v-if="canEditOrder && project.id > 0 && project.maxPermission !== null && project.maxPermission > PERMISSIONS.READ"
class="icon menu-item-icon handle drag-handle"
@mousedown.stop
@click.stop.prevent
@touchstart.stop
>
<Icon icon="grip-lines" />
</span>
</div>
<span class="project-menu-title">{{ getProjectTitle(project) }}</span>
</BaseButton>
@ -221,7 +221,7 @@ const canToggleFavorite = computed(() => {
opacity: 1;
}
.list-menu:hover > div > .drag-handle-standalone {
.list-menu:hover .color-bubble-wrapper > .drag-handle {
opacity: 1;
}
@ -252,16 +252,15 @@ const canToggleFavorite = computed(() => {
}
}
.drag-handle-standalone {
inline-size: 1rem;
block-size: 1rem;
.drag-handle {
opacity: 0;
cursor: grab;
transition: opacity $transition;
z-index: 2;
position: absolute;
inset-inline-start: 2.15rem;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
&:active {
cursor: grabbing;
@ -279,7 +278,7 @@ const canToggleFavorite = computed(() => {
}
@media (pointer: coarse) {
.drag-handle-standalone {
.drag-handle {
display: none !important;
}
}

View File

@ -37,6 +37,7 @@ import {
faEyeSlash,
faFile,
faFileImage,
faFilePdf,
faFillDrip,
faFilter,
faForward,
@ -111,6 +112,7 @@ library.add(faSquareCheck)
library.add(faTable)
library.add(faFile)
library.add(faFileImage)
library.add(faFilePdf)
library.add(faCheckSquare)
library.add(faStrikethrough)
library.add(faCode)

View File

@ -83,10 +83,11 @@
</template>
<script lang="ts" setup>
import {computed, onMounted, onUnmounted, ref} from 'vue'
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
import {useRouter, isNavigationFailure, NavigationFailureType, RouteLocationRaw} from 'vue-router'
import NotificationService from '@/services/notification'
import NotificationModel from '@/models/notification'
import BaseButton from '@/components/base/BaseButton.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import User from '@/components/misc/User.vue'
@ -95,11 +96,12 @@ import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {formatDateLong, formatDisplayDate} from '@/helpers/time/formatDate'
import {getDisplayName} from '@/models/user'
import {useAuthStore} from '@/stores/auth'
import {useWebSocket} from '@/composables/useWebSocket'
import XButton from '@/components/input/Button.vue'
import {success} from '@/message'
import {useI18n} from 'vue-i18n'
const LOAD_NOTIFICATIONS_INTERVAL = 10000
const {subscribe, connected: wsConnected} = useWebSocket()
const authStore = useAuthStore()
const router = useRouter()
@ -117,26 +119,68 @@ const notifications = computed(() => {
})
const userInfo = computed(() => authStore.info)
let interval: ReturnType<typeof setInterval>
let unsubscribeWs: (() => void) | null = null
let pollInterval: ReturnType<typeof setInterval> | null = null
const POLL_INTERVAL = 10000
onMounted(async () => {
// Initial load via REST - wrapped in try/catch so the rest of setup
// (click handler, WS subscription, polling) still runs if this fails
try {
await loadNotifications()
} catch (e) {
console.warn('Failed to load initial notifications:', e)
}
onMounted(() => {
loadNotifications()
document.addEventListener('click', hidePopup)
document.addEventListener('visibilitychange', loadNotifications)
interval = setInterval(loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
// Subscribe to real-time notifications
unsubscribeWs = subscribe('notification.created', (msg) => {
if (msg.event === 'notification.created' && msg.data) {
const notification = new NotificationModel(msg.data as Partial<INotification>)
// Avoid duplicates if the same notification was already loaded via REST
const exists = allNotifications.value.some(n => n.id === notification.id)
if (!exists) {
allNotifications.value = [notification, ...allNotifications.value]
}
}
})
// Fallback polling when WebSocket is not available
startPollingFallback()
})
// Reload notifications when WebSocket disconnects to catch any events
// that may have been missed during the disconnect window
watch(wsConnected, (isConnected, wasConnected) => {
if (wasConnected && !isConnected) {
loadNotifications().catch(e => console.warn('Failed to reload notifications after WS disconnect:', e))
}
})
onUnmounted(() => {
document.removeEventListener('click', hidePopup)
document.removeEventListener('visibilitychange', loadNotifications)
clearInterval(interval)
unsubscribeWs?.()
stopPollingFallback()
})
async function loadNotifications() {
if (document.visibilityState !== 'visible') {
return
function startPollingFallback() {
pollInterval = setInterval(async () => {
if (!wsConnected.value && document.visibilityState === 'visible') {
await loadNotifications()
}
}, POLL_INTERVAL)
}
function stopPollingFallback() {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
// We're recreating the notification service here to make sure it uses the latest api user token
}
async function loadNotifications() {
const notificationService = new NotificationService()
allNotifications.value = await notificationService.getAll()
}

View File

@ -55,10 +55,6 @@
</Card>
</template>
<script lang="ts">
export const ALPHABETICAL_SORT = 'title'
</script>
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'

View File

@ -0,0 +1,132 @@
<template>
<Popup>
<template #trigger="{toggle}">
<XButton
variant="secondary"
icon="sort"
@click.prevent.stop="toggle()"
>
{{ $t('project.list.sort') }}
</XButton>
</template>
<template #content="{close}">
<Card class="sort-popup">
<p class="sort-description has-text-grey is-size-7">
{{ $t('sorting.description') }}
</p>
<div class="field">
<div class="select is-fullwidth">
<select v-model="selected">
<option
v-for="o in options"
:key="o.value"
:value="o.value"
>
{{ o.label }}
</option>
</select>
</div>
</div>
<div class="actions">
<XButton
variant="tertiary"
@click="close()"
>
{{ $t('misc.cancel') }}
</XButton>
<XButton
variant="primary"
@click="applySort(close)"
>
{{ $t('sorting.apply') }}
</XButton>
</div>
</Card>
</template>
</Popup>
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import XButton from '@/components/input/Button.vue'
import Popup from '@/components/misc/Popup.vue'
import Card from '@/components/misc/Card.vue'
import type {SortBy} from '@/composables/useTaskList'
const props = defineProps<{ modelValue: SortBy }>()
const emit = defineEmits<{ 'update:modelValue': [value: SortBy] }>()
const {t} = useI18n({useScope: 'global'})
const MANUAL = 'position:asc'
const selected = ref<string>(MANUAL)
watch(() => props.modelValue, (val) => {
const key = Object.keys(val)[0]
if (!key || key === 'position') {
selected.value = MANUAL
return
}
const order = (val as Record<string, 'asc' | 'desc'>)[key] ?? 'asc'
selected.value = `${key}:${order}`
}, {immediate: true})
const options = computed(() => {
const manual = {value: MANUAL, label: t('sorting.manually')}
const rest = [
{value: 'title:asc', label: t('sorting.options.titleAsc')},
{value: 'title:desc', label: t('sorting.options.titleDesc')},
{value: 'priority:desc', label: t('sorting.options.priorityDesc')},
{value: 'priority:asc', label: t('sorting.options.priorityAsc')},
{value: 'due_date:asc', label: t('sorting.options.dueDateAsc')},
{value: 'due_date:desc', label: t('sorting.options.dueDateDesc')},
{value: 'start_date:asc', label: t('sorting.options.startDateAsc')},
{value: 'start_date:desc', label: t('sorting.options.startDateDesc')},
{value: 'end_date:asc', label: t('sorting.options.endDateAsc')},
{value: 'end_date:desc', label: t('sorting.options.endDateDesc')},
{value: 'percent_done:desc', label: t('sorting.options.percentDoneDesc')},
{value: 'percent_done:asc', label: t('sorting.options.percentDoneAsc')},
{value: 'created:desc', label: t('sorting.options.createdDesc')},
{value: 'created:asc', label: t('sorting.options.createdAsc')},
{value: 'updated:desc', label: t('sorting.options.updatedDesc')},
{value: 'updated:asc', label: t('sorting.options.updatedAsc')},
].sort((a, b) => a.label.localeCompare(b.label))
return [manual, ...rest]
})
function applySort(close: () => void) {
const [field, order] = selected.value.split(':') as [string, 'asc' | 'desc']
const sort: SortBy = {} as SortBy
;(sort as Record<string, 'asc' | 'desc'>)[field] = order
emit('update:modelValue', sort)
close()
}
</script>
<style scoped lang="scss">
.sort-popup {
margin: 0;
min-inline-size: 18rem;
:deep(.card-content .content) {
display: flex;
flex-direction: column;
}
.sort-description {
margin-block-end: 1rem;
}
.field {
margin-block-end: 1rem;
}
.actions {
display: flex;
justify-content: flex-end;
gap: .5rem;
}
}
</style>

View File

@ -146,13 +146,11 @@ const flatPickerDateRange = computed<Date[]>({
},
})
const initialDateRange = [filters.value.dateFrom, filters.value.dateTo]
const {t} = useI18n({useScope: 'global'})
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatShort'),
altInput: true,
defaultDate: initialDateRange,
defaultDate: [filters.value.dateFrom, filters.value.dateTo],
enableTime: false,
mode: 'range',
locale: useFlatpickrLanguage().value,
@ -162,6 +160,8 @@ const flatPickerConfig = computed(() => ({
<style lang="scss" scoped>
.gantt-chart-container {
padding-block-end: 1rem;
position: relative;
z-index: 0;
}
.gantt-options {

View File

@ -7,12 +7,15 @@
>
<template #header>
<div class="filter-container">
<SortPopup
v-model="sortByParam"
/>
<FilterPopup
v-if="!isSavedFilter(project)"
v-model="params"
:view-id="viewId"
:project-id="projectId"
@update:modelValue="prepareFiltersAndLoadTasks()"
@update:modelValue="loadTasks()"
/>
</div>
</template>
@ -49,13 +52,13 @@
v-if="tasks && tasks.length > 0"
v-model="tasks"
:group="{name: 'tasks', put: false}"
:disabled="!canDragTasks"
:disabled="!canDragTasks || !isPositionSorting"
item-key="id"
tag="ul"
:component-data="{
class: {
tasks: true,
'dragging-disabled': !canDragTasks || isAlphabeticalSorting
'dragging-disabled': !canDragTasks || !isPositionSorting
},
type: 'transition-group'
}"
@ -71,14 +74,13 @@
<SingleTaskInProject
:ref="(el) => setTaskRef(el, index)"
:show-list-color="false"
:disabled="!canDragTasks"
:can-mark-as-done="canWrite || isPseudoProject"
:the-task="t"
:all-tasks="allTasks"
@taskUpdated="updateTasks"
>
<span
v-if="canDragTasks"
v-if="canDragTasks && isPositionSorting"
class="icon handle"
>
<Icon icon="grip-lines" />
@ -109,7 +111,7 @@ import SingleTaskInProject from '@/components/tasks/partials/SingleTaskInProject
import FilterPopup from '@/components/project/partials/FilterPopup.vue'
import Nothing from '@/components/misc/Nothing.vue'
import Pagination from '@/components/misc/Pagination.vue'
import {ALPHABETICAL_SORT} from '@/components/project/partials/Filters.vue'
import SortPopup from '@/components/project/partials/SortPopup.vue'
import {useTaskList} from '@/composables/useTaskList'
import {useTaskDragToProject} from '@/composables/useTaskDragToProject'
@ -167,13 +169,12 @@ const tasks = ref<ITask[]>([])
watch(
allTasks,
() => {
tasks.value = ([...allTasks.value]).filter(t => shouldShowTaskInListView(t, allTasks.value))
const isFiltered = isSavedFilter({id: projectId.value})
tasks.value = ([...allTasks.value]).filter(t => shouldShowTaskInListView(t, allTasks.value, isFiltered))
},
)
const isAlphabeticalSorting = computed(() => {
return params.value.sort_by.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
})
const isPositionSorting = computed(() => 'position' in sortByParam.value)
const firstNewPosition = computed(() => {
if (tasks.value.length === 0) {
@ -214,7 +215,7 @@ function focusNewTaskInput() {
}
function updateTaskList(task: ITask) {
if (isAlphabeticalSorting.value) {
if (!isPositionSorting.value) {
// reload tasks with current filter and sorting
loadTasks()
} else {
@ -286,15 +287,6 @@ async function saveTaskPosition(e: { originalEvent?: MouseEvent, to: HTMLElement
}
}
function prepareFiltersAndLoadTasks() {
if (isAlphabeticalSorting.value) {
sortByParam.value = {}
sortByParam.value[ALPHABETICAL_SORT] = 'asc'
}
loadTasks()
}
const taskRefs = ref<(InstanceType<typeof SingleTaskInProject> | null)[]>([])
const focusedIndex = ref(-1)
@ -364,6 +356,18 @@ onBeforeUnmount(() => {
</script>
<style lang="scss" scoped>
.filter-container {
display: flex;
align-items: center;
gap: .5rem;
:deep(.popup) {
inset-block-start: 3rem;
inset-inline-end: 0;
max-inline-size: 300px;
}
}
.tasks {
padding: .5rem;
}

View File

@ -95,7 +95,7 @@
<Icon icon="trash-alt" />
</BaseButton>
<BaseButton
v-if="editEnabled && canPreview(a)"
v-if="editEnabled && canPreviewImage(a)"
v-tooltip="task.coverImageAttachmentId === a.id
? $t('task.attachment.unsetAsCover')
: $t('task.attachment.setAsCover')"
@ -168,6 +168,19 @@
alt=""
>
</Modal>
<!-- Attachment PDF modal -->
<Modal
:enabled="attachmentPdfBlobUrl !== null"
:wide="true"
@close="attachmentPdfBlobUrl = null"
>
<iframe
v-if="attachmentPdfBlobUrl"
:src="attachmentPdfBlobUrl"
class="pdf-preview-iframe"
/>
</Modal>
</div>
</template>
@ -180,7 +193,7 @@ import ProgressBar from '@/components/misc/ProgressBar.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import AttachmentService from '@/services/attachment'
import {canPreview} from '@/models/attachment'
import {canPreviewImage, canPreviewPdf} from '@/models/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment'
import type {ITask} from '@/modelTypes/ITask'
@ -365,10 +378,13 @@ async function deleteAttachment() {
}
const attachmentImageBlobUrl = ref<string | null>(null)
const attachmentPdfBlobUrl = ref<string | null>(null)
async function viewOrDownload(attachment: IAttachment) {
if (canPreview(attachment)) {
if (canPreviewImage(attachment)) {
attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
} else if (canPreviewPdf(attachment)) {
attachmentPdfBlobUrl.value = await attachmentService.getBlobUrl(attachment)
} else {
downloadAttachment(attachment)
}
@ -576,6 +592,15 @@ defineExpose({
block-size: 100%;
}
.pdf-preview-iframe {
inline-size: 100%;
max-inline-size: calc(100% - 4rem);
block-size: calc(100vh - var(--modal-content-spacing-tablet));
border: none;
margin: 0 auto;
display: block;
}
.is-task-cover {
background: var(--primary);
color: var(--white);

View File

@ -0,0 +1,201 @@
<template>
<template v-if="kanbanView">
<span class="has-text-grey-light"> &gt; </span>
<template v-if="canWrite">
<Dropdown>
<template #trigger="{toggleOpen}">
<BaseButton
class="bucket-name"
@click="toggleOpen"
>
{{ currentBucketTitle }}
<Icon
icon="pencil-alt"
class="change-indicator"
/>
</BaseButton>
</template>
<DropdownItem
v-for="bucket in buckets"
:key="bucket.id"
:class="{'is-active': currentBucket?.id === bucket.id}"
@click="changeBucket(bucket)"
>
{{ bucket.title }}
</DropdownItem>
</Dropdown>
</template>
<span
v-else
class="bucket-name"
>
{{ currentBucketTitle }}
</span>
</template>
</template>
<script lang="ts" setup>
import {ref, computed, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import type {ITask} from '@/modelTypes/ITask'
import type {IBucket} from '@/modelTypes/IBucket'
import {PROJECT_VIEW_KINDS} from '@/modelTypes/IProjectView'
import {useProjectStore} from '@/stores/projects'
import {useKanbanStore} from '@/stores/kanban'
import {useBaseStore} from '@/stores/base'
import BaseButton from '@/components/base/BaseButton.vue'
import Dropdown from '@/components/misc/Dropdown.vue'
import DropdownItem from '@/components/misc/DropdownItem.vue'
import BucketService from '@/services/bucket'
import TaskBucketService from '@/services/taskBucket'
import TaskBucketModel from '@/models/taskBucket'
import {success} from '@/message'
const props = defineProps<{
task: ITask
canWrite: boolean
}>()
const emit = defineEmits<{
'update:task': [task: ITask]
}>()
const {t} = useI18n({useScope: 'global'})
const projectStore = useProjectStore()
const kanbanStore = useKanbanStore()
const baseStore = useBaseStore()
const project = computed(() => projectStore.projects[props.task.projectId])
// If the project has exactly one manual kanban view, always use it.
// If there are multiple, only show the selector when the active view is one of them.
const kanbanView = computed(() => {
if (!project.value?.views) {
return null
}
const manualKanbanViews = project.value.views.filter(
v => v.viewKind === PROJECT_VIEW_KINDS.KANBAN
&& v.bucketConfigurationMode === 'manual',
)
if (manualKanbanViews.length === 1) {
return manualKanbanViews[0]
}
if (manualKanbanViews.length > 1) {
const activeViewId = baseStore.currentProjectViewId
return manualKanbanViews.find(v => v.id === activeViewId) || null
}
return null
})
const buckets = ref<IBucket[]>([])
watch(
() => kanbanView.value,
async (view) => {
if (!view) {
buckets.value = []
return
}
const bucketService = new BucketService()
try {
buckets.value = await bucketService.getAll({
projectId: props.task.projectId,
projectViewId: view.id,
} as IBucket)
} catch (e) {
console.error('Failed to load buckets:', e)
}
},
{immediate: true},
)
const currentBucket = computed(() => {
if (!kanbanView.value) {
return undefined
}
return props.task.buckets?.find(b => b.projectViewId === kanbanView.value.id)
})
const currentBucketTitle = computed(() => {
return currentBucket.value?.title || t('task.detail.noBucket')
})
async function changeBucket(bucket: IBucket) {
if (!kanbanView.value || currentBucket.value?.id === bucket.id) {
return
}
const taskBucketService = new TaskBucketService()
const updatedTaskBucket = await taskBucketService.update(new TaskBucketModel({
taskId: props.task.id,
bucketId: bucket.id,
projectViewId: kanbanView.value.id,
projectId: props.task.projectId,
}))
const updatedBuckets = (props.task.buckets || []).map(b => {
if (b.projectViewId === kanbanView.value.id) {
return {...bucket}
}
return b
})
if (!updatedBuckets.find(b => b.projectViewId === kanbanView.value.id)) {
updatedBuckets.push({...bucket})
}
kanbanStore.moveTaskToBucket(props.task, bucket.id)
// Only pick up done state from the response since moving to/from the
// done bucket can toggle it. Spreading the full response task would
// overwrite fields like maxPermission that are not part of this endpoint.
const updatedTask = {
...props.task,
done: updatedTaskBucket.task?.done ?? props.task.done,
doneAt: updatedTaskBucket.task?.doneAt ?? props.task.doneAt,
buckets: updatedBuckets,
bucketId: bucket.id,
}
emit('update:task', updatedTask)
success({message: t('task.detail.bucketChangedSuccess')})
}
</script>
<style lang="scss" scoped>
.bucket-name {
color: var(--grey-800);
&:hover {
color: var(--primary);
}
}
.change-indicator {
font-size: .75em;
margin-inline-start: .25rem;
color: var(--grey-400);
}
:deep(.dropdown) {
display: inline;
}
:deep(.dropdown-trigger) {
display: inline;
padding: 0;
}
</style>

View File

@ -6,6 +6,17 @@
alt="Attachment preview"
>
<!-- PDF icon -->
<div
v-else-if="isPdf"
class="icon-wrapper"
>
<Icon
size="6x"
icon="file-pdf"
/>
</div>
<!-- Fallback -->
<div
v-else
@ -19,10 +30,10 @@
</template>
<script setup lang="ts">
import {ref, shallowReactive, watchEffect} from 'vue'
import {computed, ref, shallowReactive, watchEffect} from 'vue'
import AttachmentService, {PREVIEW_SIZE} from '@/services/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment'
import {canPreview} from '@/models/attachment'
import {canPreviewImage, canPreviewPdf} from '@/models/attachment'
const props = defineProps<{
modelValue?: IAttachment
@ -30,9 +41,10 @@ const props = defineProps<{
const attachmentService = shallowReactive(new AttachmentService())
const blobUrl = ref<string | undefined>(undefined)
const isPdf = computed(() => props.modelValue && canPreviewPdf(props.modelValue))
watchEffect(async () => {
if (props.modelValue && canPreview(props.modelValue)) {
if (props.modelValue && canPreviewImage(props.modelValue)) {
blobUrl.value = await attachmentService.getBlobUrl(props.modelValue, PREVIEW_SIZE.MD)
}
})

View File

@ -12,12 +12,16 @@
@click="openTaskDetail"
@keyup.enter="openTaskDetail"
>
<FancyCheckbox
v-model="task.done"
:disabled="(isArchived || disabled) && !canMarkAsDone"
@update:modelValue="markAsDone"
@click.stop
/>
<span
v-tooltip="!canMarkAsDone ? $t('task.readOnlyCheckbox') : ''"
>
<FancyCheckbox
v-model="task.done"
:disabled="isArchived || disabled || !canMarkAsDone"
@update:modelValue="markAsDone"
@click.stop
/>
</span>
<ColorBubble
v-if="!showProjectSeparately && projectColor !== '' && currentProject?.id !== task.projectId"

View File

@ -0,0 +1,122 @@
import {describe, it, expect, beforeEach} from 'vitest'
import {setActivePinia, createPinia} from 'pinia'
import {createI18n} from 'vue-i18n'
import {defineComponent, h, ref, type Ref} from 'vue'
import {mount} from '@vue/test-utils'
import {useDaytimeSalutation} from './useDaytimeSalutation'
import {useAuthStore} from '@/stores/auth'
import {AUTH_TYPES} from '@/modelTypes/IUser'
import en from '@/i18n/lang/en.json'
function makeDate(iso: string): Date {
return new Date(iso)
}
function makeI18n() {
return createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
messages: {en},
})
}
function runSalutation(now: Ref<Date>): string | undefined {
let result: string | undefined
const Comp = defineComponent({
setup() {
const s = useDaytimeSalutation(now)
result = s.value
return () => h('div')
},
})
mount(Comp, {global: {plugins: [makeI18n()]}})
return result
}
function setUser() {
const authStore = useAuthStore()
authStore.setUser({
id: 42,
name: 'Ada',
username: 'ada',
type: AUTH_TYPES.LINK_SHARE,
created: new Date('2024-01-15T10:00:00Z'),
} as never, false)
}
describe('useDaytimeSalutation', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('returns undefined when the user has no display name', () => {
const now = ref(makeDate('2026-04-06T09:00:00'))
expect(runSalutation(now)).toBeUndefined()
})
it('is deterministic for the same user, date, and bucket', () => {
setUser()
const now = ref(makeDate('2026-04-06T09:00:00'))
const first = runSalutation(now)
const second = runSalutation(now)
expect(first).toBeDefined()
expect(first).toBe(second)
})
it('produces a string from the morning pool on a Monday morning', () => {
setUser()
const now = ref(makeDate('2026-04-06T09:00:00'))
const result = runSalutation(now)
expect(result).toContain('Ada')
const morningStrings = [
'Good Morning Ada!',
'Hey Ada, ready to go?',
'Fresh start, Ada',
'Coffee and tasks, Ada?',
'Rise and plan, Ada',
'Welcome back, Ada',
'Fresh week, Ada',
]
expect(morningStrings).toContain(result)
})
it('includes the Friday extra in the pool on Friday morning', () => {
setUser()
const reachable = new Set<string>()
for (let day = 3; day <= 31; day += 7) {
const iso = `2026-04-${String(day).padStart(2, '0')}T09:00:00`
const r = runSalutation(ref(makeDate(iso)))
if (r) reachable.add(r)
}
expect(reachable.size).toBeGreaterThan(1)
})
it('uses different buckets for different hours', () => {
setUser()
const dateStr = '2026-04-06'
const morning = runSalutation(ref(makeDate(`${dateStr}T09:00:00`)))
const day = runSalutation(ref(makeDate(`${dateStr}T14:00:00`)))
const evening = runSalutation(ref(makeDate(`${dateStr}T20:00:00`)))
const night = runSalutation(ref(makeDate(`${dateStr}T02:00:00`)))
expect(morning).toBeDefined()
expect(day).toBeDefined()
expect(evening).toBeDefined()
expect(night).toBeDefined()
expect(new Set([morning, day, evening, night]).size).toBeGreaterThan(1)
})
it('produces different results across consecutive days', () => {
setUser()
const results = new Set<string>()
for (let day = 1; day <= 14; day++) {
const iso = `2026-04-${String(day).padStart(2, '0')}T09:00:00`
results.add(runSalutation(ref(makeDate(iso))) ?? '')
}
expect(results.size).toBeGreaterThan(1)
})
})

View File

@ -1,26 +1,95 @@
import {computed, onActivated, ref} from 'vue'
import {computed, onActivated, ref, type Ref} from 'vue'
import {useI18n} from 'vue-i18n'
import {useAuthStore} from '@/stores/auth'
import {hourToDaytime} from '@/helpers/hourToDaytime'
import {stringHash} from '@/helpers/stringHash'
export type Daytime = 'night' | 'morning' | 'day' | 'evening'
export function useDaytimeSalutation() {
// Base i18n keys for each bucket. Existing keys (welcomeNight/Morning/Day/Evening)
// are kept as the first entry of their respective pool so prior translations remain valid.
const basePools: Record<Daytime, string[]> = {
night: [
'home.welcomeNight',
'home.welcomeNightOwl',
'home.welcomeNightBurning',
'home.welcomeNightQuiet',
'home.welcomeNightLate',
'home.welcomeNightMoonlit',
],
morning: [
'home.welcomeMorning',
'home.welcomeMorningHey',
'home.welcomeMorningFresh',
'home.welcomeMorningCoffee',
'home.welcomeMorningRise',
'home.welcomeMorningBack',
],
day: [
'home.welcomeDay',
'home.welcomeDayBack',
'home.welcomeDayFocus',
'home.welcomeDayKeepGoing',
'home.welcomeDayWhatsNext',
'home.welcomeDayGood',
],
evening: [
'home.welcomeEvening',
'home.welcomeEveningWind',
'home.welcomeEveningReturns',
'home.welcomeEveningWrap',
'home.welcomeEveningOneMore',
'home.welcomeEveningStill',
],
}
// One entry per weekday (index = Date.getDay(), Sunday = 0). Appended to the
// morning pool only, on its matching day.
const morningWeekdayExtras: (string | null)[] = [
'home.welcomeSundaySession', // 0 Sun
'home.welcomeMondayFresh', // 1 Mon
'home.welcomeTuesday', // 2 Tue
'home.welcomeWednesdayMid', // 3 Wed
'home.welcomeThursday', // 4 Thu
'home.welcomeFridayPush', // 5 Fri
'home.welcomeSaturday', // 6 Sat
]
function poolFor(bucket: Daytime, now: Date): string[] {
if (bucket !== 'morning') {
return basePools[bucket]
}
const extra = morningWeekdayExtras[now.getDay()]
return extra ? [...basePools.morning, extra] : basePools.morning
}
function dateKey(now: Date): string {
return `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`
}
export function useDaytimeSalutation(now?: Ref<Date>) {
const {t} = useI18n({useScope: 'global'})
const now = ref(new Date())
onActivated(() => now.value = new Date())
const internalNow = ref(new Date())
const currentDate = now ?? internalNow
onActivated(() => {
internalNow.value = new Date()
})
const authStore = useAuthStore()
const name = computed(() => authStore.userDisplayName)
const daytime = computed(() => hourToDaytime(now.value))
// Use the user's created timestamp as the per-user hash component.
// It's stable, unique per user, and doesn't leak the sequential user id.
const userKey = computed(() => authStore.info?.created?.getTime() ?? 0)
const bucket = computed(() => hourToDaytime(currentDate.value))
const salutations = {
'night': () => t('home.welcomeNight', {username: name.value}),
'morning': () => t('home.welcomeMorning', {username: name.value}),
'day': () => t('home.welcomeDay', {username: name.value}),
'evening': () => t('home.welcomeEvening', {username: name.value}),
} as Record<Daytime, () => string>
return computed(() => name.value ? salutations[daytime.value]() : undefined)
return computed(() => {
if (!name.value) {
return undefined
}
const pool = poolFor(bucket.value, currentDate.value)
const key = `${dateKey(currentDate.value)}_${bucket.value}_${userKey.value}`
const index = stringHash(key) % pool.length
return t(pool[index], {username: name.value})
})
}

View File

@ -79,12 +79,15 @@ export function useRouteWithModal() {
// Only navigate if we have a valid project and view
if (baseStore.currentProject.id && viewId) {
// Preserve query parameters (e.g., date range) from the backdrop view
const backdropRoute = historyState.value?.backdropView && router.resolve(historyState.value.backdropView)
const newRoute = {
name: 'project.view',
params: {
projectId: baseStore.currentProject.id,
viewId,
},
query: backdropRoute?.query || {},
}
router.push(newRoute)

View File

@ -27,6 +27,32 @@ export interface SortBy {
created?: Order
updated?: Order
done_at?: Order,
position?: Order,
}
const VALID_SORT_FIELDS = new Set<string>(
['id', 'index', 'done', 'title', 'priority', 'due_date', 'start_date',
'end_date', 'percent_done', 'created', 'updated', 'done_at', 'position'],
)
function parseSortQuery(raw: string, fallback: SortBy): SortBy {
const result: Record<string, Order> = {}
for (const part of raw.split(',')) {
const [field, order] = part.split(':')
if (!VALID_SORT_FIELDS.has(field)) continue
if (order !== 'asc' && order !== 'desc') continue
result[field] = order
}
return Object.keys(result).length > 0 ? result as SortBy : {...fallback}
}
function serializeSortBy(sortBy: SortBy, defaultSort: SortBy): string | undefined {
const keys = Object.keys(sortBy) as (keyof SortBy)[]
const defaultKeys = Object.keys(defaultSort) as (keyof SortBy)[]
const isDefault = keys.length === defaultKeys.length &&
keys.every(k => sortBy[k] === defaultSort[k])
if (isDefault) return undefined
return keys.map(k => `${k}:${sortBy[k]}`).join(',')
}
const SORT_BY_DEFAULT: SortBy = {
@ -80,8 +106,19 @@ export function useTaskList(
watch(() => params.value.filter, v => { filter.value = v || undefined })
watch(() => params.value.s, v => { s.value = v || undefined })
const sortBy = ref({ ...sortByDefault })
const sortQuery = useRouteQuery('sort')
const sortBy = computed<SortBy>({
get() {
const raw = sortQuery.value as string | undefined
if (!raw) return {...sortByDefault}
return parseSortQuery(raw, sortByDefault)
},
set(val: SortBy) {
sortQuery.value = serializeSortBy(val, sortByDefault) || undefined
},
})
const allParams = computed(() => {
const loadParams = {...params.value}

View File

@ -177,4 +177,52 @@ describe('shouldShowTaskInListView', () => {
expect(shouldShowTaskInListView(subtask as ITask, allTasks)).toBe(false)
})
it('should show subtasks in filtered views even when parent is in the same view', () => {
const parentTask: Partial<ITask> = {
id: 1,
title: 'Parent Task',
projectId: 100,
relatedTasks: {},
}
const subtask: Partial<ITask> = {
id: 2,
title: 'Subtask',
projectId: 100,
relatedTasks: {
parenttask: [{
id: 1,
title: 'Parent Task',
projectId: 100,
} as ITask],
},
}
const allTasks = [parentTask, subtask] as ITask[]
// In a filtered view, both parent and subtask should be visible
expect(shouldShowTaskInListView(parentTask as ITask, allTasks, true)).toBe(true)
expect(shouldShowTaskInListView(subtask as ITask, allTasks, true)).toBe(true)
})
it('should show subtasks in filtered views even when only subtask matches filter', () => {
const subtask: Partial<ITask> = {
id: 2,
title: 'Subtask matching filter',
projectId: 100,
relatedTasks: {
parenttask: [{
id: 1,
title: 'Parent Task',
projectId: 100,
} as ITask],
},
}
// Only the subtask is in the results (parent didn't match filter)
const allTasks = [subtask] as ITask[]
expect(shouldShowTaskInListView(subtask as ITask, allTasks, true)).toBe(true)
})
})

View File

@ -6,14 +6,24 @@ import type {ITask} from '@/modelTypes/ITask'
* Subtasks are hidden only when their parent task is also in the current view
* (same project). Cross-project subtasks remain visible.
*
* In filtered views (saved filters), all tasks are shown regardless of parent
* presence, since the user explicitly filtered for them.
*
* @param task - The task to check
* @param allTasksInView - All tasks currently visible in the view
* @param isFilteredView - Whether the current view is a saved/custom filter
* @returns true if the task should be shown, false if it should be hidden
*/
export function shouldShowTaskInListView(
task: ITask,
allTasksInView: ITask[],
isFilteredView: boolean = false,
): boolean {
// In filtered views (saved filters), show all tasks that matched the filter
if (isFilteredView) {
return true
}
// If task has no parent, always show it
const parentTasksCount = task.relatedTasks?.parenttask?.length ?? 0
if (parentTasksCount === 0) {

View File

@ -0,0 +1,208 @@
import {ref, readonly} from 'vue'
import {getToken} from '@/helpers/auth'
type MessageCallback = (msg: WebSocketEvent) => void
interface WebSocketEvent {
event?: string
action?: string
success?: boolean
error?: string
data?: unknown
}
const RECONNECT_BASE_DELAY = 1000
const RECONNECT_MAX_DELAY = 30000
let socket: WebSocket | null = null
let reconnectAttempt = 0
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
const subscriptions = new Map<string, Set<MessageCallback>>()
const connected = ref(false)
const authenticated = ref(false)
let manuallyDisconnected = false
function getWebSocketUrl(): string {
const base = window.API_URL.replace(/\/+$/, '')
const wsProtocol = base.startsWith('https') ? 'wss' : 'ws'
return base.replace(/^https?/, wsProtocol) + '/ws'
}
function sendMessage(msg: object) {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(msg))
}
}
function sendAuth() {
const token = getToken()
if (token) {
sendMessage({action: 'auth', token})
}
}
function resubscribeAll() {
for (const event of subscriptions.keys()) {
sendMessage({action: 'subscribe', event})
}
}
function handleMessage(event: MessageEvent) {
let msg: WebSocketEvent
try {
msg = JSON.parse(event.data)
} catch {
console.warn('WebSocket: invalid message', event.data)
return
}
// Handle auth success
if (msg.action === 'auth.success' && msg.success) {
authenticated.value = true
console.debug('WebSocket: authenticated')
resubscribeAll()
return
}
// Handle auth error - treat as terminal (no reconnect) so we don't
// thrash the WS endpoint with a bad token. Fallback polling kicks in.
if (msg.error === 'invalid_token' || msg.error === 'auth_required') {
console.warn('WebSocket: auth failed:', msg.error)
manuallyDisconnected = true
authenticated.value = false
connected.value = false
socket?.close()
socket = null
return
}
// Handle regular events — route by event name
if (msg.event) {
const callbacks = subscriptions.get(msg.event)
if (callbacks) {
for (const cb of callbacks) {
cb(msg)
}
}
}
}
function scheduleReconnect() {
if (manuallyDisconnected) {
return
}
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
const baseDelay = Math.min(
RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempt),
RECONNECT_MAX_DELAY,
)
// Add ±25% jitter to prevent thundering herd on server restart
const jitter = baseDelay * (0.75 + Math.random() * 0.5)
const delay = Math.round(jitter)
reconnectAttempt++
console.debug(`WebSocket: reconnecting in ${delay}ms (attempt ${reconnectAttempt})`)
reconnectTimer = setTimeout(() => {
reconnectTimer = null
connect()
}, delay)
}
function connect() {
if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
return
}
const token = getToken()
if (!token) {
return
}
manuallyDisconnected = false
authenticated.value = false
const url = getWebSocketUrl()
try {
socket = new WebSocket(url)
} catch (e) {
console.warn('WebSocket: failed to create connection', e)
scheduleReconnect()
return
}
socket.onopen = () => {
connected.value = true
reconnectAttempt = 0
console.debug('WebSocket: connected, sending auth')
sendAuth()
}
socket.onmessage = handleMessage
socket.onclose = () => {
connected.value = false
authenticated.value = false
socket = null
scheduleReconnect()
}
socket.onerror = () => {
// onclose will fire after onerror, which handles reconnect
}
}
function disconnect() {
manuallyDisconnected = true
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
reconnectAttempt = 0
if (socket) {
socket.close()
socket = null
}
connected.value = false
authenticated.value = false
subscriptions.clear()
}
function subscribe(event: string, callback: MessageCallback): () => void {
if (!subscriptions.has(event)) {
subscriptions.set(event, new Set())
}
subscriptions.get(event)!.add(callback)
// Only send subscribe if already authenticated
// (otherwise it will be sent after auth succeeds)
if (authenticated.value) {
sendMessage({action: 'subscribe', event})
}
return () => {
const callbacks = subscriptions.get(event)
if (callbacks) {
callbacks.delete(callback)
if (callbacks.size === 0) {
subscriptions.delete(event)
sendMessage({action: 'unsubscribe', event})
}
}
}
}
export function useWebSocket() {
return {
connect,
disconnect,
subscribe,
connected: readonly(connected),
authenticated: readonly(authenticated),
}
}

View File

@ -3,9 +3,17 @@ import type {AxiosRequestConfig} from 'axios'
import {getToken, refreshToken} from '@/helpers/auth'
import {AUTH_TYPES} from '@/modelTypes/IUser'
/**
* Returns the API base URL with a guaranteed trailing slash.
*/
export function getApiBaseUrl(): string {
const url = window.API_URL
return url?.endsWith('/') ? url : url + '/'
}
export function HTTPFactory() {
const instance = axios.create({
baseURL: window.API_URL,
baseURL: getApiBaseUrl(),
// Ensure the browser sends and accepts cookies (e.g. the HttpOnly
// refresh token) even when the API is on a different origin.
withCredentials: true,
@ -14,7 +22,7 @@ export function HTTPFactory() {
instance.interceptors.request.use((config) => {
// by setting the baseURL fresh for every request
// we make sure that it is never outdated in case it is updated
config.baseURL = window.API_URL
config.baseURL = getApiBaseUrl()
return config
})
@ -30,11 +38,20 @@ async function doRefresh(): Promise<string | null> {
try {
await refreshToken(true)
return getToken()
} catch {
// Refresh failed. Don't remove the token here — in a multi-tab scenario,
// another tab may have successfully rotated the refresh token, and clearing
// localStorage would log out that tab too. Let the caller decide.
return null
} catch (_e) {
// Single retry after a short delay for transient failures (network
// blip, server restart). If this also fails, give up.
try {
await new Promise(resolve => setTimeout(resolve, 1000))
await refreshToken(true)
return getToken()
} catch (retryErr) {
// Refresh failed. Don't remove the token here — in a multi-tab scenario,
// another tab may have successfully rotated the refresh token, and clearing
// localStorage would log out that tab too. Let the caller decide.
console.warn('[Vikunja] Token refresh failed:', retryErr)
return null
}
}
}

View File

@ -0,0 +1,21 @@
import {describe, it, expect} from 'vitest'
import {stringHash} from './stringHash'
describe('stringHash', () => {
it('returns a non-negative integer', () => {
expect(stringHash('hello')).toBeGreaterThanOrEqual(0)
expect(Number.isInteger(stringHash('hello'))).toBe(true)
})
it('is deterministic for the same input', () => {
expect(stringHash('foo')).toBe(stringHash('foo'))
})
it('returns different values for different inputs', () => {
expect(stringHash('foo')).not.toBe(stringHash('bar'))
})
it('handles the empty string', () => {
expect(stringHash('')).toBeGreaterThanOrEqual(0)
})
})

View File

@ -0,0 +1,13 @@
// Deterministic non-cryptographic string hash (djb2 variant).
// Used for stable pseudo-random selection keyed on date + user + bucket.
export function stringHash(input: string): number {
// 5381 is the canonical djb2 seed — a prime that empirically yields a good
// distribution when combined with the `hash * 33 + c` step below.
let hash = 5381
for (let i = 0; i < input.length; i++) {
// hash * 33 + char, kept in 32-bit range via `| 0`.
hash = ((hash << 5) + hash + input.charCodeAt(i)) | 0
}
// Ensure non-negative.
return hash >>> 0
}

View File

@ -5,9 +5,36 @@
},
"home": {
"welcomeNight": "Gute Nacht, {username}!",
"welcomeNightOwl": "Hallo, Nacht-Eule {username}",
"welcomeNightBurning": "Machst du mal wieder die Nacht zum Tag, {username}?",
"welcomeNightQuiet": "Ruhezeit, {username}",
"welcomeNightLate": "Es ist spät, {username}",
"welcomeNightMoonlit": "Mondlicht-Planung, {username}?",
"welcomeMorning": "Guten Morgen, {username}!",
"welcomeMorningHey": "Hey {username}, los geht's?",
"welcomeMorningFresh": "Frisch in den Tag, {username}",
"welcomeMorningCoffee": "Kaffee und Aufgaben, {username}?",
"welcomeMorningRise": "Morgenplan hat Gold im Mund, {username}",
"welcomeMorningBack": "Willkommen zurück, {username}",
"welcomeMondayFresh": "Frische Woche, {username}",
"welcomeTuesday": "Fröhlichen Dienstag, {username}",
"welcomeWednesdayMid": "Bergfest, {username}",
"welcomeThursday": "Fast geschafft, {username}",
"welcomeFridayPush": "Endspurt ins Wochenende, {username}?",
"welcomeSaturday": "Wochenendmodus, {username}",
"welcomeSundaySession": "Sonntagsschicht, {username}?",
"welcomeDay": "Hallo {username}!",
"welcomeDayBack": "Wieder zurück, {username}",
"welcomeDayFocus": "Fokus, {username}",
"welcomeDayKeepGoing": "Weiter geht's, {username}",
"welcomeDayWhatsNext": "Was kommt als Nächstes, {username}?",
"welcomeDayGood": "Guten Nachmittag, {username}",
"welcomeEvening": "Guten Abend, {username}!",
"welcomeEveningWind": "Feierabend, {username}?",
"welcomeEveningReturns": "{username} kehrt zurück",
"welcomeEveningWrap": "Feierabend in Sicht, {username}?",
"welcomeEveningOneMore": "Noch eine Sache, {username}?",
"welcomeEveningStill": "Immer noch da, {username}?",
"lastViewed": "Zuletzt angesehen",
"addToHomeScreen": "Füge diese App deinem Startbildschirm hinzu, um schneller darauf zuzugreifen und das Erlebnis zu verbessern.",
"goToOverview": "Zur Übersicht",
@ -135,7 +162,13 @@
"taskAndNotifications": "Projekte & Aufgaben",
"privacy": "Privatsphäre",
"localization": "Sprachauswahl",
"appearance": "Aussehen & Verhalten"
"appearance": "Aussehen & Verhalten",
"desktop": "Desktop-App"
},
"desktop": {
"quickEntryShortcut": "Quick Entry Tastenkombination",
"shortcutRecorderPlaceholder": "Klicken, um Tastenkombination zu setzen",
"shortcutRecorderRecording": "Tastenkombination eingeben…"
},
"totp": {
"title": "Zwei-Faktor-Authentifizierung",
@ -843,6 +876,7 @@
"addReminder": "Eine Erinnerung hinzufügen…",
"doneSuccess": "Die Aufgabe wurde erfolgreich als erledigt markiert.",
"undoneSuccess": "Die Aufgabe wurde erfolgreich als nicht-erledigt markiert.",
"readOnlyCheckbox": "Du hast nur Lesezugriff auf diese Aufgabe und kannst sie nicht als erledigt markieren.",
"movedToProject": "Die Aufgabe wurde nach {project} verschoben.",
"undo": "Rückgängig",
"openDetail": "Aufgabe in der Detailansicht anzeigen",
@ -873,6 +907,8 @@
"updateSuccess": "Die Aufgabe wurde erfolgreich gespeichert.",
"deleteSuccess": "Die Aufgabe wurde erfolgreich gelöscht.",
"duplicateSuccess": "Die Aufgabe wurde erfolgreich dupliziert.",
"noBucket": "Keine Spalte",
"bucketChangedSuccess": "Die Spalte der Aufgabe wurde erfolgreich geändert.",
"belongsToProject": "Diese Aufgabe gehört zum Projekt „{project}“",
"back": "Zurück zum Projekt",
"due": "Fällig {at}",
@ -1067,6 +1103,7 @@
},
"quickAddMagic": {
"hint": "Verwende magische Präfixe, um Fälligkeitsdaten, Zuweisungen und andere Aufgabeneigenschaften zu definieren.",
"quickEntryHint": "Verwende magische Präfixe für Datum, Labels & mehr. Öffne die Hauptanwendung von Vikunja und überprüfe den Tooltip bei der Aufgabeneingabe für weitere Details.",
"title": "Quick Add Magic",
"intro": "Beim Erstellen einer Aufgabe kannst du spezielle Schlüsselwörter verwenden, um Attribute direkt zu der neu erstellten Aufgabe hinzuzufügen. Dadurch können häufig verwendete Attribute schneller zu Aufgaben hinzugefügt werden.",
"multiple": "Du kannst das mehrmals benutzen.",
@ -1243,6 +1280,7 @@
"markAllReadSuccess": "Alle Benachrichtigungen erfolgreich als gelesen markiert."
},
"quickActions": {
"notLoggedIn": "Bitte melde dich zuerst im Hauptfenster von Vikunja an.",
"commands": "Befehle",
"placeholder": "Gib einen Befehl oder eine Suche ein …",
"hint": "Du kannst {project} verwenden, um die Suche auf ein Projekt zu beschränken. Kombiniere {project} oder {label} (Labels) mit einer Suchabfrage, um eine Aufgabe mit diesen Labels oder auf diesem Projekt zu suchen. Verwende {assignee}, um nur nach Teams zu suchen.",

View File

@ -5,9 +5,36 @@
},
"home": {
"welcomeNight": "Gute Nacht, {username}!",
"welcomeNightOwl": "Hallo, Nacht-Eule {username}",
"welcomeNightBurning": "Machst du mal wieder die Nacht zum Tag, {username}?",
"welcomeNightQuiet": "Ruhezeit, {username}",
"welcomeNightLate": "Es ist spät, {username}",
"welcomeNightMoonlit": "Mondlicht-Planung, {username}?",
"welcomeMorning": "Guten Morgen, {username}!",
"welcomeMorningHey": "Hey {username}, los geht's?",
"welcomeMorningFresh": "Frisch in den Tag, {username}",
"welcomeMorningCoffee": "Kaffee und Aufgaben, {username}?",
"welcomeMorningRise": "Morgenplan hat Gold im Mund, {username}",
"welcomeMorningBack": "Willkommen zurück, {username}",
"welcomeMondayFresh": "Frische Woche, {username}",
"welcomeTuesday": "Fröhlichen Dienstag, {username}",
"welcomeWednesdayMid": "Bergfest, {username}",
"welcomeThursday": "Fast geschafft, {username}",
"welcomeFridayPush": "Endspurt ins Wochenende, {username}?",
"welcomeSaturday": "Wochenendmodus, {username}",
"welcomeSundaySession": "Sonntagsschicht, {username}?",
"welcomeDay": "Hallo {username}!",
"welcomeDayBack": "Wieder zurück, {username}",
"welcomeDayFocus": "Fokus, {username}",
"welcomeDayKeepGoing": "Weiter geht's, {username}",
"welcomeDayWhatsNext": "Was kommt als Nächstes, {username}?",
"welcomeDayGood": "Guten Nachmittag, {username}",
"welcomeEvening": "Guten Abend, {username}!",
"welcomeEveningWind": "Feierabend, {username}?",
"welcomeEveningReturns": "{username} kehrt zurück",
"welcomeEveningWrap": "Feierabend in Sicht, {username}?",
"welcomeEveningOneMore": "Noch eine Sache, {username}?",
"welcomeEveningStill": "Immer noch da, {username}?",
"lastViewed": "Zletscht ahglueget",
"addToHomeScreen": "Füge diese App deinem Startbildschirm hinzu, um schneller darauf zuzugreifen und das Erlebnis zu verbessern.",
"goToOverview": "Zur Übersicht",
@ -135,7 +162,13 @@
"taskAndNotifications": "Projekte & Aufgaben",
"privacy": "Privatsphäre",
"localization": "Sprachauswahl",
"appearance": "Aussehen & Verhalten"
"appearance": "Aussehen & Verhalten",
"desktop": "Desktop-App"
},
"desktop": {
"quickEntryShortcut": "Quick Entry Tastenkombination",
"shortcutRecorderPlaceholder": "Klicken, um Tastenkombination zu setzen",
"shortcutRecorderRecording": "Tastenkombination eingeben…"
},
"totp": {
"title": "Zweifaktor Authentifizierig",
@ -843,6 +876,7 @@
"addReminder": "Eine Erinnerung hinzufügen…",
"doneSuccess": "Die Uufgab isch erfolgriich als \"Fertig\" markiert wordä.",
"undoneSuccess": "Die Uufgaab isch nüme als fertig markiert.",
"readOnlyCheckbox": "Du hast nur Lesezugriff auf diese Aufgabe und kannst sie nicht als erledigt markieren.",
"movedToProject": "Die Aufgabe wurde nach {project} verschoben.",
"undo": "Rückgängig",
"openDetail": "Uufgab i de Detailaahsicht öffne",
@ -873,6 +907,8 @@
"updateSuccess": "Die Uufgab isch erfolgriich g'speichered wore.",
"deleteSuccess": "Die Uufgab isch erfolgriich g'chüblet wore.",
"duplicateSuccess": "Die Aufgabe wurde erfolgreich dupliziert.",
"noBucket": "Keine Spalte",
"bucketChangedSuccess": "Die Spalte der Aufgabe wurde erfolgreich geändert.",
"belongsToProject": "Diese Aufgabe gehört zum Projekt „{project}“",
"back": "Zurück zum Projekt",
"due": "Fällig bis {at}",
@ -1067,6 +1103,7 @@
},
"quickAddMagic": {
"hint": "Verwende magische Präfixe, um Fälligkeitsdaten, Zuweisungen und andere Aufgabeneigenschaften zu definieren.",
"quickEntryHint": "Verwende magische Präfixe für Datum, Labels & mehr. Öffne die Hauptanwendung von Vikunja und überprüfe den Tooltip bei der Aufgabeneingabe für weitere Details.",
"title": "Quick Add Magic",
"intro": "Bim erstelle vonere Uufgab, chasch du spezielli Schlüsselwörter verwende, umm Attribute direkt zu dere Uufgab hinzuezfüege. Das Erlaubts, um pblichi Attribute schneller zu Uufgabe hinzuezfüege.",
"multiple": "Du chasch da mehrmals mache.",
@ -1243,6 +1280,7 @@
"markAllReadSuccess": "Alle Benachrichtigungen erfolgreich als gelesen markiert."
},
"quickActions": {
"notLoggedIn": "Bitte melde dich zuerst im Hauptfenster von Vikunja an.",
"commands": "Befehl",
"placeholder": "Schriib en Befehl oder suech…",
"hint": "Du kannst {project} verwenden, um die Suche auf ein Projekt zu beschränken. Kombiniere {project} oder {label} (Labels) mit einer Suchabfrage, um eine Aufgabe mit diesen Labels oder auf diesem Projekt zu suchen. Verwende {assignee}, um nur nach Teams zu suchen.",

View File

@ -1,9 +1,36 @@
{
"home": {
"welcomeNight": "Good Night {username}!",
"welcomeNightOwl": "Hello, night owl {username}",
"welcomeNightBurning": "Burning the midnight oil, {username}?",
"welcomeNightQuiet": "Quiet hours, {username}",
"welcomeNightLate": "It's late, {username}",
"welcomeNightMoonlit": "Moonlit planning, {username}?",
"welcomeMorning": "Good Morning {username}!",
"welcomeMorningHey": "Hey {username}, ready to go?",
"welcomeMorningFresh": "Fresh start, {username}",
"welcomeMorningCoffee": "Coffee and tasks, {username}?",
"welcomeMorningRise": "Rise and plan, {username}",
"welcomeMorningBack": "Welcome back, {username}",
"welcomeMondayFresh": "Fresh week, {username}",
"welcomeTuesday": "Happy Tuesday, {username}",
"welcomeWednesdayMid": "Midweek already, {username}",
"welcomeThursday": "Almost there, {username}",
"welcomeFridayPush": "Friday push, {username}?",
"welcomeSaturday": "Weekend mode, {username}",
"welcomeSundaySession": "Sunday session, {username}?",
"welcomeDay": "Hi {username}!",
"welcomeDayBack": "Back at it, {username}",
"welcomeDayFocus": "Let's focus, {username}",
"welcomeDayKeepGoing": "Keep going, {username}",
"welcomeDayWhatsNext": "What's next, {username}?",
"welcomeDayGood": "Good afternoon, {username}",
"welcomeEvening": "Good Evening {username}!",
"welcomeEveningWind": "Winding down, {username}?",
"welcomeEveningReturns": "{username} returns",
"welcomeEveningWrap": "Time to wrap up, {username}?",
"welcomeEveningOneMore": "One more thing, {username}?",
"welcomeEveningStill": "Still at it, {username}?",
"lastViewed": "Last viewed",
"addToHomeScreen": "Add this app to your home screen for faster access and improved experience.",
"goToOverview": "Go to overview",
@ -112,6 +139,7 @@
"timezone": "Time zone",
"overdueTasksRemindersTime": "Overdue tasks reminder email time",
"filterUsedOnOverview": "Saved filter used on the overview page",
"showLastViewed": "Show last viewed projects on the overview page",
"minimumPriority": "Minimum visible task priority",
"dateDisplay": "Date display format",
"dateDisplayOptions": {
@ -414,7 +442,8 @@
"addPlaceholder": "Add a task…",
"empty": "This project is currently empty.",
"newTaskCta": "Create a task.",
"editTask": "Edit Task"
"editTask": "Edit Task",
"sort": "Sort"
},
"gantt": {
"title": "Gantt",
@ -606,6 +635,29 @@
}
}
},
"sorting": {
"manually": "Manually",
"apply": "Apply sort",
"description": "Choose how tasks in this list are sorted. When sorting manually, you can drag and drop tasks to reorder them.",
"options": {
"titleAsc": "Title (AZ)",
"titleDesc": "Title (ZA)",
"priorityDesc": "Priority (Highest first)",
"priorityAsc": "Priority (Lowest first)",
"dueDateAsc": "Due date (Earliest first)",
"dueDateDesc": "Due date (Latest first)",
"startDateAsc": "Start date (Earliest first)",
"startDateDesc": "Start date (Latest first)",
"endDateAsc": "End date (Earliest first)",
"endDateDesc": "End date (Latest first)",
"percentDoneDesc": "% done (Most done first)",
"percentDoneAsc": "% done (Least done first)",
"createdDesc": "Created (Newest first)",
"createdAsc": "Created (Oldest first)",
"updatedDesc": "Updated (Newest first)",
"updatedAsc": "Updated (Oldest first)"
}
},
"migrate": {
"title": "Import from other services",
"titleService": "Import your data from {name} into Vikunja",
@ -621,7 +673,33 @@
"importUpload": "To import data from {name} into Vikunja, click the button below to select a file.",
"upload": "Upload file",
"migrationStartedWillReciveEmail": "Vikunja will now import your lists/projects, tasks, notes, reminders and files from {service}. As this will take a while, we will send you an email once done. You can close this window now.",
"migrationInProgress": "A migration is currently in progress. Please wait until it is done."
"migrationInProgress": "A migration is currently in progress. Please wait until it is done.",
"csv": {
"description": "Import tasks from a CSV file with custom column mapping.",
"uploadDescription": "Select a CSV file to import. The file should contain task data with headers in the first row.",
"selectFile": "Select CSV file",
"columnMapping": "Column Mapping",
"columnMappingDescription": "Map each column in your CSV file to a task attribute. Vikunja has auto-detected the most likely mappings. The preview below will update automatically when you change settings.",
"parsingOptions": "Parsing Options",
"delimiter": "Delimiter",
"dateFormat": "Date Format",
"skipRows": "Skip Rows",
"mapColumns": "Map Columns",
"example": "e.g.",
"preview": "Preview",
"previewDescription": "Showing first 5 of {count} tasks that will be imported.",
"previewErrors": "{count} rows had parsing errors and will be skipped.",
"import": "Import Tasks",
"untitled": "Untitled Task",
"completed": "Completed",
"ignore": "Ignore",
"delimiters": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"tab": "Tab",
"pipe": "Pipe (|)"
}
}
},
"label": {
"title": "Labels",
@ -856,6 +934,7 @@
"addReminder": "Add a reminder…",
"doneSuccess": "The task was successfully marked as done.",
"undoneSuccess": "The task was successfully un-marked as done.",
"readOnlyCheckbox": "You only have read access to this task and cannot mark it as done.",
"movedToProject": "The task was moved to {project}.",
"undo": "Undo",
"openDetail": "Open task detail view",
@ -886,6 +965,8 @@
"updateSuccess": "The task was saved successfully.",
"deleteSuccess": "The task has been deleted successfully.",
"duplicateSuccess": "The task was duplicated successfully.",
"noBucket": "No bucket",
"bucketChangedSuccess": "The task bucket has been changed successfully.",
"belongsToProject": "This task belongs to project '{project}'",
"back": "Back to project",
"due": "Due {at}",

View File

@ -54,6 +54,9 @@
"authenticating": "Todennetaan…",
"openIdStateError": "Tila ei täsmää, kieltäydytään jatkamasta!",
"openIdGeneralError": "Tapahtui virhe todennettaessa kolmatta osapuolta vastaan.",
"desktopTryDemo": "Kokeile Demoa",
"desktopWaitingForAuth": "Odotetaan autentikointia…",
"desktopOAuthError": "Autentikointi epäonnistui: {error}",
"logout": "Kirjaudu ulos",
"emailInvalid": "Ole hyvä ja syötä kelvollinen sähköpostiosoite.",
"usernameRequired": "Ole hyvä ja anna käyttäjätunnus.",
@ -199,7 +202,9 @@
},
"sessions": {
"deviceInfo": "Laite",
"ipAddress": "IP-Osoite"
"ipAddress": "IP-Osoite",
"lastActive": "Viimeksi Aktiivinen",
"noOtherSessions": "Ei muita aktiivisia sessioita."
}
},
"deletion": {

View File

@ -54,6 +54,8 @@
"authenticating": "Аутентификация…",
"openIdStateError": "Состояние не совпадает, поэтому не продолжаем!",
"openIdGeneralError": "Произошла ошибка при аутентификации через сторонний сервис.",
"oauthMissingParams": "Отсутствуют необходимые параметры OAuth: {params}",
"oauthRedirectedToApp": "Вы были перенаправлены в приложение. Теперь вы можете закрыть эту вкладку.",
"logout": "Выйти",
"emailInvalid": "Введите корректный email адрес.",
"usernameRequired": "Введите имя пользователя.",
@ -155,7 +157,8 @@
"tokenCreated": "Ваш новый токен: {token}",
"wontSeeItAgain": "Запишите его где-нибудь — у вас больше не будет возможности его увидеть.",
"mustUseToken": "Вам необходимо создать токен CalDAV, если вы хотите использовать его со сторонним клиентом. Используйте его в качестве пароля.",
"usernameIs": "Имя пользователя для CalDAV: {0}"
"usernameIs": "Имя пользователя для CalDAV: {0}",
"apiTokenHint": "Вы также можете использовать токен API с разрешением CalDAV. Создайте его в {link}."
},
"avatar": {
"title": "Аватар",
@ -863,6 +866,8 @@
"updateSuccess": "Задача сохранена.",
"deleteSuccess": "Задача удалена.",
"duplicateSuccess": "Задача продублирована.",
"noBucket": "Нет колонки",
"bucketChangedSuccess": "Колонка задачи была успешно изменена.",
"belongsToProject": "Задача принадлежит проекту «{project}»",
"back": "Вернуться к проекту",
"due": "Истекает {at}",

View File

@ -1,51 +0,0 @@
import {Document} from 'flexsearch'
export interface withId {
id: number,
}
const indexes: { [k: string]: Document<withId> } = {}
export const createNewIndexer = (name: string, fieldsToIndex: string[]) => {
if (typeof indexes[name] === 'undefined') {
indexes[name] = new Document<withId>({
tokenize: 'full',
document: {
id: 'id',
index: fieldsToIndex,
},
})
}
const index = indexes[name]
function add(item: withId) {
return index.add(item.id, item)
}
function remove(item: withId) {
return index.remove(item.id)
}
function update(item: withId) {
return index.update(item.id, item)
}
function search(query: string | null) {
if (query === '' || query === null) {
return null
}
return index.search(query)
?.flatMap(r => r.result)
.filter((value, index, self) => self.indexOf(value) === index) as number[]
|| null
}
return {
add,
remove,
update,
search,
}
}

View File

@ -58,6 +58,7 @@ export interface ITask extends IAbstract {
projectId: IProject['id'] // Meta, only used when creating a new task
bucketId: IBucket['id']
buckets: IBucket[]
}
export type ITaskPartialWithId = PartialWithId<ITask>

View File

@ -23,6 +23,7 @@ export interface IFrontendSettings {
defaultTaskRelationType: IRelationKind
backgroundBrightness: number | null
alwaysShowBucketTaskCount: boolean
showLastViewed: boolean
sidebarWidth: number | null
commentSortOrder: 'asc' | 'desc'
defaultPage: DefaultPage

View File

@ -6,9 +6,18 @@ import type { IFile } from '@/modelTypes/IFile'
import type { IAttachment } from '@/modelTypes/IAttachment'
export const SUPPORTED_IMAGE_SUFFIX = ['.jpeg', '.jpg', '.png', '.bmp', '.gif']
export const SUPPORTED_PDF_SUFFIX = ['.pdf']
export function canPreviewImage(attachment: IAttachment): boolean {
return SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.toLowerCase().endsWith(suffix))
}
export function canPreviewPdf(attachment: IAttachment): boolean {
return SUPPORTED_PDF_SUFFIX.some((suffix) => attachment.file.name.toLowerCase().endsWith(suffix))
}
export function canPreview(attachment: IAttachment): boolean {
return SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.toLowerCase().endsWith(suffix))
return canPreviewImage(attachment) || canPreviewPdf(attachment)
}
export default class AttachmentModel extends AbstractModel<IAttachment> implements IAttachment {

View File

@ -96,6 +96,7 @@ export default class TaskModel extends AbstractModel<ITask> implements ITask {
projectId: IProject['id'] = 0
bucketId: IBucket['id'] = 0
buckets: IBucket[] = []
constructor(data: Partial<ITask> = {}) {
super()

View File

@ -34,6 +34,7 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
defaultTaskRelationType: RELATION_KIND.RELATED,
backgroundBrightness: null,
alwaysShowBucketTaskCount: false,
showLastViewed: true,
sidebarWidth: null,
commentSortOrder: 'asc',
defaultPage: DEFAULT_PAGE.LAST_VISITED,

View File

@ -195,6 +195,11 @@ const router = createRouter({
name: 'migrate.start',
component: () => import('@/views/migrate/Migration.vue'),
},
{
path: '/migrate/csv',
name: 'migrate.csv',
component: () => import('@/views/migrate/MigrationCSV.vue'),
},
{
path: '/migrate/:service',
name: 'migrate.service',

View File

@ -0,0 +1,123 @@
import AbstractService from '../abstractService'
export interface ColumnMapping {
column_index: number
column_name: string
attribute: TaskAttribute
}
export type TaskAttribute =
| 'title'
| 'description'
| 'due_date'
| 'start_date'
| 'end_date'
| 'done'
| 'priority'
| 'labels'
| 'project'
| 'reminder'
| 'ignore'
export const TASK_ATTRIBUTES: TaskAttribute[] = [
'title',
'description',
'due_date',
'start_date',
'end_date',
'done',
'priority',
'labels',
'project',
'reminder',
'ignore',
]
export interface DetectionResult {
columns: string[]
delimiter: string
quote_char: string
date_format: string
suggested_mapping: ColumnMapping[]
preview_rows: string[][]
}
export interface ImportConfig {
delimiter: string
quote_char: string
date_format: string
skip_rows: number
mapping: ColumnMapping[]
}
export interface PreviewTask {
title: string
description: string
due_date?: string
start_date?: string
end_date?: string
done: boolean
priority: number
labels?: string[]
project?: string
}
export interface PreviewResult {
tasks: PreviewTask[]
total_rows: number
}
export interface MigrationStatus {
started_at: string | null
finished_at: string | null
}
export const SUPPORTED_DELIMITERS = [',', ';', '\t', '|'] as const
export const SUPPORTED_DATE_FORMATS = [
'2006-01-02',
'2006-01-02T15:04:05',
'02/01/2006',
'01/02/2006',
'02-01-2006',
'01-02-2006',
'02.01.2006',
'2006/01/02',
'2006-01-02 15:04:05',
] as const
export default class CSVMigrationService extends AbstractService {
constructor() {
super({})
}
getStatus(): Promise<MigrationStatus> {
return this.getM('/migration/csv/status')
}
useCreateInterceptor() {
return false
}
async detect(file: File): Promise<DetectionResult> {
return this.uploadFile(
'/migration/csv/detect',
file,
'import',
)
}
async preview(file: File, config: ImportConfig): Promise<PreviewResult> {
const data = new FormData()
data.append('import', file)
data.append('config', JSON.stringify(config))
return this.uploadFormData('/migration/csv/preview', data)
}
async migrate(file: File, config: ImportConfig): Promise<{ message: string }> {
const data = new FormData()
data.append('import', file)
data.append('config', JSON.stringify(config))
return this.uploadFormData('/migration/csv/migrate', data)
}
}

View File

@ -8,6 +8,7 @@ import UserModel, {getDisplayName, fetchAvatarBlobUrl, invalidateAvatarCache} fr
import AvatarService from '@/services/avatar'
import UserSettingsService from '@/services/userSettings'
import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth'
import {useWebSocket} from '@/composables/useWebSocket'
import {setModuleLoading} from '@/stores/helper'
import {success, error} from '@/message'
import {
@ -140,6 +141,7 @@ export const useAuthStore = defineStore('auth', () => {
timeFormat: TIME_FORMAT.HOURS_24,
defaultTaskRelationType: RELATION_KIND.RELATED,
backgroundBrightness: 100,
showLastViewed: true,
sidebarWidth: null,
commentSortOrder: 'asc',
defaultPage: DEFAULT_PAGE.LAST_VISITED,
@ -509,6 +511,9 @@ export const useAuthStore = defineStore('auth', () => {
}
async function logout() {
const {disconnect} = useWebSocket()
disconnect()
// Revoke the server session so the refresh token can't be reused.
// Best-effort: if the network call fails, still clean up locally.
try {

View File

@ -4,12 +4,9 @@ import {acceptHMRUpdate, defineStore} from 'pinia'
import LabelService from '@/services/label'
import {success} from '@/message'
import {i18n} from '@/i18n'
import {createNewIndexer} from '@/indexes'
import {setModuleLoading} from '@/stores/helper'
import type {ILabel} from '@/modelTypes/ILabel'
const {add, remove, update, search} = createNewIndexer('labels', ['title', 'description'])
async function getAllLabels(page = 1): Promise<ILabel[]> {
const labelService = new LabelService()
const labels = await labelService.getAll({}, {}, page) as ILabel[]
@ -48,12 +45,12 @@ export const useLabelStore = defineStore('label', () => {
// **
const filterLabelsByQuery = computed(() => {
return (labelsToHide: ILabel[], query: string) => {
if (query === '') return []
const labelIdsToHide: number[] = labelsToHide.map(({id}) => id)
return search(query)
?.filter(value => !labelIdsToHide.includes(value))
.map(id => labels.value[id])
|| []
const q = query.toLowerCase()
return labelsArray.value
.filter(l => !labelIdsToHide.includes(l.id))
.filter(l => l.title.toLowerCase().includes(q) || (l.description ?? '').toLowerCase().includes(q))
}
})
@ -75,17 +72,14 @@ export const useLabelStore = defineStore('label', () => {
function setLabels(newLabels: ILabel[]) {
newLabels.forEach(l => {
labels.value[l.id] = l
add(l)
})
}
function setLabel(label: ILabel) {
labels.value[label.id] = {...label}
update(label)
}
function removeLabelById(label: ILabel) {
remove(label)
delete labels.value[label.id]
}

View File

@ -30,15 +30,6 @@ vi.mock('@/stores/base', () => ({
}),
}))
vi.mock('@/indexes', () => ({
createNewIndexer: () => ({
add: vi.fn(),
remove: vi.fn(),
search: vi.fn(),
update: vi.fn(),
}),
}))
function createMockProject(overrides: Partial<IProject>): IProject {
return {
id: 1,

View File

@ -8,7 +8,6 @@ import ProjectDuplicateService from '@/services/projectDuplicateService'
import ProjectDuplicateModel from '@/models/projectDuplicateModel'
import {setModuleLoading} from '@/stores/helper'
import {removeProjectFromHistory} from '@/modules/projectHistory'
import {createNewIndexer} from '@/indexes'
import type {IProject} from '@/modelTypes/IProject'
@ -21,8 +20,6 @@ import SavedFilterModel from '@/models/savedFilter'
import type {IProjectView} from '@/modelTypes/IProjectView'
import {PERMISSIONS} from '@/constants/permissions.ts'
const {add, remove, search, update} = createNewIndexer('projects', ['title', 'description'])
export const useProjectStore = defineStore('project', () => {
const baseStore = useBaseStore()
const router = useRouter()
@ -99,32 +96,33 @@ export const useProjectStore = defineStore('project', () => {
}
})
function searchByQuery(query: string): IProject[] {
if (query === '') return []
const q = query.toLowerCase()
return projectsArray.value.filter(p =>
p.title.toLowerCase().includes(q) || (p.description ?? '').toLowerCase().includes(q),
)
}
const searchProjectAndFilter = computed(() => {
return (query: string, includeArchived = false) => {
return search(query)
?.map(id => projects.value[id])
.filter(project => project?.isArchived === includeArchived)
|| []
return searchByQuery(query).filter(project => project.isArchived === includeArchived)
}
})
const searchProject = computed(() => {
return (query: string, includeArchived = false) => {
return search(query)
?.filter(value => value > 0)
.map(id => projects.value[id])
.filter(project => project?.isArchived === includeArchived)
|| []
return searchByQuery(query)
.filter(p => p.id > 0)
.filter(project => project.isArchived === includeArchived)
}
})
const searchSavedFilter = computed(() => {
return (query: string, includeArchived = false) => {
return search(query)
?.filter(value => getSavedFilterIdFromProjectId(value) > 0)
.map(id => projects.value[id])
.filter(project => project?.isArchived === includeArchived)
|| []
return searchByQuery(query)
.filter(p => getSavedFilterIdFromProjectId(p.id) > 0)
.filter(project => project.isArchived === includeArchived)
}
})
@ -134,7 +132,6 @@ export const useProjectStore = defineStore('project', () => {
function setProject(project: IProject) {
projects.value[project.id] = project
update(project)
// FIXME: This should be a watcher, but using a watcher instead will sometimes crash browser processes.
// Reverted from 31b7c1f217532bf388ba95a03f469508bee46f6a
@ -154,7 +151,6 @@ export const useProjectStore = defineStore('project', () => {
.filter(p => p.parentProjectId === project.id)
.forEach(p => removeProjectById(p))
remove(project)
delete projects.value[project.id]
}
@ -276,7 +272,6 @@ export const useProjectStore = defineStore('project', () => {
projects.value = {}
setProjects(loadedProjects)
loadedProjects.forEach(p => add(p))
return loadedProjects
}

View File

@ -25,7 +25,7 @@
/>
<ImportHint v-if="tasksLoaded" />
<div
v-if="projectHistory.length > 0"
v-if="authStore.settings.frontendSettings.showLastViewed !== false && projectHistory.length > 0"
class="is-max-width-desktop has-text-start mbs-4"
>
<h3>{{ $t('home.lastViewed') }}</h3>

View File

@ -4,10 +4,10 @@
<p>{{ $t('migrate.description') }}</p>
<div class="migration-services">
<RouterLink
v-for="{name, id, icon} in availableMigrators"
v-for="{name, id, icon, isCSVMigrator} in availableMigrators"
:key="id"
class="migration-service-link"
:to="{name: 'migrate.service', params: {service: id}}"
:to="isCSVMigrator ? {name: 'migrate.csv'} : {name: 'migrate.service', params: {service: id}}"
>
<img
class="migration-service-image"
@ -45,7 +45,10 @@ const availableMigrators = computed(() => configStore.availableMigrators
}
.migration-service-link {
display: inline-block;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
inline-size: 100px;
text-transform: capitalize;
margin-inline-end: 1rem;
@ -53,5 +56,8 @@ const availableMigrators = computed(() => configStore.availableMigrators
.migration-service-image {
display: block;
max-block-size: 80px;
inline-size: auto;
margin-block-end: 0.5rem;
}
</style>

View File

@ -0,0 +1,490 @@
<template>
<div class="content csv-migration">
<h1>{{ $t('migrate.titleService', {name: 'CSV'}) }}</h1>
<p>{{ $t('migrate.csv.description') }}</p>
<Message
v-if="error"
variant="danger"
class="mbe-4"
>
{{ error }}
</Message>
<!-- Step 1: File Upload -->
<div
v-if="step === 'upload'"
class="upload-step"
>
<p>{{ $t('migrate.csv.uploadDescription') }}</p>
<input
ref="uploadInput"
class="is-hidden"
type="file"
accept=".csv,.txt"
@change="handleFileUpload"
>
<XButton
:loading="isLoading"
:disabled="isLoading || undefined"
@click="uploadInput?.click()"
>
{{ $t('migrate.csv.selectFile') }}
</XButton>
</div>
<!-- Step 2: Column Mapping -->
<div
v-else-if="step === 'mapping'"
class="mapping-step"
>
<div class="mapping-header">
<p>{{ $t('migrate.csv.columnMappingDescription') }}</p>
</div>
<!-- Parsing Options -->
<div class="parsing-options card">
<h3>{{ $t('migrate.csv.parsingOptions') }}</h3>
<div class="options-grid">
<div class="option-group">
<label for="delimiter">{{ $t('migrate.csv.delimiter') }}</label>
<div class="select is-fullwidth">
<select
id="delimiter"
v-model="config.delimiter"
@change="updatePreview"
>
<option
v-for="delim in SUPPORTED_DELIMITERS"
:key="delim"
:value="delim"
>
{{ getDelimiterLabel(delim) }}
</option>
</select>
</div>
</div>
<div class="option-group">
<label for="dateFormat">{{ $t('migrate.csv.dateFormat') }}</label>
<div class="select is-fullwidth">
<select
id="dateFormat"
v-model="config.date_format"
@change="updatePreview"
>
<option
v-for="format in SUPPORTED_DATE_FORMATS"
:key="format"
:value="format"
>
{{ getDateFormatLabel(format) }}
</option>
</select>
</div>
</div>
<div class="option-group">
<label for="skipRows">{{ $t('migrate.csv.skipRows') }}</label>
<input
id="skipRows"
v-model.number="config.skip_rows"
type="number"
class="input"
min="0"
@change="updatePreview"
>
</div>
</div>
</div>
<!-- Column Mappings -->
<div class="column-mappings card">
<h3>{{ $t('migrate.csv.mapColumns') }}</h3>
<div class="mappings-grid">
<div
v-for="(mapping, index) in config.mapping"
:key="index"
class="mapping-row"
>
<div class="column-name">
<strong>{{ mapping.column_name }}</strong>
<span
v-if="detectionResult && detectionResult.preview_rows[0]"
class="preview-value"
>
{{ $t('migrate.csv.example') }}: {{ detectionResult.preview_rows[0][index] || '-' }}
</span>
</div>
<div class="select is-fullwidth">
<select
v-model="mapping.attribute"
@change="updatePreview"
>
<option
v-for="attr in TASK_ATTRIBUTES"
:key="attr"
:value="attr"
>
{{ getAttributeLabel(attr) }}
</option>
</select>
</div>
</div>
</div>
</div>
<!-- Preview -->
<div
v-if="previewResult"
class="preview-section card"
>
<h3>{{ $t('migrate.csv.preview') }}</h3>
<p>{{ $t('migrate.csv.previewDescription', {count: previewResult.total_rows}) }}</p>
<div class="preview-tasks">
<div
v-for="(task, index) in previewTasks"
:key="index"
@click.capture.prevent.stop
>
<SingleTaskInProject
:the-task="task"
disabled
:can-mark-as-done="false"
/>
</div>
</div>
</div>
<!-- Actions -->
<div class="actions">
<XButton
variant="tertiary"
@click="resetToUpload"
>
{{ $t('misc.cancel') }}
</XButton>
<XButton
:loading="isLoading"
:disabled="!hasValidMapping || isLoading"
@click="performImport"
>
{{ $t('migrate.csv.import') }}
</XButton>
</div>
</div>
<!-- Step 3: Success -->
<div
v-else-if="step === 'success'"
class="success-step"
>
<Message class="mbe-4">
{{ successMessage }}
</Message>
<XButton :to="{name: 'home'}">
{{ $t('home.goToOverview') }}
</XButton>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, ref, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import Message from '@/components/misc/Message.vue'
import SingleTaskInProject from '@/components/tasks/partials/SingleTaskInProject.vue'
import TaskModel from '@/models/task'
import CSVMigrationService, {
type DetectionResult,
type ImportConfig,
type PreviewResult,
TASK_ATTRIBUTES,
SUPPORTED_DELIMITERS,
SUPPORTED_DATE_FORMATS,
} from '@/services/migrator/csvMigration'
import {useTitle} from '@/composables/useTitle'
import {useProjectStore} from '@/stores/projects'
import {getErrorText} from '@/message'
type Step = 'upload' | 'mapping' | 'success'
const {t} = useI18n({useScope: 'global'})
useTitle(() => t('migrate.titleService', {name: 'CSV'}))
const csvService = shallowReactive(new CSVMigrationService())
const step = ref<Step>('upload')
const error = ref('')
const successMessage = ref('')
const isLoading = ref(false)
const uploadInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const detectionResult = ref<DetectionResult | null>(null)
const previewResult = ref<PreviewResult | null>(null)
const config = ref<ImportConfig>({
delimiter: ',',
quote_char: '"',
date_format: '2006-01-02',
skip_rows: 0,
mapping: [],
})
const previewTasks = computed(() => {
if (!previewResult.value) return []
return previewResult.value.tasks.map((pt, i) => new TaskModel({
id: -(i + 1),
title: pt.title || t('migrate.csv.untitled'),
description: pt.description || '',
done: pt.done,
dueDate: pt.due_date || null,
startDate: pt.start_date || null,
endDate: pt.end_date || null,
priority: pt.priority,
labels: (pt.labels || []).map((l, li) => ({id: -(li + 1), title: l})),
}))
})
const hasValidMapping = computed(() => {
if (!config.value.mapping.length) return false
// At least one column should be mapped to title
return config.value.mapping.some(m => m.attribute === 'title')
})
// Map snake_case attribute names to translation keys
function getAttributeLabel(attribute: string): string {
const attributeMap: Record<string, string> = {
title: 'task.attributes.title',
description: 'task.attributes.description',
due_date: 'task.attributes.dueDate',
start_date: 'task.attributes.startDate',
end_date: 'task.attributes.endDate',
done: 'task.attributes.done',
priority: 'task.attributes.priority',
labels: 'task.attributes.labels',
reminder: 'task.attributes.reminders',
project: 'project.title',
ignore: 'migrate.csv.ignore',
}
return t(attributeMap[attribute] || attribute)
}
function getDelimiterLabel(delimiter: string): string {
const labels: Record<string, string> = {
',': t('migrate.csv.delimiters.comma'),
';': t('migrate.csv.delimiters.semicolon'),
'\t': t('migrate.csv.delimiters.tab'),
'|': t('migrate.csv.delimiters.pipe'),
}
return labels[delimiter] || delimiter
}
function getDateFormatLabel(format: string): string {
const labels: Record<string, string> = {
'2006-01-02': 'YYYY-MM-DD (2024-01-15)',
'2006-01-02T15:04:05': 'ISO DateTime (2024-01-15T10:30:00)',
'02/01/2006': 'DD/MM/YYYY (15/01/2024)',
'01/02/2006': 'MM/DD/YYYY (01/15/2024)',
'02-01-2006': 'DD-MM-YYYY (15-01-2024)',
'01-02-2006': 'MM-DD-YYYY (01-15-2024)',
'02.01.2006': 'DD.MM.YYYY (15.01.2024)',
'2006/01/02': 'YYYY/MM/DD (2024/01/15)',
'2006-01-02 15:04:05': 'DateTime (2024-01-15 10:30:00)',
}
return labels[format] || format
}
async function handleFileUpload() {
const files = uploadInput.value?.files
if (!files || files.length === 0) return
selectedFile.value = files[0]
error.value = ''
isLoading.value = true
try {
const result = await csvService.detect(selectedFile.value)
detectionResult.value = result
// Apply detected values
config.value = {
delimiter: result.delimiter,
quote_char: result.quote_char,
date_format: result.date_format,
skip_rows: 0,
mapping: result.suggested_mapping,
}
// Get initial preview
await updatePreview()
step.value = 'mapping'
} catch (e) {
error.value = getErrorText(e)
} finally {
isLoading.value = false
}
}
async function updatePreview() {
if (!selectedFile.value) return
isLoading.value = true
try {
previewResult.value = await csvService.preview(selectedFile.value, config.value)
} catch (e) {
error.value = getErrorText(e)
previewResult.value = null
} finally {
isLoading.value = false
}
}
async function performImport() {
if (!selectedFile.value || !hasValidMapping.value) return
isLoading.value = true
error.value = ''
try {
const result = await csvService.migrate(selectedFile.value, config.value)
successMessage.value = result.message
// Reload projects
const projectStore = useProjectStore()
await projectStore.loadAllProjects()
step.value = 'success'
} catch (e) {
error.value = getErrorText(e)
} finally {
isLoading.value = false
}
}
function resetToUpload() {
step.value = 'upload'
selectedFile.value = null
detectionResult.value = null
previewResult.value = null
error.value = ''
if (uploadInput.value) {
uploadInput.value.value = ''
}
config.value = {
delimiter: ',',
quote_char: '"',
date_format: '2006-01-02',
skip_rows: 0,
mapping: [],
}
}
</script>
<style lang="scss" scoped>
.csv-migration {
max-inline-size: 900px;
margin: 0 auto;
}
.card {
background: var(--white);
border-radius: var(--border-radius);
padding: 1.5rem;
margin-block-end: 1.5rem;
box-shadow: var(--shadow-sm);
}
.mapping-header {
margin-block-end: 1.5rem;
h2 {
margin-block-end: 0.5rem;
}
}
.parsing-options {
h3 {
margin-block-end: 1rem;
}
}
.options-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.option-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
label {
font-weight: 500;
}
}
.column-mappings {
h3 {
margin-block-end: 1rem;
}
}
.mappings-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.mapping-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
align-items: center;
padding: 0.75rem;
background: var(--grey-100);
border-radius: var(--border-radius);
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.column-name {
display: flex;
flex-direction: column;
gap: 0.25rem;
.preview-value {
font-size: 0.85rem;
color: var(--grey-500);
}
}
.preview-section {
h3 {
margin-block-end: 0.5rem;
}
}
.preview-tasks {
margin-block-start: 1rem;
}
.actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-block-start: 1.5rem;
}
.success-step {
text-align: center;
padding: 2rem;
}
</style>

View File

@ -0,0 +1,16 @@
<svg viewBox="0 0 88 88" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<!-- Document background -->
<rect x="14" y="4" width="60" height="80" rx="4" fill="#4772FA"/>
<!-- Folded corner -->
<path d="M54 4 L74 24 L54 24 Z" fill="#2a5bd7"/>
<!-- CSV text lines representing data rows -->
<rect x="22" y="34" width="44" height="6" rx="2" fill="#ffffff"/>
<rect x="22" y="46" width="44" height="6" rx="2" fill="#ffffff" opacity="0.8"/>
<rect x="22" y="58" width="44" height="6" rx="2" fill="#ffffff" opacity="0.6"/>
<rect x="22" y="70" width="30" height="6" rx="2" fill="#ffffff" opacity="0.4"/>
<!-- Vertical separators (comma separators) -->
<rect x="36" y="34" width="2" height="42" fill="#4772FA" opacity="0.3"/>
<rect x="52" y="34" width="2" height="42" fill="#4772FA" opacity="0.3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 875 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -4,11 +4,14 @@ import trelloIcon from './icons/trello.svg?url'
import microsoftTodoIcon from './icons/microsoft-todo.svg?url'
import vikunjaFileIcon from './icons/vikunja-file.png?url'
import tickTickIcon from './icons/ticktick.svg?url'
import wekanIcon from './icons/wekan.png?url'
import csvIcon from './icons/csv.svg?url'
export interface Migrator {
id: string
name: string
isFileMigrator?: boolean
isCSVMigrator?: boolean
icon: string
}
@ -49,4 +52,17 @@ export const MIGRATORS = {
icon: tickTickIcon as string,
isFileMigrator: true,
},
wekan: {
id: 'wekan',
name: 'WeKan ®',
icon: wekanIcon,
isFileMigrator: true,
},
csv: {
id: 'csv',
name: 'CSV',
icon: csvIcon as string,
isFileMigrator: true,
isCSVMigrator: true,
},
} as const satisfies IMigratorRecord

View File

@ -87,6 +87,7 @@
:key="task.id"
:show-project="true"
:the-task="task"
:can-mark-as-done="(projectStore.projects[task.projectId]?.maxPermission ?? 0) > PERMISSIONS.READ"
@taskUpdated="updateTasks"
/>
</div>
@ -123,6 +124,7 @@ import {useProjectStore} from '@/stores/projects'
import {useLabelStore} from '@/stores/labels'
import type {TaskFilterParams} from '@/services/taskCollection'
import TaskCollectionService from '@/services/taskCollection'
import {PERMISSIONS} from '@/constants/permissions'
const props = withDefaults(defineProps<{
dateFrom?: Date | string,

View File

@ -55,6 +55,11 @@
class="has-text-grey-light"
> &gt; </span>
</template>
<BucketSelect
:task="task"
:can-write="canWrite"
@update:task="Object.assign(task, $event)"
/>
</h6>
<ChecklistSummary :task="task" />
@ -659,6 +664,7 @@ import RepeatAfter from '@/components/tasks/partials/RepeatAfter.vue'
import TaskSubscription from '@/components/misc/Subscription.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import AssigneeList from '@/components/tasks/partials/AssigneeList.vue'
import BucketSelect from '@/components/tasks/partials/BucketSelect.vue'
import Reactions from '@/components/input/Reactions.vue'
import {uploadFile} from '@/helpers/attachments'
@ -899,7 +905,7 @@ watch(
}
try {
const loaded = await taskService.get({id}, {expand: ['reactions', 'comments', 'is_unread']})
const loaded = await taskService.get({id}, {expand: ['reactions', 'comments', 'is_unread', 'buckets']})
Object.assign(task.value, loaded)
taskColor.value = task.value.hexColor
setActiveFields()

View File

@ -123,6 +123,15 @@
/>
</label>
</div>
<div class="field">
<label class="checkbox">
<input
v-model="settings.frontendSettings.showLastViewed"
type="checkbox"
>
{{ $t('user.settings.general.showLastViewed') }}
</label>
</div>
<div class="field">
<label class="checkbox">
<input

View File

@ -2,7 +2,6 @@ import {test, expect} from '../../support/fixtures'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {ProjectViewFactory} from '../../factories/project_view'
import {SavedFilterFactory} from '../../factories/saved_filter'
/**
* Tests for filter autocomplete functionality, specifically for:
@ -31,11 +30,6 @@ function getFilterInput(page) {
test.describe('Filter Autocomplete', () => {
test.beforeEach(async ({authenticatedPage, currentUser}) => {
// authenticatedPage fixture triggers apiContext which sets up Factory.request
await ProjectFactory.truncate()
await TaskFactory.truncate()
await ProjectViewFactory.truncate()
await SavedFilterFactory.truncate()
const userId = currentUser.id
// Create projects - one with spaces in name (the bug case)

View File

@ -17,7 +17,7 @@ test.describe('Parent Project Clear', () => {
title: 'Child Project',
parent_project_id: parentProjects[0].id,
}, false)
const childViews = await createDefaultViews(childProjects[0].id, 104, false)
const childViews = await createDefaultViews(childProjects[0].id, 104)
// Navigate to the child project first
await page.goto(`/projects/${childProjects[0].id}/${childViews[0].id}`)
@ -75,7 +75,7 @@ test.describe('Parent Project Clear', () => {
title: 'Test Child',
parent_project_id: parentProjects[0].id,
}, false)
const childViews = await createDefaultViews(childProjects[0].id, 204, false)
const childViews = await createDefaultViews(childProjects[0].id, 204)
// Navigate to the child project first
await page.goto(`/projects/${childProjects[0].id}/${childViews[0].id}`)

View File

@ -2,10 +2,7 @@ import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {ProjectViewFactory} from '../../factories/project_view'
export async function createDefaultViews(projectId: number, startViewId = 1, truncate: boolean = true) {
if (truncate) {
await ProjectViewFactory.truncate()
}
export async function createDefaultViews(projectId: number, startViewId = 1) {
const list = await ProjectViewFactory.create(1, {
id: startViewId,
project_id: projectId,
@ -41,11 +38,8 @@ export async function createProjects(count: number = 1) {
title: i => count === 1 ? 'First Project' : `Project ${i + 1}`,
})
await TaskFactory.truncate()
await ProjectViewFactory.truncate()
for (let i = 0; i < projects.length; i++) {
const views = await createDefaultViews(projects[i].id, i * 4 + 1, false)
const views = await createDefaultViews(projects[i].id, i * 4 + 1)
projects[i].views = views
}

View File

@ -1,12 +1,30 @@
import {test, expect} from '../../support/fixtures'
import {ProjectFactory} from '../../factories/project'
import {ProjectViewFactory} from '../../factories/project_view'
import {updateUserSettings} from '../../support/updateUserSettings'
import type {Page} from '@playwright/test'
async function visitProjectsToBuildHistory(page: Page, projects: any[]) {
for (const project of projects) {
const loadProjectPromise = page.waitForResponse(response =>
response.url().includes(`/projects/${project.id}`) && response.request().method() === 'GET',
)
await page.goto(`/projects/${project.id}/${project.id}`)
await loadProjectPromise
await page.waitForFunction(
(projectId) => {
const history = JSON.parse(localStorage.getItem('projectHistory') || '[]')
return history.some((h: any) => h.id === projectId)
},
project.id,
)
}
}
test.describe('Project History', () => {
test('should show a project history on the home page', async ({authenticatedPage: page}) => {
test.setTimeout(60000)
const projects = await ProjectFactory.create(7)
await ProjectViewFactory.truncate()
for (const p of projects) {
await ProjectViewFactory.create(1, {
id: p.id,
@ -46,4 +64,80 @@ test.describe('Project History', () => {
await expect(page.locator('.project-grid')).toContainText(projects[5].title)
await expect(page.locator('.project-grid')).toContainText(projects[6].title)
})
test('should hide the last viewed section when showLastViewed setting is disabled', async ({authenticatedPage: page, apiContext}) => {
test.setTimeout(60000)
const projects = await ProjectFactory.create(3)
await ProjectViewFactory.truncate()
for (const p of projects) {
await ProjectViewFactory.create(1, {
id: p.id,
project_id: p.id,
}, false)
}
// Visit projects to build up history
await visitProjectsToBuildHistory(page, projects)
// Go to overview and verify section is visible
await page.goto('/')
await expect(page.locator('body')).toContainText('Last viewed')
// Disable the setting via API
const token = await page.evaluate(() => localStorage.getItem('token'))
await updateUserSettings(apiContext, token!, {
frontendSettings: {
showLastViewed: false,
},
})
// Reload and verify section is hidden
await page.reload()
await page.waitForLoadState('networkidle')
await expect(page.locator('body')).not.toContainText('Last viewed')
})
test('should show the last viewed section again when re-enabling showLastViewed', async ({authenticatedPage: page, apiContext}) => {
test.setTimeout(60000)
const projects = await ProjectFactory.create(2)
await ProjectViewFactory.truncate()
for (const p of projects) {
await ProjectViewFactory.create(1, {
id: p.id,
project_id: p.id,
}, false)
}
// Navigate to app first so localStorage is accessible
await page.goto('/')
await page.waitForLoadState('networkidle')
// Disable the setting first
const token = await page.evaluate(() => localStorage.getItem('token'))
await updateUserSettings(apiContext, token!, {
frontendSettings: {
showLastViewed: false,
},
})
// Visit projects to build up history
await visitProjectsToBuildHistory(page, projects)
// Verify section is hidden
await page.goto('/')
await page.waitForLoadState('networkidle')
await expect(page.locator('body')).not.toContainText('Last viewed')
// Re-enable the setting
await updateUserSettings(apiContext, token!, {
frontendSettings: {
showLastViewed: true,
},
})
// Reload and verify section is visible again
await page.reload()
await page.waitForLoadState('networkidle')
await expect(page.locator('body')).toContainText('Last viewed')
})
})

View File

@ -132,4 +132,29 @@ test.describe('Project View Gantt', () => {
await expect(page).toHaveURL(new RegExp(`/tasks/${tasks[0].id}`))
})
test('Should preserve date range query parameters after opening and closing a task modal', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1)
await ProjectViewFactory.create(1, {id: 2, project_id: 1, view_kind: 1})
await TaskFactory.create(1, {
start_date: new Date(2022, 9, 1).toISOString(),
end_date: new Date(2022, 9, 5).toISOString(),
})
await page.goto('/projects/1/2?dateFrom=2022-09-25&dateTo=2022-11-05')
// Verify the date range is shown
await expect(page).toHaveURL(/dateFrom=2022-09-25/)
await expect(page).toHaveURL(/dateTo=2022-11-05/)
// Double-click the task to open the modal
await page.locator('.gantt-container .gantt-row-bars .gantt-bar').dblclick()
await expect(page).toHaveURL(/\/tasks\//)
// Close the modal
await page.locator('dialog[open] .modal-container > .close').click()
// Verify the date range query parameters are preserved
await expect(page).toHaveURL(/dateFrom=2022-09-25/)
await expect(page).toHaveURL(/dateTo=2022-11-05/)
})
})

View File

@ -38,7 +38,6 @@ async function createTaskWithBuckets(buckets, count = 1) {
const data = await TaskFactory.create(count, {
project_id: 1,
})
await TaskBucketFactory.truncate()
for (const t of data) {
await TaskBucketFactory.create(1, {
task_id: t.id,

View File

@ -109,7 +109,6 @@ test.describe('Project View List', () => {
}, false)
// Make task 2 a subtask of task 1
await TaskRelationFactory.truncate()
await TaskRelationFactory.create(1, {
id: 1,
task_id: 2,
@ -143,7 +142,6 @@ test.describe('Project View List', () => {
}, false)
// Make task 2 a subtask of task 1
await TaskRelationFactory.truncate()
await TaskRelationFactory.create(1, {
id: 1,
task_id: 2,

View File

@ -5,7 +5,6 @@ import {UserFactory} from '../../factories/user'
test.describe('Team', () => {
test('Creates a new team', async ({authenticatedPage: page}) => {
await TeamFactory.truncate()
await page.goto('/teams')
const newTeamName = 'New Team'

View File

@ -0,0 +1,202 @@
import {test, expect} from '../../support/fixtures'
import {BucketFactory} from '../../factories/bucket'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {ProjectViewFactory} from '../../factories/project_view'
import {TaskBucketFactory} from '../../factories/task_buckets'
import {TaskRelationFactory} from '../../factories/task_relation'
async function createKanbanTaskInBucket() {
const projects = await ProjectFactory.create(1)
const views = await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
const buckets = await BucketFactory.create(2, {
project_view_id: views[0].id,
})
const tasks = await TaskFactory.create(1, {
project_id: projects[0].id,
})
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
project_view_id: views[0].id,
})
return {
project: projects[0],
view: views[0],
buckets,
task: tasks[0],
}
}
test.describe('Task Bucket Select', () => {
test('Shows the current bucket name when opening a task from a kanban view', async ({authenticatedPage: page}) => {
const {project, view, buckets, task} = await createKanbanTaskInBucket()
await page.goto(`/projects/${project.id}/${view.id}`)
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
await expect(page.locator('.task-view .subtitle')).toContainText(buckets[0].title)
})
test('Can change the bucket from the task detail view', async ({authenticatedPage: page}) => {
const {project, view, buckets, task} = await createKanbanTaskInBucket()
await page.goto(`/projects/${project.id}/${view.id}`)
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
// Click the bucket name to open the dropdown
await page.locator('.task-view .subtitle .bucket-name').click()
// Select the other bucket
await page.locator('.task-view .subtitle .dropdown-item').filter({hasText: buckets[1].title}).click()
await expect(page.locator('.global-notification')).toContainText('Success')
await expect(page.locator('.task-view .subtitle')).toContainText(buckets[1].title)
})
test('Does not show the bucket selector when project has no kanban view', async ({authenticatedPage: page}) => {
// Truncate leftover data from previous tests
await BucketFactory.truncate()
await TaskBucketFactory.truncate()
await TaskRelationFactory.truncate()
const projects = await ProjectFactory.create(1)
// Only create a list view, no kanban view
const views = await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 0,
})
const tasks = await TaskFactory.create(1, {
project_id: projects[0].id,
})
await page.goto(`/projects/${projects[0].id}/${views[0].id}`)
await page.locator('.tasks .task').filter({hasText: tasks[0].title}).click()
await expect(page).toHaveURL(new RegExp(`/tasks/${tasks[0].id}`))
await expect(page.locator('.task-view .subtitle .bucket-name')).not.toBeVisible()
})
test.describe('Multiple kanban views', () => {
async function createTaskWithMultipleKanbanViews() {
// Truncate leftover task relations from previous tests
await TaskRelationFactory.truncate()
const projects = await ProjectFactory.create(1)
const listView = (await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 0,
}))[0]
const kanbanView1 = (await ProjectViewFactory.create(1, {
id: 2,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
}, false))[0]
const kanbanView2 = (await ProjectViewFactory.create(1, {
id: 3,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
}, false))[0]
const bucketsView1 = await BucketFactory.create(2, {
project_view_id: kanbanView1.id,
})
const bucketsView2 = await BucketFactory.create(2, {
id: (i: number) => i + 2,
project_view_id: kanbanView2.id,
}, false)
const tasks = await TaskFactory.create(1, {
project_id: projects[0].id,
})
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: bucketsView1[0].id,
project_view_id: kanbanView1.id,
})
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: bucketsView2[0].id,
project_view_id: kanbanView2.id,
}, false)
return {
project: projects[0],
listView,
kanbanView1,
kanbanView2,
bucketsView1,
bucketsView2,
task: tasks[0],
}
}
test('Does not show the bucket selector when opening a task from the list view', async ({authenticatedPage: page}) => {
const {project, listView, task} = await createTaskWithMultipleKanbanViews()
await page.goto(`/projects/${project.id}/${listView.id}`)
await page.locator('.tasks .task').filter({hasText: task.title}).click()
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
await expect(page.locator('.task-view .subtitle .bucket-name')).not.toBeVisible()
})
test('Shows the correct buckets when opening a task from the first kanban view', async ({authenticatedPage: page}) => {
const {project, kanbanView1, bucketsView1, task} = await createTaskWithMultipleKanbanViews()
await page.goto(`/projects/${project.id}/${kanbanView1.id}`)
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
await expect(page.locator('.task-view .subtitle')).toContainText(bucketsView1[0].title)
await page.locator('.task-view .subtitle .bucket-name').click()
await expect(page.locator('.task-view .subtitle .dropdown-item')).toHaveCount(bucketsView1.length)
for (const bucket of bucketsView1) {
await expect(page.locator('.task-view .subtitle .dropdown-item').filter({hasText: bucket.title})).toBeVisible()
}
})
test('Shows the correct buckets when opening a task from the second kanban view', async ({authenticatedPage: page}) => {
const {project, kanbanView2, bucketsView2, task} = await createTaskWithMultipleKanbanViews()
await page.goto(`/projects/${project.id}/${kanbanView2.id}`)
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
await expect(page.locator('.task-view .subtitle')).toContainText(bucketsView2[0].title)
await page.locator('.task-view .subtitle .bucket-name').click()
await expect(page.locator('.task-view .subtitle .dropdown-item')).toHaveCount(bucketsView2.length)
for (const bucket of bucketsView2) {
await expect(page.locator('.task-view .subtitle .dropdown-item').filter({hasText: bucket.title})).toBeVisible()
}
})
})
test('Keeps action buttons visible after changing the bucket', async ({authenticatedPage: page}) => {
const {project, view, buckets, task} = await createKanbanTaskInBucket()
await page.goto(`/projects/${project.id}/${view.id}`)
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
// Change the bucket
await page.locator('.task-view .subtitle .bucket-name').click()
await page.locator('.task-view .subtitle .dropdown-item').filter({hasText: buckets[1].title}).click()
await expect(page.locator('.global-notification')).toContainText('Success')
// Action buttons should still be visible
await expect(page.locator('.task-view .action-buttons .button').filter({hasText: 'Done'})).toBeVisible()
})
})

View File

@ -9,7 +9,6 @@ test.describe('Task comment pagination', () => {
await ProjectFactory.create(1)
await createDefaultViews(1)
await TaskFactory.create(1, {id: 1})
await TaskCommentFactory.truncate()
})
test('shows pagination when more comments than configured page size', async ({authenticatedPage: page, apiContext}) => {

View File

@ -39,7 +39,6 @@ test.describe('Comment sort order', () => {
await ProjectFactory.create(1)
await createDefaultViews(1)
await TaskFactory.create(1, {id: 1})
await TaskCommentFactory.truncate()
})
test('defaults to oldest first', async ({authenticatedPage: page}) => {
@ -199,7 +198,6 @@ test.describe('Comment sort order', () => {
frontend_settings: JSON.stringify({commentSortOrder: 'desc'}),
}))[0]
const project = (await ProjectFactory.create(1, {owner_id: user.id}))[0]
await TaskFactory.truncate()
await TaskFactory.create(1, {id: 1, project_id: project.id, created_by_id: user.id})
await createCommentsWithTimestamps(3)

View File

@ -74,7 +74,6 @@ test.describe('Date display setting', () => {
frontend_settings: JSON.stringify({dateDisplay: format, timeFormat: TIME_FORMAT.HOURS_12}),
}))[0]
const project = (await ProjectFactory.create(1, {owner_id: user.id}))[0]
await TaskFactory.truncate()
const task = (await TaskFactory.create(1, {
id: 1,
project_id: project.id,
@ -96,7 +95,6 @@ test.describe('Date display setting', () => {
frontend_settings: JSON.stringify({dateDisplay: format, timeFormat: TIME_FORMAT.HOURS_24}),
}))[0]
const project = (await ProjectFactory.create(1, {owner_id: user.id}))[0]
await TaskFactory.truncate()
const task = (await TaskFactory.create(1, {
id: 1,
project_id: project.id,

View File

@ -15,8 +15,6 @@ async function createProjectsWithTasks() {
})
// Create views for both projects
await ProjectViewFactory.truncate()
// List view for source project
const sourceListView = await ProjectViewFactory.create(1, {
id: 1,
@ -45,7 +43,6 @@ async function createProjectsWithTasks() {
})
// Create tasks in source project
await TaskFactory.truncate()
const tasks = await TaskFactory.create(3, {
id: '{increment}',
title: i => `Task ${i + 1}`,
@ -53,7 +50,6 @@ async function createProjectsWithTasks() {
})
// Assign tasks to bucket for kanban view
await TaskBucketFactory.truncate()
for (const task of tasks) {
await TaskBucketFactory.create(1, {
task_id: task.id,
@ -180,7 +176,6 @@ test.describe('Drag Task to Project in Sidebar', () => {
title: 'Source Project',
})
await ProjectViewFactory.truncate()
const sourceListView = await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
@ -194,7 +189,6 @@ test.describe('Drag Task to Project in Sidebar', () => {
owner_id: 1,
})
await TaskFactory.truncate()
const tasks = await TaskFactory.create(1, {
id: 1,
title: 'Test Task',
@ -249,7 +243,6 @@ test.describe('Drag Task to Project in Sidebar', () => {
permission: 0,
})
await ProjectViewFactory.truncate()
const sourceListView = await ProjectViewFactory.create(1, {
id: 1,
project_id: sourceProject[0].id,
@ -263,7 +256,6 @@ test.describe('Drag Task to Project in Sidebar', () => {
view_kind: 0,
}, false)
await TaskFactory.truncate()
const tasks = await TaskFactory.create(1, {
id: 1,
title: 'Test Task',

View File

@ -9,7 +9,6 @@ test.describe('Mention in task comment', () => {
await ProjectFactory.create(1)
await createDefaultViews(1)
await TaskFactory.create(1, {id: 1})
await TaskCommentFactory.truncate()
})
test('typing @ in comment editor does not throw TypeError', async ({authenticatedPage: page}) => {

View File

@ -157,7 +157,10 @@ test.describe('Home Page Task Overview', () => {
})
test('Should show the cta buttons for new project when there are no tasks', async ({authenticatedPage: page}) => {
await TaskFactory.truncate()
// Need a project so that ShowTasks renders (which sets tasksLoaded=true),
// but no tasks so the ImportHint becomes visible.
const project = (await ProjectFactory.create())[0]
await createDefaultViews(project.id)
await page.goto('/')

View File

@ -0,0 +1,86 @@
import {test, expect} from '../../support/fixtures'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {UserProjectFactory} from '../../factories/users_project'
import {UserFactory} from '../../factories/user'
import {BucketFactory} from '../../factories/bucket'
import {createDefaultViews} from '../project/prepareProjects'
test.describe('Read-only checkbox on Overview', () => {
test('Should disable checkboxes for tasks from read-only shared projects', async ({authenticatedPage: page, apiContext, currentUser}) => {
// Create a second user who will own the shared project
const [otherUser] = await UserFactory.create(1, {
id: 2,
}, false)
// Create the own project (owned by test user, id=1)
const [ownProject] = await ProjectFactory.create(1, {
id: 1,
title: 'Own Project',
owner_id: currentUser.id,
})
const ownViews = await createDefaultViews(ownProject.id, 1)
await BucketFactory.create(1, {
project_view_id: ownViews[3].id,
})
// Create the shared project (owned by user 2)
const [sharedProject] = await ProjectFactory.create(1, {
id: 2,
title: 'Shared Read-Only Project',
owner_id: otherUser.id,
}, false)
const sharedViews = await createDefaultViews(sharedProject.id, 5, false)
await BucketFactory.create(1, {
id: 2,
project_view_id: sharedViews[3].id,
}, false)
// Share the project read-only (permission=0) with the test user
await UserProjectFactory.create(1, {
id: 1,
project_id: sharedProject.id,
user_id: currentUser.id,
permission: 0,
})
const now = new Date()
const soon = new Date(now.getTime() + 24 * 60 * 60 * 1000) // tomorrow
// Create a task in the own project
await TaskFactory.create(1, {
id: 1,
title: 'Own Task',
project_id: ownProject.id,
created_by_id: currentUser.id,
due_date: soon.toISOString(),
})
// Create a task in the shared read-only project
await TaskFactory.create(1, {
id: 2,
title: 'Read Only Task',
project_id: sharedProject.id,
created_by_id: otherUser.id,
due_date: soon.toISOString(),
}, false)
await page.goto('/')
await page.waitForLoadState('networkidle')
// Wait for both tasks to appear on the overview
const ownTaskRow = page.locator('.single-task', {hasText: 'Own Task'})
const readOnlyTaskRow = page.locator('.single-task', {hasText: 'Read Only Task'})
await expect(ownTaskRow).toBeVisible({timeout: 10000})
await expect(readOnlyTaskRow).toBeVisible({timeout: 10000})
// The checkbox for the own task should be enabled
const ownCheckbox = ownTaskRow.locator('input[type="checkbox"]')
await expect(ownCheckbox).toBeEnabled()
// The checkbox for the read-only task should be disabled
const readOnlyCheckbox = readOnlyTaskRow.locator('input[type="checkbox"]')
await expect(readOnlyCheckbox).toBeDisabled()
})
})

View File

@ -20,13 +20,6 @@ test.describe('Subtask duplicate handling', () => {
let subtask
test.beforeEach(async ({authenticatedPage: page, apiContext}) => {
await Promise.all([
ProjectFactory.truncate(),
ProjectViewFactory.truncate(),
TaskFactory.truncate(),
TaskRelationFactory.truncate(),
])
projectA = (await ProjectFactory.create(1, {id: 1, title: 'Project A'}))[0]
await createViews(projectA.id, 1)
projectB = (await ProjectFactory.create(1, {id: 2, title: 'Project B'}, false))[0]

View File

@ -95,8 +95,6 @@ test.describe('Task', () => {
buckets = await BucketFactory.create(1, {
project_view_id: views[3].id,
}) as Bucket[]
await TaskFactory.truncate()
await UserProjectFactory.truncate()
})
test('Should be created new', async ({authenticatedPage: page}) => {
@ -190,12 +188,6 @@ test.describe('Task', () => {
})
test.describe('Task Detail View', () => {
test.beforeEach(async ({authenticatedPage: page}) => {
await TaskCommentFactory.truncate()
await LabelTaskFactory.truncate()
await TaskAttachmentFactory.truncate()
})
test('provides back navigation to the project in the list view', async ({authenticatedPage: page}) => {
const tasks = await TaskFactory.create(1)
const loadTasksPromise = page.waitForResponse(response =>
@ -429,9 +421,9 @@ test.describe('Task', () => {
test('Can move a task to another project', async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(2)
const views = await createDefaultViews(projects[0].id)
const views = await createDefaultViews(projects[0].id, 10)
// Also create views for the target project
await createDefaultViews(projects[1].id)
await createDefaultViews(projects[1].id, 14)
await BucketFactory.create(2, {
project_view_id: views[3].id,
})
@ -471,8 +463,6 @@ test.describe('Task', () => {
})
test('Can add an assignee to a task', async ({authenticatedPage: page}) => {
await TaskAssigneeFactory.truncate()
// Create users with IDs starting at 100 to avoid conflict with logged-in user (ID 1)
// Don't truncate to preserve the authenticated user from the fixture
const users = await UserFactory.create(5, {
@ -538,7 +528,6 @@ test.describe('Task', () => {
id: 1,
project_id: 1,
})
await LabelFactory.truncate()
const newLabelText = 'some new label'
await page.goto(`/tasks/${tasks[0].id}`)
@ -559,7 +548,6 @@ test.describe('Task', () => {
project_id: 1,
})
const labels = await LabelFactory.create(1)
await LabelTaskFactory.truncate()
await page.goto(`/tasks/${tasks[0].id}`)
@ -572,7 +560,6 @@ test.describe('Task', () => {
project_id: projects[0].id,
})
const labels = await LabelFactory.create(1)
await LabelTaskFactory.truncate()
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
@ -722,7 +709,6 @@ test.describe('Task', () => {
})
test('Can paste an image into the description editor which uploads it as an attachment', async ({authenticatedPage: page}) => {
await TaskAttachmentFactory.truncate()
const tasks = await TaskFactory.create(1, {
id: 1,
}) as Task[]
@ -745,7 +731,6 @@ test.describe('Task', () => {
})
test('Can set a reminder', async ({authenticatedPage: page}) => {
await TaskReminderFactory.truncate()
const tasks = await TaskFactory.create(1, {
id: 1,
done: false,
@ -764,7 +749,6 @@ test.describe('Task', () => {
})
test('Allows to set a relative reminder when the task already has a due date', async ({authenticatedPage: page}) => {
await TaskReminderFactory.truncate()
const tasks = await TaskFactory.create(1, {
id: 1,
done: false,
@ -785,7 +769,6 @@ test.describe('Task', () => {
})
test('Allows to set a relative reminder when the task already has a start date', async ({authenticatedPage: page}) => {
await TaskReminderFactory.truncate()
const tasks = await TaskFactory.create(1, {
id: 1,
done: false,
@ -806,7 +789,6 @@ test.describe('Task', () => {
})
test('Allows to set a custom relative reminder when the task already has a due date', async ({authenticatedPage: page}) => {
await TaskReminderFactory.truncate()
const tasks = await TaskFactory.create(1, {
id: 1,
done: false,
@ -831,7 +813,6 @@ test.describe('Task', () => {
})
test('Allows to set a fixed reminder when the task already has a due date', async ({authenticatedPage: page}) => {
await TaskReminderFactory.truncate()
const tasks = await TaskFactory.create(1, {
id: 1,
done: false,
@ -856,7 +837,6 @@ test.describe('Task', () => {
})
test('Does not auto-save when clicking a date in the absolute reminder picker', async ({authenticatedPage: page}) => {
await TaskReminderFactory.truncate()
const tasks = await TaskFactory.create(1, {
id: 1,
done: false,
@ -900,7 +880,6 @@ test.describe('Task', () => {
})
test('Shows Confirm button for absolute date reminder when task has no due date', async ({authenticatedPage: page}) => {
await TaskReminderFactory.truncate()
// Task with no due_date — defaultRelativeTo will be null
const tasks = await TaskFactory.create(1, {
id: 1,
@ -949,7 +928,6 @@ test.describe('Task', () => {
})
test('Can add an attachment to a task', async ({authenticatedPage: page}) => {
await TaskAttachmentFactory.truncate()
const tasks = await TaskFactory.create(1, {
id: 1,
})
@ -959,13 +937,11 @@ test.describe('Task', () => {
})
test('Can add an attachment to a task and see it appearing on kanban', async ({authenticatedPage: page}) => {
await TaskAttachmentFactory.truncate()
const tasks = await TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
})
const labels = await LabelFactory.create(1)
await LabelTaskFactory.truncate()
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
@ -1121,8 +1097,6 @@ test.describe('Task', () => {
})
test('Should render an image from attachment', async ({authenticatedPage: page, apiContext}) => {
await TaskAttachmentFactory.truncate()
const tasks = await TaskFactory.create(1, {
id: 1,
description: '',

View File

@ -5,7 +5,6 @@ import {ProjectFactory} from '../../factories/project'
test.describe('TipTap Editor Save', () => {
test.beforeEach(async ({authenticatedPage: page}) => {
await ProjectFactory.create(1)
await TaskFactory.truncate()
})
/**

View File

@ -8,9 +8,6 @@ test.describe('Email Confirmation', () => {
let confirmationToken
test.beforeEach(async ({page, apiContext}) => {
await UserFactory.truncate()
await TokenFactory.truncate()
// Create a user with status = 1 (StatusEmailConfirmationRequired)
const users = await UserFactory.create(1, {
username: 'unconfirmeduser',

View File

@ -45,7 +45,7 @@ test.describe('Login', () => {
test('Should log in with the right credentials', async ({page}) => {
await page.goto('/login')
await login(page)
await expect(page.locator('main h2')).toContainText(`Hi ${credentials.username}!`)
await expect(page.locator('main h2')).toContainText(credentials.username)
})
test('Should fail with a bad password', async ({page}) => {

View File

@ -31,7 +31,6 @@ test.describe('Log out', () => {
test('Should clear the project history after logging the user out', async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(1)
await ProjectViewFactory.truncate()
await ProjectViewFactory.create(1, {
id: projects[0].id,
project_id: projects[0].id,

View File

@ -15,7 +15,7 @@ test.describe('OpenID Login', () => {
// Should redirect back to the app
await expect(page).toHaveURL(/\//)
await expect(page.locator('main.app-content .content h2')).toContainText('test!')
await expect(page.locator('main.app-content .content h2')).toContainText('test')
await expect(page.locator('.show-tasks h3')).toContainText('Current Tasks')
})
})

View File

@ -6,8 +6,6 @@ test.describe('Password Reset', () => {
let user: UserAttributes
test.beforeEach(async ({page, apiContext}) => {
await UserFactory.truncate()
await TokenFactory.truncate()
const users = await UserFactory.create(1)
user = users[0] as UserAttributes
})

View File

@ -27,7 +27,7 @@ test.describe('Registration', () => {
await page.locator('#password').fill(fixture.password)
await page.locator('#register-submit').click()
await expect(page).toHaveURL('/')
await expect(page.locator('main h2')).toContainText(`Hi ${fixture.username}!`)
await expect(page.locator('main h2')).toContainText(fixture.username)
})
test('Should fail', async ({page, apiContext}) => {

View File

@ -0,0 +1,58 @@
import {test, expect} from '../../support/fixtures'
import {openWs, waitForMessage, authenticateWs, subscribeWs, closeWs} from '../../support/websocket'
import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project'
import {ProjectViewFactory} from '../../factories/project_view'
import {TaskFactory} from '../../factories/task'
import {UserProjectFactory} from '../../factories/users_project'
import {TEST_PASSWORD} from '../../support/constants'
import type {APIRequestContext} from '@playwright/test'
async function loginRaw(apiContext: APIRequestContext, user: {username: string}): Promise<{token: string}> {
const response = await apiContext.post('login', {
data: {username: user.username, password: TEST_PASSWORD},
})
return response.json()
}
test.describe('WebSocket Comment Notifications', () => {
test('receives notification when mentioned in a task comment', async ({apiContext, userToken, currentUser}) => {
const ws = await openWs()
try {
await authenticateWs(ws, userToken)
subscribeWs(ws, 'notification.created')
// Create a second user who will post the comment
const [commenter] = await UserFactory.create(1, {id: 100}, false)
const {token: commenterToken} = await loginRaw(apiContext, commenter)
// Seed a project owned by the commenter with a task
await ProjectFactory.create(1, {id: 100, owner_id: 100}, false)
await ProjectViewFactory.create(1, {id: 100, project_id: 100}, false)
await TaskFactory.create(1, {id: 100, project_id: 100, created_by_id: 100}, false)
// Share the project with currentUser so the mention access check passes
await UserProjectFactory.create(1, {id: 100, project_id: 100, user_id: 1}, false)
// Commenter posts a comment mentioning currentUser
const commentBody = `<p>Hey <mention-user data-id="${currentUser.username}">@${currentUser.username}</mention-user> check this out</p>`
const commentResponse = await apiContext.put('tasks/100/comments', {
data: {comment: commentBody},
headers: {Authorization: `Bearer ${commenterToken}`},
})
expect(commentResponse.ok()).toBe(true)
// currentUser should receive the notification via WebSocket
const msg = await waitForMessage(ws, 15000)
expect(msg.event).toBe('notification.created')
expect(msg.data).toBeDefined()
// The notification payload must include a valid created timestamp (not zero)
const created = new Date(msg.data.created)
expect(created.getFullYear()).toBeGreaterThanOrEqual(2020)
} finally {
closeWs(ws)
}
})
})

View File

@ -0,0 +1,97 @@
import {test, expect} from '../../support/fixtures'
import {UserFactory} from '../../factories/user'
import {TEST_PASSWORD} from '../../support/constants'
test.describe('WebSocket Frontend Integration', () => {
test('notification badge updates in real-time when added to team', async ({
authenticatedPage: page,
apiContext,
currentUser,
}) => {
// Navigate to the app so WebSocket connects
await page.goto('/')
await page.waitForLoadState('networkidle')
// Verify no unread indicator initially
await expect(page.locator('.notifications .unread-indicator')).toHaveCount(0)
// Create a second user who will add currentUser to a team
const [userA] = await UserFactory.create(1, {id: 100}, false)
const loginResponse = await apiContext.post('login', {
data: {username: userA.username, password: TEST_PASSWORD},
})
const {token: tokenA} = await loginResponse.json()
// User A creates a team
const teamResponse = await apiContext.put('teams', {
data: {name: 'Real-Time Test Team'},
headers: {Authorization: `Bearer ${tokenA}`},
})
const team = await teamResponse.json()
// User A adds currentUser to the team — this triggers a notification
await apiContext.put(`teams/${team.id}/members`, {
data: {username: currentUser.username},
headers: {Authorization: `Bearer ${tokenA}`},
})
// The unread indicator should appear without page refresh
await expect(page.locator('.notifications .unread-indicator')).toBeVisible({
timeout: 10000,
})
})
test('notification appears in dropdown after real-time delivery', async ({
authenticatedPage: page,
apiContext,
currentUser,
}) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// Create user A and trigger notification
const [userA] = await UserFactory.create(1, {id: 100}, false)
const loginResponse = await apiContext.post('login', {
data: {username: userA.username, password: TEST_PASSWORD},
})
const {token: tokenA} = await loginResponse.json()
const teamResponse = await apiContext.put('teams', {
data: {name: 'Dropdown Test Team'},
headers: {Authorization: `Bearer ${tokenA}`},
})
const team = await teamResponse.json()
await apiContext.put(`teams/${team.id}/members`, {
data: {username: currentUser.username},
headers: {Authorization: `Bearer ${tokenA}`},
})
// Wait for unread indicator then click the bell
await expect(page.locator('.notifications .unread-indicator')).toBeVisible({
timeout: 10000,
})
await page.locator('.notifications .trigger-button').click()
// Notification dropdown should contain the team notification
const notificationsList = page.locator('.notifications .notifications-list')
await expect(notificationsList).toBeVisible()
await expect(notificationsList.locator('.single-notification')).toHaveCount(1)
})
test('websocket disconnects on logout', async ({authenticatedPage: page}) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// Perform logout — click user menu then logout button
await page.locator('.navbar .username-dropdown-trigger').click()
await page.locator('.navbar .dropdown-item').filter({hasText: 'Logout'}).click()
// After logout, should redirect to login page
await expect(page).toHaveURL(/\/login/, {timeout: 5000})
// Verify the notification bell is gone (no authenticated UI)
await expect(page.locator('.notifications .trigger-button')).toHaveCount(0)
})
})

View File

@ -0,0 +1,247 @@
import {test, expect} from '../../support/fixtures'
import {openWs, waitForMessage, sendMessage, authenticateWs, subscribeWs, collectMessages, closeWs} from '../../support/websocket'
import {UserFactory} from '../../factories/user'
import {TEST_PASSWORD} from '../../support/constants'
import type {APIRequestContext} from '@playwright/test'
/** Login without setting page localStorage — just returns the token. */
async function loginRaw(apiContext: APIRequestContext, user: {username: string}): Promise<{token: string}> {
const response = await apiContext.post('login', {
data: {username: user.username, password: TEST_PASSWORD},
})
return response.json()
}
test.describe('WebSocket Protocol', () => {
test.describe('Authentication', () => {
test('authenticates with valid token', async ({userToken}) => {
const ws = await openWs()
try {
const msg = await authenticateWs(ws, userToken)
expect(msg.action).toBe('auth.success')
expect(msg.success).toBe(true)
} finally {
closeWs(ws)
}
})
test('rejects invalid token', async () => {
const ws = await openWs()
try {
sendMessage(ws, {action: 'auth', token: 'invalid-token'})
const msg = await waitForMessage(ws)
expect(msg.error).toBe('invalid_token')
} finally {
closeWs(ws)
}
})
test('closes connection after auth timeout', async () => {
test.setTimeout(45000)
const ws = await openWs()
const closed = new Promise<{code: number; reason: string}>((resolve) => {
ws.on('close', (code, reason) => {
resolve({code, reason: reason.toString()})
})
})
const result = await closed
// websocket StatusPolicyViolation = 1008
expect(result.code).toBe(1008)
})
test('rejects double authentication', async ({userToken}) => {
const ws = await openWs()
try {
await authenticateWs(ws, userToken)
sendMessage(ws, {action: 'auth', token: userToken})
const msg = await waitForMessage(ws)
expect(msg.error).toBe('already_authenticated')
} finally {
closeWs(ws)
}
})
})
test.describe('Subscribe / Unsubscribe Events', () => {
test('subscribes to valid event', async ({userToken}) => {
const ws = await openWs()
try {
await authenticateWs(ws, userToken)
sendMessage(ws, {action: 'subscribe', event: 'notification.created'})
// No error response means success — verify by collecting messages
// for a short window. If there was an error, it would arrive.
const messages = await collectMessages(ws, 500)
const errors = messages.filter(m => m.error)
expect(errors).toHaveLength(0)
} finally {
closeWs(ws)
}
})
test('rejects invalid event', async ({userToken}) => {
const ws = await openWs()
try {
await authenticateWs(ws, userToken)
sendMessage(ws, {action: 'subscribe', event: 'nonexistent.event'})
const msg = await waitForMessage(ws)
expect(msg.error).toBe('invalid_event')
expect(msg.event).toBe('nonexistent.event')
} finally {
closeWs(ws)
}
})
test('requires auth before subscribe', async () => {
const ws = await openWs()
try {
sendMessage(ws, {action: 'subscribe', event: 'notification.created'})
const msg = await waitForMessage(ws)
expect(msg.error).toBe('auth_required')
} finally {
closeWs(ws)
}
})
test('unsubscribe stops receiving events', async ({apiContext, userToken, currentUser}) => {
const ws = await openWs()
try {
await authenticateWs(ws, userToken)
subscribeWs(ws, 'notification.created')
// Create a second user to trigger the notification
const [userA] = await UserFactory.create(1, {id: 100}, false)
const {token: tokenA} = await loginRaw(apiContext, userA)
// User A creates a team
const teamResponse = await apiContext.put('teams', {
data: {name: 'Test Team'},
headers: {Authorization: `Bearer ${tokenA}`},
})
const team = await teamResponse.json()
// Unsubscribe before the notification is triggered
sendMessage(ws, {action: 'unsubscribe', event: 'notification.created'})
// Give the server a moment to process the unsubscribe
await new Promise(r => setTimeout(r, 200))
// Now add currentUser to team — should NOT receive WS notification
await apiContext.put(`teams/${team.id}/members`, {
data: {username: currentUser.username},
headers: {Authorization: `Bearer ${tokenA}`},
})
// Collect messages for 2 seconds — should get none
const messages = await collectMessages(ws, 2000)
const notifications = messages.filter(m => m.event === 'notification.created')
expect(notifications).toHaveLength(0)
} finally {
closeWs(ws)
}
})
})
test.describe('Message Delivery', () => {
test('receives notification when added to team', async ({apiContext, userToken, currentUser}) => {
const ws = await openWs()
try {
await authenticateWs(ws, userToken)
subscribeWs(ws, 'notification.created')
// Create a second user (the doer)
const [userA] = await UserFactory.create(1, {id: 100}, false)
const {token: tokenA} = await loginRaw(apiContext, userA)
// User A creates a team
const teamResponse = await apiContext.put('teams', {
data: {name: 'Notification Test Team'},
headers: {Authorization: `Bearer ${tokenA}`},
})
const team = await teamResponse.json()
// User A adds currentUser to the team
const addResponse = await apiContext.put(`teams/${team.id}/members`, {
data: {username: currentUser.username},
headers: {Authorization: `Bearer ${tokenA}`},
})
expect(addResponse.ok()).toBe(true)
// currentUser should receive the notification via WebSocket
const msg = await waitForMessage(ws, 10000)
expect(msg.event).toBe('notification.created')
expect(msg.data).toBeDefined()
} finally {
closeWs(ws)
}
})
test('doer does not receive own notification', async ({apiContext, userToken}) => {
const ws = await openWs()
try {
await authenticateWs(ws, userToken)
subscribeWs(ws, 'notification.created')
// Create a second user
const [otherUser] = await UserFactory.create(1, {id: 100}, false)
// currentUser creates a team (they are the doer)
const teamResponse = await apiContext.put('teams', {
data: {name: 'Doer Test Team'},
headers: {Authorization: `Bearer ${userToken}`},
})
const team = await teamResponse.json()
// currentUser adds otherUser — currentUser is the doer
await apiContext.put(`teams/${team.id}/members`, {
data: {username: otherUser.username},
headers: {Authorization: `Bearer ${userToken}`},
})
// currentUser should NOT receive a notification (they did the action)
const messages = await collectMessages(ws, 3000)
const notifications = messages.filter(m => m.event === 'notification.created')
expect(notifications).toHaveLength(0)
} finally {
closeWs(ws)
}
})
test('multiple connections receive same notification', async ({apiContext, userToken, currentUser}) => {
const ws1 = await openWs()
const ws2 = await openWs()
try {
// Both connections authenticate as the same user
await authenticateWs(ws1, userToken)
await authenticateWs(ws2, userToken)
subscribeWs(ws1, 'notification.created')
subscribeWs(ws2, 'notification.created')
// Create a second user to trigger notification
const [userA] = await UserFactory.create(1, {id: 100}, false)
const {token: tokenA} = await loginRaw(apiContext, userA)
const teamResponse = await apiContext.put('teams', {
data: {name: 'Multi-Connection Team'},
headers: {Authorization: `Bearer ${tokenA}`},
})
const team = await teamResponse.json()
await apiContext.put(`teams/${team.id}/members`, {
data: {username: currentUser.username},
headers: {Authorization: `Bearer ${tokenA}`},
})
// Both connections should receive the notification
const [msg1, msg2] = await Promise.all([
waitForMessage(ws1, 10000),
waitForMessage(ws2, 10000),
])
expect(msg1.event).toBe('notification.created')
expect(msg2.event).toBe('notification.created')
} finally {
closeWs(ws1)
closeWs(ws2)
}
})
})
})

View File

@ -96,4 +96,17 @@ export class Factory {
static async truncate() {
await this.seed(this.table, null)
}
static async truncateAll() {
const response = await this.request.delete('test/all', {
headers: {
'Authorization': process.env.VIKUNJA_SERVICE_TESTINGTOKEN || 'averyLongSecretToSe33dtheDB',
},
})
if (!response.ok()) {
const body = await response.json()
throw new Error(`Failed to truncate all tables (${response.status()}): ${body.message}`)
}
}
}

View File

@ -2,8 +2,6 @@ import {TaskFactory} from '../factories/task'
import {TaskBucketFactory} from '../factories/task_buckets'
export async function createTasksWithPriorities(buckets?: any[]) {
await TaskFactory.truncate()
const highPriorityTask1 = (await TaskFactory.create(1, {
id: 1,
project_id: 1,
@ -34,7 +32,6 @@ export async function createTasksWithPriorities(buckets?: any[]) {
// If buckets are provided (for Kanban), add tasks to buckets
if (buckets && buckets.length > 0) {
await TaskBucketFactory.truncate()
await TaskBucketFactory.create(1, {
task_id: highPriorityTask1.id,
bucket_id: buckets[0].id,
@ -64,8 +61,6 @@ export async function createTasksWithPriorities(buckets?: any[]) {
}
export async function createTasksWithSearch(buckets?: any[]) {
await TaskFactory.truncate()
const task1 = (await TaskFactory.create(1, {
id: 1,
project_id: 1,
@ -92,7 +87,6 @@ export async function createTasksWithSearch(buckets?: any[]) {
// If buckets are provided (for Kanban), add tasks to buckets
if (buckets && buckets.length > 0) {
await TaskBucketFactory.truncate()
await TaskBucketFactory.create(1, {
task_id: task1.id,
bucket_id: buckets[0].id,

View File

@ -8,16 +8,17 @@ export const test = base.extend<{
currentUser: any;
userToken: string;
}>({
apiContext: async ({playwright}, use) => {
apiContext: [async ({playwright}, use) => {
const baseURL = process.env.API_URL || 'http://localhost:3456/api/v1/'
const apiContext = await playwright.request.newContext({
baseURL,
})
Factory.setRequestContext(apiContext)
await Factory.truncateAll()
await use(apiContext)
await apiContext.dispose()
},
}, {auto: true}],
currentUser: async ({apiContext}, use) => {
const user = await createFakeUser()

View File

@ -0,0 +1,94 @@
import WebSocket from 'ws'
const API_URL = process.env.API_URL || 'http://localhost:3456/api/v1'
export interface WsMessage {
event?: string
action?: string
success?: boolean
error?: string
data?: unknown
}
/**
* Returns the WebSocket URL derived from the API base URL.
*/
export function getWsUrl(): string {
return API_URL.replace(/\/+$/, '').replace(/^http/, 'ws') + '/ws'
}
/**
* Opens a raw WebSocket connection to the API.
*/
export function openWs(): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(getWsUrl())
ws.on('open', () => resolve(ws))
ws.on('error', reject)
})
}
/**
* Waits for the next message on a WebSocket connection.
*/
export function waitForMessage(ws: WebSocket, timeout = 5000): Promise<WsMessage> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('WebSocket message timeout')), timeout)
ws.once('message', (data) => {
clearTimeout(timer)
resolve(JSON.parse(data.toString()))
})
})
}
/**
* Sends a JSON message on the WebSocket.
*/
export function sendMessage(ws: WebSocket, msg: object): void {
ws.send(JSON.stringify(msg))
}
/**
* Authenticates a WebSocket connection and returns the auth.success message.
*/
export async function authenticateWs(ws: WebSocket, token: string): Promise<WsMessage> {
sendMessage(ws, {action: 'auth', token})
const msg = await waitForMessage(ws)
if (msg.action !== 'auth.success') {
throw new Error(`Expected auth.success, got: ${JSON.stringify(msg)}`)
}
return msg
}
/**
* Subscribes to an event on an authenticated WebSocket connection.
*/
export function subscribeWs(ws: WebSocket, event: string): void {
sendMessage(ws, {action: 'subscribe', event})
}
/**
* Collects all messages received within a time window.
*/
export function collectMessages(ws: WebSocket, duration: number): Promise<WsMessage[]> {
return new Promise((resolve) => {
const messages: WsMessage[] = []
const handler = (data: WebSocket.Data) => {
messages.push(JSON.parse(data.toString()))
}
ws.on('message', handler)
setTimeout(() => {
ws.off('message', handler)
resolve(messages)
}, duration)
})
}
/**
* Closes a WebSocket connection safely.
*/
export function closeWs(ws: WebSocket): void {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close()
}
}

7
go.mod
View File

@ -29,9 +29,10 @@ require (
github.com/aws/aws-sdk-go-v2/config v1.32.10
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2
github.com/aws/smithy-go v1.24.1
github.com/aws/smithy-go v1.24.2
github.com/bbrks/go-blurhash v1.1.1
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500
github.com/coder/websocket v1.8.14
github.com/coreos/go-oidc/v3 v3.17.0
github.com/d4l3k/messagediff v1.2.1
github.com/disintegration/imaging v1.6.2
@ -96,7 +97,7 @@ require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
@ -131,7 +132,7 @@ require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-chi/chi/v5 v5.2.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect

14
go.sum
View File

@ -34,8 +34,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
@ -68,8 +68,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWA
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bbrks/go-blurhash v1.1.1 h1:uoXOxRPDca9zHYabUTwvS4KnY++KKUbwFo+Yxb8ME4M=
@ -103,6 +103,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@ -164,8 +166,8 @@ github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=

View File

@ -447,7 +447,14 @@ func (Test) Filter(ctx context.Context, filter string) error {
func (Test) All() {
mg.Deps(initVars)
mg.Deps(Test.Feature, Test.Web, Test.E2EApi)
mg.Deps(Test.Feature, Test.Web, Test.Caldav, Test.E2EApi)
}
// Caldav runs the CalDAV protocol compliance tests in pkg/caldavtests.
// These tests exercise the full HTTP router with WebDAV/CalDAV requests.
func (Test) Caldav(ctx context.Context) error {
mg.Deps(initVars)
return runAndStreamOutput(ctx, "go", "test", goDetectVerboseFlag(), "-p", "1", "-timeout", "45m", "./pkg/caldavtests")
}
// E2EApi runs the end-to-end API tests in pkg/e2etests.
@ -1638,7 +1645,7 @@ func (Generate) ConfigYAML(commented bool) {
// PrepareWorktree creates a new git worktree for development.
// The first argument is the name, which becomes both the folder name and branch name.
// The second argument is a path to a plan file that will be copied to the new worktree (pass "" to skip).
// The second argument is a path to a plan file that will be moved to the new worktree (pass "" to skip).
// The worktree is created in the parent directory (../).
// It also copies the current config.yml with an updated rootpath, and initializes the frontend.
func (Dev) PrepareWorktree(ctx context.Context, name string, planPath string) error {
@ -1721,10 +1728,10 @@ func (Dev) PrepareWorktree(ctx context.Context, name string, planPath string) er
}
dstPlanPath := filepath.Join(plansDir, filepath.Base(planPath))
if err := copyFile(srcPlanPath, dstPlanPath); err != nil {
return fmt.Errorf("failed to copy plan file: %w", err)
if err := os.Rename(srcPlanPath, dstPlanPath); err != nil {
return fmt.Errorf("failed to move plan file: %w", err)
}
printSuccess("Plan file copied to %s!", dstPlanPath)
printSuccess("Plan file moved to %s!", dstPlanPath)
}
}

View File

@ -0,0 +1,182 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldavtests
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestAuth(t *testing.T) {
t.Run("Valid credentials return 200/207", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavGET(t, e, "/dav/projects/36")
assert.True(t, rec.Code >= 200 && rec.Code < 300,
"Valid credentials should succeed. Got %d", rec.Code)
})
t.Run("No auth returns 401", func(t *testing.T) {
e := setupTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/dav/projects/36", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code,
"Request without auth should return 401")
})
t.Run("Wrong password returns 401", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavRequest(t, e, http.MethodGet, "/dav/projects/36", "", map[string]string{
"Authorization": basicAuthHeader(testuser15.Username, "wrongpassword"),
})
assert.Equal(t, http.StatusUnauthorized, rec.Code,
"Wrong password should return 401")
})
t.Run("Nonexistent user returns 401", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavRequest(t, e, http.MethodGet, "/dav/projects/36", "", map[string]string{
"Authorization": basicAuthHeader("nonexistent_user", fixturePassword),
})
assert.Equal(t, http.StatusUnauthorized, rec.Code,
"Nonexistent user should return 401")
})
t.Run("Empty Authorization header returns 401", func(t *testing.T) {
e := setupTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/dav/projects/36", nil)
req.Header.Set("Authorization", "")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code,
"Empty auth header should return 401")
})
t.Run("Auth on /dav/ entry point", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavRequest(t, e, "PROPFIND", "/dav/", PropfindCurrentUserPrincipal, map[string]string{
"Depth": "0",
})
// Should succeed with valid auth
assert.True(t, rec.Code >= 200 && rec.Code < 300 || rec.Code == 207,
"Authenticated PROPFIND on /dav/ should succeed. Got %d", rec.Code)
})
t.Run("Auth on /.well-known/caldav", func(t *testing.T) {
e := setupTestEnv(t)
// Without auth
req := httptest.NewRequest("PROPFIND", "/.well-known/caldav", strings.NewReader(PropfindCurrentUserPrincipal))
req.Header.Set("Depth", "0")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code,
"/.well-known/caldav without auth should return 401")
})
}
func TestPermissions(t *testing.T) {
t.Run("User cannot GET project they do not have access to", func(t *testing.T) {
t.Skip("Known bug: CalDAV returns 500 instead of 403/404 — ErrUserDoesNotHaveAccessToProject is not recognized by caldav-go")
e := setupTestEnv(t)
// testuser1 should not be able to access project 36 (owned by user15)
rec := caldavRequest(t, e, http.MethodGet, "/dav/projects/36", "", map[string]string{
"Authorization": basicAuthHeader(testuser1.Username, fixturePassword),
})
// Should be 403 Forbidden or 404 Not Found (both are acceptable for access denial)
assert.True(t, rec.Code == http.StatusForbidden || rec.Code == http.StatusNotFound,
"Unauthorized user should get 403 or 404, got %d. Body:\n%s", rec.Code, rec.Body.String())
})
t.Run("User cannot PUT task to project they do not have access to", func(t *testing.T) {
e := setupTestEnv(t)
vtodo := NewVTodo("unauthorized-task", "Should Fail").Build()
rec := caldavRequest(t, e, http.MethodPut, "/dav/projects/36/unauthorized-task.ics", vtodo, map[string]string{
"Authorization": basicAuthHeader(testuser1.Username, fixturePassword),
"Content-Type": "text/calendar; charset=utf-8",
})
assert.True(t, rec.Code == http.StatusForbidden || rec.Code == http.StatusNotFound,
"PUT to unauthorized project should fail with 403 or 404, got %d", rec.Code)
})
t.Run("User cannot DELETE task from project they do not have access to", func(t *testing.T) {
e := setupTestEnv(t)
// Try to delete task 40 (uid-caldav-test) in project 36 as user1
rec := caldavRequest(t, e, http.MethodDelete, "/dav/projects/36/uid-caldav-test.ics", "", map[string]string{
"Authorization": basicAuthHeader(testuser1.Username, fixturePassword),
})
assert.True(t, rec.Code == http.StatusForbidden || rec.Code == http.StatusNotFound,
"DELETE on unauthorized project should fail with 403 or 404, got %d", rec.Code)
})
t.Run("User cannot REPORT on project they do not have access to", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavRequest(t, e, "REPORT", "/dav/projects/36", ReportCalendarQuery, map[string]string{
"Authorization": basicAuthHeader(testuser1.Username, fixturePassword),
})
assert.True(t, rec.Code == http.StatusForbidden || rec.Code == http.StatusNotFound || rec.Code == 207,
"REPORT on unauthorized project should fail or return empty, got %d", rec.Code)
// If it returns 207, it should have no results
if rec.Code == 207 {
ms := parseMultistatus(t, rec)
assert.Empty(t, ms.Responses,
"REPORT on unauthorized project should return empty multistatus if 207")
}
})
t.Run("Project listing only shows accessible projects", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavRequest(t, e, "PROPFIND", "/dav/projects", PropfindCalendarCollectionProperties, map[string]string{
"Depth": "1",
"Authorization": basicAuthHeader(testuser1.Username, fixturePassword),
})
assertResponseStatus(t, rec, 207)
body := rec.Body.String()
// user1 should see their own projects but NOT user15's projects
assert.NotContains(t, body, "Project 36 for Caldav tests",
"user1 should not see user15's Project 36")
})
}

View File

@ -0,0 +1,51 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldavtests
import (
"testing"
)
// TestBugs contains tests that reproduce specific bugs reported by users.
// Each test references the GitHub issue it reproduces.
// These tests are expected to FAIL until the bug is fixed.
//
// To add a new bug reproduction test:
// 1. Create a new t.Run with the issue number in the name
// 2. Reproduce the exact CalDAV request sequence from the bug report
// 3. Assert what the correct behavior SHOULD be (not what it currently does)
// 4. The test will fail until the bug is fixed — this is expected and good
func TestBugs(t *testing.T) {
// Template for adding bug reproductions:
//
// t.Run("GitHub_Issue_NNNN_short_description", func(t *testing.T) {
// e := setupTestEnv(t)
//
// // Reproduce the steps from the issue...
// vtodo := NewVTodo("issue-NNNN", "...").Build()
// rec := caldavPUT(t, e, "/dav/projects/36/issue-NNNN.ics", vtodo)
//
// // Assert the expected (correct) behavior
// assert.Equal(t, 201, rec.Code)
// })
t.Run("placeholder_no_bugs_yet", func(t *testing.T) {
// Remove this placeholder once real bug tests are added
t.Skip("No bug reproductions added yet")
})
}

View File

@ -0,0 +1,215 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldavtests
import (
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClientDAVx5Flow(t *testing.T) {
t.Run("Full DAVx5 sync flow", func(t *testing.T) {
e := setupTestEnv(t)
// Step 1: Discover principal
// DAVx5 sends PROPFIND to the server root or well-known URL
rec := caldavPROPFIND(t, e, "/dav/", "0", PropfindCurrentUserPrincipal)
assert.True(t, rec.Code == 207 || rec.Code == 301,
"Step 1: PROPFIND /dav/ should return 207 or redirect. Got %d", rec.Code)
// Step 2: Get calendar-home-set from principal
rec = caldavPROPFIND(t, e, "/dav/principals/user15/", "0", PropfindCalendarHomeSet)
assertResponseStatus(t, rec, 207)
assert.Contains(t, rec.Body.String(), "calendar-home-set",
"Step 2: Principal should advertise calendar-home-set")
// Step 3: List calendars
rec = caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec, 207)
ms := parseMultistatus(t, rec)
assert.GreaterOrEqual(t, len(ms.Responses), 2,
"Step 3: Should list calendars")
// Step 4: Check CTag for a specific calendar
rec = caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec, 207)
// Step 5: Full sync — calendar-query to get all task ETags
rec = caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery)
assertResponseStatus(t, rec, 207)
ms = parseMultistatus(t, rec)
assert.NotEmpty(t, ms.Responses,
"Step 5: calendar-query should return tasks")
// Collect hrefs for multiget
var hrefs []string
for _, r := range ms.Responses {
if strings.HasSuffix(r.Href, ".ics") {
hrefs = append(hrefs, r.Href)
}
}
// Step 6: Multiget to fetch specific tasks
if len(hrefs) > 0 {
body := ReportCalendarMultiget(hrefs[:1]...) // Just fetch first task
rec = caldavREPORT(t, e, "/dav/projects/36", body)
assertResponseStatus(t, rec, 207)
ms = parseMultistatus(t, rec)
assert.Len(t, ms.Responses, 1,
"Step 6: multiget should return requested task")
}
// Step 7: Push a local change via PUT
vtodo := NewVTodo("davx5-sync-test", "DAVx5 Synced Task").
Due(time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)).
Build()
rec = caldavPUT(t, e, "/dav/projects/36/davx5-sync-test.ics", vtodo)
assert.Equal(t, http.StatusCreated, rec.Code,
"Step 7: PUT should create the task")
})
}
func TestClientThunderbirdFlow(t *testing.T) {
t.Run("Thunderbird discovery and initial sync", func(t *testing.T) {
e := setupTestEnv(t)
// Step 1: Thunderbird starts with OPTIONS to check DAV support
rec := caldavOPTIONS(t, e, "/dav/")
assert.Equal(t, http.StatusOK, rec.Code,
"Step 1: OPTIONS should succeed")
davHeader := rec.Header().Get("DAV")
assert.NotEmpty(t, davHeader,
"Step 1: Should have DAV header")
// Step 2: PROPFIND on well-known for principal
rec = caldavRequest(t, e, "PROPFIND", "/.well-known/caldav", PropfindCurrentUserPrincipal, map[string]string{
"Depth": "0",
})
assert.True(t, rec.Code == 207 || rec.Code == 301 || rec.Code == 302,
"Step 2: well-known should respond. Got %d", rec.Code)
// Step 3: PROPFIND principal for calendar-home-set
rec = caldavPROPFIND(t, e, "/dav/principals/user15/", "0", PropfindCalendarHomeSet)
assertResponseStatus(t, rec, 207)
// Step 4: Thunderbird checks current-user-privilege-set to know if it can write
// RFC 3744 §5.4 (rfc3744.txt line 1158)
rec = caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCurrentUserPrivilegeSet)
// This may return 207 with or without the property — document the behavior
assert.True(t, rec.Code == 207 || rec.Code == 200,
"Step 4: PROPFIND for privileges should not error. Got %d", rec.Code)
// Step 5: List calendars
rec = caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec, 207)
// Step 6: Sync via calendar-query
rec = caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery)
assertResponseStatus(t, rec, 207)
})
}
func TestClientTasksOrgSubtasks(t *testing.T) {
t.Run("Tasks.org subtask sync: child-only RELATED-TO", func(t *testing.T) {
// Tasks.org behavior:
// - Child tasks include RELATED-TO;RELTYPE=PARENT:<parent-uid>
// - Parent tasks have NO RELATED-TO at all
// - Tasks may arrive in any order
// - On re-sync, parent is sent again without RELATED-TO
e := setupTestEnv(t)
// Round 1: Initial sync — parent first, then children
parent := NewVTodo("tasks-org-parent", "Buy groceries").Build()
rec := caldavPUT(t, e, "/dav/projects/36/tasks-org-parent.ics", parent)
require.Equal(t, 201, rec.Code)
child1 := NewVTodo("tasks-org-child-1", "Buy milk").
RelatedToParent("tasks-org-parent").Build()
rec = caldavPUT(t, e, "/dav/projects/36/tasks-org-child-1.ics", child1)
require.Equal(t, 201, rec.Code)
child2 := NewVTodo("tasks-org-child-2", "Buy eggs").
RelatedToParent("tasks-org-parent").Build()
rec = caldavPUT(t, e, "/dav/projects/36/tasks-org-child-2.ics", child2)
require.Equal(t, 201, rec.Code)
// Verify parent shows children
rec = caldavGET(t, e, "/dav/projects/36/tasks-org-parent.ics")
body := rec.Body.String()
assert.Contains(t, body, "tasks-org-child-1")
assert.Contains(t, body, "tasks-org-child-2")
// Round 2: Re-sync — parent updated (title change), still no RELATED-TO
parentUpdated := NewVTodo("tasks-org-parent", "Buy groceries (updated list)").Build()
rec = caldavPUT(t, e, "/dav/projects/36/tasks-org-parent.ics", parentUpdated)
require.True(t, rec.Code >= 200 && rec.Code < 300)
// Verify children are still linked after parent re-sync
rec = caldavGET(t, e, "/dav/projects/36/tasks-org-parent.ics")
body = rec.Body.String()
assert.Contains(t, body, "Buy groceries (updated list)",
"Parent title should be updated")
assert.Contains(t, body, "tasks-org-child-1",
"Child 1 relation should survive parent re-sync")
assert.Contains(t, body, "tasks-org-child-2",
"Child 2 relation should survive parent re-sync")
// Round 3: Complete child via PUT with STATUS:COMPLETED
child1Done := NewVTodo("tasks-org-child-1", "Buy milk").
RelatedToParent("tasks-org-parent").
Status("COMPLETED").
Completed(time.Now().UTC()).
Build()
rec = caldavPUT(t, e, "/dav/projects/36/tasks-org-child-1.ics", child1Done)
require.True(t, rec.Code >= 200 && rec.Code < 300)
// Verify child is completed
rec = caldavGET(t, e, "/dav/projects/36/tasks-org-child-1.ics")
assert.Contains(t, rec.Body.String(), "STATUS:COMPLETED")
})
t.Run("Tasks.org subtask sync: children arrive before parent", func(t *testing.T) {
e := setupTestEnv(t)
// Children arrive first (reverse order)
child := NewVTodo("tasks-rev-child", "Subtask").
RelatedToParent("tasks-rev-parent").Build()
rec := caldavPUT(t, e, "/dav/projects/36/tasks-rev-child.ics", child)
require.Equal(t, 201, rec.Code)
// Parent arrives later — no RELATED-TO
parent := NewVTodo("tasks-rev-parent", "Main Task").Build()
rec = caldavPUT(t, e, "/dav/projects/36/tasks-rev-parent.ics", parent)
require.Equal(t, 201, rec.Code)
// Verify bidirectional relations
rec = caldavGET(t, e, "/dav/projects/36/tasks-rev-parent.ics")
assert.Contains(t, rec.Body.String(), "SUMMARY:Main Task",
"Parent should have real title, not DUMMY")
assert.Contains(t, rec.Body.String(), "tasks-rev-child",
"Parent should show child relation")
rec = caldavGET(t, e, "/dav/projects/36/tasks-rev-child.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:tasks-rev-parent")
})
}

View File

@ -0,0 +1,358 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldavtests
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestCRUDCreate(t *testing.T) {
// RFC 4791 §5.3.2 (rfc4791.txt line 1358):
// "A PUT request on a calendar collection creates a new calendar
// object resource when the Request-URI does not identify an
// existing resource."
t.Run("PUT new task returns 201 Created", func(t *testing.T) {
e := setupTestEnv(t)
vtodo := NewVTodo("test-create-uid", "Test Create Task").
Due(time.Date(2024, 3, 1, 15, 0, 0, 0, time.UTC)).
Build()
rec := caldavPUT(t, e, "/dav/projects/36/test-create-uid.ics", vtodo)
assert.Equal(t, http.StatusCreated, rec.Code,
"PUT of new resource should return 201. Body:\n%s", rec.Body.String())
})
t.Run("PUT new task sets ETag in response", func(t *testing.T) {
e := setupTestEnv(t)
vtodo := NewVTodo("test-etag-uid", "Test ETag Task").Build()
rec := caldavPUT(t, e, "/dav/projects/36/test-etag-uid.ics", vtodo)
assert.Equal(t, http.StatusCreated, rec.Code)
etag := rec.Header().Get("ETag")
assert.NotEmpty(t, etag,
"PUT response should include ETag header for the newly created resource")
})
t.Run("Created task is retrievable via GET", func(t *testing.T) {
e := setupTestEnv(t)
vtodo := NewVTodo("test-roundtrip-uid", "Roundtrip Test Task").
Description("A task created via CalDAV PUT").
Priority(3).
Build()
rec := caldavPUT(t, e, "/dav/projects/36/test-roundtrip-uid.ics", vtodo)
assert.Equal(t, http.StatusCreated, rec.Code)
// Now GET the task back
rec2 := caldavGET(t, e, "/dav/projects/36/test-roundtrip-uid.ics")
assert.Equal(t, http.StatusOK, rec2.Code)
body := rec2.Body.String()
assert.Contains(t, body, "BEGIN:VCALENDAR")
assert.Contains(t, body, "BEGIN:VTODO")
assert.Contains(t, body, "UID:test-roundtrip-uid")
assert.Contains(t, body, "SUMMARY:Roundtrip Test Task")
})
t.Run("PUT with invalid VCALENDAR returns error", func(t *testing.T) {
t.Skip("Known bug: parse errors propagate as 500 instead of 400 — caldav-go does not map parse failures to 4xx")
e := setupTestEnv(t)
rec := caldavPUT(t, e, "/dav/projects/36/bad-task.ics", "not a valid vcalendar")
// Should fail with a 4xx error
assert.GreaterOrEqual(t, rec.Code, 400,
"PUT with invalid VCALENDAR should return 4xx error")
assert.Less(t, rec.Code, 500,
"PUT with invalid VCALENDAR should not be a server error")
})
t.Run("PUT to nonexistent project returns 404", func(t *testing.T) {
e := setupTestEnv(t)
vtodo := NewVTodo("test-noproject-uid", "No Project Task").Build()
rec := caldavPUT(t, e, "/dav/projects/99999/test-noproject-uid.ics", vtodo)
assert.Equal(t, http.StatusNotFound, rec.Code,
"PUT to nonexistent project should return 404")
})
t.Run("PUT task with all supported fields", func(t *testing.T) {
e := setupTestEnv(t)
vtodo := NewVTodo("test-allfields-uid", "All Fields Task").
Description("Full description\\nwith newlines").
Priority(1). // Highest priority in CalDAV
Due(time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)).
DtStart(time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)).
Categories("work", "urgent").
Status("IN-PROCESS").
AlarmAbsolute(time.Date(2024, 6, 15, 8, 0, 0, 0, time.UTC)).
Build()
rec := caldavPUT(t, e, "/dav/projects/36/test-allfields-uid.ics", vtodo)
assert.Equal(t, http.StatusCreated, rec.Code,
"PUT with all fields should succeed. Body:\n%s", rec.Body.String())
})
}
func TestCRUDRead(t *testing.T) {
t.Run("GET existing task returns VCALENDAR", func(t *testing.T) {
e := setupTestEnv(t)
// Task 40 (uid-caldav-test) exists in project 36 from fixtures
rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics")
assert.Equal(t, http.StatusOK, rec.Code)
body := rec.Body.String()
assert.Contains(t, body, "BEGIN:VCALENDAR")
assert.Contains(t, body, "BEGIN:VTODO")
assert.Contains(t, body, "UID:uid-caldav-test")
assert.Contains(t, body, "SUMMARY:Title Caldav Test")
assert.Contains(t, body, "END:VTODO")
assert.Contains(t, body, "END:VCALENDAR")
})
t.Run("GET returns correct Content-Type", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics")
assert.Equal(t, http.StatusOK, rec.Code)
contentType := rec.Header().Get("Content-Type")
// Should be text/calendar per RFC 4791
assert.Contains(t, contentType, "text/calendar",
"GET on .ics resource should return Content-Type: text/calendar, got: %s", contentType)
})
t.Run("GET returns ETag header", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics")
assert.Equal(t, http.StatusOK, rec.Code)
etag := rec.Header().Get("ETag")
assert.NotEmpty(t, etag, "GET response should include ETag header")
})
t.Run("GET nonexistent task returns 404", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavGET(t, e, "/dav/projects/36/nonexistent-uid.ics")
assert.Equal(t, http.StatusNotFound, rec.Code,
"GET nonexistent task should return 404")
})
t.Run("GET project returns all tasks as VCALENDAR", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavGET(t, e, "/dav/projects/36")
assert.Equal(t, http.StatusOK, rec.Code)
body := rec.Body.String()
assert.Contains(t, body, "BEGIN:VCALENDAR")
assert.Contains(t, body, "X-WR-CALNAME:Project 36 for Caldav tests")
// Should contain multiple VTODOs
assert.Contains(t, body, "uid-caldav-test")
})
t.Run("GET task with .ics suffix works", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics")
assert.Equal(t, http.StatusOK, rec.Code)
})
t.Run("GET task without .ics suffix works", func(t *testing.T) {
e := setupTestEnv(t)
// Some clients may not include .ics suffix
rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test")
// This might 404 depending on implementation — document the behavior
// Either 200 or 404 is acceptable, but should be consistent
assert.True(t, rec.Code == http.StatusOK || rec.Code == http.StatusNotFound,
"GET without .ics should return 200 or 404, got %d", rec.Code)
})
}
func TestCRUDUpdate(t *testing.T) {
t.Run("PUT to existing task updates it", func(t *testing.T) {
e := setupTestEnv(t)
// First create
vtodo := NewVTodo("test-update-uid", "Original Title").Build()
rec := caldavPUT(t, e, "/dav/projects/36/test-update-uid.ics", vtodo)
assert.Equal(t, http.StatusCreated, rec.Code)
// Then update
vtodoUpdated := NewVTodo("test-update-uid", "Updated Title").
Description("Now with a description").
Build()
rec2 := caldavPUT(t, e, "/dav/projects/36/test-update-uid.ics", vtodoUpdated)
// Update should return 200 or 204 (not 201)
assert.True(t, rec2.Code == http.StatusOK ||
rec2.Code == http.StatusNoContent ||
rec2.Code == http.StatusCreated, // Some implementations return 201 for updates too
"PUT update should return 200, 204, or 201, got %d", rec2.Code)
// Verify the update took effect
rec3 := caldavGET(t, e, "/dav/projects/36/test-update-uid.ics")
assert.Equal(t, http.StatusOK, rec3.Code)
assert.Contains(t, rec3.Body.String(), "Updated Title")
assert.Contains(t, rec3.Body.String(), "Now with a description")
})
t.Run("PUT update changes ETag", func(t *testing.T) {
e := setupTestEnv(t)
// Create
vtodo := NewVTodo("test-etag-change-uid", "ETag Change Test").Build()
rec1 := caldavPUT(t, e, "/dav/projects/36/test-etag-change-uid.ics", vtodo)
assert.Equal(t, http.StatusCreated, rec1.Code)
etag1 := rec1.Header().Get("ETag")
// ETag uses second-precision timestamps, so we must wait to ensure a different value
time.Sleep(time.Second)
// Update
vtodoUpdated := NewVTodo("test-etag-change-uid", "ETag Change Test Updated").Build()
rec2 := caldavPUT(t, e, "/dav/projects/36/test-etag-change-uid.ics", vtodoUpdated)
etag2 := rec2.Header().Get("ETag")
// ETags should differ after update
if etag1 != "" && etag2 != "" {
assert.NotEqual(t, etag1, etag2,
"ETag should change after update. Before: %s, After: %s", etag1, etag2)
}
})
t.Run("PUT update preserves UID", func(t *testing.T) {
e := setupTestEnv(t)
// Create
vtodo := NewVTodo("test-preserve-uid", "Preserve UID Test").Build()
rec := caldavPUT(t, e, "/dav/projects/36/test-preserve-uid.ics", vtodo)
assert.Equal(t, http.StatusCreated, rec.Code)
// Update with different title but same UID
vtodoUpdated := NewVTodo("test-preserve-uid", "Updated Preserve UID").Build()
caldavPUT(t, e, "/dav/projects/36/test-preserve-uid.ics", vtodoUpdated)
// Verify UID is preserved
rec3 := caldavGET(t, e, "/dav/projects/36/test-preserve-uid.ics")
assert.Contains(t, rec3.Body.String(), "UID:test-preserve-uid")
})
}
func TestCRUDDelete(t *testing.T) {
t.Run("DELETE existing task returns 204", func(t *testing.T) {
e := setupTestEnv(t)
// Task 40 (uid-caldav-test) exists in project 36
rec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test.ics")
assert.Equal(t, http.StatusNoContent, rec.Code,
"DELETE should return 204 No Content. Body:\n%s", rec.Body.String())
})
t.Run("DELETE task makes it unreachable", func(t *testing.T) {
e := setupTestEnv(t)
// Delete task 40
rec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test.ics")
assert.Equal(t, http.StatusNoContent, rec.Code)
// Try to GET it — should 404
rec2 := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics")
assert.Equal(t, http.StatusNotFound, rec2.Code,
"GET after DELETE should return 404")
})
t.Run("DELETE nonexistent task returns 404", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavDELETE(t, e, "/dav/projects/36/nonexistent-uid.ics")
assert.Equal(t, http.StatusNotFound, rec.Code,
"DELETE nonexistent task should return 404")
})
t.Run("DELETE task removes it from project listing", func(t *testing.T) {
t.Skip("Known bug: DeleteResource relies on GetResource being called first to populate task ID — delete silently fails")
e := setupTestEnv(t)
// First verify task exists in project listing
rec := caldavGET(t, e, "/dav/projects/36")
assert.Contains(t, rec.Body.String(), "uid-caldav-test")
// Delete it
caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test.ics")
// Verify it's gone from the listing
rec2 := caldavGET(t, e, "/dav/projects/36")
assert.NotContains(t, rec2.Body.String(), "uid-caldav-test")
})
t.Run("Full lifecycle: PUT create -> GET read -> PUT update -> DELETE", func(t *testing.T) {
e := setupTestEnv(t)
uid := "test-lifecycle-uid"
path := "/dav/projects/36/" + uid + ".ics"
// Create
vtodo := NewVTodo(uid, "Lifecycle Test").Build()
rec := caldavPUT(t, e, path, vtodo)
assert.Equal(t, http.StatusCreated, rec.Code, "Create failed")
// Read
rec = caldavGET(t, e, path)
assert.Equal(t, http.StatusOK, rec.Code, "Read failed")
assert.Contains(t, rec.Body.String(), "Lifecycle Test")
// Update
vtodo2 := NewVTodo(uid, "Lifecycle Test Updated").Build()
rec = caldavPUT(t, e, path, vtodo2)
assert.True(t, rec.Code >= 200 && rec.Code < 300, "Update failed with %d", rec.Code)
// Verify update
rec = caldavGET(t, e, path)
assert.Contains(t, rec.Body.String(), "Lifecycle Test Updated")
// Delete
rec = caldavDELETE(t, e, path)
assert.Equal(t, http.StatusNoContent, rec.Code, "Delete failed")
// Verify gone
rec = caldavGET(t, e, path)
assert.Equal(t, http.StatusNotFound, rec.Code, "Task should be gone after delete")
})
}

View File

@ -0,0 +1,284 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldavtests
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestDiscovery(t *testing.T) {
// RFC 6764 §5 (rfc6764.txt line 205):
// "A CalDAV server SHOULD provide a well-known URI that redirects
// to the context path of the CalDAV service."
t.Run("well-known/caldav responds", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavRequest(t, e, "PROPFIND", "/.well-known/caldav", PropfindCurrentUserPrincipal, map[string]string{
"Depth": "0",
})
// Should get either a redirect (301/302) or a 207 with principal info
// Both are acceptable per RFC 6764 §5
assert.True(t,
rec.Code == http.StatusMovedPermanently ||
rec.Code == http.StatusFound ||
rec.Code == http.StatusMultiStatus,
"Expected 301, 302, or 207 from /.well-known/caldav, got %d. Body:\n%s", rec.Code, rec.Body.String())
})
t.Run("well-known/caldav/ with trailing slash responds", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavRequest(t, e, "PROPFIND", "/.well-known/caldav/", PropfindCurrentUserPrincipal, map[string]string{
"Depth": "0",
})
assert.True(t,
rec.Code == http.StatusMovedPermanently ||
rec.Code == http.StatusFound ||
rec.Code == http.StatusMultiStatus,
"Expected 301, 302, or 207 from /.well-known/caldav/, got %d. Body:\n%s", rec.Code, rec.Body.String())
})
t.Run("well-known/caldav without auth returns 401", func(t *testing.T) {
e := setupTestEnv(t)
req := httptest.NewRequest("PROPFIND", "/.well-known/caldav", strings.NewReader(PropfindCurrentUserPrincipal))
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
req.Header.Set("Depth", "0")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code,
"CalDAV well-known endpoint should require authentication")
})
}
func TestDiscoveryPrincipal(t *testing.T) {
// RFC 5397 §3 (rfc5397.txt line 126):
// "This property contains a URL that identifies the principal resource
// corresponding to the currently authenticated user."
t.Run("PROPFIND on /dav/ returns current-user-principal", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavPROPFIND(t, e, "/dav/", "0", PropfindCurrentUserPrincipal)
// Should get 207 Multi-Status
assertResponseStatus(t, rec, 207)
ms := parseMultistatus(t, rec)
assert.NotEmpty(t, ms.Responses, "Multistatus should contain at least one response")
// The current-user-principal should point to a principal resource
// containing the username
body := rec.Body.String()
assert.Contains(t, body, "current-user-principal",
"Response should contain current-user-principal property")
// Should contain the username in the principal URL
assert.Contains(t, body, "user15",
"Principal URL should contain the authenticated username")
})
t.Run("PROPFIND on /dav/principals/user15/ returns principal info", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavPROPFIND(t, e, "/dav/principals/user15/", "0", PropfindCalendarHomeSet)
assertResponseStatus(t, rec, 207)
body := rec.Body.String()
// Per RFC 4791 §6.2.1, the principal should advertise calendar-home-set
assert.Contains(t, body, "calendar-home-set",
"Principal resource should include calendar-home-set property")
// The home set should point to /dav/projects
assert.Contains(t, body, "/dav/projects",
"calendar-home-set should point to /dav/projects")
})
}
func TestDiscoveryCalendarHome(t *testing.T) {
// RFC 4791 §6.2.1 (rfc4791.txt line 1651):
// "The calendar-home-set property identifies the URL of any
// WebDAV collections that contain calendar collections owned
// by the associated principal resource."
t.Run("PROPFIND Depth:1 on /dav/projects lists calendars", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec, 207)
ms := parseMultistatus(t, rec)
// testuser15 owns projects 36 and 38 (from fixtures)
// The response should include at least these projects
assert.GreaterOrEqual(t, len(ms.Responses), 2,
"Should list at least the 2 projects owned by testuser15")
// Each response should have an href and a displayname
for _, r := range ms.Responses {
assert.NotEmpty(t, r.Href, "Each calendar response should have an href")
}
body := rec.Body.String()
// Check that the projects we know about are listed
assert.Contains(t, body, "Project 36 for Caldav tests",
"Should list Project 36")
assert.Contains(t, body, "Project 38 for Caldav tests",
"Should list Project 38")
})
t.Run("PROPFIND Depth:0 on /dav/projects returns just the home collection", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavPROPFIND(t, e, "/dav/projects", "0", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec, 207)
ms := parseMultistatus(t, rec)
// Depth 0 should return just the collection itself, not children
assert.Len(t, ms.Responses, 1,
"Depth 0 PROPFIND should return only the collection itself")
})
t.Run("Each listed calendar has resourcetype with calendar", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec, 207)
body := rec.Body.String()
// Per RFC 4791 §5.2, calendar collections MUST report
// DAV:collection and CALDAV:calendar in resourcetype
assert.Contains(t, body, "calendar",
"Calendar collections should have calendar in resourcetype")
})
t.Run("Each listed calendar has supported-calendar-component-set", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec, 207)
body := rec.Body.String()
// Per RFC 4791 §5.2.3 (rfc4791.txt line 768), calendar collections
// SHOULD report supported-calendar-component-set
assert.Contains(t, body, "VTODO",
"supported-calendar-component-set should include VTODO")
})
}
func TestDiscoveryOPTIONS(t *testing.T) {
// RFC 4791 §5.1 (rfc4791.txt line 602):
// "A CalDAV server MUST include 'calendar-access' as a field in the
// DAV response header from an OPTIONS request on any resource that
// supports the CalDAV extensions."
t.Run("OPTIONS on /dav/ returns DAV header with calendar-access", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavOPTIONS(t, e, "/dav/")
assert.Equal(t, http.StatusOK, rec.Code)
davHeader := rec.Header().Get("DAV")
assert.NotEmpty(t, davHeader, "OPTIONS response should include DAV header")
assert.Contains(t, davHeader, "calendar-access",
"DAV header should include 'calendar-access' per RFC 4791 §5.1")
})
t.Run("OPTIONS on /dav/projects/36 returns DAV header", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavOPTIONS(t, e, "/dav/projects/36")
assert.Equal(t, http.StatusOK, rec.Code)
davHeader := rec.Header().Get("DAV")
assert.NotEmpty(t, davHeader, "OPTIONS response should include DAV header")
})
t.Run("OPTIONS on /dav/ returns Allow header with supported methods", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavOPTIONS(t, e, "/dav/")
allowHeader := rec.Header().Get("Allow")
// A CalDAV server should advertise at least these methods
for _, method := range []string{"OPTIONS", "GET", "PUT", "DELETE", "PROPFIND", "REPORT"} {
assert.Contains(t, allowHeader, method,
"Allow header should include %s", method)
}
})
}
func TestDiscoveryFullChain(t *testing.T) {
// RFC 6764 §6 (rfc6764.txt line 254) describes the full bootstrapping flow:
// 1. Client does PROPFIND on /.well-known/caldav (or follows redirect)
// 2. Client extracts current-user-principal from response
// 3. Client does PROPFIND on principal URL for calendar-home-set
// 4. Client does PROPFIND Depth:1 on calendar-home-set to list calendars
t.Run("Full discovery chain: well-known -> principal -> home -> calendars", func(t *testing.T) {
e := setupTestEnv(t)
// Step 1: Hit well-known endpoint
rec1 := caldavRequest(t, e, "PROPFIND", "/.well-known/caldav", PropfindCurrentUserPrincipal, map[string]string{
"Depth": "0",
})
// Accept either redirect or direct response
assert.True(t, rec1.Code == 207 || rec1.Code == 301 || rec1.Code == 302,
"Step 1: /.well-known/caldav should respond with 207, 301, or 302, got %d", rec1.Code)
// Step 2: PROPFIND the entry point for principal info
rec2 := caldavPROPFIND(t, e, "/dav/", "0", PropfindCurrentUserPrincipal)
assertResponseStatus(t, rec2, 207)
// Step 3: PROPFIND the principal URL for calendar-home-set
// The principal URL for testuser15 should be /dav/principals/user15/
rec3 := caldavPROPFIND(t, e, "/dav/principals/user15/", "0", PropfindCalendarHomeSet)
assertResponseStatus(t, rec3, 207)
body3 := rec3.Body.String()
assert.Contains(t, body3, "calendar-home-set",
"Step 3: Principal should advertise calendar-home-set")
assert.Contains(t, body3, "/dav/projects",
"Step 3: calendar-home-set should point to /dav/projects")
// Step 4: PROPFIND Depth:1 on calendar home to list calendars
rec4 := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec4, 207)
ms4 := parseMultistatus(t, rec4)
assert.GreaterOrEqual(t, len(ms4.Responses), 2,
"Step 4: Should list at least 2 calendars for testuser15")
body4 := rec4.Body.String()
assert.Contains(t, body4, "Project 36 for Caldav tests",
"Step 4: Should list Project 36")
})
}

View File

@ -0,0 +1,146 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldavtests
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/keyvalue"
"code.vikunja.io/api/pkg/routes"
"code.vikunja.io/api/pkg/user"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/require"
)
// These are the test users, the same way they are in the test database
var (
testuser1 = user.User{ //nolint:gosec // test fixture credentials
ID: 1,
Username: "user1",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Email: "user1@example.com",
Issuer: "local",
}
testuser15 = user.User{ //nolint:gosec // test fixture credentials
ID: 15,
Username: "user15",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Email: "user15@example.com",
Issuer: "local",
}
)
// fixturePassword is the plaintext password for all test fixture users
const fixturePassword = "12345678"
func setupTestEnv(t *testing.T) *echo.Echo {
t.Helper()
config.InitDefaultConfig()
config.ServicePublicURL.Set("https://localhost")
log.InitLogger()
files.InitTests()
user.InitTests()
models.SetupTests()
events.Fake()
keyvalue.InitStorage()
err := db.LoadFixtures()
require.NoError(t, err)
e := routes.NewEcho()
routes.RegisterRoutes(e)
return e
}
// basicAuthHeader returns the Authorization header value for HTTP Basic Auth.
func basicAuthHeader(username, password string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
}
// caldavRequest sends an HTTP request through the full Echo router and returns the response.
func caldavRequest(t *testing.T, e *echo.Echo, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
// Default to testuser15 basic auth (the caldav test user) unless overridden
if _, hasAuth := headers["Authorization"]; !hasAuth {
req.Header.Set("Authorization", basicAuthHeader(testuser15.Username, fixturePassword))
}
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// caldavPROPFIND sends a PROPFIND request.
func caldavPROPFIND(t *testing.T, e *echo.Echo, path, depth, body string) *httptest.ResponseRecorder {
t.Helper()
return caldavRequest(t, e, "PROPFIND", path, body, map[string]string{
"Depth": depth,
})
}
// caldavREPORT sends a REPORT request.
func caldavREPORT(t *testing.T, e *echo.Echo, path, body string) *httptest.ResponseRecorder {
t.Helper()
return caldavRequest(t, e, "REPORT", path, body, nil)
}
// caldavGET sends a GET request.
func caldavGET(t *testing.T, e *echo.Echo, path string) *httptest.ResponseRecorder {
t.Helper()
return caldavRequest(t, e, http.MethodGet, path, "", nil)
}
// caldavPUT sends a PUT request with iCalendar content.
func caldavPUT(t *testing.T, e *echo.Echo, path, vcalendar string) *httptest.ResponseRecorder {
t.Helper()
return caldavRequest(t, e, http.MethodPut, path, vcalendar, map[string]string{
"Content-Type": "text/calendar; charset=utf-8",
})
}
// caldavDELETE sends a DELETE request.
func caldavDELETE(t *testing.T, e *echo.Echo, path string) *httptest.ResponseRecorder {
t.Helper()
return caldavRequest(t, e, http.MethodDelete, path, "", nil)
}
// caldavOPTIONS sends an OPTIONS request.
func caldavOPTIONS(t *testing.T, e *echo.Echo, path string) *httptest.ResponseRecorder {
t.Helper()
return caldavRequest(t, e, http.MethodOptions, path, "", nil)
}

View File

@ -0,0 +1,32 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldavtests
import (
"flag"
"os"
"testing"
)
func TestMain(m *testing.M) {
flag.Parse()
if testing.Short() {
println("-short requested, skipping long-running caldav tests")
return
}
os.Exit(m.Run())
}

View File

@ -0,0 +1,107 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldavtests
// PROPFIND request bodies used by CalDAV clients.
// PropfindCurrentUserPrincipal requests the current-user-principal property.
// RFC 5397 §3
const PropfindCurrentUserPrincipal = `<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:current-user-principal/>
</D:prop>
</D:propfind>`
// PropfindCalendarHomeSet requests the calendar-home-set property.
// RFC 4791 §6.2.1
const PropfindCalendarHomeSet = `<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-home-set/>
</D:prop>
</D:propfind>`
// PropfindCalendarCollectionProperties requests common calendar collection properties.
// RFC 4791 §5.2
const PropfindCalendarCollectionProperties = `<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:IC="http://apple.com/ns/ical/">
<D:prop>
<D:displayname/>
<D:resourcetype/>
<D:getetag/>
<CS:getctag/>
<C:supported-calendar-component-set/>
<C:calendar-description/>
</D:prop>
</D:propfind>`
// PropfindResourceProperties requests properties of a calendar resource (task).
const PropfindResourceProperties = `<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
</D:propfind>`
// PropfindAllProps requests all properties (allprop).
// RFC 4918 §9.1
const PropfindAllProps = `<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:">
<D:allprop/>
</D:propfind>`
// PropfindCurrentUserPrivilegeSet requests the current-user-privilege-set property.
// RFC 3744 §5.4
const PropfindCurrentUserPrivilegeSet = `<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:current-user-privilege-set/>
</D:prop>
</D:propfind>`
// ReportCalendarQuery is a calendar-query REPORT requesting all VTODOs.
// RFC 4791 §7.8
const ReportCalendarQuery = `<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VTODO"/>
</C:comp-filter>
</C:filter>
</C:calendar-query>`
// ReportCalendarMultiget builds a calendar-multiget REPORT for specific hrefs.
// RFC 4791 §7.9
func ReportCalendarMultiget(hrefs ...string) string {
var hrefXML string
for _, href := range hrefs {
hrefXML += " <D:href>" + href + "</D:href>\n"
}
return `<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-multiget xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
` + hrefXML + `</C:calendar-multiget>`
}

View File

@ -0,0 +1,240 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldavtests
import (
"net/http"
"strings"
"testing"
ics "github.com/arran4/golang-ical"
"github.com/stretchr/testify/assert"
)
func TestPropfindCollection(t *testing.T) {
// RFC 4918 §9.1 (rfc4918.txt line 1939):
// "The PROPFIND method retrieves properties defined on the resource
// identified by the Request-URI."
t.Run("Depth 0 on project returns collection properties", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec, 207)
ms := parseMultistatus(t, rec)
// Depth 0 should return exactly 1 response (the collection itself)
assert.Len(t, ms.Responses, 1,
"Depth 0 should return exactly the collection")
r := ms.Responses[0]
prop := getSuccessfulProp(t, r)
// displayname should be the project title
assert.Equal(t, "Project 36 for Caldav tests", prop.DisplayName,
"displayname should match project title")
// resourcetype should include both DAV:collection and CALDAV:calendar
assert.Contains(t, prop.ResourceType.InnerXML, "collection",
"resourcetype should include DAV:collection")
})
t.Run("Depth 1 on project returns collection plus tasks", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavPROPFIND(t, e, "/dav/projects/36", "1", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec, 207)
ms := parseMultistatus(t, rec)
// Project 36 has 5 tasks in fixtures (tasks 40-43, 45)
// Depth 1 should return the collection + all tasks = 6 responses
assert.GreaterOrEqual(t, len(ms.Responses), 6,
"Depth 1 should return collection + all tasks")
// First response should be the collection itself
// Subsequent responses should be individual tasks
body := rec.Body.String()
assert.Contains(t, body, ".ics",
"Task responses should have .ics hrefs")
})
t.Run("Depth 1 on project returns ETags for each resource", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavPROPFIND(t, e, "/dav/projects/36", "1", PropfindResourceProperties)
assertResponseStatus(t, rec, 207)
ms := parseMultistatus(t, rec)
for _, r := range ms.Responses {
prop := getSuccessfulProp(t, r)
// Every resource should have an ETag
// RFC 4918 §15.6: "strong ETags MUST be used"
assert.NotEmpty(t, prop.GetETag,
"Every resource in PROPFIND should have an ETag. Href: %s", r.Href)
}
})
t.Run("PROPFIND on nonexistent project returns 404", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavPROPFIND(t, e, "/dav/projects/99999", "0", PropfindCalendarCollectionProperties)
assert.Equal(t, http.StatusNotFound, rec.Code,
"PROPFIND on nonexistent project should return 404")
})
t.Run("Depth 1 includes calendar-data for each task", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavPROPFIND(t, e, "/dav/projects/36", "1", PropfindResourceProperties)
assertResponseStatus(t, rec, 207)
ms := parseMultistatus(t, rec)
taskCount := 0
for _, r := range ms.Responses {
prop := getSuccessfulProp(t, r)
if prop.CalendarData != "" {
taskCount++
// Each calendar-data should be valid iCalendar
cal := parseICalFromString(t, prop.CalendarData)
vtodo := getVTodo(t, cal)
uid := getVTodoProperty(vtodo, ics.ComponentPropertyUniqueId)
assert.NotEmpty(t, uid, "Each VTODO should have a UID")
}
}
assert.Positive(t, taskCount, "Should have at least one task with calendar-data")
})
}
func TestPropfindResource(t *testing.T) {
t.Run("Depth 0 on task returns task properties", func(t *testing.T) {
e := setupTestEnv(t)
// Task 40 has UID "uid-caldav-test" in project 36
rec := caldavPROPFIND(t, e, "/dav/projects/36/uid-caldav-test.ics", "0", PropfindResourceProperties)
assertResponseStatus(t, rec, 207)
ms := parseMultistatus(t, rec)
assert.Len(t, ms.Responses, 1,
"Depth 0 on a task should return exactly 1 response")
r := ms.Responses[0]
prop := getSuccessfulProp(t, r)
assert.NotEmpty(t, prop.GetETag, "Task should have an ETag")
assert.NotEmpty(t, prop.CalendarData, "Task should have calendar-data")
// Parse and validate the calendar data
cal := parseICalFromString(t, prop.CalendarData)
vtodo := getVTodo(t, cal)
assert.Equal(t, "uid-caldav-test", getVTodoProperty(vtodo, ics.ComponentPropertyUniqueId))
assert.Equal(t, "Title Caldav Test", getVTodoProperty(vtodo, ics.ComponentPropertySummary))
})
t.Run("PROPFIND on nonexistent task returns 404", func(t *testing.T) {
t.Skip("Known limitation: caldav-go returns 207 with 404 propstat instead of top-level 404")
e := setupTestEnv(t)
rec := caldavPROPFIND(t, e, "/dav/projects/36/nonexistent-uid.ics", "0", PropfindResourceProperties)
assert.Equal(t, http.StatusNotFound, rec.Code,
"PROPFIND on nonexistent task should return 404")
})
t.Run("ETag format is quoted string", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavPROPFIND(t, e, "/dav/projects/36/uid-caldav-test.ics", "0", PropfindResourceProperties)
assertResponseStatus(t, rec, 207)
ms := parseMultistatus(t, rec)
r := ms.Responses[0]
prop := getSuccessfulProp(t, r)
// RFC 4918 requires ETags to be quoted strings
assert.True(t, len(prop.GetETag) > 2 &&
prop.GetETag[0] == '"' && prop.GetETag[len(prop.GetETag)-1] == '"',
"ETag should be a quoted string, got: %s", prop.GetETag)
})
}
func TestPropfindCalendarHome(t *testing.T) {
t.Run("Depth 1 on /dav/projects lists all accessible calendars", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec, 207)
ms := parseMultistatus(t, rec)
// testuser15 should see at least projects 36 and 38
projectFound36 := false
projectFound38 := false
for _, r := range ms.Responses {
if strings.Contains(r.Href, "36") {
projectFound36 = true
}
if strings.Contains(r.Href, "38") {
projectFound38 = true
}
}
assert.True(t, projectFound36, "Should list project 36 in calendar home")
assert.True(t, projectFound38, "Should list project 38 in calendar home")
})
t.Run("Each calendar has displayname matching project title", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec, 207)
ms := parseMultistatus(t, rec)
for _, r := range ms.Responses {
prop := getSuccessfulProp(t, r)
if prop.DisplayName != "" {
// Every calendar with a displayname should have a reasonable title
assert.NotEmpty(t, prop.DisplayName,
"Calendar at %s should have a displayname", r.Href)
}
}
})
t.Run("User only sees projects they have access to", func(t *testing.T) {
e := setupTestEnv(t)
// testuser1 should NOT see testuser15's projects (36, 38)
rec := caldavRequest(t, e, "PROPFIND", "/dav/projects", PropfindCalendarCollectionProperties, map[string]string{
"Depth": "1",
"Authorization": basicAuthHeader(testuser1.Username, fixturePassword),
})
assertResponseStatus(t, rec, 207)
body := rec.Body.String()
// user1 should not see project 36 or 38 (owned by user15)
assert.NotContains(t, body, "Project 36 for Caldav tests",
"user1 should not see user15's project 36")
assert.NotContains(t, body, "Project 38 for Caldav tests",
"user1 should not see user15's project 38")
})
}

View File

@ -0,0 +1,235 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldavtests
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRelationsBasic(t *testing.T) {
// RFC 5545 §3.8.4.5 (rfc5545.txt line 6391):
// "This property is used to represent a relationship or reference
// between one calendar component and another."
t.Run("Parent with RELTYPE=CHILD and child with RELTYPE=PARENT", func(t *testing.T) {
e := setupTestEnv(t)
// Create parent (no relations)
parent := NewVTodo("rel-parent-1", "Parent Task").Build()
rec := caldavPUT(t, e, "/dav/projects/36/rel-parent-1.ics", parent)
require.Equal(t, 201, rec.Code)
// Create child referencing parent
child := NewVTodo("rel-child-1", "Child Task").
RelatedToParent("rel-parent-1").
Build()
rec = caldavPUT(t, e, "/dav/projects/36/rel-child-1.ics", child)
require.Equal(t, 201, rec.Code)
// GET child — should have RELATED-TO;RELTYPE=PARENT
rec = caldavGET(t, e, "/dav/projects/36/rel-child-1.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:rel-parent-1",
"Child should have RELATED-TO pointing to parent")
// GET parent — should have RELATED-TO;RELTYPE=CHILD (inverse)
rec = caldavGET(t, e, "/dav/projects/36/rel-parent-1.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:rel-child-1",
"Parent should have inverse RELATED-TO pointing to child")
})
t.Run("Grandchild chain: parent -> child -> grandchild", func(t *testing.T) {
e := setupTestEnv(t)
// Create in order: parent, child, grandchild
parent := NewVTodo("rel-gp-parent", "Grandparent").Build()
caldavPUT(t, e, "/dav/projects/36/rel-gp-parent.ics", parent)
child := NewVTodo("rel-gp-child", "Parent").
RelatedToParent("rel-gp-parent").
Build()
caldavPUT(t, e, "/dav/projects/36/rel-gp-child.ics", child)
grandchild := NewVTodo("rel-gp-grandchild", "Child").
RelatedToParent("rel-gp-child").
Build()
caldavPUT(t, e, "/dav/projects/36/rel-gp-grandchild.ics", grandchild)
// Verify middle node has both parent and child relations
rec := caldavGET(t, e, "/dav/projects/36/rel-gp-child.ics")
body := rec.Body.String()
assert.Contains(t, body, "RELATED-TO;RELTYPE=PARENT:rel-gp-parent")
assert.Contains(t, body, "RELATED-TO;RELTYPE=CHILD:rel-gp-grandchild")
})
}
func TestRelationsReverseOrder(t *testing.T) {
t.Run("Child arrives before parent (Tasks.org pattern)", func(t *testing.T) {
// This is the most common real-world scenario:
// Tasks.org sends child with RELATED-TO;RELTYPE=PARENT but the parent
// has NO RELATED-TO at all. The child may arrive before the parent.
e := setupTestEnv(t)
// Step 1: Child arrives first
child := NewVTodo("rev-child-first", "Child First").
RelatedToParent("rev-parent-late").
Build()
rec := caldavPUT(t, e, "/dav/projects/36/rev-child-first.ics", child)
require.Equal(t, 201, rec.Code)
// Step 2: Parent arrives later (no RELATED-TO)
parent := NewVTodo("rev-parent-late", "Parent Late").Build()
rec = caldavPUT(t, e, "/dav/projects/36/rev-parent-late.ics", parent)
require.Equal(t, 201, rec.Code)
// Step 3: Verify parent has correct title (not DUMMY-UID)
rec = caldavGET(t, e, "/dav/projects/36/rev-parent-late.ics")
assert.Contains(t, rec.Body.String(), "SUMMARY:Parent Late",
"Parent should have its real title, not DUMMY-UID")
assert.NotContains(t, rec.Body.String(), "DUMMY",
"DUMMY placeholder should be replaced")
// Step 4: Verify child still has parent relation
rec = caldavGET(t, e, "/dav/projects/36/rev-child-first.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:rev-parent-late",
"Child should still have parent relation after parent arrives")
})
t.Run("Multiple children before parent", func(t *testing.T) {
e := setupTestEnv(t)
// Two children arrive before parent
child1 := NewVTodo("rev-mc1", "Multi Child 1").
RelatedToParent("rev-mparent").Build()
caldavPUT(t, e, "/dav/projects/36/rev-mc1.ics", child1)
child2 := NewVTodo("rev-mc2", "Multi Child 2").
RelatedToParent("rev-mparent").Build()
caldavPUT(t, e, "/dav/projects/36/rev-mc2.ics", child2)
// Parent arrives
parent := NewVTodo("rev-mparent", "Multi Parent").Build()
caldavPUT(t, e, "/dav/projects/36/rev-mparent.ics", parent)
// Verify parent shows both children
rec := caldavGET(t, e, "/dav/projects/36/rev-mparent.ics")
body := rec.Body.String()
assert.Contains(t, body, "RELATED-TO;RELTYPE=CHILD:rev-mc1")
assert.Contains(t, body, "RELATED-TO;RELTYPE=CHILD:rev-mc2")
})
}
func TestRelationsCrossProject(t *testing.T) {
t.Run("Parent in project 36, child in project 38", func(t *testing.T) {
e := setupTestEnv(t)
parent := NewVTodo("xp-parent", "Cross-Project Parent").Build()
rec := caldavPUT(t, e, "/dav/projects/36/xp-parent.ics", parent)
require.Equal(t, 201, rec.Code)
child := NewVTodo("xp-child", "Cross-Project Child").
RelatedToParent("xp-parent").Build()
rec = caldavPUT(t, e, "/dav/projects/38/xp-child.ics", child)
require.Equal(t, 201, rec.Code)
// Verify parent in project 36 knows about child
rec = caldavGET(t, e, "/dav/projects/36/xp-parent.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:xp-child",
"Parent should have cross-project child relation")
// Verify child in project 38 knows about parent
rec = caldavGET(t, e, "/dav/projects/38/xp-child.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:xp-parent",
"Child should have cross-project parent relation")
})
t.Run("Pre-existing cross-project relations from fixtures", func(t *testing.T) {
e := setupTestEnv(t)
// Task 45 (project 36) and task 46 (project 38) have cross-project relations in fixtures
rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test-parent-task-another-list.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task-another-list")
rec = caldavGET(t, e, "/dav/projects/38/uid-caldav-test-child-task-another-list.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task-another-list")
})
}
func TestRelationsDeletion(t *testing.T) {
t.Run("Deleting child removes relation from parent", func(t *testing.T) {
e := setupTestEnv(t)
// Task 41 is parent of task 43 (from fixtures)
rec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test-child-task.ics")
assert.Equal(t, 204, rec.Code)
// Parent should no longer reference deleted child
rec = caldavGET(t, e, "/dav/projects/36/uid-caldav-test-parent-task.ics")
assert.NotContains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task\r\n",
"Parent should not reference deleted child")
})
t.Run("Deleting parent removes relation from child", func(t *testing.T) {
e := setupTestEnv(t)
// Delete parent task 41
rec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test-parent-task.ics")
assert.Equal(t, 204, rec.Code)
// Child should no longer reference deleted parent
rec = caldavGET(t, e, "/dav/projects/36/uid-caldav-test-child-task.ics")
assert.NotContains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task",
"Child should not reference deleted parent")
})
}
func TestRelationsResync(t *testing.T) {
t.Run("Parent re-sync without RELATED-TO preserves child relations", func(t *testing.T) {
// This is the DAVx5 behavior: parent is updated (e.g., title change)
// and re-synced without any RELATED-TO. The child-declared relations
// should survive.
e := setupTestEnv(t)
// Create parent
parent := NewVTodo("resync-parent", "Original Parent").Build()
caldavPUT(t, e, "/dav/projects/36/resync-parent.ics", parent)
// Create child with parent relation
child := NewVTodo("resync-child", "Child").
RelatedToParent("resync-parent").Build()
caldavPUT(t, e, "/dav/projects/36/resync-child.ics", child)
// Re-sync parent with updated title but NO RELATED-TO
parentUpdated := NewVTodo("resync-parent", "Updated Parent Title").Build()
caldavPUT(t, e, "/dav/projects/36/resync-parent.ics", parentUpdated)
// Verify relations survived
rec := caldavGET(t, e, "/dav/projects/36/resync-parent.ics")
body := rec.Body.String()
assert.Contains(t, body, "Updated Parent Title", "Title should be updated")
assert.Contains(t, body, "RELATED-TO;RELTYPE=CHILD:resync-child",
"Child relation should survive parent re-sync without RELATED-TO")
rec = caldavGET(t, e, "/dav/projects/36/resync-child.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:resync-parent",
"Parent relation on child should survive parent re-sync")
})
}

Some files were not shown because too many files have changed in this diff Show More