// ==UserScript==
// @name CELCAT to .ics
// @namespace http://tampermonkey.net/
// @version 1.3
// @description This is a custom browser script that converts CELCAT calendar data into iCalendar (.ics) format that can be exported and used in other schedule management applications. based on kokomax24
// @homepageURL https://github.com/tasye24/CELCAT-to-.ics
// @author kokomax24, tasye24
// @match your website here
// @license MIT LICENCE (c) kokomax24
// @grant
// ==/UserScript==
// Change @match to your website calendar
// Chande this variable by finding the depot on the website calls (when you change calendar)
let CALENDAR_url = "https://YOUR_WEBSITE.DOMAIN"; // your url HERE
const CALENDAR = CALENDAR_url+"/calendar/Home/GetCalendarData";
/**
* conver a json object to a HTML element
* @param data json object to convert to html
* @returns HTMLElement from the json object
*/
function jsonToHTML(data) {
const element = document.createElement(data.elementType)
for (let [key, object] of Object.entries(data)){
if (object == undefined) continue
switch (key) {
case "elementType":
break
case "content":
element.appendChild(document.createTextNode(object))
break
case "class":
element.classList.add(...object)
break
case "childs":
object.forEach(child => {
if (child == undefined) return
element.appendChild(jsonToHTML(child))
})
break
case "events":
for (let [event, callback] of Object.entries(object)){
element.addEventListener(event, callback)
}
break
default:
element.setAttribute(key, object)
break
}
}
return element
}
function filterClick(name) {
return (event) => {
if (event.target.classList.contains("modal")) {
hideDialog(name)
}
}
}
const selectedLanguage = document.querySelector('#requestCulture_RequestCulture_UICulture_Name').value;
const messages = {
'en-US': {
exportButtonLabel: 'Export',
errorDialogTitle: 'Error',
errorDialogMessage: 'An error occurred.',
okButtonLabel: 'Ok',
cancelButtonLabel: 'Cancel',
notConnectedMessage: 'You must be connected to use this script',
},
'fr-FR': {
exportButtonLabel: 'Exporter',
errorDialogTitle: 'Erreur',
errorDialogMessage: 'Une erreur s\'est produite.',
okButtonLabel: 'Ok',
cancelButtonLabel: 'Annuler',
notConnectedMessage: 'Vous devez être connecté pour utiliser ce script',
},
'en-GB': {
exportButtonLabel: 'Export',
errorDialogTitle: 'Error',
errorDialogMessage: 'An error occurred.',
okButtonLabel: 'Ok',
cancelButtonLabel: 'Cancel',
notConnectedMessage: 'You must be connected to use this script',
}
};
/**
* show a dialog with the given name
* @param name name of the dialog to show
*/
function showDialog(name) {
const dialog = document.getElementById(`customDialog-${name}`);
document.body.classList.add("modal-open");
document.body.appendChild(modalFadeIn);
dialog.style.display = "block";
dialog.style.paddingLeft = "0px";
setTimeout(() => {
dialog.classList.add("in");
document.querySelector(`#customDialog-${name} .btn-default`).style.display = "none";
}, 200);
}
function dialogShowEvent(name) {
return () => {
showDialog(name)
}
}
/**
* hide a dialog with the given name
* @param name name of the dialog to hide
*/
function hideDialog(name) {
// GM_log(`Hiding dialog ${name}`)
const dialog = document.getElementById(`customDialog-${name}`)
dialog.classList.remove("in")
modalFadeIn.classList.remove("in")
setTimeout(() => {
dialog.style.display = "none"
document.body.classList.remove("modal-open")
document.body.removeChild(modalFadeIn)
modalFadeIn.classList.add("in")
}, 200)
}
function dialogHideEvent(name) {
return () => {
hideDialog(name)
}
}
/**
* Generate a new dialog with all the appropriate styles, events and animations and add it to the page
*
* @param name name of the dialog to create
* @param title title to display in the dialog header
* @param description description which is displayer above the dialog content
* @param dataValidation function to call when the user click on the validation button
* @param jsonHTMLContent dialog body content
* @returns HTMLElement the created dialog
*/
function buildDialog(name, title, description, dataValidation, jsonHTMLContent) {
const dialog = jsonToHTML({
elementType: "div",
class: ["modal", "fade"],
tabindex: -1,
role: "dialog",
"aria-labelledby": "eventFilterModalLabel",
id: `customDialog-${name}`,
events: {
"click": filterClick(name)
},
childs: [
{
elementType: "div",
class: ["modal-dialog"],
role: "document",
childs: [
{
elementType: "div",
class: ["modal-content"],
childs: [
{
elementType: "div",
class: ["modal-header"],
childs: [
{
elementType: "button",
class: ["close"],
type: "button",
"data-dismiss": "modal",
"aria-label": "Close",
events: {
"click": dialogHideEvent(name)
},
childs: [
{
elementType: "span",
"aria-hidden": "true",
content: "×"
}
]
},
{
elementType: "h4",
class: ["modal-title"],
content: title
}
]
},
{
elementType: "div",
class: ["modal-body"],
childs: [
{
elementType: "p",
content: description
},
jsonHTMLContent
]
},
{
elementType: "div",
class: ["modal-footer"],
childs: [
{
elementType: "button",
class: ["btn", "btn-default"],
type: "button",
"data-dismiss": "modal",
content: selectedLanguage === 'fr-FR' ? "Exporter" : "Export",
events: {
"click": () => {
if (!dataValidation()) return
hideDialog(name)
}
}
},
{
elementType: "button",
class: ["btn", "btn-default"],
type: "button",
"data-dismiss": "modal",
content: selectedLanguage === 'fr-FR' ? "Annuler" : "Cancel",
events: {
"click": dialogHideEvent(name)
}
}
]
}
]
}
]
}
]
})
document.getElementById("mainContentDiv").appendChild(dialog)
return dialog
}
const mainDialog = buildDialog(
"mainDialog",
selectedLanguage === 'fr-FR' ? "Exporter en .ics" : "Export to .ics",
selectedLanguage === 'fr-FR' ? "Sélectionner les dates de l'export que vous voulez faire." : "Select the export dates you want to use.",
exportData,
{
elementType: "form",
role: "form",
events: {
"submit": (event) => {
event.preventDefault()
return false
}
},
childs: [
{
elementType: "div",
class: ["form-group"],
childs: [
{
elementType: "label",
for: "startDate",
content: selectedLanguage === 'fr-FR' ? "Date de début:" : "Start Date:",
},
{
elementType: "input",
class: ["form-control"],
tabindex: -1,
"aria-hidden": true,
type: "date",
id: "startDate",
name: "startDate",
value: new Date().toISOString().split("T")[0]
}
]
},
{
elementType: "div",
class: ["form-group"],
childs: [
{
elementType: "label",
for: "endDate",
content: selectedLanguage === 'fr-FR' ? "Date de fin:" : "End Date:",
},
{
elementType: "input",
class: ["form-control"],
tabindex: -1,
"aria-hidden": true,
type: "date",
id: "endDate",
name: "endDate",
value: new Date().toISOString().split("T")[0]
}
]
},
{
elementType: "div",
class: ["errorMessage", "alert", "alert-danger", "hidden"],
role: "alert",
childs: [
{
elementType: "span",
class: ["glyphicon", "glyphicon-exclamation-sign"],
"aria-hidden": "true"
},
{
elementType: "span",
class: ["sr-only"],
content: "Error:"
},
{
elementType: "span",
class: ["errorMessageContent"],
content: selectedLanguage === 'fr-FR' ? "Message d'erreur" : "Error message",
}
]
}
]
}
)
mainDialog.setErrorMessage = (message) => {
if (!message) {
mainDialog.querySelector(".errorMessage").classList.add("hidden")
return
}
mainDialog.querySelector(".modal-body p").textContent = messages[selectedLanguage].errorDialogMessage;
mainDialog.querySelector(".errorMessage").classList.remove("hidden")
}
const errorDialog = buildDialog(
"errorDialog",
messages[selectedLanguage].errorDialogTitle,
messages[selectedLanguage].notConnectedMessage,
() => true,
{}
);
errorDialog.show = (message) => {
errorDialog.querySelector(".modal-body p").textContent = message
showDialog("errorDialog")
}
const modalFadeIn = jsonToHTML({
elementType: "div",
class: ["modal-backdrop", "fade", "in"]
})
/************
* MAIN CODE *
************/
/**
* check if the user is logged in
* @returns true if the user is logged in, false otherwise
*/
function isConnected() {
const textContent = document.querySelector(".logInOrOut").innerText;
return /Déconnexion - [0-9]+|Log Out - [0-9]+/i.test(textContent);
}
function getFederalId() {
const textContent = document.querySelector(".logInOrOut").innerText;
const match = textContent.match(/Déconnexion - ([0-9]+)|Log Out - ([0-9]+)/i);
if (match) {
return match[1] || match[2];
}
return null;
}
/**
* reset error visual and message in the main dialog
*/
function resetMainDialogError() {
mainDialog.setErrorMessage()
document.getElementById("startDate").parentElement.classList.remove("has-error")
document.getElementById("startDate").parentElement.classList.remove("has-error")
}
/**
* fetch json data of the user CELCAT calendar
* @param startDate the start date of the export
* @param endDate the end date of the export
* @returns the json data fetched from the CELCAT api
*/
async function getData(startDate, endDate) {
const headers = new Headers()
headers.append("Content-Type", "application/x-www-form-urlencoded")
const params = {
method: "POST",
headers: headers,
mode: "cors",
body: new URLSearchParams({
start: startDate.toISOString().split("T")[0],
end: endDate.toISOString().split("T")[0],
resType: "104",
calView: "agendaWeek",
"federationIds[]": getFederalId(),
colourScheme: "3"
})
}
const result = await fetch(CALENDAR, params)
return result.json()
}
/**
* clean the data fetched from the CELCAT api by removing <br/> tags and repetitive \n or \r
* @param description the description of the event
* @returns cleaned description
*/
function cleanDescription(description) {
return description.replace(/(<br \/>|[\r])/g, "")
.replace(/[\n]+/g, "\n")
.replace(/è/g,"è")
.replace(/é/g,"è")
.replace(/â/g,"â")
}
/**
* take and iso date given by the CELCAT api or javascript date and convert it to ISO 8601 format
* @param dateString the date in ISO format
* @returns the date in the format YYYYMMDDTHHmmss (ISO 8601)
*/
function formatDate(dateString) {
return dateString.replace(/([-:]|\.[0-9]+)/g, "")
}
/**
* parse event description to get the location, the event type, the room, and the couse id and name
* @param description the description of the event
* @returns the parsed data
*/
function parseDescription(description) {
const details = description.split("\n")
return {
type: details[0],
title: details[1].split(" - ")[0],
course: details[1].split(" - ")[1],
room: details[2],
group: details[3]
}
}
function dataToIcal(data) {
let result = "BEGIN:VCALENDAR\r\n"
result += "VERSION:2.0\r\n"
result += "PRODID:-//Robotechnic//Univ Toulouse III//CELCAT//FR\r\n"
result += "CALSCALE:GREGORIAN\r\n"
result += "METHOD:PUBLISH\r\n"
result += "X-WR-CALNAME:CELCAT-EDT\r\n"
result += "X-WR-TIMEZONE:Europe/Paris\r\n"
const dstamp = `DTSTAMP:${formatDate(new Date().toISOString())}\r\n`
for (const event of data) {
if (event.eventCategory == "CONGES" || event.eventCategory == "FERIE" || event.eventCategory == "PONT") continue
event.description = cleanDescription(event.description)
debugger
const details = parseDescription(event.description)
const categoryColor = getCategoryColor(event.type || event.eventCategory);
result += "BEGIN:VEVENT\r\n"
result += `UID:${event.id}\r\n`
result += dstamp
result += `DTSTART:${formatDate(event.start)} \r\n`
result += `DTEND:${formatDate(event.end)} \r\n`
result += `SUMMARY:${details.course || details.room || 'Undefined Event'} \r\n`
result += `DESCRIPTION:${event.description.replace(/\n/g,"\\n")}\r`
result += `LOCATION:${details.group || details.room} \r\n`
result += `CATEGORIES:${event.eventCategory} \r\n`
result += `COLOR:${categoryColor}\r\n`;
result += "END:VEVENT\r\n"
}
result += "END:VCALENDAR\r\n"
return result
}
// Change based on your colors
function getCategoryColor(category) {
switch (category) {
case "COURS":
return "ff0000";
case "TD":
return "4a4aff";
case "TP":
return "#408080";
case "REUNION / RENCONTRE":
return "a953ff";
case "CONTROLE CONTINU":
return "#808000";
default:
return "#ffc4c4";
}
}
/**
* export dat to iCalendar format and download it
* @returns true if the exportation is a success, false otherwise
*/
function exportData() {
const startElement = document.getElementById("startDate")
const startDate = new Date(startElement.value)
const endElement = document.getElementById("endDate")
const endDate = new Date(endElement.value)
resetMainDialogError()
if (startDate == "Invalid Date") {
mainDialog.setErrorMessage("La date de début est invalide.")
startElement.parentElement.classList.add("has-error")
startElement.focus()
return false
}
if (endDate == "Invalid Date") {
mainDialog.setErrorMessage("La date de fin est invalide.")
endElement.parentElement.classList.add("has-error")
endElement.focus()
return false
}
if (startDate > endDate) {
mainDialog.setErrorMessage("La date de début doit être avant la date de fin.")
startElement.parentElement.classList.add("has-error")
endElement.parentElement.classList.add("has-error")
startElement.focus()
return false
}
resetMainDialogError()
//GM_log(`Exporting data from ${startDate} to ${endDate}`)
getData(startDate, endDate)
.then(data => {
const ical = dataToIcal(data)
const link = document.createElement("a")
link.setAttribute("href", `data:text/calendar;charset=utf-8,${encodeURIComponent(ical)}`)
link.download = `export-${startDate.toISOString().split("T")[0]}-${endDate.toISOString().split("T")[0]}.ics`
link.click()
})
.catch(error => {
errorDialog.show(error)
//GM_log(error)
})
return true;
}
/**
* Add the export button to the page interface
*/
function addButton() {
const navBar = document.querySelector("#main-navbar-collapse .navbar-nav");
const button = jsonToHTML({
elementType: "li",
class: ["navbar-link"],
childs: [
{
elementType: "a",
id: "iCalendarGeneration",
content: messages[selectedLanguage].exportButtonLabel,
style: "cursor:pointer;",
events: {
"click": () => {
if (isConnected()) {
showDialog("mainDialog");
} else {
errorDialog.show(messages[selectedLanguage].notConnectedMessage);
}
}
}
}
]
});
navBar.appendChild(button);
mainDialog.querySelector(".modal-footer").prepend(
jsonToHTML({
elementType: "button",
class: ["btn", "btn-default"],
type: "button",
"data-dismiss": "modal",
content: messages[selectedLanguage].exportButtonLabel,
events: {
"click": () => {
if (!exportData()) return;
hideDialog("mainDialog");
}
}
})
);
}
(function() {
addButton()
})();