CELCAT to .ics

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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(/&#232;/g,"è")
			  .replace(/&#233;/g,"è")
			  .replace(/&#226;/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()
})();