FEATURE: save local date to calendar (#14486)

It allows saving local date to calendar.
Modal is giving option to pick between ics and google. User choice can be remembered as a default for the next actions.
This commit is contained in:
Krzysztof Kotlarek 2021-10-06 14:11:52 +11:00 committed by GitHub
parent 6ab5f70090
commit cb5b0cb9d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 741 additions and 5 deletions

View File

@ -31,6 +31,7 @@
//= require ./discourse/app/lib/text-direction
//= require ./discourse/app/lib/eyeline
//= require ./discourse/app/lib/show-modal
//= require ./discourse/app/lib/download-calendar
//= require ./discourse/app/mixins/scrolling
//= require ./discourse/app/lib/ajax-error
//= require ./discourse/app/models/result-set

View File

@ -0,0 +1,26 @@
import { action } from "@ember/object";
import Controller from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { downloadGoogle, downloadIcs } from "discourse/lib/download-calendar";
export default Controller.extend(ModalFunctionality, {
selectedCalendar: "ics",
remember: false,
@action
downloadCalendar() {
if (this.remember) {
this.currentUser.setProperties({
default_calendar: this.selectedCalendar,
user_option: { default_calendar: this.selectedCalendar },
});
this.currentUser.save(["default_calendar"]);
}
if (this.selectedCalendar === "ics") {
downloadIcs(this.model.postId, this.model.title, this.model.dates);
} else {
downloadGoogle(this.model.title, this.model.dates);
}
this.send("closeModal");
},
});

View File

@ -23,6 +23,12 @@ export default Controller.extend({
"card_background_upload_url",
"date_of_birth",
"timezone",
"default_calendar",
];
this.calendarOptions = [
{ name: I18n.t("download_calendar.google"), value: "google" },
{ name: I18n.t("download_calendar.ics"), value: "ics" },
];
},
@ -45,6 +51,11 @@ export default Controller.extend({
}
},
@discourseComputed("model.default_calendar")
canChangeDefaultCalendar(defaultCalendar) {
return defaultCalendar !== "none_selected";
},
canChangeBio: readOnly("model.can_change_bio"),
canChangeLocation: readOnly("model.can_change_location"),

View File

@ -0,0 +1,67 @@
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) {
const currentUser = User.current();
const formattedDates = formatDates(dates);
switch (currentUser.default_calendar) {
case "none_selected":
_displayModal(postId, title, formattedDates);
break;
case "ics":
downloadIcs(postId, title, formattedDates);
break;
case "google":
downloadGoogle(title, formattedDates);
break;
}
}
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}`
);
});
const link = getURL(
`/calendars.ics?post_id=${postId}&title=${title}&${datesParam}`
);
window.open(link, "_blank", "noopener", "noreferrer");
}
export function downloadGoogle(title, dates) {
dates.forEach((date) => {
const encodedTitle = encodeURIComponent(title);
const link = getURL(`
https://www.google.com/calendar/event?action=TEMPLATE&text=${encodedTitle}&dates=${_formatDateForGoogleApi(
date.startsAt
)}/${_formatDateForGoogleApi(date.endsAt)}
`).trim();
window.open(link, "_blank", "noopener", "noreferrer");
});
}
export function formatDates(dates) {
return dates.map((date) => {
return {
startsAt: date.startsAt,
endsAt: date.endsAt
? date.endsAt
: moment.utc(date.startsAt).add(1, "hours").format(),
};
});
}
function _displayModal(postId, title, dates) {
showModal("download-calendar", { model: { title, postId, dates } });
}
function _formatDateForGoogleApi(date) {
return moment(date)
.toISOString()
.replace(/-|:|\.\d\d\d/g, "");
}

View File

@ -97,6 +97,7 @@ let userOptionFields = [
"title_count_mode",
"timezone",
"skip_new_user_tips",
"default_calendar",
];
export function addSaveableUserOptionField(fieldName) {

View File

@ -0,0 +1,45 @@
<div>
{{#d-modal-body title="download_calendar.title"}}
<div class="control-group">
<div class="ics">
<label class="radio" for="ics">
{{radio-button
name="select-calendar"
id="ics"
value="ics"
selection=selectedCalendar
onChange=(action (mut selectedCalendar))
}}
{{i18n "download_calendar.save_ics"}}
</label>
</div>
<div class="google">
<label class="radio" for="google">
{{radio-button
name="select-calendar"
id="google"
value="google"
selection=selectedCalendar
onChange=(action (mut selectedCalendar))
}}
{{i18n "download_calendar.save_google"}}
</label>
</div>
</div>
<div class="control-group remember">
<label>
{{input type="checkbox" checked=remember}} <span>{{i18n "download_calendar.remember"}}</span>
</label>
<span>{{i18n "download_calendar.remember_explanation"}}</span>
</div>
{{/d-modal-body}}
<div class="modal-footer">
{{d-button
class="btn-primary"
action=(action "downloadCalendar")
label="download_calendar.download"
}}
{{d-modal-cancel close=(route-action "closeModal")}}
</div>
</div>

View File

@ -103,6 +103,24 @@
</div>
{{/if}}
{{#if canChangeDefaultCalendar }}
<div class="control-group">
<label class="control-label">{{i18n "download_calendar.default_calendar"}}</label>
<div>
{{combo-box
valueProperty="value"
content=calendarOptions
value=model.user_option.default_calendar
id="user-default-calendar"
onChange=(action (mut model.user_option.default_calendar))
}}
</div>
<div class="instructions">
{{i18n "download_calendar.default_calendar_instruction"}}
</div>
</div>
{{/if}}
{{plugin-outlet name="user-preferences-profile" args=(hash model=model save=(action "save"))}}
{{plugin-outlet name="user-custom-preferences" args=(hash model=model)}}

View File

@ -0,0 +1,35 @@
import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers";
import { visit } from "@ember/test-helpers";
import { test } from "qunit";
acceptance(
"User profile preferences without default calendar set",
function (needs) {
needs.user({ default_calendar: "none_selected" });
test("default calendar option is not visible", async function (assert) {
await visit("/u/eviltrout/preferences/profile");
assert.ok(
!exists("#user-default-calendar"),
"option to change default calendar is hidden"
);
});
}
);
acceptance(
"User profile preferences with default calendar set",
function (needs) {
needs.user({ default_calendar: "google" });
test("default calendar can be changed", async function (assert) {
await visit("/u/eviltrout/preferences/profile");
assert.ok(
exists("#user-default-calendar"),
"option to change default calendar"
);
});
}
);

View File

@ -3301,6 +3301,196 @@ export default {
],
tags: null,
},
"/t/281.json": {
post_stream: {
posts: [
{
id: 133,
name: null,
username: "bianca",
avatar_template: "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png",
created_at: "2020-07-05T09:28:36.371Z",
cooked:
"<p><span data-date=\"2021-09-30\" data-time=\"13:00:00\" class=\"discourse-local-date\" data-timezone=\"Africa/Cairo\" data-email-preview=\"2021-09-30T11:00:00Z UTC\">2021-09-30T11:00:00Z</span></p>",
post_number: 1,
post_type: 1,
updated_at: "2020-07-05T09:28:36.371Z",
reply_count: 0,
reply_to_post_number: null,
quote_count: 0,
incoming_link_count: 0,
reads: 1,
readers_count: 0,
score: 0,
yours: true,
topic_id: 281,
topic_slug: "local-dates",
display_username: null,
primary_group_name: null,
flair_name: null,
flair_url: null,
flair_bg_color: null,
flair_color: null,
version: 1,
can_edit: true,
can_delete: false,
can_recover: false,
can_wiki: true,
read: true,
user_title: "Tester",
title_is_group: false,
actions_summary: [
{
id: 3,
can_act: true,
},
{
id: 4,
can_act: true,
},
{
id: 8,
can_act: true,
},
{
id: 7,
can_act: true,
},
],
moderator: false,
admin: true,
staff: true,
user_id: 1,
hidden: false,
trust_level: 0,
deleted_at: null,
user_deleted: false,
edit_reason: null,
can_view_edit_history: true,
wiki: false,
reviewable_id: 0,
reviewable_score_count: 0,
reviewable_score_pending_count: 0,
},
],
stream: [133],
},
timeline_lookup: [[1, 0]],
related_messages: [],
suggested_topics: [],
id: 281,
title: "Local dates",
fancy_title: "Local dates",
posts_count: 1,
created_at: "2020-07-05T09:28:36.260Z",
views: 1,
reply_count: 0,
like_count: 0,
last_posted_at: "2020-07-05T09:28:36.371Z",
visible: true,
closed: false,
archived: false,
has_summary: false,
archetype: "regular",
slug: "local-dates",
category_id: null,
word_count: 86,
deleted_at: null,
user_id: 1,
featured_link: null,
pinned_globally: false,
pinned_at: null,
pinned_until: null,
image_url: null,
draft: null,
draft_key: "topic_281",
draft_sequence: 0,
posted: true,
unpinned: null,
pinned: false,
current_post_number: 1,
highest_post_number: 1,
last_read_post_number: 1,
last_read_post_id: 133,
deleted_by: null,
has_deleted: false,
actions_summary: [
{
id: 4,
count: 0,
hidden: false,
can_act: true,
},
{
id: 8,
count: 0,
hidden: false,
can_act: true,
},
{
id: 7,
count: 0,
hidden: false,
can_act: true,
},
],
chunk_size: 20,
bookmarked: false,
bookmarks: [],
message_archived: false,
topic_timer: null,
message_bus_last_id: 5,
participant_count: 1,
pm_with_non_human_user: false,
show_read_indicator: false,
requested_group_name: null,
thumbnails: null,
tags_disable_ads: false,
details: {
notification_level: 3,
notifications_reason_id: 1,
can_move_posts: true,
can_edit: true,
can_delete: true,
can_remove_allowed_users: true,
can_invite_to: true,
can_invite_via_email: true,
can_create_post: true,
can_reply_as_new_topic: true,
can_flag_topic: true,
can_convert_topic: true,
can_review_topic: true,
can_remove_self_id: 1,
participants: [
{
id: 1,
username: "bianca",
name: null,
avatar_template: "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png",
post_count: 1,
primary_group_name: null,
flair_name: null,
flair_url: null,
flair_color: null,
flair_bg_color: null,
},
],
allowed_users: [],
created_by: {
id: 1,
username: "bianca",
name: null,
avatar_template: "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png",
},
last_poster: {
id: 1,
username: "bianca",
name: null,
avatar_template: "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png",
},
allowed_groups: [],
},
},
"/t/28830/1.json": {
post_stream: {
posts: [

View File

@ -0,0 +1,63 @@
import { module, test } from "qunit";
import {
downloadGoogle,
downloadIcs,
formatDates,
} from "discourse/lib/download-calendar";
import sinon from "sinon";
module("Unit | Utility | download-calendar", function (hooks) {
hooks.beforeEach(function () {
let win = { focus: function () {} };
sinon.stub(window, "open").returns(win);
sinon.stub(win, "focus");
});
test("correct url for Ics", function (assert) {
downloadIcs(1, "event", [
{
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"
)
);
});
test("correct url for Google", function (assert) {
downloadGoogle("event", [
{
startsAt: "2021-10-12T15:00:00.000Z",
endsAt: "2021-10-12T16:00:00.000Z",
},
]);
assert.ok(
window.open.calledWith(
"https://www.google.com/calendar/event?action=TEMPLATE&text=event&dates=20211012T150000Z/20211012T160000Z",
"_blank",
"noopener",
"noreferrer"
)
);
});
test("calculates end date when none given", function (assert) {
let dates = formatDates([{ startsAt: "2021-10-12T15:00:00.000Z" }]);
assert.deepEqual(
dates,
[
{
startsAt: "2021-10-12T15:00:00.000Z",
endsAt: "2021-10-12T16:00:00Z",
},
],
"endsAt is one hour after startsAt"
);
});
});

View File

@ -20,6 +20,7 @@
@import "pick-files-button";
@import "relative-time-picker";
@import "share-and-invite-modal";
@import "download-calendar";
@import "svg";
@import "tap-tile";
@import "time-input";

View File

@ -0,0 +1,13 @@
.download-calendar-modal .remember {
margin-top: 2em;
}
#d-popover .download-calendar {
color: var(--primary-med-or-secondary-med);
}
.user-preferences {
#user-default-calendar {
min-width: 175px;
}
}

View File

@ -0,0 +1,31 @@
# 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

@ -11,6 +11,8 @@ class UserOption < ActiveRecord::Base
after_save :update_tracked_topics
enum default_calendar: { none_selected: 0, ics: 1, google: 2 }
def self.ensure_consistency!
sql = <<~SQL
SELECT u.id FROM users u
@ -256,8 +258,10 @@ end
# dark_scheme_id :integer
# skip_new_user_tips :boolean default(FALSE), not null
# color_scheme_id :integer
# default_calendar :integer default("none_selected"), not null
#
# Indexes
#
# index_user_options_on_user_id (user_id) UNIQUE
# index_user_options_on_user_id_and_default_calendar (user_id,default_calendar)
#

View File

@ -66,6 +66,7 @@ class CurrentUserSerializer < BasicUserSerializer
:has_topic_draft,
:can_review,
:draft_count,
:default_calendar,
def groups
owned_group_ids = GroupUser.where(user_id: id, owner: true).pluck(:group_id).to_set
@ -140,6 +141,10 @@ class CurrentUserSerializer < BasicUserSerializer
object.user_option.timezone
end
def default_calendar
object.user_option.default_calendar
end
def can_send_private_email_messages
scope.can_send_private_messages_to_email?
end

View File

@ -32,7 +32,8 @@ class UserOptionSerializer < ApplicationSerializer
:text_size_seq,
:title_count_mode,
:timezone,
:skip_new_user_tips
:skip_new_user_tips,
:default_calendar,
def auto_track_topics_after_msecs
object.auto_track_topics_after_msecs || SiteSetting.default_other_auto_track_topics_after_msecs

View File

@ -46,7 +46,8 @@ class UserUpdater
:text_size,
:title_count_mode,
:timezone,
:skip_new_user_tips
:skip_new_user_tips,
:default_calendar
]
NOTIFICATION_SCHEDULE_ATTRS = -> {

View File

@ -0,0 +1,15 @@
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

@ -3723,6 +3723,18 @@ en:
favorite_max_not_reached: "Mark this badge as favorite"
favorite_count: "%{count}/%{max} badges marked as favorite"
download_calendar:
title: "Download calendar"
save_ics: "Download .ics file"
save_google: "Add to Google calendar"
remember: "Dont ask me again"
remember_explanation: "(you can change this preference in your user prefs)"
download: "Download"
default_calendar: "Default calendar"
default_calendar_instruction: "Determine which calendar should be used when dates are saved"
add_to_calendar: "Add to calendar"
google: "Google Calendar"
ics: "ICS"
tagging:
all_tags: "All Tags"
other_tags: "Other Tags"

View File

@ -650,6 +650,8 @@ 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

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class AddDefaultCalendarToUserOptions < ActiveRecord::Migration[6.1]
def change
add_column :user_options, :default_calendar, :integer, default: 0, null: false
add_index :user_options, [:user_id, :default_calendar]
end
end

View File

@ -4,6 +4,8 @@ import { hidePopover, showPopover } from "discourse/lib/d-popover";
import LocalDateBuilder from "../lib/local-date-builder";
import { withPluginApi } from "discourse/lib/plugin-api";
import showModal from "discourse/lib/show-modal";
import { downloadCalendar } from "discourse/lib/download-calendar";
import { renderIcon } from "discourse-common/lib/icon-library";
export function applyLocalDates(dates, siteSettings) {
if (!siteSettings.discourse_local_dates_enabled) {
@ -162,9 +164,54 @@ function buildHtmlPreview(element, siteSettings) {
previewsNode.classList.add("locale-dates-previews");
htmlPreviews.forEach((htmlPreview) => previewsNode.appendChild(htmlPreview));
previewsNode.appendChild(_downloadCalendarNode(element));
return previewsNode.outerHTML;
}
function _downloadCalendarNode(element) {
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 || ""}`,
startDataset.timezone
)
.toISOString()
);
if (endDataset) {
node.setAttribute(
"data-ends-at",
moment
.tz(`${endDataset.date} ${endDataset.time || ""}`, endDataset.timezone)
.toISOString()
);
}
if (!startDataset.time && !endDataset) {
node.setAttribute(
"data-ends-at",
moment
.tz(`${startDataset.date}`, startDataset.timezone)
.add(24, "hours")
.toISOString()
);
}
node.setAttribute(
"data-title",
document.querySelector("#topic-title a").innerText
);
node.setAttribute("data-post-id", element.closest("article").dataset.postId);
return node;
}
function _calculateDuration(element) {
const [startDataset, endDataset] = _rangeElements(element).map(
(dateElement) => dateElement.dataset
@ -199,6 +246,17 @@ export default {
htmlContent: buildHtmlPreview(event.target, siteSettings),
});
}
} else if (event?.target?.classList?.contains("download-calendar")) {
const dataset = event.target.dataset;
hidePopover(event);
downloadCalendar(dataset.postId, dataset.title, [
{
startsAt: dataset.startsAt,
endsAt: dataset.endsAt,
},
]);
} else {
hidePopover(event);
}
},
@ -213,7 +271,6 @@ export default {
router.on("routeWillChange", hidePopover);
window.addEventListener("click", this.showDatePopover);
window.addEventListener("mouseout", this.hideDatePopover);
const siteSettings = container.lookup("site-settings:main");
if (siteSettings.discourse_local_dates_enabled) {
@ -231,6 +288,5 @@ export default {
teardown() {
window.removeEventListener("click", this.showDatePopover);
window.removeEventListener("mouseout", this.hideDatePopover);
},
};

View File

@ -40,6 +40,12 @@
}
}
}
.download-calendar {
text-align: right;
cursor: pointer;
margin-top: 0.5em;
}
}
.discourse-local-dates-create-modal-footer {

View File

@ -0,0 +1,68 @@
import {
acceptance,
exists,
query,
} from "discourse/tests/helpers/qunit-helpers";
import { click, visit } from "@ember/test-helpers";
import I18n from "I18n";
import { test } from "qunit";
import { fixturesByUrl } from "discourse/tests/helpers/create-pretender";
import sinon from "sinon";
acceptance(
"Local Dates - Download calendar without default calendar option set",
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"] };
server.get("/t/281.json", () => helper.response(response));
});
test("Display pick calendar modal", async function (assert) {
await visit("/t/local-dates/281");
await click(".discourse-local-date");
await click(document.querySelector(".download-calendar"));
assert.equal(
query("#discourse-modal-title").textContent.trim(),
I18n.t("download_calendar.title"),
"it should display modal to select calendar"
);
});
}
);
acceptance(
"Local Dates - Download calendar with default calendar option set",
function (needs) {
needs.user({ default_calendar: "google" });
needs.settings({ discourse_local_dates_enabled: true });
needs.pretender((server, helper) => {
const response = { ...fixturesByUrl["/t/281.json"] };
server.get("/t/281.json", () => helper.response(response));
});
needs.hooks.beforeEach(function () {
let win = { focus: function () {} };
sinon.stub(window, "open").returns(win);
sinon.stub(win, "focus");
});
test("saves into default calendar", async function (assert) {
await visit("/t/local-dates/281");
await click(".discourse-local-date");
await click(document.querySelector(".download-calendar"));
assert.ok(!exists(document.querySelector("#discourse-modal-title")));
assert.ok(
window.open.calledWith(
"https://www.google.com/calendar/event?action=TEMPLATE&text=Local%20dates%20&dates=20210930T110000Z/20210930T120000Z",
"_blank",
"noopener",
"noreferrer"
)
);
});
}
);

View File

@ -775,6 +775,9 @@
},
"skip_new_user_tips": {
"type": "boolean"
},
"default_calendar": {
"type": "none_selected"
}
},
"required": [

View File

@ -0,0 +1,53 @@
# 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