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
This commit is contained in:
Krzysztof Kotlarek 2023-11-30 13:56:22 +11:00 committed by GitHub
parent 50bafd48cd
commit 7e013b2120
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 120 additions and 36 deletions

View File

@ -22,12 +22,14 @@ export default class downloadCalendar extends Component {
if (this.selectedCalendar === "ics") { if (this.selectedCalendar === "ics") {
downloadIcs( downloadIcs(
this.args.model.calendar.title, this.args.model.calendar.title,
this.args.model.calendar.dates this.args.model.calendar.dates,
this.args.model.calendar.recurrenceRule
); );
} else { } else {
downloadGoogle( downloadGoogle(
this.args.model.calendar.title, this.args.model.calendar.title,
this.args.model.calendar.dates this.args.model.calendar.dates,
this.args.model.calendar.recurrenceRule
); );
} }
this.args.closeModal(); this.args.closeModal();

View File

@ -3,7 +3,7 @@ import User from "discourse/models/user";
import { getOwnerWithFallback } from "discourse-common/lib/get-owner"; import { getOwnerWithFallback } from "discourse-common/lib/get-owner";
import getURL from "discourse-common/lib/get-url"; 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 currentUser = User.current();
const formattedDates = formatDates(dates); const formattedDates = formatDates(dates);
@ -11,20 +11,20 @@ export function downloadCalendar(title, dates) {
switch (currentUser.user_option.default_calendar) { switch (currentUser.user_option.default_calendar) {
case "none_selected": case "none_selected":
_displayModal(title, formattedDates); _displayModal(title, formattedDates, recurrenceRule);
break; break;
case "ics": case "ics":
downloadIcs(title, formattedDates); downloadIcs(title, formattedDates, recurrenceRule);
break; break;
case "google": case "google":
downloadGoogle(title, formattedDates); downloadGoogle(title, formattedDates, recurrenceRule);
break; break;
} }
} }
export function downloadIcs(title, dates) { export function downloadIcs(title, dates, recurrenceRule) {
const REMOVE_FILE_AFTER = 20_000; const REMOVE_FILE_AFTER = 20_000;
const file = new File([generateIcsData(title, dates)], { const file = new File([generateIcsData(title, dates, recurrenceRule)], {
type: "text/plain", 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 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) => { dates.forEach((date) => {
const encodedTitle = encodeURIComponent(title); const link = new URL("https://www.google.com/calendar/event");
const link = getURL(` link.searchParams.append("action", "TEMPLATE");
https://www.google.com/calendar/event?action=TEMPLATE&text=${encodedTitle}&dates=${_formatDateForGoogleApi( link.searchParams.append("text", title);
date.startsAt link.searchParams.append(
)}/${_formatDateForGoogleApi(date.endsAt)} "dates",
`).trim(); `${_formatDateForGoogleApi(date.startsAt)}/${_formatDateForGoogleApi(
window.open(link, "_blank", "noopener", "noreferrer"); 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"; let data = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Discourse//EN\n";
dates.forEach((date) => { dates.forEach((date) => {
const startDate = moment(date.startsAt); const startDate = moment(date.startsAt);
@ -72,6 +80,7 @@ export function generateIcsData(title, dates) {
`DTSTAMP:${moment().utc().format("YMMDDTHHmmss")}Z\n` + `DTSTAMP:${moment().utc().format("YMMDDTHHmmss")}Z\n` +
`DTSTART:${startDate.utc().format("YMMDDTHHmmss")}Z\n` + `DTSTART:${startDate.utc().format("YMMDDTHHmmss")}Z\n` +
`DTEND:${endDate.utc().format("YMMDDTHHmmss")}Z\n` + `DTEND:${endDate.utc().format("YMMDDTHHmmss")}Z\n` +
(recurrenceRule ? `RRULE:${recurrenceRule}\n` : ``) +
`SUMMARY:${title}\n` + `SUMMARY:${title}\n` +
"END:VEVENT\n" "END:VEVENT\n"
); );
@ -80,9 +89,11 @@ export function generateIcsData(title, dates) {
return data; return data;
} }
function _displayModal(title, dates) { function _displayModal(title, dates, recurrenceRule) {
const modal = getOwnerWithFallback(this).lookup("service:modal"); 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) { function _formatDateForGoogleApi(date) {

View File

@ -141,7 +141,7 @@ import { modifySelectKit } from "select-kit/mixins/plugin-api";
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version // docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
// using the format described at https://keepachangelog.com/en/1.0.0/. // 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. // This helper prevents us from applying the same `modifyClass` over and over in test mode.
function canModify(klass, type, resolverName, changes) { 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", [ * api.downloadCalendar("title of the event", [
@ -1843,12 +1843,14 @@ class PluginApi {
startsAt: "2021-10-12T15:00:00.000Z", startsAt: "2021-10-12T15:00:00.000Z",
endsAt: "2021-10-12T16:00:00.000Z", endsAt: "2021-10-12T16:00:00.000Z",
}, },
* ]); * ],
* "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR"
* );
* ``` * ```
* *
*/ */
downloadCalendar(title, dates) { downloadCalendar(title, dates, recurrenceRule = null) {
downloadCalendar(title, dates); downloadCalendar(title, dates, recurrenceRule);
} }
/** /**

View File

@ -16,31 +16,73 @@ module("Unit | Utility | download-calendar", function (hooks) {
sinon.stub(win, "focus"); 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", [ const data = generateIcsData("event test", [
{ {
startsAt: "2021-10-12T15:00:00.000Z", startsAt: "2021-10-12T15:00:00.000Z",
endsAt: "2021-10-12T16:00:00.000Z", endsAt: "2021-10-12T16:00:00.000Z",
}, },
]); ]);
assert.ok( assert.equal(
data, data,
` `BEGIN:VCALENDAR
BEGIN:VCALENDAR
VERSION:2.0 VERSION:2.0
PRODID:-//Discourse//EN PRODID:-//Discourse//EN
BEGIN:VEVENT BEGIN:VEVENT
UID:1634050800000_1634054400000 UID:1634050800000_1634054400000
DTSTAMP:20213312T223320Z DTSTAMP:20220404T211500Z
DTSTART:20210012T150000Z DTSTART:20211012T150000Z
DTEND:20210012T160000Z DTEND:20211012T160000Z
SUMMARY:event2 SUMMARY:event test
END:VEVENT 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) { test("correct url for Google", function (assert) {
downloadGoogle("event", [ downloadGoogle("event", [
{ {
@ -50,7 +92,28 @@ END:VCALENDAR
]); ]);
assert.ok( assert.ok(
window.open.calledWith( 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", "_blank",
"noopener", "noopener",
"noreferrer" "noreferrer"

View File

@ -7,6 +7,12 @@ in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 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). 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 ## [1.15.0] - 2023-10-18
### Added ### Added

View File

@ -90,7 +90,7 @@ acceptance(
assert.deepEqual( assert.deepEqual(
[...arguments], [...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", "_blank",
"noopener", "noopener",
"noreferrer", "noreferrer",