nextcloud-tasks/src/models/task.js

695 lines
17 KiB
JavaScript

/**
* Nextcloud - Tasks
*
* @author John Molakvoæ
* @copyright 2018 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author Raimund Schlüßler
* @copyright 2018 Raimund Schlüßler <raimund.schluessler@mailbox.org>
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
* License as published by the Free Software Foundation; either
* version 3 of the License, or any later version.
*
* This library 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 library. If not, see <http://www.gnu.org/licenses/>.
*
*/
import moment from '@nextcloud/moment'
import { v4 as uuid } from 'uuid'
import ICAL from 'ical.js'
import PQueue from 'p-queue'
export default class Task {
/**
* Creates an instance of Task
*
* @param {string} vcalendar the vcalendar data as string with proper new lines
* @param {object} calendar the calendar which the task belongs to
* @memberof Task
*/
constructor(vcalendar, calendar) {
if (typeof vcalendar !== 'string' || vcalendar.length === 0) {
throw new Error('Invalid vCalendar')
}
const jCal = ICAL.parse(vcalendar)
if (jCal[0] !== 'vcalendar') {
throw new Error('Only one task is allowed in the vCalendar data')
}
this.jCal = jCal
this.calendar = calendar
this.vCalendar = new ICAL.Component(this.jCal)
this.subTasks = {}
// used to state a task is not up to date with
// the server and cannot be pushed (etag)
this.conflict = false
this.initTodo()
this.syncstatus = null
// Queue for update requests with concurrency 1,
// because we only want to allow one request at a time
// (otherwise we will run into problems with changed ETags).
this.updateQueue = new PQueue({ concurrency: 1 })
}
initTodo() {
// if no uid set, create one
this.vtodo = this.vCalendar.getFirstSubcomponent('vtodo')
if (!this.vtodo) {
this.vtodo = new ICAL.Component('vtodo')
this.vCalendar.addSubcomponent(this.vtodo)
}
if (!this.vtodo.hasProperty('uid')) {
console.debug('This task did not have a proper uid. Setting a new one for ', this)
this.vtodo.addPropertyWithValue('uid', uuid())
}
// Define properties, so Vue reacts to changes of them
this._uid = this.vtodo.getFirstPropertyValue('uid') || ''
this._summary = this.vtodo.getFirstPropertyValue('summary') || ''
this._priority = this.vtodo.getFirstPropertyValue('priority')
this._complete = this.vtodo.getFirstPropertyValue('percent-complete') || 0
const comp = this.vtodo.getFirstPropertyValue('completed')
this._completed = !!comp
this._completedDate = comp ? comp.toJSDate() : null
this._completedDateMoment = moment(this._completedDate, 'YYYYMMDDTHHmmss')
this._status = this.vtodo.getFirstPropertyValue('status')
this._note = this.vtodo.getFirstPropertyValue('description') || ''
this._related = this.getParent()?.getFirstValue() || null
this._hideSubtaks = +this.vtodo.getFirstPropertyValue('x-oc-hidesubtasks') || 0
this._hideCompletedSubtaks = +this.vtodo.getFirstPropertyValue('x-oc-hidecompletedsubtasks') || 0
this._start = this.vtodo.getFirstPropertyValue('dtstart')
this._startMoment = moment(this._start, 'YYYYMMDDTHHmmss')
this._due = this.vtodo.getFirstPropertyValue('due')
this._dueMoment = moment(this._due, 'YYYYMMDDTHHmmss')
const start = this.vtodo.getFirstPropertyValue('dtstart')
const due = this.vtodo.getFirstPropertyValue('due')
const d = due || start
this._allDay = d !== null && d.isDate
this._loaded = false
this._categories = this.getCategories()
this._modified = this.vtodo.getFirstPropertyValue('last-modified')
this._modifiedMoment = moment(this._modified, 'YYYYMMDDTHHmmss')
this._created = this.vtodo.getFirstPropertyValue('created')
this._createdMoment = moment(this._created, 'YYYYMMDDTHHmmss')
this._class = this.vtodo.getFirstPropertyValue('class') || 'PUBLIC'
this._pinned = this.vtodo.getFirstPropertyValue('x-pinned') === 'true'
let sortOrder = this.vtodo.getFirstPropertyValue('x-apple-sort-order')
if (sortOrder === null) {
sortOrder = this.getSortOrder()
}
this._sortOrder = +sortOrder
this._searchQuery = ''
this._matchesSearchQuery = true
}
/**
* Update internal data of this task
*
* @param {jCal} jCal jCal object from ICAL.js
* @memberof Task
*/
updateTask(jCal) {
this.jCal = jCal
this.vCalendar = new ICAL.Component(this.jCal)
this.initTodo()
}
/**
* Update linked calendar of this task
*
* @param {Object} calendar the calendar
* @memberof Contact
*/
updateCalendar(calendar) {
this.calendar = calendar
}
/**
* Ensure we're normalizing the possible arrays
* into a string by taking the first element
* e.g. ORG:ABC\, Inc.; will output an array because of the semi-colon
*
* @param {Array|string} data the data to normalize
* @returns {string}
* @memberof Task
*/
firstIfArray(data) {
return Array.isArray(data) ? data[0] : data
}
/**
* Return the key
*
* @readonly
* @memberof Task
*/
get key() {
return this.uid + '~' + this.calendar.id
}
/**
* Return the url
*
* @readonly
* @memberof Task
*/
get url() {
if (this.dav) {
return this.dav.url
}
return ''
}
/**
* Return the uri
*
* @readonly
* @memberof Task
*/
get uri() {
if (this.dav) {
return this.dav.url.substr(this.dav.url.lastIndexOf('/') + 1)
}
return ''
}
/**
* Return the uid
*
* @readonly
* @memberof Task
*/
get uid() {
return this._uid
}
/**
* Set the uid
*
* @param {string} uid the uid to set
* @memberof Task
*/
set uid(uid) {
this.vtodo.updatePropertyWithValue('uid', uid)
this._uid = this.vtodo.getFirstPropertyValue('uid') || ''
return true
}
/**
* Return the first summary
*
* @readonly
* @memberof Task
*/
get summary() {
return this._summary
}
/**
* Set the summary
*
* @param {string} summary the summary
* @memberof Task
*/
set summary(summary) {
this.vtodo.updatePropertyWithValue('summary', summary)
this.updateLastModified()
this._summary = this.vtodo.getFirstPropertyValue('summary') || ''
}
get priority() {
return this._priority
}
set priority(priority) {
// TODO: check that priority is >= 0 and <10
this.vtodo.updatePropertyWithValue('priority', priority)
this.updateLastModified()
this._priority = this.vtodo.getFirstPropertyValue('priority')
}
get complete() {
return this._complete
}
set complete(complete) {
// Make complete a number
complete = +complete
this.setComplete(complete)
if (complete < 100) {
this.setCompleted(false)
if (complete === 0) {
this.setStatus('NEEDS-ACTION')
} else {
this.setStatus('IN-PROCESS')
}
} else {
this.setCompleted(true)
this.setStatus('COMPLETED')
}
}
setComplete(complete) {
this.vtodo.updatePropertyWithValue('percent-complete', complete)
this.updateLastModified()
this._complete = this.vtodo.getFirstPropertyValue('percent-complete') || 0
}
get completed() {
return this._completed
}
set completed(completed) {
this.setCompleted(completed)
if (completed) {
this.setComplete(100)
this.setStatus('COMPLETED')
} else {
if (this.complete === 100) {
this.setComplete(99)
this.setStatus('IN-PROCESS')
}
}
}
setCompleted(completed) {
if (completed) {
this.vtodo.updatePropertyWithValue('completed', ICAL.Time.now())
} else {
this.vtodo.removeProperty('completed')
}
this.updateLastModified()
const comp = this.vtodo.getFirstPropertyValue('completed')
this._completed = !!comp
this._completedDate = comp ? comp.toJSDate() : null
this._completedDateMoment = moment(this._completedDate, 'YYYYMMDDTHHmmss')
}
get completedDate() {
return this._completedDate
}
get completedDateMoment() {
return this._completedDateMoment.clone()
}
get status() {
return this._status
}
set status(status) {
this.setStatus(status)
if (status === 'COMPLETED') {
this.setComplete(100)
this.setCompleted(true)
} else if (status === 'IN-PROCESS') {
this.setCompleted(false)
if (this.complete === 100) {
this.setComplete(99)
} else if (this.complete === 0) {
this.setComplete(1)
}
} else if (status === 'NEEDS-ACTION') {
this.setComplete(0)
this.setCompleted(false)
}
}
setStatus(status) {
this.vtodo.updatePropertyWithValue('status', status)
this.updateLastModified()
this._status = this.vtodo.getFirstPropertyValue('status')
}
get note() {
return this._note
}
set note(note) {
this.vtodo.updatePropertyWithValue('description', note)
this.updateLastModified()
this._note = this.vtodo.getFirstPropertyValue('description') || ''
}
get related() {
return this._related
}
set related(related) {
const parent = this.getParent()
// If a parent already exists, update or remove it
if (parent) {
if (related) {
parent.setValue(related)
} else {
this.vtodo.removeProperty(parent)
}
// Otherwise create a new property, so we don't overwrite RELTYPE=CHILD/SIBLING entries.
} else {
if (related) {
this.vtodo.addPropertyWithValue('related-to', related)
}
}
this.updateLastModified()
this._related = this.getParent()?.getFirstValue() || null
}
getParent() {
const related = this.vtodo.getAllProperties('related-to')
// Return only the first parent for now
return related.find(related => {
return related.getFirstParameter('reltype') === 'PARENT' || related.getFirstParameter('reltype') === undefined
})
}
get pinned() {
return this._pinned
}
set pinned(pinned) {
if (pinned === true) {
this.vtodo.updatePropertyWithValue('x-pinned', 'true')
} else {
this.vtodo.removeProperty('x-pinned')
}
this.updateLastModified()
this._pinned = this.vtodo.getFirstPropertyValue('x-pinned') === 'true'
}
get hideSubtasks() {
return this._hideSubtaks
}
set hideSubtasks(hide) {
this.vtodo.updatePropertyWithValue('x-oc-hidesubtasks', +hide)
this.updateLastModified()
this._hideSubtaks = +this.vtodo.getFirstPropertyValue('x-oc-hidesubtasks') || 0
}
get hideCompletedSubtasks() {
return this._hideCompletedSubtaks
}
set hideCompletedSubtasks(hide) {
this.vtodo.updatePropertyWithValue('x-oc-hidecompletedsubtasks', +hide)
this.updateLastModified()
this._hideCompletedSubtaks = +this.vtodo.getFirstPropertyValue('x-oc-hidecompletedsubtasks') || 0
}
get start() {
return this._start
}
set start(start) {
if (start) {
this.vtodo.updatePropertyWithValue('dtstart', start)
} else {
this.vtodo.removeProperty('dtstart')
}
this.updateLastModified()
this._start = this.vtodo.getFirstPropertyValue('dtstart')
this._startMoment = moment(this._start, 'YYYYMMDDTHHmmss')
// Check all day setting
const d = this._due || this._start
this._allDay = d !== null && d.isDate
}
get startMoment() {
return this._startMoment.clone()
}
get due() {
return this._due
}
set due(due) {
if (due) {
this.vtodo.updatePropertyWithValue('due', due)
} else {
this.vtodo.removeProperty('due')
}
this.updateLastModified()
this._due = this.vtodo.getFirstPropertyValue('due')
this._dueMoment = moment(this._due, 'YYYYMMDDTHHmmss')
// Check all day setting
const d = this._due || this._start
this._allDay = d !== null && d.isDate
}
get dueMoment() {
return this._dueMoment.clone()
}
get allDay() {
return this._allDay
}
set allDay(allDay) {
let start = this.vtodo.getFirstPropertyValue('dtstart')
if (start) {
start.isDate = allDay
this.vtodo.updatePropertyWithValue('dtstart', start)
}
let due = this.vtodo.getFirstPropertyValue('due')
if (due) {
due.isDate = allDay
this.vtodo.updatePropertyWithValue('due', due)
}
this.updateLastModified()
start = this.vtodo.getFirstPropertyValue('dtstart')
due = this.vtodo.getFirstPropertyValue('due')
const d = due || start
this._allDay = d !== null && d.isDate
}
get comments() {
return null
}
get loadedCompleted() {
return this._loaded
}
set loadedCompleted(loadedCompleted) {
this._loaded = loadedCompleted
}
get reminder() {
return null
}
/**
* Return the categories
*
* @readonly
* @memberof Task
*/
get categories() {
return this._categories
}
getCategories() {
let categories = []
for (const cats of this.vtodo.getAllProperties('categories')) {
if (cats) {
categories = categories.concat(cats.getValues())
}
}
return categories
}
/**
* Set the categories
*
* @param {string} newCategories the categories
* @memberof Task
*/
set categories(newCategories) {
if (newCategories.length > 0) {
let categories = this.vtodo.getAllProperties('categories')
// If there are no categories set yet, just set them
if (categories.length < 1) {
const prop = new ICAL.Property('categories')
prop.setValues(newCategories)
categories = this.vtodo.addProperty(prop)
// If there is only one categories property, overwrite it
} else if (categories.length < 2) {
categories[0].setValues(newCategories)
// If there are multiple categories properties, we have to iterate over all
// and remove unwanted categories and add new ones
} else {
const toRemove = this._categories.filter(c => !newCategories.includes(c))
const toAdd = newCategories.filter(c => !this._categories.includes(c))
// Remove all unwanted categories
for (const cats of categories) {
const c = cats.getValues().filter(c => !toRemove.includes(c))
if (c.length) {
cats.setValues(c)
} else {
this.vtodo.removeProperty(cats)
}
}
// Add new categories
categories[0].setValues(categories[0].getValues().concat(toAdd))
}
} else {
this.vtodo.removeAllProperties('categories')
}
this.updateLastModified()
this._categories = this.getCategories()
}
updateLastModified() {
const now = ICAL.Time.now()
this.vtodo.updatePropertyWithValue('last-modified', now)
this.vtodo.updatePropertyWithValue('dtstamp', now)
this._modified = now
this._modifiedMoment = moment(this._modified, 'YYYYMMDDTHHmmss')
}
get modified() {
return this._modified
}
get modifiedMoment() {
return this._modifiedMoment.clone()
}
get created() {
return this._created
}
get createdMoment() {
return this._createdMoment.clone()
}
set created(createdDate) {
this.vtodo.updatePropertyWithValue('created', createdDate)
this.updateLastModified()
this._created = this.vtodo.getFirstPropertyValue('created')
this._createdMoment = moment(this._created, 'YYYYMMDDTHHmmss')
// Update the sortorder if necessary
if (this.vtodo.getFirstPropertyValue('x-apple-sort-order') === null) {
this._sortOrder = this.getSortOrder()
}
}
get class() {
return this._class
}
set class(classification) {
if (classification) {
this.vtodo.updatePropertyWithValue('class', classification)
} else {
this.vtodo.removeProperty('class')
}
this.updateLastModified()
this._class = this.vtodo.getFirstPropertyValue('class') || 'PUBLIC'
}
get sortOrder() {
return this._sortOrder
}
set sortOrder(sortOrder) {
// We expect an integer for the sort order.
sortOrder = parseInt(sortOrder)
if (isNaN(sortOrder)) {
this.vtodo.removeProperty('x-apple-sort-order')
// Get the default sort order.
sortOrder = this.getSortOrder()
} else {
this.vtodo.updatePropertyWithValue('x-apple-sort-order', sortOrder)
}
this.updateLastModified()
this._sortOrder = sortOrder
}
/**
* Construct the default value for the sort order
* from the created date.
*
* @returns {Integer} The sort order
*/
getSortOrder() {
// If there is no created date we return 0.
if (this._created === null) {
return 0
}
return this._created.subtractDate(
new ICAL.Time({
year: 2001,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
isDate: false,
})
).toSeconds()
}
/**
* Checks if the task matches the search query
*
* @param {String} searchQuery The search string
* @returns {Boolean} If the task matches
*/
matches(searchQuery) {
// If the search query maches the previous search, we don't have to search again.
if (this._searchQuery === searchQuery) {
return this._matchesSearchQuery
}
// We cache the current search query for faster future comparison.
this._searchQuery = searchQuery
// If the search query is empty, the task matches by default.
if (!searchQuery) {
this._matchesSearchQuery = true
return this._matchesSearchQuery
}
// We search in these task properties
const keys = ['summary', 'note', 'categories']
// Make search case-insensitive.
searchQuery = searchQuery.toLowerCase()
for (const key of keys) {
// For the categories search the array
if (key === 'categories') {
for (const category of this[key]) {
if (category.toLowerCase().indexOf(searchQuery) > -1) {
this._matchesSearchQuery = true
return this._matchesSearchQuery
}
}
} else {
if (this[key].toLowerCase().indexOf(searchQuery) > -1) {
this._matchesSearchQuery = true
return this._matchesSearchQuery
}
}
}
this._matchesSearchQuery = false
return this._matchesSearchQuery
}
}