From 9062fd9b7ada5d2e7ebfd94d28eb002a74cf7f90 Mon Sep 17 00:00:00 2001 From: Krzysztof Kotlarek Date: Thu, 14 Oct 2021 09:22:44 +1100 Subject: [PATCH] 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. --- .../app/controllers/download-calendar.js | 2 +- .../discourse/app/lib/download-calendar.js | 53 ++++++++++---- .../tests/unit/lib/download-calendar-test.js | 26 ++++--- app/controllers/calendars_controller.rb | 31 --------- app/views/calendars/download.ics.erb | 15 ---- config/routes.rb | 2 - .../initializers/discourse-local-dates.js.es6 | 69 ++++++++++--------- .../acceptance/download-calendar-test.js.es6 | 44 +++++++++++- spec/requests/calendars_controller_spec.rb | 53 -------------- 9 files changed, 134 insertions(+), 161 deletions(-) delete mode 100644 app/controllers/calendars_controller.rb delete mode 100644 app/views/calendars/download.ics.erb delete mode 100644 spec/requests/calendars_controller_spec.rb diff --git a/app/assets/javascripts/discourse/app/controllers/download-calendar.js b/app/assets/javascripts/discourse/app/controllers/download-calendar.js index a956fb1c180..7cc803a171a 100644 --- a/app/assets/javascripts/discourse/app/controllers/download-calendar.js +++ b/app/assets/javascripts/discourse/app/controllers/download-calendar.js @@ -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); } diff --git a/app/assets/javascripts/discourse/app/lib/download-calendar.js b/app/assets/javascripts/discourse/app/lib/download-calendar.js index de1b615b174..49e289400ad 100644 --- a/app/assets/javascripts/discourse/app/lib/download-calendar.js +++ b/app/assets/javascripts/discourse/app/lib/download-calendar.js @@ -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) { 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 09bfc9614ee..afd189f9cb4 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 @@ -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 + ` ); }); diff --git a/app/controllers/calendars_controller.rb b/app/controllers/calendars_controller.rb deleted file mode 100644 index d1f28ed01f7..00000000000 --- a/app/controllers/calendars_controller.rb +++ /dev/null @@ -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 diff --git a/app/views/calendars/download.ics.erb b/app/views/calendars/download.ics.erb deleted file mode 100644 index 055170d8b91..00000000000 --- a/app/views/calendars/download.ics.erb +++ /dev/null @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 4483ed40971..1c52c52961a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6 b/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6 index e19c50702de..ec84cfc0dea 100644 --- a/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6 +++ b/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6 @@ -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, diff --git a/plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js.es6 b/plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js.es6 index 25b3e4562be..4d776bbf89f 100644 --- a/plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js.es6 +++ b/plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js.es6 @@ -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 = `

${startDate}T11:00:00Z

`; + 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 = `

${startDate}T11:00:00Z

`; + + 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 = `

${startDate}T11:00:00Z

`; + 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" diff --git a/spec/requests/calendars_controller_spec.rb b/spec/requests/calendars_controller_spec.rb deleted file mode 100644 index 43db456216c..00000000000 --- a/spec/requests/calendars_controller_spec.rb +++ /dev/null @@ -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