FIX: improvements for download local dates (#14588)

* FIX: do not display add to calendar for past dates

There is no value in saving past dates into calendar

* FIX: remove postId and move ICS to frontend

PostId is not necessary and will make the solution more generic for dates which doesn't belong to a specific post.

Also, ICS file can be generated in JavaScript to avoid calling backend.
This commit is contained in:
Krzysztof Kotlarek 2021-10-14 09:22:44 +11:00 committed by GitHub
parent ae0ca39bd1
commit 9062fd9b7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 134 additions and 161 deletions

View File

@ -17,7 +17,7 @@ export default Controller.extend(ModalFunctionality, {
this.currentUser.save(["default_calendar"]); this.currentUser.save(["default_calendar"]);
} }
if (this.selectedCalendar === "ics") { if (this.selectedCalendar === "ics") {
downloadIcs(this.model.postId, this.model.title, this.model.dates); downloadIcs(this.model.title, this.model.dates);
} else { } else {
downloadGoogle(this.model.title, this.model.dates); downloadGoogle(this.model.title, this.model.dates);
} }

View File

@ -2,17 +2,18 @@ import User from "discourse/models/user";
import showModal from "discourse/lib/show-modal"; import showModal from "discourse/lib/show-modal";
import getURL from "discourse-common/lib/get-url"; import getURL from "discourse-common/lib/get-url";
export function downloadCalendar(postId, title, dates) { export function downloadCalendar(title, dates) {
const currentUser = User.current(); const currentUser = User.current();
const formattedDates = formatDates(dates); const formattedDates = formatDates(dates);
title = title.trim();
switch (currentUser.default_calendar) { switch (currentUser.default_calendar) {
case "none_selected": case "none_selected":
_displayModal(postId, title, formattedDates); _displayModal(title, formattedDates);
break; break;
case "ics": case "ics":
downloadIcs(postId, title, formattedDates); downloadIcs(title, formattedDates);
break; break;
case "google": case "google":
downloadGoogle(title, formattedDates); downloadGoogle(title, formattedDates);
@ -20,17 +21,19 @@ export function downloadCalendar(postId, title, dates) {
} }
} }
export function downloadIcs(postId, title, dates) { export function downloadIcs(title, dates) {
let datesParam = ""; const REMOVE_FILE_AFTER = 20_000;
dates.forEach((date, index) => { const file = new File([generateIcsData(title, dates)], {
datesParam = datesParam.concat( type: "text/plain",
`&dates[${index}][starts_at]=${date.startsAt}&dates[${index}][ends_at]=${date.endsAt}`
);
}); });
const link = getURL(
`/calendars.ics?post_id=${postId}&title=${title}&${datesParam}` const a = document.createElement("a");
); document.body.appendChild(a);
window.open(link, "_blank", "noopener", "noreferrer"); a.style = "display: none";
a.href = window.URL.createObjectURL(file);
a.download = `${title.toLowerCase().replace(/[^\w]/g, "-")}.ics`;
a.click();
setTimeout(() => window.URL.revokeObjectURL(file), REMOVE_FILE_AFTER); //remove file to avoid memory leaks
} }
export function downloadGoogle(title, dates) { export function downloadGoogle(title, dates) {
@ -56,8 +59,28 @@ export function formatDates(dates) {
}); });
} }
function _displayModal(postId, title, dates) { export function generateIcsData(title, dates) {
showModal("download-calendar", { model: { title, postId, dates } }); let data = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Discourse//EN\n";
dates.forEach((date) => {
const startDate = moment(date.startsAt);
const endDate = moment(date.endsAt);
data = data.concat(
"BEGIN:VEVENT\n" +
`UID:${startDate.utc().format("x")}_${endDate.format("x")}\n` +
`DTSTAMP:${moment().utc().format("YMMDDTHHmmss")}Z\n` +
`DTSTART:${startDate.utc().format("YMMDDTHHmmss")}Z\n` +
`DTEND:${endDate.utc().format("YMMDDTHHmmss")}Z\n` +
`SUMMARY:${title}\n` +
"END:VEVENT\n"
);
});
data = data.concat("END:VCALENDAR");
return data;
}
function _displayModal(title, dates) {
showModal("download-calendar", { model: { title, dates } });
} }
function _formatDateForGoogleApi(date) { function _formatDateForGoogleApi(date) {

View File

@ -1,8 +1,8 @@
import { module, test } from "qunit"; import { module, test } from "qunit";
import { import {
downloadGoogle, downloadGoogle,
downloadIcs,
formatDates, formatDates,
generateIcsData,
} from "discourse/lib/download-calendar"; } from "discourse/lib/download-calendar";
import sinon from "sinon"; import sinon from "sinon";
@ -13,20 +13,28 @@ module("Unit | Utility | download-calendar", function (hooks) {
sinon.stub(win, "focus"); sinon.stub(win, "focus");
}); });
test("correct url for Ics", function (assert) { test("correct data for Ics", function (assert) {
downloadIcs(1, "event", [ 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.ok(
window.open.calledWith( data,
"/calendars.ics?post_id=1&title=event&&dates[0][starts_at]=2021-10-12T15:00:00.000Z&dates[0][ends_at]=2021-10-12T16:00:00.000Z", `
"_blank", BEGIN:VCALENDAR
"noopener", VERSION:2.0
"noreferrer" PRODID:-//Discourse//EN
) BEGIN:VEVENT
UID:1634050800000_1634054400000
DTSTAMP:20213312T223320Z
DTSTART:20210012T150000Z
DTEND:20210012T160000Z
SUMMARY:event2
END:VEVENT
END:VCALENDAR
`
); );
}); });

View File

@ -1,31 +0,0 @@
# frozen_string_literal: true
class CalendarsController < ApplicationController
skip_before_action :check_xhr, only: [ :index ], if: :ics_request?
requires_login
def download
@post = Post.find(calendar_params[:post_id])
@title = calendar_params[:title]
@dates = calendar_params[:dates].values
guardian.ensure_can_see!(@post)
respond_to do |format|
format.ics do
filename = "events-#{@title.parameterize}"
response.headers['Content-Disposition'] = "attachment; filename=\"#{filename}.#{request.format.symbol}\""
end
end
end
private
def ics_request?
request.format.symbol == :ics
end
def calendar_params
params.permit(:post_id, :title, dates: [:starts_at, :ends_at])
end
end

View File

@ -1,15 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Discourse//<%= Discourse.current_hostname %>//<%= Discourse.full_version %>//EN
<% @dates.each do |date, index| %>
BEGIN:VEVENT
UID:post_#<%= @post.id %>_<%= date[:starts_at].to_datetime.to_i %>_<%= date[:ends_at].to_datetime.to_i %>@<%= Discourse.current_hostname %>
DTSTAMP:<%= Time.now.utc.strftime("%Y%m%dT%H%M%SZ") %>
DTSTART:<%= date[:starts_at].to_datetime.strftime("%Y%m%dT%H%M%SZ") %>
DTEND:<%= date[:ends_at].presence ? date[:ends_at].to_datetime.strftime("%Y%m%dT%H%M%SZ") : (date[:starts_at].to_datetime + 1.hour).strftime("%Y%m%dT%H%M%SZ") %>
SUMMARY:<%= @title %>
DESCRIPTION:<%= PrettyText.format_for_email(@post.excerpt, @post).html_safe %>
URL:<%= Discourse.base_url %>/t/-/<%= @post.topic_id %>/<%= @post.post_number %>
END:VEVENT
<% end %>
END:VCALENDAR

View File

@ -650,8 +650,6 @@ Discourse::Application.routes.draw do
end end
end end
get "/calendars" => "calendars#download", constraints: { format: :ics }
resources :bookmarks, only: %i[create destroy update] do resources :bookmarks, only: %i[create destroy update] do
put "toggle_pin" put "toggle_pin"
end end

View File

@ -172,54 +172,55 @@ function buildHtmlPreview(element, siteSettings) {
previewsNode.classList.add("locale-dates-previews"); previewsNode.classList.add("locale-dates-previews");
htmlPreviews.forEach((htmlPreview) => previewsNode.appendChild(htmlPreview)); htmlPreviews.forEach((htmlPreview) => previewsNode.appendChild(htmlPreview));
previewsNode.appendChild(_downloadCalendarNode(element)); const calendarNode = _downloadCalendarNode(element);
if (calendarNode) {
previewsNode.appendChild(calendarNode);
}
return previewsNode.outerHTML; return previewsNode.outerHTML;
} }
function calculateStartAndEndDate(startDataset, endDataset) {
let startDate, endDate;
startDate = moment.tz(
`${startDataset.date} ${startDataset.time || ""}`.trim(),
startDataset.timezone
);
if (endDataset) {
endDate = moment.tz(
`${endDataset.date} ${endDataset.time || ""}`.trim(),
endDataset.timezone
);
}
return [startDate, endDate];
}
function _downloadCalendarNode(element) { function _downloadCalendarNode(element) {
const [startDataset, endDataset] = _rangeElements(element).map(
(dateElement) => dateElement.dataset
);
const [startDate, endDate] = calculateStartAndEndDate(
startDataset,
endDataset
);
if (startDate < moment().tz(startDataset.timezone)) {
return false;
}
const node = document.createElement("div"); const node = document.createElement("div");
node.classList.add("download-calendar"); node.classList.add("download-calendar");
node.innerHTML = `${renderIcon("string", "file")} ${I18n.t( node.innerHTML = `${renderIcon("string", "file")} ${I18n.t(
"download_calendar.add_to_calendar" "download_calendar.add_to_calendar"
)}`; )}`;
const [startDataset, endDataset] = _rangeElements(element).map( node.setAttribute("data-starts-at", startDate.toISOString());
(dateElement) => dateElement.dataset
);
node.setAttribute(
"data-starts-at",
moment
.tz(
`${startDataset.date} ${startDataset.time || ""}`.trim(),
startDataset.timezone
)
.toISOString()
);
if (endDataset) { if (endDataset) {
node.setAttribute( node.setAttribute("data-ends-at", endDate.toISOString());
"data-ends-at",
moment
.tz(
`${endDataset.date} ${endDataset.time || ""}`.trim(),
endDataset.timezone
)
.toISOString()
);
} }
if (!startDataset.time && !endDataset) { if (!startDataset.time && !endDataset) {
node.setAttribute( node.setAttribute("data-ends-at", startDate.add(24, "hours").toISOString());
"data-ends-at",
moment
.tz(`${startDataset.date}`, startDataset.timezone)
.add(24, "hours")
.toISOString()
);
} }
node.setAttribute("data-title", startDataset.title); node.setAttribute("data-title", startDataset.title);
node.setAttribute(
"data-post-id",
element.closest("article")?.dataset?.postId
);
return node; return node;
} }
@ -260,7 +261,7 @@ export default {
} else if (event?.target?.classList?.contains("download-calendar")) { } else if (event?.target?.classList?.contains("download-calendar")) {
const dataset = event.target.dataset; const dataset = event.target.dataset;
hidePopover(event); hidePopover(event);
downloadCalendar(dataset.postId, dataset.title, [ downloadCalendar(dataset.title, [
{ {
startsAt: dataset.startsAt, startsAt: dataset.startsAt,
endsAt: dataset.endsAt, endsAt: dataset.endsAt,

View File

@ -16,6 +16,12 @@ acceptance(
needs.settings({ discourse_local_dates_enabled: true }); needs.settings({ discourse_local_dates_enabled: true });
needs.pretender((server, helper) => { needs.pretender((server, helper) => {
const response = { ...fixturesByUrl["/t/281.json"] }; const response = { ...fixturesByUrl["/t/281.json"] };
const startDate = moment
.tz("Africa/Cairo")
.add(1, "days")
.format("YYYY-MM-DD");
response.post_stream.posts[0].cooked = `<p><span data-date=\"${startDate}\" data-time=\"13:00:00\" class=\"discourse-local-date\" data-timezone=\"Africa/Cairo\" data-email-preview=\"${startDate}T11:00:00Z UTC\">${startDate}T11:00:00Z</span></p>`;
server.get("/t/281.json", () => helper.response(response)); server.get("/t/281.json", () => helper.response(response));
}); });
@ -33,6 +39,32 @@ acceptance(
} }
); );
acceptance(
"Local Dates - Download calendar is not available for dates in the past",
function (needs) {
needs.user({ default_calendar: "none_selected" });
needs.settings({ discourse_local_dates_enabled: true });
needs.pretender((server, helper) => {
const response = { ...fixturesByUrl["/t/281.json"] };
const startDate = moment
.tz("Africa/Cairo")
.subtract(1, "days")
.format("YYYY-MM-DD");
response.post_stream.posts[0].cooked = `<p><span data-date=\"${startDate}\" data-time=\"13:00:00\" class=\"discourse-local-date\" data-timezone=\"Africa/Cairo\" data-email-preview=\"${startDate}T11:00:00Z UTC\">${startDate}T11:00:00Z</span></p>`;
server.get("/t/281.json", () => helper.response(response));
});
test("Does not show add to calendar button", async function (assert) {
await visit("/t/local-dates/281");
await click(".discourse-local-date");
assert.ok(!exists(document.querySelector(".download-calendar")));
});
}
);
acceptance( acceptance(
"Local Dates - Download calendar with default calendar option set", "Local Dates - Download calendar with default calendar option set",
function (needs) { function (needs) {
@ -40,6 +72,12 @@ acceptance(
needs.settings({ discourse_local_dates_enabled: true }); needs.settings({ discourse_local_dates_enabled: true });
needs.pretender((server, helper) => { needs.pretender((server, helper) => {
const response = { ...fixturesByUrl["/t/281.json"] }; const response = { ...fixturesByUrl["/t/281.json"] };
const startDate = moment
.tz("Africa/Cairo")
.add(1, "days")
.format("YYYY-MM-DD");
response.post_stream.posts[0].cooked = `<p><span data-date=\"${startDate}\" data-time=\"13:00:00\" class=\"discourse-local-date\" data-timezone=\"Africa/Cairo\" data-email-preview=\"${startDate}T11:00:00Z UTC\">${startDate}T11:00:00Z</span></p>`;
response.title = " title to trim ";
server.get("/t/281.json", () => helper.response(response)); server.get("/t/281.json", () => helper.response(response));
}); });
@ -50,6 +88,10 @@ acceptance(
}); });
test("saves into default calendar", async function (assert) { test("saves into default calendar", async function (assert) {
const startDate = moment
.tz("Africa/Cairo")
.add(1, "days")
.format("YYYYMMDD");
await visit("/t/local-dates/281"); await visit("/t/local-dates/281");
await click(".discourse-local-date"); await click(".discourse-local-date");
@ -57,7 +99,7 @@ acceptance(
assert.ok(!exists(document.querySelector("#discourse-modal-title"))); assert.ok(!exists(document.querySelector("#discourse-modal-title")));
assert.ok( assert.ok(
window.open.calledWith( window.open.calledWith(
"https://www.google.com/calendar/event?action=TEMPLATE&text=Local%20dates&dates=20210930T110000Z/20210930T120000Z", `https://www.google.com/calendar/event?action=TEMPLATE&text=title%20to%20trim&dates=${startDate}T110000Z/${startDate}T120000Z`,
"_blank", "_blank",
"noopener", "noopener",
"noreferrer" "noreferrer"

View File

@ -1,53 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe CalendarsController do
fab!(:user) { Fabricate(:user) }
fab!(:post) { Fabricate(:post) }
describe "#download" do
it "returns an .ics file for dates" do
sign_in(user)
get "/calendars.ics", params: {
post_id: post.id,
title: "event title",
dates: {
"0": {
starts_at: "2021-10-12T15:00:00.000Z",
ends_at: "2021-10-13T16:30:00.000Z",
},
"1": {
starts_at: "2021-10-15T17:00:00.000Z",
ends_at: "2021-10-15T18:00:00.000Z",
},
}
}
expect(response.status).to eq(200)
expect(response.body).to eq(<<~ICS)
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Discourse//#{Discourse.current_hostname}//#{Discourse.full_version}//EN
BEGIN:VEVENT
UID:post_##{post.id}_#{"2021-10-12T15:00:00.000Z".to_datetime.to_i}_#{"2021-10-13T16:30:00.000Z".to_datetime.to_i}@#{Discourse.current_hostname}
DTSTAMP:#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}
DTSTART:#{"2021-10-12T15:00:00.000Z".to_datetime.strftime("%Y%m%dT%H%M%SZ")}
DTEND:#{"2021-10-13T16:30:00.000Z".to_datetime.strftime("%Y%m%dT%H%M%SZ")}
SUMMARY:event title
DESCRIPTION:Hello world
URL:#{Discourse.base_url}/t/-/#{post.topic_id}/#{post.post_number}
END:VEVENT
BEGIN:VEVENT
UID:post_##{post.id}_#{"2021-10-15T17:00:00.000Z".to_datetime.to_i}_#{"2021-10-15T18:00:00.000Z".to_datetime.to_i}@#{Discourse.current_hostname}
DTSTAMP:#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}
DTSTART:#{"2021-10-15T17:00:00.000Z".to_datetime.strftime("%Y%m%dT%H%M%SZ")}
DTEND:#{"2021-10-15T18:00:00.000Z".to_datetime.strftime("%Y%m%dT%H%M%SZ")}
SUMMARY:event title
DESCRIPTION:Hello world
URL:#{Discourse.base_url}/t/-/#{post.topic_id}/#{post.post_number}
END:VEVENT
END:VCALENDAR
ICS
end
end
end