diff --git a/plugins/nextcloud/index.php b/plugins/nextcloud/index.php index 469ecf1b8..3bf4d65db 100644 --- a/plugins/nextcloud/index.php +++ b/plugins/nextcloud/index.php @@ -35,6 +35,7 @@ class NextcloudPlugin extends \RainLoop\Plugins\AbstractPlugin $this->addTemplate('templates/PopupsNextcloudFiles.html'); $this->addTemplate('templates/PopupsNextcloudCalendars.html'); + $this->addTemplate('templates/PopupsNextcloudInvites.html'); // $this->addHook('login.credentials.step-2', 'loginCredentials2'); // $this->addHook('login.credentials', 'loginCredentials'); diff --git a/plugins/nextcloud/js/message.js b/plugins/nextcloud/js/message.js index f5154b745..bab6793a4 100644 --- a/plugins/nextcloud/js/message.js +++ b/plugins/nextcloud/js/message.js @@ -21,9 +21,18 @@ // https://github.com/nextcloud/calendar/issues/4684 if (cfg.CalDAV) { - attachmentsControls.append(Element.fromHTML(` + attachmentsControls.append(Element.fromHTML(` `)); + attachmentsControls.append(Element.fromHTML(` + + `)) + attachmentsControls.append(Element.fromHTML(` + + `)) + attachmentsControls.append(Element.fromHTML(` + + `)) } } /* @@ -107,18 +116,169 @@ }; view.nextcloudICS = ko.observable(null); + view.nextcloudICSOldInvitation = ko.observable(null); + view.nextcloudICSNewInvitation = ko.observable(null); + view.nextcloudICSLastInvitation = ko.observable(null); - view.nextcloudSaveICS = () => { + view.nextcloudICSShow = ko.observable(null); + + view.nextcloudICSCalendar = ko.observable(null); + view.filteredEventsUrls = ko.observable([]); + + view.nextcloudSaveICS = async () => { let VEVENT = view.nextcloudICS(); - VEVENT && rl.nextcloud.selectCalendar() - .then(href => href && rl.nextcloud.calendarPut(href, VEVENT)); + VEVENT = await view.handleUpdatedRecurrentEvents(VEVENT) + VEVENT && rl.nextcloud.selectCalendar(VEVENT) } + + view.handleUpdatedRecurrentEvents = async (VEVENT) => { + + const uid = VEVENT.UID + + let makePUTRequest = false + + const filteredEventUrls = view.filteredEventUrls + + if (filteredEventUrls.length > 1) { + // console.warn('filteredEventUrls.length > 1') + } + else if (filteredEventUrls.length == 1) { + const eventUrl = filteredEventUrls[0] + const eventText = await fetchEvent(eventUrl) + + // don't do anything for equal cards + if (VEVENT.rawText == eventText) { + return VEVENT + } + + const newVeventsText = extractVEVENTs(VEVENT.rawText) + const oldVeventsText = extractVEVENTs(eventText) + + // if there's only one event in the old card, it can't be an edit of the exception card + if (oldVeventsText.length == 1) { + const newRecurrenceId = extractProperties('RECURRENCE-ID', newVeventsText[0]) + + // if there's a property RECURRENCE-ID in the new card, it's an recurrence exception event + if (newRecurrenceId.length > 0) { + const updatedVEVENT = { + 'SUMMARY' : VEVENT.SUMMARY, + 'UID' : VEVENT.UID, + 'rawText' : mergeEventTexts(eventText, newVeventsText[0]) + } + VEVENT = updatedVEVENT + makePUTRequest = true + } + else { + return VEVENT + } + } + // if there's more than one event in the old card, it's possible to be an inclusion of a new exception card + // or update of old exception card + else { + let recurrenceIdMatch = false + + let isUpdate = false + const updateData = { + 'oldEventIndex': null, + 'newEventIndex': null, + } + + // check if it's an update + for (let i = 0; i < oldVeventsText.length; i++) { + let oldVeventText = oldVeventsText[i] + + for (let j = 0; j < newVeventsText.length; j++) { + let newVeventText = newVeventsText[j] + + let oldRecurrenceId = extractProperties('RECURRENCE-ID', oldVeventText) + let newRecurrenceId = extractProperties('RECURRENCE-ID', newVeventText) + let oldSequence = extractProperties('SEQUENCE', oldVeventText) + let newSequence = extractProperties('SEQUENCE', newVeventText) + + if (oldRecurrenceId.length == 0 || newRecurrenceId.length == 0 || oldSequence.length == 0 || newSequence.length == 0) { + continue + } + + if (oldRecurrenceId[0] == newRecurrenceId[0]) { + if (newSequence[0] > oldSequence[0]) { + isUpdate = true + + updateData.oldEventIndex = i + updateData.newEventIndex = j + + i = oldVeventsText.length + j = newVeventsText.length + } + } + } + } + + // if it's an update... + if (isUpdate) { + // substitute old event text for new event text + const oldEventStart = eventText.indexOf(oldVeventsText[updateData.oldEventIndex]) + const oldEventEnd = oldEventStart + oldVeventsText[updateData.oldEventIndex].length + + const newEvent = eventText.substring(0, oldEventStart) + newVeventsText[updateData.newEventIndex] + eventText.substring(oldEventEnd) + + const updatedVEVENT = { + 'SUMMARY' : VEVENT.SUMMARY, + 'UID': VEVENT.UID, + 'rawText' : newEvent + } + VEVENT = updatedVEVENT + makePUTRequest = true + } + // if it's not an update, it's an inclusion, as there's no match of RECURRENCE-ID + else { + const updatedVEVENT = { + 'SUMMARY' : VEVENT.SUMMARY, + 'UID' : VEVENT.UID, + 'rawText' : mergeEventTexts(eventText, newVeventsText[0]) + } + VEVENT = updatedVEVENT + makePUTRequest = true + } + } + } + + if (makePUTRequest) { + let href = "/" + (filteredEventUrls[0].split('/').slice(5, -1)[0]) + + rl.nextcloud.calendarPut(href, VEVENT, (response) => { + if (response.status != 201 && response.status != 204) { + InvitesPopupView.showModal([ + rl.i18n('NEXTCLOUD/EVENT_UPDATE_FAILURE_TITLE'), + rl.i18n('NEXTCLOUD/EVENT_UPDATE_FAILURE_BODY', {eventName: VEVENT.SUMMARY}) + ]) + return + } + InvitesPopupView.showModal([ + rl.i18n('NEXTCLOUD/EVENT_UPDATED_TITLE'), + rl.i18n('NEXTCLOUD/EVENT_UPDATED_BODY', {eventName: VEVENT.SUMMARY}) + ]) + }) + + return null + } + + return VEVENT + } + + + /** * TODO */ view.message.subscribe(msg => { view.nextcloudICS(null); + view.nextcloudICSOldInvitation(null); + view.nextcloudICSNewInvitation(null); + view.nextcloudICSLastInvitation(null); + view.nextcloudICSShow(view.nextcloudICS()) + + if (msg && cfg.CalDAV) { // let ics = msg.attachments.find(attachment => 'application/ics' == attachment.mimeType); let ics = msg.attachments.find(attachment => 'text/calendar' == attachment.mimeType); @@ -126,7 +286,7 @@ // fetch it and parse the VEVENT rl.fetch(ics.linkDownload()) .then(response => (response.status < 400) ? response.text() : Promise.reject(new Error({ response }))) - .then(text => { + .then(async (text) => { let VEVENT, VALARM, multiple = ['ATTACH','ATTENDEE','CATEGORIES','COMMENT','CONTACT','EXDATE', @@ -177,6 +337,158 @@ shouldReply: VEVENT.shouldReply() }); view.nextcloudICS(VEVENT); + view.nextcloudICSShow(true); + + + // try to get calendars, save + const calendarUrls = await fetchCalendarUrls() + + const filteredEventUrls = [] + for (let i = 0; i < calendarUrls.length; i++) { + let calendarUrl = calendarUrls[i] + + const skipCalendars = ['/inbox/', '/outbox/', '/trashbin/'] + let skip = false + for (let j = 0; j < skipCalendars.length; j++) { + if (calendarUrl.includes(skipCalendars[j])) { + skip = true + break + } + } + if (skip) { + continue + } + + // try to get event + const eventUrls = await fetchEventUrl(calendarUrl, VEVENT.UID) + + if (eventUrls.length == 0) { + continue + } + + eventUrls.forEach((url) => { + filteredEventUrls.push(url) + }) + } + view.filteredEventUrls = filteredEventUrls + + // if there's none, save in view.nextcloudICS + if (filteredEventUrls.length == 0) { + view.nextcloudICS(VEVENT); + view.nextcloudICSShow(true) + } + // if there's some... + else { + const savedEvent = await fetchEvent(filteredEventUrls[0]) + + const newVeventsText = extractVEVENTs(VEVENT.rawText) + const oldVeventsText = extractVEVENTs(savedEvent) + + // if there's more than one event in the old card, it's possible to be inclusion of new exception card + // or updated of old exception card + if (oldVeventsText.length > 1) { + let recurrenceIdMatch = false + + let oldNewestCreated = null + let newNewestCreated = null + + // check if it's an update + for (let i = 0; i < oldVeventsText.length; i++) { + let oldVeventText = oldVeventsText[i] + + for (let j = 0; j < newVeventsText.length; j++) { + let newVeventText = newVeventsText[j] + + let oldRecurrenceId = extractProperties('RECURRENCE-ID', oldVeventText) + let newRecurrenceId = extractProperties('RECURRENCE-ID', newVeventText) + let oldSequence = extractProperties('SEQUENCE', oldVeventText) + let newSequence = extractProperties('SEQUENCE', newVeventText) + + if (newRecurrenceId.length == 0 && oldRecurrenceId.length == 1) { + view.nextcloudICSShow(false) + view.nextcloudICSOldInvitation(true) + } + + if (oldRecurrenceId.length > 0 && newRecurrenceId.length > 0 && oldSequence.length > 0 && newSequence.length > 0) { + if (oldRecurrenceId[0] == newRecurrenceId[0]) { + if (newSequence[0] < oldSequence[0]) { + view.nextcloudICSOldInvitation(true) + view.nextcloudICSShow(false) + } + else if (newSequence[0] == oldSequence[0]) { + view.nextcloudICSLastInvitation(true) + view.nextcloudICSShow(false) + } + else if (newSequence[0] > oldSequence[0]) { + view.nextcloudICSNewInvitation(true) + view.nextcloudICSShow(false) + } + + // exit for loops + j = newVeventsText.length + i = oldVeventsText.length + } + } + + let oldCreated = extractProperties('CREATED', oldVeventText) + let newCreated = extractProperties('CREATED', newVeventText) + + if (oldCreated.length == 0 || newCreated.length == 0) { + continue + } + + const formattedOldDate = oldCreated[0].replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/, '$1-$2-$3T$4:$5:$6Z'); + const formattedNewDate = newCreated[0].replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/, '$1-$2-$3T$4:$5:$6Z'); + + const oldDate = new Date(formattedOldDate) + const newDate = new Date(formattedNewDate) + + if (oldNewestCreated == null || oldDate > oldNewestCreated) { + oldNewestCreated = oldDate + } + if (newNewestCreated == null || newDate > newNewestCreated) { + newNewestCreated = newDate + } + } + } + + if (newNewestCreated != null && oldNewestCreated != null) { + if (newNewestCreated < oldNewestCreated) { + view.nextcloudICSOldInvitation(true) + view.nextcloudICSShow(false) + } + else if (newNewestCreated == oldNewestCreated) { + view.nextcloudICSLastInvitation(true) + view.nextcloudICSShow(false) + } + } + } + else { + const oldLastModified = extractProperties('LAST-MODIFIED', oldVeventsText[0]) + const newLastModified = extractProperties('LAST-MODIFIED', newVeventsText[0]) + + if (oldLastModified.length == 1 && newLastModified.length == 1) { + const formattedOldDate = oldLastModified[0].replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/, '$1-$2-$3T$4:$5:$6Z'); + const formattedNewDate = newLastModified[0].replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/, '$1-$2-$3T$4:$5:$6Z'); + + const oldDate = new Date(formattedOldDate) + const newDate = new Date(formattedNewDate) + + if (newDate > oldDate) { + view.nextcloudICSNewInvitation(true) + view.nextcloudICSShow(false) + } + else if (newDate < oldDate) { + view.nextcloudICSOldInvitation(true) + view.nextcloudICSShow(false) + } + else { + view.nextcloudICSLastInvitation(true) + view.nextcloudICSShow(false) + } + } + } + } } }); } @@ -186,3 +498,148 @@ }); })(window.rl); + + +async function fetchCalendarUrls () { + const username = OC.currentUser + const requestToken = OC.requestToken + + const url = '/remote.php/dav/calendars/' + username + const response = await fetch(url, { + 'method': 'PROPFIND', + 'headers': { + 'Depth': '1', + 'Content-Type': 'application/xml', + 'requesttoken' : requestToken + } + }) + + if (!response.ok) { + throw new Error('Error fetching calendars', response) + } + + const responseText = await response.text() + + const parser = new DOMParser() + const xmlDoc = parser.parseFromString(responseText, 'application/xml') + + const calendarUrls = [] + + const hrefElements = xmlDoc.getElementsByTagName('d:href') + for (let i = 1; i < hrefElements.length; i++) { + let calendarUrl = hrefElements[i].textContent.trim() + calendarUrls.push(calendarUrl) + } + + return calendarUrls +} + + +async function fetchEventUrl (calendarUrl, uid) { + const requestToken = OC.requestToken + + const xmlRequestBody = ` + + + + + + + + + ${uid} + + + + +` + + const response = await fetch(calendarUrl, { + 'method': 'REPORT', + 'headers': { + 'Content-Type': 'application/xml', + 'Depth': 1, + 'requesttoken': requestToken + }, + 'body': xmlRequestBody + }) + + const responseText = await response.text() + + const parser = new DOMParser() + const xmlDoc = parser.parseFromString(responseText, 'application/xml') + + const hrefElements = xmlDoc.getElementsByTagName('d:href') + const hrefValues = Array.from(hrefElements).map(element => element.textContent) + + return hrefValues +} + + +async function fetchEvent (eventUrl) { + const requestToken = OC.requestToken + const response = await fetch(eventUrl, { + 'method': 'GET', + 'headers': { + 'requesttoken': requestToken + } + }) + const responseText = await response.text() + + return responseText +} + + +function extractVEVENTs(text) { + let lastEndIndex = 0 + const vEvents = [] + + let textToSearch = "" + let beginIndex = 0 + let endIndex = 0 + let foundVevent = false + + while (true) { + textToSearch = text.substring(lastEndIndex) + + beginIndex = textToSearch.indexOf('BEGIN:VEVENT') + if (beginIndex == -1) { + break + } + endIndex = textToSearch.substring(beginIndex).indexOf('END:VEVENT') + if (endIndex == -1) { + break + } + endIndex += beginIndex + + lastEndIndex = lastEndIndex + endIndex + 'END:VEVENT'.length + + foundVevent = textToSearch.substring(beginIndex + 'BEGIN:VEVENT'.length, endIndex) + + vEvents.push(foundVevent) + } + + return vEvents +} + + +function extractProperties (property, text) { + const matches = text.match(`${property}.*`) + + if (matches == null) { + return [] + } + + const separatedMatches = matches.map((match) => { + return match.substring(property.length + 1) + }) + + return separatedMatches +} + + +function mergeEventTexts (oldEventText, newEventText) { + const appendIndex = oldEventText.indexOf('END:VEVENT') + 'END:VEVENT'.length + const updatedEventText = oldEventText.substring(0, appendIndex) + "\nBEGIN:VEVENT" + newEventText + "END:VEVENT" + oldEventText.substring(appendIndex) + return updatedEventText +} diff --git a/plugins/nextcloud/js/webdav.js b/plugins/nextcloud/js/webdav.js index 24aa1f226..4f2e2cafd 100644 --- a/plugins/nextcloud/js/webdav.js +++ b/plugins/nextcloud/js/webdav.js @@ -320,12 +320,28 @@ class NextcloudCalendarsPopupView extends rl.pluginPopupView { } onBuild(dom) { + let modalObj = this this.tree = dom.querySelector('#sm-nc-calendars'); this.tree.addEventListener('click', event => { let el = event.target; if (el.matches('button')) { this.select = el.href; - this.close(); + let VEVENT = this.VEVENT + + rl.nextcloud.calendarPut(this.select, this.VEVENT, (response) => { + if (response.status != 201 && response.status != 204) { + InvitesPopupView.showModal([ + rl.i18n('MESSAGE/EVENT_ADDITION_FAILURE_TITLE'), + rl.i18n('MESSAGE/EVENT_ADDITION_FAILURE_BODY', {eventName: VEVENT.SUMMARY}), + () => {modalObj.close()} + ]) + } + InvitesPopupView.showModal([ + rl.i18n('MESSAGE/EVENT_ADDED_TITLE'), + rl.i18n('MESSAGE/EVENT_ADDED_BODY', {eventName: VEVENT.SUMMARY}), + () => {modalObj.close()} + ]) + }) } }); } @@ -375,9 +391,10 @@ class NextcloudCalendarsPopupView extends rl.pluginPopupView { treeElement.appendChild(li); } // Happens after showModal() - beforeShow(fResolve) { + beforeShow(fResolve, VEVENT) { this.select = ''; this.fResolve = fResolve; + this.VEVENT = VEVENT this.tree.innerHTML = ''; davFetch('calendars', '/', { method: 'PROPFIND', @@ -432,14 +449,15 @@ close() {} } rl.nextcloud = { - selectCalendar: () => + selectCalendar: (VEVENT) => new Promise(resolve => { NextcloudCalendarsPopupView.showModal([ href => resolve(href), + VEVENT ]); }), - calendarPut: (path, event) => { + calendarPut: (path, event, callback) => { davFetch('calendars', path + '/' + event.UID + '.ics', { method: 'PUT', headers: { @@ -463,6 +481,8 @@ rl.nextcloud = { // response.text().then(text => console.error({status:response.status, body:text})); Promise.reject(new Error({ response })); } + + callback && callback(response) }); }, @@ -500,3 +520,27 @@ function getElementsInNamespaces(xmlDocument, tagName) { } return results; } + + +class InvitesPopupView extends rl.pluginPopupView { + constructor() { + super('NextcloudInvites') + } + + onBuild(dom) { + this.title = dom.querySelector('#sm-invites-popup-title') + this.body = dom.querySelector('#sm-invites-popup-body') + } + + beforeShow(title, body, onHide_) { + this.title.innerHTML = title + this.body.innerHTML = body + this.onHide_ = onHide_ + } + + onHide() { + if (this.onHide_) { + this.onHide_() + } + } +} diff --git a/plugins/nextcloud/langs/en.json b/plugins/nextcloud/langs/en.json index 2c19d11c2..d7843c3b9 100644 --- a/plugins/nextcloud/langs/en.json +++ b/plugins/nextcloud/langs/en.json @@ -2,13 +2,21 @@ "NEXTCLOUD": { "SAVE_ATTACHMENTS": "Save in Nextcloud", "SAVE_EML": "Save as .eml in Nextcloud", - "SAVE_ICS": "Add to calendar", "SELECT_FOLDER": "Select folder", "SELECT_FILES": "Select file(s)", "ATTACH_FILES": "Attach Nextcloud files", - "SELECT_CALENDAR": "Select calendar", "FILE_ATTACH": "attach", "FILE_INTERNAL": "internal", - "FILE_PUBLIC": "public" + "FILE_PUBLIC": "public", + + "SELECT_CALENDAR": "Select calendar", + "SAVE_ICS": "Add to calendar", + "OLD_INVITATION": "Old invitation", + "UPDATE_ON_MY_CALENDAR": "Update invitation", + "LAST_INVITATION": "Last invitation", + "EVENT_UPDATE_FAILURE_TITLE": "Update failed", + "EVENT_UPDATE_FAILURE_BODY": "Why, i have no clue", + "EVENT_UPDATED_TITLE": "Updated", + "EVENT_UPDATED_BODY": "Why, i have no clue" } } diff --git a/plugins/nextcloud/templates/PopupsNextcloudInvites.html b/plugins/nextcloud/templates/PopupsNextcloudInvites.html new file mode 100644 index 000000000..6056fb2ed --- /dev/null +++ b/plugins/nextcloud/templates/PopupsNextcloudInvites.html @@ -0,0 +1,8 @@ +
+ × +

+
+ +