From 776a0eb7531487b64d4baad3194115cdd3862efe Mon Sep 17 00:00:00 2001 From: Maximilian Martin Date: Sun, 7 Dec 2025 10:18:03 +0100 Subject: [PATCH 1/3] feat: task duplication Signed-off-by: Maximilian Martin --- src/components/TaskBody.vue | 12 ++++++ src/store/tasks.js | 77 +++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/src/components/TaskBody.vue b/src/components/TaskBody.vue index 6b0848504..651862c5f 100644 --- a/src/components/TaskBody.vue +++ b/src/components/TaskBody.vue @@ -126,6 +126,15 @@ License along with this library. If not, see . {{ t('tasks', 'Delete task') }} + + + {{ t('tasks', 'Duplicate task') }} + } The newly created duplicate task + */ + async duplicateTask(context, payload) { + // Support being called with either the Task directly or a payload object + // called as: duplicateTask(task) or duplicateTask({ task, calendar, parent }) + const task = payload && payload.task ? payload.task : payload + const calendar = payload && payload.calendar ? payload.calendar : (task ? task.calendar : null) + const parent = payload && Object.prototype.hasOwnProperty.call(payload, 'parent') ? payload.parent : null + + // Don't try to duplicate non-existing tasks + if (!task) { + return null + } + // Don't try to duplicate tasks into read-only calendars + if (!calendar || calendar.readOnly) { + return null + } + // Don't duplicate tasks with access class not PUBLIC into calendars shared with me + if (calendar.isSharedWithMe && task.class !== 'PUBLIC') { + return null + } + + // Create a new Task from the existing task's jCal + const vData = ICAL.stringify(task.jCal) + const newTask = new Task(vData, calendar) + + // Assign a new UID and created timestamp + newTask.uid = randomUUID() + newTask.created = ICAL.Time.fromJSDate(new Date(), true) + newTask.dav = null + newTask.conflict = false + + // If a parent was provided, link to it. Otherwise, if the original task had + // a related parent and that parent exists in the target calendar, keep relation. + if (parent) { + newTask.related = parent.uid + } else if (task.related) { + const existingParent = context.getters.getTaskByUid(task.related) + if (existingParent && existingParent.calendar && existingParent.calendar.id === calendar.id) { + newTask.related = task.related + } else { + newTask.related = null + } + } + + // Create the new vObject on the server + try { + const response = await calendar.dav.createVObject(ICAL.stringify(newTask.jCal)) + newTask.dav = response + newTask.syncStatus = new SyncStatus('success', t('tasks', 'Successfully duplicated the task.')) + context.commit('appendTask', newTask) + context.commit('addTaskToCalendar', newTask) + const parentLocal = context.getters.getTaskByUid(newTask.related) + context.commit('addTaskToParent', { task: newTask, parent: parentLocal }) + } catch (error) { + console.error(error) + showError(t('tasks', 'Could not duplicate the task.')) + return null + } + + // Duplicate subtasks recursively, attaching them to the new parent + await Promise.all(Object.values(task.subTasks).map(async (subTask) => { + await context.dispatch('duplicateTask', { task: subTask, calendar, parent: newTask }) + })) + + return newTask + }, + /** * Deletes a task * From 210abade5da8318406e1502e4a0ceccf0ee5cc38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raimund=20Schl=C3=BC=C3=9Fler?= Date: Sun, 15 Feb 2026 22:21:16 +0100 Subject: [PATCH 2/3] chore: show delete task last MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Raimund Schlüßler --- src/components/TaskBody.vue | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/TaskBody.vue b/src/components/TaskBody.vue index 2e3444c25..30b0e705b 100644 --- a/src/components/TaskBody.vue +++ b/src/components/TaskBody.vue @@ -119,21 +119,21 @@ License along with this library. If not, see . {{ task.hideCompletedSubtasks ? t('tasks', 'Show closed subtasks') : t('tasks', 'Hide closed subtasks') }} + @click="duplicateTask({ task })"> - {{ t('tasks', 'Delete task') }} + {{ t('tasks', 'Duplicate task') }} + @click="scheduleTaskDeletion(task)"> - {{ t('tasks', 'Duplicate task') }} + {{ t('tasks', 'Delete task') }} @@ -214,8 +214,8 @@ import NcTextField from '@nextcloud/vue/components/NcTextField' import Linkify from '@nextcloud/vue/directives/Linkify' import Bell from 'vue-material-design-icons/BellOutline.vue' -import Delete from 'vue-material-design-icons/TrashCanOutline.vue' import ContentDuplicate from 'vue-material-design-icons/ContentDuplicate.vue' +import Delete from 'vue-material-design-icons/TrashCanOutline.vue' import Eye from 'vue-material-design-icons/EyeOutline.vue' import Pin from 'vue-material-design-icons/PinOutline.vue' import Plus from 'vue-material-design-icons/Plus.vue' @@ -247,8 +247,8 @@ export default { NcProgressBar, NcTextField, Bell, - Delete, ContentDuplicate, + Delete, Eye, Pin, Plus, From cc4f055f224005e9b9af83f56e6041cac26e8fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raimund=20Schl=C3=BC=C3=9Fler?= Date: Sun, 15 Feb 2026 22:21:36 +0100 Subject: [PATCH 3/3] feat: show duplicate button in sidebar too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Raimund Schlüßler --- src/views/AppSidebar.vue | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/views/AppSidebar.vue b/src/views/AppSidebar.vue index c6b02c180..051c25f5c 100644 --- a/src/views/AppSidebar.vue +++ b/src/views/AppSidebar.vue @@ -116,6 +116,14 @@ License along with this library. If not, see . {{ t('tasks', 'Export') }} + + + {{ t('tasks', 'Duplicate task') }} +