mirror of
https://github.com/discourse/discourse.git
synced 2025-05-22 03:01:22 +08:00
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:

committed by
GitHub

parent
ae0ca39bd1
commit
9062fd9b7a
@ -17,7 +17,7 @@ export default Controller.extend(ModalFunctionality, {
|
||||
this.currentUser.save(["default_calendar"]);
|
||||
}
|
||||
if (this.selectedCalendar === "ics") {
|
||||
downloadIcs(this.model.postId, this.model.title, this.model.dates);
|
||||
downloadIcs(this.model.title, this.model.dates);
|
||||
} else {
|
||||
downloadGoogle(this.model.title, this.model.dates);
|
||||
}
|
||||
|
@ -2,17 +2,18 @@ import User from "discourse/models/user";
|
||||
import showModal from "discourse/lib/show-modal";
|
||||
import getURL from "discourse-common/lib/get-url";
|
||||
|
||||
export function downloadCalendar(postId, title, dates) {
|
||||
export function downloadCalendar(title, dates) {
|
||||
const currentUser = User.current();
|
||||
|
||||
const formattedDates = formatDates(dates);
|
||||
title = title.trim();
|
||||
|
||||
switch (currentUser.default_calendar) {
|
||||
case "none_selected":
|
||||
_displayModal(postId, title, formattedDates);
|
||||
_displayModal(title, formattedDates);
|
||||
break;
|
||||
case "ics":
|
||||
downloadIcs(postId, title, formattedDates);
|
||||
downloadIcs(title, formattedDates);
|
||||
break;
|
||||
case "google":
|
||||
downloadGoogle(title, formattedDates);
|
||||
@ -20,17 +21,19 @@ export function downloadCalendar(postId, title, dates) {
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadIcs(postId, title, dates) {
|
||||
let datesParam = "";
|
||||
dates.forEach((date, index) => {
|
||||
datesParam = datesParam.concat(
|
||||
`&dates[${index}][starts_at]=${date.startsAt}&dates[${index}][ends_at]=${date.endsAt}`
|
||||
);
|
||||
export function downloadIcs(title, dates) {
|
||||
const REMOVE_FILE_AFTER = 20_000;
|
||||
const file = new File([generateIcsData(title, dates)], {
|
||||
type: "text/plain",
|
||||
});
|
||||
const link = getURL(
|
||||
`/calendars.ics?post_id=${postId}&title=${title}&${datesParam}`
|
||||
);
|
||||
window.open(link, "_blank", "noopener", "noreferrer");
|
||||
|
||||
const a = document.createElement("a");
|
||||
document.body.appendChild(a);
|
||||
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) {
|
||||
@ -56,8 +59,28 @@ export function formatDates(dates) {
|
||||
});
|
||||
}
|
||||
|
||||
function _displayModal(postId, title, dates) {
|
||||
showModal("download-calendar", { model: { title, postId, dates } });
|
||||
export function generateIcsData(title, 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) {
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { module, test } from "qunit";
|
||||
import {
|
||||
downloadGoogle,
|
||||
downloadIcs,
|
||||
formatDates,
|
||||
generateIcsData,
|
||||
} from "discourse/lib/download-calendar";
|
||||
import sinon from "sinon";
|
||||
|
||||
@ -13,20 +13,28 @@ module("Unit | Utility | download-calendar", function (hooks) {
|
||||
sinon.stub(win, "focus");
|
||||
});
|
||||
|
||||
test("correct url for Ics", function (assert) {
|
||||
downloadIcs(1, "event", [
|
||||
test("correct data for Ics", function (assert) {
|
||||
const data = generateIcsData("event test", [
|
||||
{
|
||||
startsAt: "2021-10-12T15:00:00.000Z",
|
||||
endsAt: "2021-10-12T16:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
assert.ok(
|
||||
window.open.calledWith(
|
||||
"/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",
|
||||
"noopener",
|
||||
"noreferrer"
|
||||
)
|
||||
data,
|
||||
`
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Discourse//EN
|
||||
BEGIN:VEVENT
|
||||
UID:1634050800000_1634054400000
|
||||
DTSTAMP:20213312T223320Z
|
||||
DTSTART:20210012T150000Z
|
||||
DTEND:20210012T160000Z
|
||||
SUMMARY:event2
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -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
|
@ -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
|
@ -650,8 +650,6 @@ Discourse::Application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
get "/calendars" => "calendars#download", constraints: { format: :ics }
|
||||
|
||||
resources :bookmarks, only: %i[create destroy update] do
|
||||
put "toggle_pin"
|
||||
end
|
||||
|
@ -172,54 +172,55 @@ function buildHtmlPreview(element, siteSettings) {
|
||||
previewsNode.classList.add("locale-dates-previews");
|
||||
htmlPreviews.forEach((htmlPreview) => previewsNode.appendChild(htmlPreview));
|
||||
|
||||
previewsNode.appendChild(_downloadCalendarNode(element));
|
||||
const calendarNode = _downloadCalendarNode(element);
|
||||
if (calendarNode) {
|
||||
previewsNode.appendChild(calendarNode);
|
||||
}
|
||||
|
||||
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) {
|
||||
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");
|
||||
node.classList.add("download-calendar");
|
||||
node.innerHTML = `${renderIcon("string", "file")} ${I18n.t(
|
||||
"download_calendar.add_to_calendar"
|
||||
)}`;
|
||||
const [startDataset, endDataset] = _rangeElements(element).map(
|
||||
(dateElement) => dateElement.dataset
|
||||
);
|
||||
node.setAttribute(
|
||||
"data-starts-at",
|
||||
moment
|
||||
.tz(
|
||||
`${startDataset.date} ${startDataset.time || ""}`.trim(),
|
||||
startDataset.timezone
|
||||
)
|
||||
.toISOString()
|
||||
);
|
||||
node.setAttribute("data-starts-at", startDate.toISOString());
|
||||
if (endDataset) {
|
||||
node.setAttribute(
|
||||
"data-ends-at",
|
||||
moment
|
||||
.tz(
|
||||
`${endDataset.date} ${endDataset.time || ""}`.trim(),
|
||||
endDataset.timezone
|
||||
)
|
||||
.toISOString()
|
||||
);
|
||||
node.setAttribute("data-ends-at", endDate.toISOString());
|
||||
}
|
||||
if (!startDataset.time && !endDataset) {
|
||||
node.setAttribute(
|
||||
"data-ends-at",
|
||||
moment
|
||||
.tz(`${startDataset.date}`, startDataset.timezone)
|
||||
.add(24, "hours")
|
||||
.toISOString()
|
||||
);
|
||||
node.setAttribute("data-ends-at", startDate.add(24, "hours").toISOString());
|
||||
}
|
||||
node.setAttribute("data-title", startDataset.title);
|
||||
node.setAttribute(
|
||||
"data-post-id",
|
||||
element.closest("article")?.dataset?.postId
|
||||
);
|
||||
return node;
|
||||
}
|
||||
|
||||
@ -260,7 +261,7 @@ export default {
|
||||
} else if (event?.target?.classList?.contains("download-calendar")) {
|
||||
const dataset = event.target.dataset;
|
||||
hidePopover(event);
|
||||
downloadCalendar(dataset.postId, dataset.title, [
|
||||
downloadCalendar(dataset.title, [
|
||||
{
|
||||
startsAt: dataset.startsAt,
|
||||
endsAt: dataset.endsAt,
|
||||
|
@ -16,6 +16,12 @@ acceptance(
|
||||
needs.settings({ discourse_local_dates_enabled: true });
|
||||
needs.pretender((server, helper) => {
|
||||
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));
|
||||
});
|
||||
|
||||
@ -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(
|
||||
"Local Dates - Download calendar with default calendar option set",
|
||||
function (needs) {
|
||||
@ -40,6 +72,12 @@ acceptance(
|
||||
needs.settings({ discourse_local_dates_enabled: true });
|
||||
needs.pretender((server, helper) => {
|
||||
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));
|
||||
});
|
||||
|
||||
@ -50,6 +88,10 @@ acceptance(
|
||||
});
|
||||
|
||||
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 click(".discourse-local-date");
|
||||
@ -57,7 +99,7 @@ acceptance(
|
||||
assert.ok(!exists(document.querySelector("#discourse-modal-title")));
|
||||
assert.ok(
|
||||
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",
|
||||
"noopener",
|
||||
"noreferrer"
|
||||
|
@ -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
|
Reference in New Issue
Block a user