FIX: improvements for download local dates (#14588)

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.

diff --git a/app/assets/javascripts/discourse/app/controllers/download-calendar.js b/app/assets/javascripts/discourse/app/controllers/download-calendar.js
index a956fb1..7cc803a 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 de1b615..49e2894 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 09bfc96..afd189f 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 d1f28ed..0000000
--- 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 055170d..0000000
--- 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 4483ed4..1c52c52 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 too long, it was truncated ...]

GitHub sha: 9062fd9b7ada5d2e7ebfd94d28eb002a74cf7f90

This commit appears in #14588 which was approved by davidtaylorhq. It was merged by lis2.