import { Promise } from "rsvp"; import { isPresent } from "@ember/utils"; import DiscourseURL from "discourse/lib/url"; import { cookAsync } from "discourse/lib/text"; import { escapeExpression } from "discourse/lib/utilities"; import loadScript from "discourse/lib/load-script"; import { withPluginApi } from "discourse/lib/plugin-api"; import { ajax } from "discourse/lib/ajax"; import { showPopover, hidePopover } from "discourse/lib/d-popover"; import Category from "discourse/models/category"; // https://stackoverflow.com/a/16348977 /* eslint-disable */ // prettier-ignore function stringToHexColor(str) { var hash = 0; for (var i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); } var hex = "#"; for (var i = 0; i < 3; i++) { var value = (hash >> (i * 8)) & 0xff; hex += ("00" + value.toString(16)).substr(-2); } return hex; } function loadFullCalendar() { return loadScript( "/plugins/discourse-calendar/javascripts/fullcalendar-with-moment-timezone.min.js" ); } function initializeDiscourseCalendar(api) { let _topicController; const outletName = Discourse.SiteSettings.calendar_categories_outlet; const site = api.container.lookup("site:main"); const isMobileView = site && site.mobileView; let selector = `.${outletName}-outlet`; if (outletName === "before-topic-list-body") { selector = `.topic-list:not(.shared-drafts) .${outletName}-outlet`; } api.onPageChange((url, title) => { const $calendarContainer = $(`${selector}.category-calendar`); if (!$calendarContainer.length) return; $calendarContainer.hide(); const browsedCategory = Category.findBySlugPathWithID(url); if (browsedCategory) { const settings = Discourse.SiteSettings.calendar_categories .split("|") .filter(Boolean) .map(stringSetting => { const data = {}; stringSetting .split(";") .filter(Boolean) .forEach(s => { const parts = s.split("="); data[parts[0]] = parts[1]; }); return data; }); const categorySetting = settings.findBy( "categoryId", browsedCategory.id.toString() ); if (categorySetting && categorySetting.postId) { $calendarContainer.show(); const postId = categorySetting.postId; const $spinner = $( '
' ); $calendarContainer.html($spinner); loadFullCalendar().then(() => { const options = [`postId=${postId}`]; const optionals = ["weekends", "tzPicker", "defaultView"]; optionals.forEach(optional => { if (isPresent(categorySetting[optional])) { options.push( `${optional}=${escapeExpression(categorySetting[optional])}` ); } }); const rawCalendar = `[calendar ${options.join(" ")}]\n[/calendar]`; const cookRaw = cookAsync(rawCalendar); const loadPost = ajax(`/posts/${postId}.json`); Promise.all([cookRaw, loadPost]).then(results => { const cooked = results[0]; const post = results[1]; const $cooked = $(cooked.string); $calendarContainer.html($cooked); render($(".calendar", $cooked), post); }); }); } } }); api.decorateCooked(attachCalendar, { onlyStream: true, id: "discourse-calendar" }); api.cleanupStream(cleanUp); api.registerCustomPostMessageCallback("calendar_change", topicController => { const stream = topicController.get("model.postStream"); const post = stream.findLoadedPost(stream.get("firstPostId")); const $op = $(".topic-post article#post_1"); const $calendar = $op.find(".calendar").first(); if (post && $calendar.length > 0) { ajax(`/posts/${post.id}.json`).then(post => loadFullCalendar().then(() => render($calendar, post)) ); } }); function render($calendar, post) { $calendar = $calendar.empty(); const timezone = _getTimeZone($calendar, api.getCurrentUser()); const calendar = _buildCalendar($calendar, timezone); const isStatic = $calendar.attr("data-calendar-type") === "static"; if (isStatic) { calendar.render(); _setStaticCalendarEvents(calendar, $calendar, post); } else { _setDynamicCalendarEvents(calendar, post); calendar.render(); _setDynamicCalendarOptions(calendar, $calendar); } _setupTimezonePicker(calendar, timezone); } function cleanUp() { window.removeEventListener("scroll", hidePopover); } function attachCalendar($elem, helper) { window.addEventListener("scroll", hidePopover); const $calendar = $(".calendar", $elem); if ($calendar.length === 0) { return; } loadFullCalendar().then(() => render($calendar, helper.getModel())); } function _buildCalendar($calendar, timeZone) { let $calendarTitle = document.querySelector( ".discourse-calendar-header > .discourse-calendar-title" ); const defaultView = escapeExpression( $calendar.attr("data-calendar-default-view") || (isMobileView ? "listNextYear" : "month") ); const showAddToCalendar = $calendar.attr("data-calendar-show-add-to-calendar") !== "false"; return new window.FullCalendar.Calendar($calendar[0], { timeZone, timeZoneImpl: "moment-timezone", nextDayThreshold: "06:00:00", displayEventEnd: true, height: 650, firstDay: 1, defaultView, views: { listNextYear: { type: "list", duration: { days: 365 }, buttonText: "list", listDayFormat: { month: "long", year: "numeric", day: "numeric", weekday: "long" } } }, header: { left: "prev,next today", center: "title", right: "month,basicWeek,listNextYear" }, datesRender: info => { if (showAddToCalendar) { _insertAddToCalendarLinks(info); $calendarTitle.innerText = info.view.title; } } }); } function _convertHtmlToDate(html) { const date = html.attr("data-date"); if (!date) { return null; } const time = html.attr("data-time"); const timezone = html.attr("data-timezone"); let dateTime = date; if (time) { dateTime = `${dateTime} ${time}`; } return { weeklyRecurring: html.attr("data-recurring") === "1.weeks", dateTime: moment.tz(dateTime, timezone || "Etc/UTC") }; } function _buildEventObject(from, to) { const hasTimeSpecified = d => { return d.hours() !== 0 || d.minutes() !== 0 || d.seconds() !== 0; }; let event = { start: from.dateTime.toDate(), allDay: false }; if (to) { if (hasTimeSpecified(to.dateTime) || hasTimeSpecified(from.dateTime)) { event.end = to.dateTime.toDate(); } else { event.end = to.dateTime.add(1, "days").toDate(); event.allDay = true; } } else { event.allDay = true; if (from.weeklyRecurring) { event.startTime = { hours: from.dateTime.hours(), minutes: from.dateTime.minutes(), seconds: from.dateTime.seconds() }; event.daysOfWeek = [from.dateTime.isoWeekday()]; } } return event; } function _setStaticCalendarEvents(calendar, $calendar, post) { $(`
${post.cooked}
`) .find('.calendar[data-calendar-type="static"] p') .html() .trim() .split("
") .forEach(line => { const html = $.parseHTML(line); const htmlDates = html.filter(h => $(h).hasClass("discourse-local-date") ); const from = _convertHtmlToDate($(htmlDates[0])); const to = _convertHtmlToDate($(htmlDates[1])); let event = _buildEventObject(from, to); event.title = html[0].textContent.trim(); calendar.addEvent(event); }); } function _setDynamicCalendarOptions(calendar, $calendar) { const skipWeekends = $calendar.attr("data-weekends") === "false"; const hiddenDays = $calendar.attr("data-hidden-days"); if (skipWeekends) { calendar.setOption("weekends", false); } if (hiddenDays) { calendar.setOption( "hiddenDays", hiddenDays.split(",").map(d => parseInt(d)) ); } calendar.setOption("eventClick", ({ event, jsEvent }) => { hidePopover(jsEvent); const { htmlContent, postNumber, postUrl } = event.extendedProps; if (postUrl) { DiscourseURL.routeTo(postUrl); } else if (postNumber) { _topicController = _topicController || api.container.lookup("controller:topic"); _topicController.send("jumpToPost", postNumber); } else if (isMobileView && htmlContent) { showPopover(jsEvent, { htmlContent }); } }); calendar.setOption("eventMouseEnter", ({ event, jsEvent }) => { const { htmlContent } = event.extendedProps; if (!htmlContent) return; showPopover(jsEvent, { htmlContent }); }); calendar.setOption("eventMouseLeave", ({ jsEvent }) => { hidePopover(jsEvent); }); } function _buildEvent(detail) { const event = _buildEventObject( detail.from ? { dateTime: moment(detail.from), weeklyRecurring: detail.recurring === "1.weeks" } : null, detail.to ? { dateTime: moment(detail.to), weeklyRecurring: detail.recurring === "1.weeks" } : null ); event.extendedProps = {}; if (detail.post_url) { event.extendedProps.postUrl = detail.post_url; } else if (detail.post_number) { event.extendedProps.postNumber = detail.post_number; } else { event.classNames = ["holiday"]; } return event; } function _addStandaloneEvent(calendar, post, detail) { const event = _buildEvent(detail); const holidayCalendarTopicId = parseInt( Discourse.SiteSettings.holiday_calendar_topic_id, 10 ); const text = detail.message.split("\n").filter(e => e); if ( text.length && post.topic_id && holidayCalendarTopicId !== post.topic_id ) { event.title = text[0]; event.extendedProps.description = text.slice(1).join(" "); } else { event.title = detail.username; event.backgroundColor = stringToHexColor(detail.username); } let popupText = detail.message.substr(0, 50); if (detail.message.length > 50) { popupText = popupText + "..."; } event.extendedProps.htmlContent = popupText; event.title = event.title.replace(/]*>/g, ""); calendar.addEvent(event); } function _addGroupedEvent(calendar, post, detail) { let peopleCount = 0; let htmlContent = ""; let usernames = []; let localEventNames = []; Object.keys(detail.localEvents) .sort() .forEach(key => { const localEvent = detail.localEvents[key]; htmlContent += `${key}: ${localEvent.usernames .sort() .join(", ")}
`; usernames = usernames.concat(localEvent.usernames); localEventNames.push(key); }); const event = _buildEvent(detail); event.classNames = ["grouped-event"]; if (usernames.length > 3) { event.title = isMobileView ? usernames.length : `(${usernames.length}) ` + I18n.t("discourse_calendar.holiday"); } else if (usernames.length === 1) { event.title = usernames[0]; } else { event.title = isMobileView ? usernames.length : `(${usernames.length}) ` + usernames.slice(0, 3).join(", "); } if (localEventNames.length > 1) { event.extendedProps.htmlContent = htmlContent; } else { if (usernames.length > 1) { event.extendedProps.htmlContent = htmlContent; } else { event.extendedProps.htmlContent = localEventNames[0]; } } calendar.addEvent(event); } function _setDynamicCalendarEvents(calendar, post) { const groupedEvents = []; (post.calendar_details || []).forEach(detail => { switch (detail.type) { case "grouped": groupedEvents.push(detail); break; case "standalone": _addStandaloneEvent(calendar, post, detail); break; } }); const formatedGroupedEvents = {}; groupedEvents.forEach(groupedEvent => { const minDate = moment(groupedEvent.from) .utc() .startOf("day") .toISOString(); const maxDate = moment(groupedEvent.to || groupedEvent.from) .utc() .endOf("day") .toISOString(); const identifier = `${minDate}-${maxDate}`; formatedGroupedEvents[identifier] = formatedGroupedEvents[identifier] || { from: minDate, to: maxDate || minDate, localEvents: {} }; formatedGroupedEvents[identifier].localEvents[ groupedEvent.name ] = formatedGroupedEvents[identifier].localEvents[groupedEvent.name] || { usernames: [] }; formatedGroupedEvents[identifier].localEvents[ groupedEvent.name ].usernames.push.apply( formatedGroupedEvents[identifier].localEvents[groupedEvent.name] .usernames, groupedEvent.usernames ); }); Object.keys(formatedGroupedEvents).forEach(key => { const formatedGroupedEvent = formatedGroupedEvents[key]; _addGroupedEvent(calendar, post, formatedGroupedEvent); }); } function _getTimeZone($calendar, currentUser) { let defaultTimezone = $calendar.attr("data-calendar-default-timezone"); const isValidDefaultTimezone = !!moment.tz.zone(defaultTimezone); if (!isValidDefaultTimezone) { defaultTimezone = null; } return ( defaultTimezone || (currentUser && currentUser.timezone) || moment.tz.guess() ); } function _setupTimezonePicker(calendar, timezone) { let $timezonePicker = $(".discourse-calendar-timezone-picker"); if ($timezonePicker.length) { $timezonePicker.on("change", function(event) { calendar.setOption("timeZone", event.target.value); _insertAddToCalendarLinks(calendar); }); moment.tz.names().forEach(timezone => { $timezonePicker.append(new Option(timezone, timezone)); }); $timezonePicker.val(timezone); } else { $(".discourse-calendar-timezone-wrap").text(timezone); } } function _insertAddToCalendarLinks(info) { if (info.view.type !== "listNextYear") return; const eventSegments = info.view.eventRenderer.segs; const eventSegmentDefMap = _eventSegmentDefMap(info); for (const event of eventSegments) { _insertAddToCalendarLinkForEvent(event, eventSegmentDefMap); } } function _insertAddToCalendarLinkForEvent(event, eventSegmentDefMap) { const eventTitle = event.eventRange.def.title; let map = eventSegmentDefMap[event.eventRange.def.defId]; let startDate = map.start; let endDate = map.end; endDate = endDate ? _formatDateForGoogleApi(endDate, event.eventRange.def.allDay) : _endDateForAllDayEvent(startDate, event.eventRange.def.allDay); startDate = _formatDateForGoogleApi(startDate, event.eventRange.def.allDay); const link = document.createElement("a"); const title = I18n.t("discourse_calendar.add_to_calendar"); link.title = title; link.appendChild(document.createTextNode(title)); link.href = ` http://www.google.com/calendar/event?action=TEMPLATE&text=${encodeURIComponent( eventTitle )}&dates=${startDate}/${endDate}&details=${encodeURIComponent( event.eventRange.def.extendedProps.description )}`; link.target = "_blank"; link.classList.add("fc-list-item-add-to-calendar"); event.el.querySelector(".fc-list-item-title").appendChild(link); } function _formatDateForGoogleApi(date, allDay = false) { if (!allDay) return date.toISOString().replace(/-|:|\.\d\d\d/g, ""); return moment(date) .utc() .format("YYYYMMDD"); } function _endDateForAllDayEvent(startDate, allDay) { const unit = allDay ? "days" : "hours"; return _formatDateForGoogleApi( moment(startDate) .add(1, unit) .toDate(), allDay ); } function _eventSegmentDefMap(info) { let map = {}; for (let event of info.view.calendar.getEvents()) { map[event._instance.defId] = { start: event.start, end: event.end }; } return map; } } export default { name: "discourse-calendar", initialize(container) { const siteSettings = container.lookup("site-settings:main"); if (siteSettings.calendar_enabled) { withPluginApi("0.8.22", initializeDiscourseCalendar); } } };