|
// ==UserScript== |
|
// @name CEFET Google Calendar |
|
// @namespace https://gist.github.com/ |
|
// @version 0.3 |
|
// @description Adiciona suporte para o Google Calendar na grade de horários do CEFET/RJ. |
|
// @author https://github.com/saulodias |
|
// @match https://alunos.cefet-rj.br/aluno/aluno/quadrohorario/* |
|
// @icon https://upload.wikimedia.org/wikipedia/commons/a/a5/Google_Calendar_icon_%282020%29.svg |
|
// @grant none |
|
// @require https://code.jquery.com/jquery-3.6.0.min.js |
|
// ==/UserScript== |
|
|
|
const querySelectorAllAsync = (el) => { |
|
return new Promise((resolve) => { |
|
let element; |
|
const check = (el) => { |
|
element = document.querySelectorAll(el); |
|
if (element.length) { |
|
resolve(element); |
|
} else { |
|
setTimeout(() => { |
|
check(el); |
|
}, 100); |
|
} |
|
}; |
|
check(el); |
|
}); |
|
}; |
|
|
|
const trim = (value) => value.replace(/\s+/g, " ").trim(); |
|
|
|
const mapRow = (headings) => { |
|
return ({ cells }) => { |
|
return [...cells].reduce(function (result, cell, i) { |
|
const input = cell.querySelector("input,select"); |
|
let value; |
|
|
|
if (input) { |
|
value = input.type === "checkbox" ? input.checked : input.value; |
|
} else { |
|
value = cell.innerText; |
|
} |
|
value = trim(value); |
|
return Object.assign(result, { [headings[i]]: value }); |
|
}, {}); |
|
}; |
|
}; |
|
|
|
/** |
|
* Parse table by Wicky Nilliams |
|
* https://gist.github.com/WickyNilliams/9252235 |
|
* License: MIT |
|
*/ |
|
const parseTable = (table) => { |
|
var headings = [...table.tHead.rows[0].cells].map( |
|
(heading) => heading.innerText |
|
); |
|
return [...table.tBodies[0].rows].map(mapRow(headings)); |
|
}; |
|
|
|
const getDadosTurma = (link) => { |
|
return new Promise((resolve) => { |
|
$("<div />").load(link, (data) => { |
|
const doc = new DOMParser().parseFromString(data, "text/html"); |
|
const tableVagas = doc.getElementsByClassName("tablevagas")[0]; |
|
tableVagas.parentNode.removeChild(tableVagas); // remove tablevagas |
|
const turma = trim(doc.querySelectorAll(".topopage")[0].innerText).split( |
|
" " |
|
)[3]; |
|
|
|
const labels = doc.querySelectorAll(".label"); // querySelector gets a static collection |
|
const dadosGerais = { turma }; |
|
for (let l of labels) { |
|
const labelParent = l.parentNode; |
|
const label = l.parentNode.removeChild(l); |
|
dadosGerais[String(trim(label.innerText).replace(":", ""))] = trim( |
|
labelParent.innerText |
|
); |
|
} |
|
const docentesTabela = doc |
|
.querySelectorAll('[title="Docentes"]')[0] |
|
?.querySelectorAll("table")?.[0]; |
|
const docentes = docentesTabela && parseTable(docentesTabela); |
|
const horariosTabela = doc |
|
.querySelectorAll("[title=Horários]")[0] |
|
?.querySelectorAll("table")?.[0]; |
|
const horarios = horariosTabela && parseTable(horariosTabela); |
|
const espacoFisicoTabela = doc |
|
.querySelectorAll('[title="Espaço Físico"]')[0] |
|
?.querySelectorAll("table")?.[0]; |
|
|
|
// Caso especial quando a tabela de locais vem com informação pra duas linhas mas a segunda linha vem zoada |
|
const espacoFisicoCelulas = Array.from( |
|
espacoFisicoTabela.getElementsByTagName("td") |
|
); |
|
if (espacoFisicoCelulas.length === 7) { |
|
const segundaLinhaCelulas = espacoFisicoCelulas.splice(4); |
|
segundaLinhaCelulas.unshift(document.createElement("td")); // adiciona célula vazia no inicio da segunda linha |
|
|
|
let primeiraLinhaHTML = ""; |
|
espacoFisicoCelulas.forEach((cel) => { |
|
primeiraLinhaHTML += cel.outerHTML; |
|
}); |
|
|
|
let segundaLinhaHTML = ""; |
|
segundaLinhaCelulas.forEach((cel) => { |
|
segundaLinhaHTML += cel.outerHTML; |
|
}); |
|
|
|
const tbodyHTML = |
|
"<tr>" + primeiraLinhaHTML + "</tr><tr>" + segundaLinhaHTML + "</tr>"; |
|
espacoFisicoTabela.getElementsByTagName("tbody")[0].innerHTML = |
|
tbodyHTML; |
|
} // fim do caso especial |
|
|
|
const espacoFisico = espacoFisicoTabela && parseTable(espacoFisicoTabela); |
|
resolve({ dadosGerais, docentes, horarios, espacoFisico }); |
|
}); |
|
}); |
|
}; |
|
|
|
const normalizarNome = (texto) => { |
|
const excecoes = ["a", "e", "o", "da", "de", "do", "das", "dos"]; |
|
const preservar = ["I", "II", "III", "IV", "V"]; |
|
return texto |
|
.split(" ") |
|
.map((p) => (preservar.includes(p) ? p : p.toLowerCase())) |
|
.map((p) => (excecoes.includes(p) ? p : p[0].toUpperCase() + p.slice(1))) |
|
.join(" "); |
|
}; |
|
|
|
const toRFC5545UTC = (date = new Date()) => |
|
new Date(date).toISOString().split(".")[0].replace(/[-:]/g, "") + "Z"; |
|
|
|
const toLocalTimezone = (date = new Date()) => |
|
new Date(date) |
|
.toLocaleString("sv-SE", { hour12: false }) |
|
.replace(" ", "T") |
|
.replace(/[-:]/g, ""); |
|
|
|
const nextWeekdayDate = (day_in_week, date = null) => { |
|
const ret = new Date(date || new Date()); |
|
ret.setDate(ret.getDate() + ((day_in_week - 1 - ret.getDay() + 7) % 7) + 1); |
|
return ret; |
|
}; |
|
|
|
const proximoDia = (hhmm, dia, data) => { |
|
const [hh, mm] = hhmm.split(":").map((v) => Number(v)); |
|
const d = data || new Date(); |
|
d.setHours(hh); |
|
d.setMinutes(mm); |
|
return nextWeekdayDate(dia, d); |
|
}; |
|
|
|
const data = (ddmmyyyy) => { |
|
const [dd, mm, yyyy] = ddmmyyyy.split("/"); |
|
return new Date(yyyy, mm - 1, dd); |
|
}; |
|
|
|
/** |
|
* https://dylanbeattie.net/2021/01/12/adding-events-to-google-calendar-via-a-link.html |
|
*/ |
|
const gerarEventoGoogleCalendar = (dados) => { |
|
const { titulo, detalhes, local, horaInicio, horaFim, dataFim } = dados; |
|
const apiUrl = "https://calendar.google.com/calendar/u/0/r/eventedit"; |
|
const text = titulo; |
|
const dates = toRFC5545UTC(horaInicio) + "/" + toRFC5545UTC(horaFim); |
|
const details = detalhes; |
|
const location = local; |
|
const recur = "RRULE:FREQ=WEEKLY;UNTIL=" + toRFC5545UTC(dataFim); |
|
const paramsString = new URLSearchParams({ |
|
text, |
|
dates, |
|
details, |
|
location, |
|
recur, |
|
}).toString(); |
|
return apiUrl + "?" + paramsString; |
|
}; |
|
|
|
const templateVCalendar = (vEvents) => |
|
"BEGIN:VCALENDAR\r\n" + |
|
"PRODID:-//Google Inc//Google Calendar 70.9054//EN\r\n" + |
|
"VERSION:2.0\r\n" + |
|
"CALSCALE:GREGORIAN\r\n" + |
|
"METHOD:PUBLISH\r\n" + |
|
"X-WR-CALNAME:CEFET\r\n" + |
|
"X-WR-TIMEZONE:America/Sao_Paulo\r\n" + |
|
"BEGIN:VTIMEZONE\r\n" + |
|
"TZID:America/Sao_Paulo\r\n" + |
|
"X-LIC-LOCATION:America/Sao_Paulo\r\n" + |
|
"BEGIN:STANDARD\r\n" + |
|
"TZOFFSETFROM:-0300\r\n" + |
|
"TZOFFSETTO:-0300\r\n" + |
|
"TZNAME:-03\r\n" + |
|
"DTSTART:19700101T000000\r\n" + |
|
"END:STANDARD\r\n" + |
|
"END:VTIMEZONE\r\n" + |
|
vEvents + |
|
"END:VCALENDAR"; |
|
|
|
const gerarVEvent = (dados) => { |
|
const { titulo, detalhes, local, horaInicio, horaFim, dataFim } = dados; |
|
return ( |
|
Object.entries({ |
|
BEGIN: "VEVENT", |
|
"DTSTART;TZID=America/Sao_Paulo": toLocalTimezone(horaInicio), |
|
"DTEND;TZID=America/Sao_Paulo": toLocalTimezone(horaFim), |
|
RRULE: "FREQ=WEEKLY;UNTIL=" + toRFC5545UTC(dataFim), |
|
DTSTAMP: toRFC5545UTC(), |
|
DESCRIPTION: detalhes, |
|
LOCATION: local, |
|
SEQUENCE: "0", |
|
STATUS: "CONFIRMED", |
|
SUMMARY: titulo, |
|
TRANSP: "OPAQUE", |
|
END: "VEVENT", |
|
}) |
|
.map(([key, value]) => `${key}:${value}`) |
|
.join("\r\n") + "\r\n" |
|
); |
|
}; |
|
|
|
const gerarResumo = (dados) => { |
|
const { detalhes, local } = dados; |
|
return ` ${detalhes} ${local}`; |
|
}; |
|
|
|
const gerarDadosDaAula = (cadeira, dia, hora) => { |
|
const aula = cadeira.horarios.findIndex( |
|
(h) => h["Dia da Semana"][0] - 1 === dia && h["Hora Início"] === hora |
|
); |
|
const turma = cadeira.dadosGerais.turma; |
|
const titulo = normalizarNome(cadeira.dadosGerais.Disciplina); |
|
const horario = cadeira.horarios[aula] || cadeira.horarios[0]; |
|
const docente = cadeira.docentes?.[aula] || cadeira.docentes?.[0]; |
|
const nome = docente?.["Nome do Docente"]; |
|
const prof = (nome && ` com prof(a). ${normalizarNome(nome)} `) || ""; |
|
const detalhes = `Aula ${horario.Aula.toLowerCase()} ${prof}- Turma: ${turma}`; |
|
const espaco = cadeira.espacoFisico[aula] || cadeira.espacoFisico[0]; |
|
const local = [ |
|
espaco["Nome do Campus"], |
|
espaco["Nome do Prédio"], |
|
espaco["Número da Sala"], |
|
] |
|
.filter((v) => !!v) |
|
.join(", "); |
|
const dataInicio = data(horario["Data Início Período"]); |
|
const dataFim = data(horario["Data Fim Período"]); |
|
const horaInicio = proximoDia( |
|
horario["Hora Início"], |
|
horario["Dia da Semana"][0] - 1, |
|
dataInicio |
|
); |
|
const horaFim = proximoDia( |
|
horario["Hora Fim"], |
|
horario["Dia da Semana"][0] - 1, |
|
dataInicio |
|
); |
|
return { titulo, detalhes, local, horaInicio, horaFim, dataFim, turma }; |
|
}; |
|
|
|
(async () => { |
|
"use strict"; |
|
await querySelectorAllAsync("#quadrohorario"); |
|
const aulas = document.getElementsByClassName("turmaqh"); |
|
const periodos = []; |
|
let vEvents = ""; |
|
for (let aula of aulas) { |
|
const a = aula.getElementsByTagName("a")[0]; |
|
const celula = aula.parentNode.parentNode; |
|
const dia = celula.cellIndex - 1; |
|
const periodo = a.title + dia; |
|
const hora = a.title.split("Horário: ")[1].split(" às ")[0]; |
|
if (periodos.includes(periodo)) continue; |
|
const link = a.href; |
|
const dadosTurma = await getDadosTurma(link); |
|
const dados = gerarDadosDaAula(dadosTurma, dia, hora); |
|
const botaoGCalendar = document.createElement("a"); |
|
const linkGoogleCalendar = gerarEventoGoogleCalendar(dados); |
|
botaoGCalendar.href = linkGoogleCalendar; |
|
botaoGCalendar.target = "_blank"; |
|
botaoGCalendar.innerHTML = `<img src="https://upload.wikimedia.org/wikipedia/commons/a/a5/Google_Calendar_icon_%282020%29.svg" |
|
alt="Adicionar ao calendário do Google" |
|
title="Adicionar ao calendário do Google ${gerarResumo(dados)}" |
|
height="30" |
|
width="30" />`; |
|
celula.appendChild(botaoGCalendar); |
|
vEvents += gerarVEvent(dados); |
|
periodos.push(periodo); |
|
} |
|
const vCalendar = templateVCalendar(vEvents); |
|
const url = window.URL.createObjectURL( |
|
new Blob([vCalendar], { type: "text/plain;charset=utf-8" }) |
|
); |
|
const botaoIcs = document.createElement("a"); |
|
botaoIcs.href = url; |
|
botaoIcs.download = "cefet_horarios.ics"; |
|
const aHTML = `<img src ="https://upload.wikimedia.org/wikipedia/commons/c/cd/Circle-icons-calendar.svg" |
|
width="49" |
|
height="49" |
|
alt = "Exportar como ICS" |
|
title = "Exportar como ICS (Google Calendar, Outlook Calendar e Apple Calendar)"/>`; |
|
botaoIcs.innerHTML = aHTML; |
|
const topoHorarios = document.getElementsByClassName("topohorarios")[0]; |
|
topoHorarios.style.display = "flex"; |
|
topoHorarios.appendChild(botaoIcs); |
|
})(); |