diff --git a/src/components/TaskBody.vue b/src/components/TaskBody.vue
index 8e0849c43..30b0e705b 100644
--- a/src/components/TaskBody.vue
+++ b/src/components/TaskBody.vue
@@ -118,6 +118,15 @@ License along with this library. If not, see .
{{ task.hideCompletedSubtasks ? t('tasks', 'Show closed subtasks') : t('tasks', 'Hide closed subtasks') }}
+
+
+
+
+ {{ t('tasks', 'Duplicate task') }}
+
@@ -205,6 +214,7 @@ import NcTextField from '@nextcloud/vue/components/NcTextField'
import Linkify from '@nextcloud/vue/directives/Linkify'
import Bell from 'vue-material-design-icons/BellOutline.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'
@@ -237,6 +247,7 @@ export default {
NcProgressBar,
NcTextField,
Bell,
+ ContentDuplicate,
Delete,
Eye,
Pin,
@@ -487,6 +498,7 @@ export default {
'toggleCompleted',
'toggleStarred',
'createTask',
+ 'duplicateTask',
'getTasksFromCalendar',
'toggleSubtasksVisibility',
'toggleCompletedSubtasksVisibility',
diff --git a/src/store/tasks.js b/src/store/tasks.js
index 8652d1cad..0b3f0f2cb 100644
--- a/src/store/tasks.js
+++ b/src/store/tasks.js
@@ -33,6 +33,7 @@ import { translate as t } from '@nextcloud/l10n'
import dayjs from 'dayjs'
import ICAL from 'ical.js'
+import { randomUUID } from '../utils/crypto.js'
const state = {
tasks: {},
@@ -859,6 +860,82 @@ const actions = {
}
},
+ /**
+ * Duplicates an existing task. Subtasks are duplicated recursively.
+ *
+ * @param {object} context The store context
+ * @param {object} payload Destructuring object
+ * @param {Task} payload.task The task to duplicate
+ * @param {Calendar} [payload.calendar] The calendar to create the duplicate in (defaults to original task calendar)
+ * @param {Task|null} [payload.parent] The parent task to attach the duplicate to (optional)
+ * @return {Promise} 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
*
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') }}
+
@@ -304,6 +312,7 @@ import Calendar from 'vue-material-design-icons/Calendar.vue'
import CalendarCheck from 'vue-material-design-icons/CalendarCheck.vue'
import CalendarEnd from 'vue-material-design-icons/CalendarEnd.vue'
import CalendarStart from 'vue-material-design-icons/CalendarStart.vue'
+import ContentDuplicate from 'vue-material-design-icons/ContentDuplicate.vue'
import Delete from 'vue-material-design-icons/TrashCanOutline.vue'
import Download from 'vue-material-design-icons/TrayArrowDown.vue'
import InformationOutline from 'vue-material-design-icons/InformationOutline.vue'
@@ -337,6 +346,7 @@ export default {
CalendarEnd,
CalendarStart,
CalendarCheck,
+ ContentDuplicate,
Delete,
Download,
InformationOutline,
@@ -751,6 +761,7 @@ export default {
'setStatus',
'getTaskByUri',
'togglePinned',
+ 'duplicateTask',
]),
async loadTask() {