From 7e013b2120657295ad7387e22438e1c9133b6eaa Mon Sep 17 00:00:00 2001 From: Krzysztof Kotlarek Date: Thu, 30 Nov 2023 13:56:22 +1100 Subject: [PATCH] DEV: add recurrence rule parameter to downloadCalendar API (#24404) Add option to create recurrent calendar events. Recurrence rule parameter follows rfc5545 specification: https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10 --- .../app/components/modal/download-calendar.js | 6 +- .../discourse/app/lib/download-calendar.js | 45 ++++++---- .../discourse/app/lib/plugin-api.js | 12 +-- .../tests/unit/lib/download-calendar-test.js | 85 ++++++++++++++++--- docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md | 6 ++ .../acceptance/download-calendar-test.js | 2 +- 6 files changed, 120 insertions(+), 36 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/modal/download-calendar.js b/app/assets/javascripts/discourse/app/components/modal/download-calendar.js index 27daf1b957b..b289d864d64 100644 --- a/app/assets/javascripts/discourse/app/components/modal/download-calendar.js +++ b/app/assets/javascripts/discourse/app/components/modal/download-calendar.js @@ -22,12 +22,14 @@ export default class downloadCalendar extends Component { if (this.selectedCalendar === "ics") { downloadIcs( this.args.model.calendar.title, - this.args.model.calendar.dates + this.args.model.calendar.dates, + this.args.model.calendar.recurrenceRule ); } else { downloadGoogle( this.args.model.calendar.title, - this.args.model.calendar.dates + this.args.model.calendar.dates, + this.args.model.calendar.recurrenceRule ); } this.args.closeModal(); diff --git a/app/assets/javascripts/discourse/app/lib/download-calendar.js b/app/assets/javascripts/discourse/app/lib/download-calendar.js index 9a9e7a5ad3a..238a8cf0dde 100644 --- a/app/assets/javascripts/discourse/app/lib/download-calendar.js +++ b/app/assets/javascripts/discourse/app/lib/download-calendar.js @@ -3,7 +3,7 @@ import User from "discourse/models/user"; import { getOwnerWithFallback } from "discourse-common/lib/get-owner"; import getURL from "discourse-common/lib/get-url"; -export function downloadCalendar(title, dates) { +export function downloadCalendar(title, dates, recurrenceRule = null) { const currentUser = User.current(); const formattedDates = formatDates(dates); @@ -11,20 +11,20 @@ export function downloadCalendar(title, dates) { switch (currentUser.user_option.default_calendar) { case "none_selected": - _displayModal(title, formattedDates); + _displayModal(title, formattedDates, recurrenceRule); break; case "ics": - downloadIcs(title, formattedDates); + downloadIcs(title, formattedDates, recurrenceRule); break; case "google": - downloadGoogle(title, formattedDates); + downloadGoogle(title, formattedDates, recurrenceRule); break; } } -export function downloadIcs(title, dates) { +export function downloadIcs(title, dates, recurrenceRule) { const REMOVE_FILE_AFTER = 20_000; - const file = new File([generateIcsData(title, dates)], { + const file = new File([generateIcsData(title, dates, recurrenceRule)], { type: "text/plain", }); @@ -37,15 +37,23 @@ export function downloadIcs(title, dates) { setTimeout(() => window.URL.revokeObjectURL(file), REMOVE_FILE_AFTER); //remove file to avoid memory leaks } -export function downloadGoogle(title, dates) { +export function downloadGoogle(title, dates, recurrenceRule) { dates.forEach((date) => { - const encodedTitle = encodeURIComponent(title); - const link = getURL(` - https://www.google.com/calendar/event?action=TEMPLATE&text=${encodedTitle}&dates=${_formatDateForGoogleApi( - date.startsAt - )}/${_formatDateForGoogleApi(date.endsAt)} - `).trim(); - window.open(link, "_blank", "noopener", "noreferrer"); + const link = new URL("https://www.google.com/calendar/event"); + link.searchParams.append("action", "TEMPLATE"); + link.searchParams.append("text", title); + link.searchParams.append( + "dates", + `${_formatDateForGoogleApi(date.startsAt)}/${_formatDateForGoogleApi( + date.endsAt + )}` + ); + + if (recurrenceRule) { + link.searchParams.append("recur", `RRULE:${recurrenceRule}`); + } + + window.open(getURL(link.href).trim(), "_blank", "noopener", "noreferrer"); }); } @@ -60,7 +68,7 @@ export function formatDates(dates) { }); } -export function generateIcsData(title, dates) { +export function generateIcsData(title, dates, recurrenceRule) { let data = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Discourse//EN\n"; dates.forEach((date) => { const startDate = moment(date.startsAt); @@ -72,6 +80,7 @@ export function generateIcsData(title, dates) { `DTSTAMP:${moment().utc().format("YMMDDTHHmmss")}Z\n` + `DTSTART:${startDate.utc().format("YMMDDTHHmmss")}Z\n` + `DTEND:${endDate.utc().format("YMMDDTHHmmss")}Z\n` + + (recurrenceRule ? `RRULE:${recurrenceRule}\n` : ``) + `SUMMARY:${title}\n` + "END:VEVENT\n" ); @@ -80,9 +89,11 @@ export function generateIcsData(title, dates) { return data; } -function _displayModal(title, dates) { +function _displayModal(title, dates, recurrenceRule) { const modal = getOwnerWithFallback(this).lookup("service:modal"); - modal.show(downloadCalendarModal, { model: { calendar: { title, dates } } }); + modal.show(downloadCalendarModal, { + model: { calendar: { title, dates, recurrenceRule } }, + }); } function _formatDateForGoogleApi(date) { diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js index ca12d2c4e4c..70af87b8690 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.js +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js @@ -141,7 +141,7 @@ import { modifySelectKit } from "select-kit/mixins/plugin-api"; // docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version // using the format described at https://keepachangelog.com/en/1.0.0/. -export const PLUGIN_API_VERSION = "1.15.0"; +export const PLUGIN_API_VERSION = "1.16.0"; // This helper prevents us from applying the same `modifyClass` over and over in test mode. function canModify(klass, type, resolverName, changes) { @@ -1835,7 +1835,7 @@ class PluginApi { } /** - * Download calendar modal which allow to pick between ICS and Google Calendar + * Download calendar modal which allow to pick between ICS and Google Calendar. Optionally, recurrence rule can be specified - https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10 * * ``` * api.downloadCalendar("title of the event", [ @@ -1843,12 +1843,14 @@ class PluginApi { startsAt: "2021-10-12T15:00:00.000Z", endsAt: "2021-10-12T16:00:00.000Z", }, - * ]); + * ], + * "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" + * ); * ``` * */ - downloadCalendar(title, dates) { - downloadCalendar(title, dates); + downloadCalendar(title, dates, recurrenceRule = null) { + downloadCalendar(title, dates, recurrenceRule); } /** diff --git a/app/assets/javascripts/discourse/tests/unit/lib/download-calendar-test.js b/app/assets/javascripts/discourse/tests/unit/lib/download-calendar-test.js index c5a7b7d5c43..9ff25083d13 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/download-calendar-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/download-calendar-test.js @@ -16,31 +16,73 @@ module("Unit | Utility | download-calendar", function (hooks) { sinon.stub(win, "focus"); }); - test("correct data for Ics", function (assert) { + test("correct data for ICS", function (assert) { + const now = moment.tz("2022-04-04 23:15", "Europe/Paris").valueOf(); + sinon.useFakeTimers({ + now, + toFake: ["Date"], + shouldAdvanceTime: true, + shouldClearNativeTimers: true, + }); const data = generateIcsData("event test", [ { startsAt: "2021-10-12T15:00:00.000Z", endsAt: "2021-10-12T16:00:00.000Z", }, ]); - assert.ok( + assert.equal( data, - ` -BEGIN:VCALENDAR + `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Discourse//EN BEGIN:VEVENT UID:1634050800000_1634054400000 -DTSTAMP:20213312T223320Z -DTSTART:20210012T150000Z -DTEND:20210012T160000Z -SUMMARY:event2 +DTSTAMP:20220404T211500Z +DTSTART:20211012T150000Z +DTEND:20211012T160000Z +SUMMARY:event test END:VEVENT -END:VCALENDAR - ` +END:VCALENDAR` ); }); + test("correct data for ICS when recurring event", function (assert) { + const now = moment.tz("2022-04-04 23:15", "Europe/Paris").valueOf(); + sinon.useFakeTimers({ + now, + toFake: ["Date"], + shouldAdvanceTime: true, + shouldClearNativeTimers: true, + }); + const data = generateIcsData( + "event test", + [ + { + startsAt: "2021-10-12T15:00:00.000Z", + endsAt: "2021-10-12T16:00:00.000Z", + }, + ], + "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" + ); + assert.equal( + data, + `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Discourse//EN +BEGIN:VEVENT +UID:1634050800000_1634054400000 +DTSTAMP:20220404T211500Z +DTSTART:20211012T150000Z +DTEND:20211012T160000Z +RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR +SUMMARY:event test +END:VEVENT +END:VCALENDAR` + ); + + sinon.restore(); + }); + test("correct url for Google", function (assert) { downloadGoogle("event", [ { @@ -50,7 +92,28 @@ END:VCALENDAR ]); assert.ok( window.open.calledWith( - "https://www.google.com/calendar/event?action=TEMPLATE&text=event&dates=20211012T150000Z/20211012T160000Z", + "https://www.google.com/calendar/event?action=TEMPLATE&text=event&dates=20211012T150000Z%2F20211012T160000Z", + "_blank", + "noopener", + "noreferrer" + ) + ); + }); + + test("correct url for Google when recurring event", function (assert) { + downloadGoogle( + "event", + [ + { + startsAt: "2021-10-12T15:00:00.000Z", + endsAt: "2021-10-12T16:00:00.000Z", + }, + ], + "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" + ); + assert.ok( + window.open.calledWith( + "https://www.google.com/calendar/event?action=TEMPLATE&text=event&dates=20211012T150000Z%2F20211012T160000Z&recur=RRULE%3AFREQ%3DDAILY%3BBYDAY%3DMO%2CTU%2CWE%2CTH%2CFR", "_blank", "noopener", "noreferrer" diff --git a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md index a6aab86a274..0d651279dfe 100644 --- a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md +++ b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md @@ -7,6 +7,12 @@ in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.16.0] - 2023-11-17 + +### Added + +- Added `recurrenceRule` option to `downloadCalendar`, this can be used to set recurring events in the calendar. Rule syntax can be found at https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10. + ## [1.15.0] - 2023-10-18 ### Added diff --git a/plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js b/plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js index b92932b7495..017a76e2b25 100644 --- a/plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js +++ b/plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js @@ -90,7 +90,7 @@ acceptance( assert.deepEqual( [...arguments], [ - `https://www.google.com/calendar/event?action=TEMPLATE&text=title%20to%20trim&dates=${startDate}T180000Z/${startDate}T190000Z`, + `https://www.google.com/calendar/event?action=TEMPLATE&text=title+to+trim&dates=${startDate}T180000Z%2F${startDate}T190000Z`, "_blank", "noopener", "noreferrer",