import Debug from "debug"; import autoprefixer from "autoprefixer"; import postcss from "postcss"; import tailwindcss from "tailwindcss"; import { Event, Medal, NOC, Sport } from "./types"; import { getAllSportsKeys, getSportIcon } from "./sports"; import { existsSync, writeFileSync } from "fs"; import { hasFile, readFile, saveFile } from "./io"; import * as cheerio from "cheerio"; import { getNOCFlag, getNOCName, isValidNOC } from "./nocs"; import * as translate from "./translate"; import { generateICS } from "./ics"; export class Calendar { private language: string; private debug: Debug.Debugger; private events: Event[] = []; private nocs: string[] = []; private sports: Sport[] = []; private medals: Medal[] = []; constructor(language: string) { this.language = language; this.debug = Debug(`paris2024:calendar:${language}`); } private addSport(sportKey: string, sportName: string) { if (!this.sports.find(sport => sport.key === sportKey)) { // this.debug(`Adding sport: ${sportName} (${sportKey})`); this.sports.push({ key: sportKey, name: sportName, NOCS: [], }); } } private addNOC(noc: string) { if (!this.nocs.includes(noc)) { // this.debug(`Adding NOC: ${noc}`); this.nocs.push(noc); } } private addSportNOC(sportKey: string, sportName: string, noc: string) { this.addSport(sportKey, sportName); const sport = this.sports.find((sport) => sport.key === sportKey)!; if (!sport.NOCS.includes(noc)) { // this.debug(`Adding NOC: ${noc} to sport: ${sportKey}`); sport.NOCS.push(noc); } }; public async generate() { this.debug(`Generating calendar for ${this.language}`); await Promise.all(getAllSportsKeys().map((sportKey) => this.getSportCalendar(sportKey))); this.genereateEventsCeremonies(); this.generateCalendars(); this.generateMainPage(); this.generateTodaysPage(); this.genereateMedalsPage(); this.generateCSS(); } private async getSportCalendar(sportKey: string) { const schedule = await this.downloadScheduleFromOfficialWebsite(sportKey); this.generateEventsFromSchedule(sportKey, schedule); } private async downloadScheduleFromOfficialWebsite(sportKey: string) { // this.debug(`Checking cache for schedule for ${sportKey}`); const cacheFile = `${__dirname}/../cache/${this.language}/${sportKey}.html`; if (!hasFile(cacheFile)) { this.debug(`Downloading schedule for ${sportKey} in ${this.language}`); const response = await fetch(`https://olympics.com/${this.language}/paris-2024/paralympic-games/schedule/${sportKey}`); const content = await response.text(); saveFile(cacheFile, content); } const html = readFile(cacheFile); const $ = cheerio.load(html); return JSON.parse($("#__NEXT_DATA__").text()); } private generateEventsFromSchedule(sportKey: string, data: any) { const sportName = data.query.pDisciplineLabel; const sportIcon = getSportIcon(sportKey); this.addSport(sportKey, sportName); data.props.pageProps.scheduleDataSource.initialSchedule.units.forEach((unit: any) => { unit.startDateTimeUtc = new Date(unit.startDate).toISOString().replace(".000", ""); unit.endDateTimeUtc = new Date(unit.endDate).toISOString().replace(".000", ""); const slugify = (text: string) => text.toLowerCase().replace(/\s/g, "-") .replace(/[^a-z0-9-]/g, "") .replace(/-+/g, "-"); const event: Event = { UID: `${unit.startDateTimeUtc.replace(/[:-]/g, "")}-${sportKey}-${slugify(unit.eventUnitName).toUpperCase()}`, DTSTAMP: unit.startDateTimeUtc.replace(/[:-]/g, ""), DTSTART: unit.startDateTimeUtc.replace(/[:-]/g, ""), DTEND: unit.endDateTimeUtc.replace(/[:-]/g, ""), DESCRIPTION: `${sportName} - ${unit.eventUnitName}`, SUMMARY: `${sportIcon} ${unit.eventUnitName}`.trim(), LOCATION: unit.venueDescription, _SPORT: sportKey, _NOCS: [], _COMPETITORS: [], _UNITNAME: unit.eventUnitName, _MEDAL: !!unit.medalFlag, _GENDER: unit.genderCode, }; if (unit.competitors) { const competitors = unit.competitors .filter((competitor: any) => competitor.noc && isValidNOC(competitor.noc)) .sort((a: any, b: any) => a.order > b.order ? 1 : -1); for (const competitor of competitors) { this.addSportNOC(sportKey, sportName, competitor.noc); this.addNOC(competitor.noc); event._COMPETITORS.push({ noc: competitor.noc, name: competitor.name }); if (!event._NOCS.includes(competitor.noc)) { event._NOCS.push(competitor.noc); } switch (competitor.results?.medalType) { case "ME_GOLD": this.medals.push({ name: competitor.name, noc: competitor.noc, sport: sportName, unit: unit.eventUnitName, date: unit.endDateTimeUtc, color: "gold" }); break; case "ME_SILVER": this.medals.push({ name: competitor.name, noc: competitor.noc, sport: sportName, unit: unit.eventUnitName, date: unit.endDateTimeUtc, color: "silver" }); break; case "ME_BRONZE": this.medals.push({ name: competitor.name, noc: competitor.noc, sport: sportName, unit: unit.eventUnitName, date: unit.endDateTimeUtc, color: "bronze" }); break; } } // two competitors, we put them in the summary if (competitors.length === 2) { const competitor1 = competitors.shift(); const competitor2 = competitors.shift(); event.UID += `-${competitor1.noc}-${competitor2.noc}`; if (competitor1.name !== getNOCName(competitor1.noc)) { event.SUMMARY = `${sportIcon} ${competitor1.name} ${getNOCFlag(competitor1.noc)} - ${getNOCFlag(competitor2.noc)} ${competitor2.name}`; } else { event.SUMMARY = `${sportIcon} ${competitor1.noc} ${getNOCFlag(competitor1.noc)} - ${getNOCFlag(competitor2.noc)} ${competitor2.noc}`; } } else if (competitors.length !== 0) { // more than two, we put them in the description competitors .sort((a: any, b: any) => a.name > b.name ? 1 : -1) .forEach((competitor: any) => { if (competitor.name !== getNOCName(competitor.noc)) { event.DESCRIPTION += `\\n${getNOCFlag(competitor.noc)} ${competitor.name}`; } else { event.DESCRIPTION += `\\n${getNOCFlag(competitor.noc)} ${competitor.noc}`; } }); } } this.events.push(event); }); } private genereateEventsCeremonies() { let startDateUtc = new Date("2024-08-28T18:00:00Z").toISOString().replace(".000", ""); let endDateUtc = new Date("2024-08-28T21:00:00Z").toISOString().replace(".000", ""); const opening: Event = { UID: `${startDateUtc.replace(/[:-]/g, "")}-opening-ceremony`, DTSTAMP: startDateUtc.replace(/[:-]/g, ""), DTSTART: startDateUtc.replace(/[:-]/g, ""), DTEND: endDateUtc.replace(/[:-]/g, ""), DESCRIPTION: translate.translate(`Paris 2024 - {{translate_openingCeremony}}`, this.language), SUMMARY: translate.translate(`Paris 2024 - {{translate_openingCeremony}}`, this.language), LOCATION: "Paris", _COMPETITORS: [], _GENDER: "", _MEDAL: false, _NOCS: this.nocs, _SPORT: "", _UNITNAME: translate.translate(`Paris 2024 - {{translate_openingCeremony}}`, this.language), }; this.events.push(opening); startDateUtc = new Date("2024-09-08T19:00:00Z").toISOString().replace(".000", ""); endDateUtc = new Date("2024-09-08T22:00:00Z").toISOString().replace(".000", ""); const closing: Event = { UID: `${startDateUtc.replace(/[:-]/g, "")}-closing-ceremony`, DTSTAMP: startDateUtc.replace(/[:-]/g, ""), DTSTART: startDateUtc.replace(/[:-]/g, ""), DTEND: endDateUtc.replace(/[:-]/g, ""), DESCRIPTION: translate.translate(`Paris 2024 - {{translate_closingCeremony}}`, this.language), SUMMARY: translate.translate(`Paris 2024 - {{translate_closingCeremony}}`, this.language), LOCATION: "Stade de France, Saint-Denis", _COMPETITORS: [], _GENDER: "", _MEDAL: false, _NOCS: this.nocs, _SPORT: "", _UNITNAME: translate.translate(`Paris 2024 - {{translate_closingCeremony}}`, this.language), }; this.events.push(closing); } private getKey(sportKey: string, noc: string) { return `${this.language}/${sportKey}/${noc}`; } private sortEvents(a: Event, b: Event) { if (a.DTSTART !== b.DTSTART) { return a.DTSTART > b.DTSTART ? 1 : -1; } if (a.DTEND !== b.DTEND) { return a.DTEND > b.DTEND ? 1 : -1; } if (a.SUMMARY !== b.SUMMARY) { return a.SUMMARY > b.SUMMARY ? 1 : -1; } if (a.DESCRIPTION !== b.DESCRIPTION) { return a.DESCRIPTION > b.DESCRIPTION ? 1 : -1; } return 0; } private generateCalendars() { // sports for (const sport of this.sports) { // sport/general let events = this.events .filter((event) => event._SPORT === sport.key) .sort(this.sortEvents); let key = this.getKey(sport.key, "general"); let title = `${getSportIcon(sport.key)} ${sport.name} | Paris 2024`; if (events.length > 0) { generateICS(title, key, events); } // sport/medals events = this.events .filter((event) => event._SPORT === sport.key && event._MEDAL) .sort(this.sortEvents); key = this.getKey(sport.key, "medals"); title = `${getSportIcon(sport.key)} ${sport.name} 🏅 | Paris 2024`; if (events.length > 0) { generateICS(title, key, events); } // sport/noc for (const noc of sport.NOCS) { events = this.events .filter((event) => event._SPORT === sport.key && event._NOCS.includes(noc)) .sort(this.sortEvents); key = this.getKey(sport.key, noc); title = `${getNOCFlag(noc)} ${getNOCName(noc)} ${sport.name} | Paris 2024`; if (events.length > 0) { generateICS(title, key, events); } } } // nocs for (const noc of this.nocs) { // general/noc let events = this.events .filter((event) => event._NOCS.includes(noc)) .sort(this.sortEvents); let key = this.getKey("general", noc); let title = `${getNOCFlag(noc)} ${getNOCName(noc)} | Paris 2024`; if (events.length > 0) { generateICS(title, key, events); } // medals/noc events = this.events .filter((event) => event._NOCS.includes(noc) && event._MEDAL) .sort(this.sortEvents); key = this.getKey("medals", noc); title = `${getNOCFlag(noc)} ${getNOCName(noc)} 🏅 | Paris 2024` if (events.length > 0) { generateICS(title, key, events); } } // general/general const events = this.events .sort(this.sortEvents); const key = this.getKey("general", "general"); const title = `Paris 2024`; if (events.length > 0) { generateICS(title, key, events); } // medals/general const medals = this.events .filter((event) => event._MEDAL) .sort(this.sortEvents); const medalsKey = this.getKey("medals", "general"); const medalsTitle = `🏅 Paris 2024`; if (medals.length > 0) { generateICS(medalsTitle, medalsKey, medals); } } private generateMainPage() { const accordionClass = "collapse collapse-arrow bg-gray-100 mb-1" const buttonClass = "btn btn-sm bg-gray-300 min-w-24 mb-1"; const calendars: string[] = []; calendars.push(`
`); calendars.push(` `); calendars.push(`
{{translate_allSports}}
`); calendars.push(`
`) calendars.push(`
`); calendars.push(` `); calendars.push(`
`); for (const noc of this.nocs.sort()) { calendars.push(` `); } calendars.push(`
`); calendars.push(`
`); calendars.push(`
`); calendars.push(` `); calendars.push(`
🏅 {{translate_medalEvents}}
`); calendars.push(`
`) calendars.push(`
`); calendars.push(` `); calendars.push(`
`); for (const noc of this.nocs.sort()) { calendars.push(` `); } calendars.push(`
`); calendars.push(`
`); calendars.push(`
`); calendars.push(` `); calendars.push(`
📅 {{translate_todaysEvents}}
`); calendars.push(`
`) for (const noc of this.nocs.sort()) { calendars.push(` ${getNOCFlag(noc)} ${noc}`); } calendars.push(`
`); calendars.push(`
`); for (const sport of this.sports.sort((a: Sport, b: Sport) => a.name > b.name ? 1 : -1)) { calendars.push(`
`); calendars.push(` `); calendars.push(`
${getSportIcon(sport.key)} ${sport.name}
`); calendars.push(`
`) calendars.push(`
`); calendars.push(` `); calendars.push(` `); calendars.push(`
`); for (const noc of sport.NOCS.sort()) { calendars.push(` `); } calendars.push(`
`); calendars.push(`
`); } const template = readFile(`${__dirname}/index/template.html`); const output = translate.translate( template.replace("{{calendars}}", calendars.join("\r\n")), this.language, ); saveFile( this.language === "en" ? "docs/index.html" : `docs/${this.language}/index.html`, output); } private generateTodaysPage() { const content: string[] = []; for (const event of this.events.sort(this.sortEvents)) { let sport = this.sports.find((sport) => sport.key === event._SPORT); if (!sport) { sport = { name: "Ceremony", key: "", NOCS: [], }; } let medalColor = ""; if (event._MEDAL) { if (event._UNITNAME.match(/bronze/gi)) { medalColor = "bg-orange-700"; } else { medalColor = "bg-amber-400"; } } content.push(`
`); content.push(`
`); content.push(" __:__"); content.push("
__:__
"); content.push("
"); content.push("
"); content.push("
"); content.push(` ${event._MEDAL ? "🏅" : ""}`); content.push(` ${sport.name.toUpperCase()}`); if (event._GENDER === "M") { content.push(` {{translate_genderMen}}`); } else if (event._GENDER === "W") { content.push(` {{translate_genderWomen}}`); } content.push("
"); content.push(`
${event._UNITNAME}
`); if (event._COMPETITORS) { if (event._COMPETITORS.length === 2) { content.push(`
`); content.push(` ${event._COMPETITORS[0].name}`); content.push(` ${getNOCFlag(event._COMPETITORS[0].noc)}`); content.push(` -`); content.push(` ${getNOCFlag(event._COMPETITORS[1].noc)}`); content.push(` ${event._COMPETITORS[1].name}`); content.push(`
`); } else { event._COMPETITORS.sort((a, b) => a.name > b.name ? 1 : -1).forEach((competitor) => { content.push(`
${getNOCFlag(competitor.noc)} ${competitor.name}
`); }); } } content.push("
"); content.push("
"); } const template = readFile(`${__dirname}/today/template.html`); const output = translate.translate( template.replace("{{events}}", content.join("\r\n")), this.language, ); saveFile( this.language === "en" ? "docs/today.html" : `docs/${this.language}/today.html`, output ); } private genereateMedalsPage() { const table: any[] = []; for (const medal of this.medals) { if (!table.find((noc) => noc.noc === medal.noc)) { table.push({ noc: medal.noc, gold: 0, silver: 0, bronze: 0 }); } table.find((noc) => noc.noc === medal.noc)[medal.color] += 1; } const content: string[] = []; content.push(`
`); content.push(`
`); content.push(` `); content.push(` `); content.push(` `); content.push(` `); content.push(` TOTAL`); content.push(`
`); content.push(`
`); table.sort((a, b) => { if (a.gold !== b.gold) { return a.gold < b.gold ? 1 : -1; } if (a.silver !== b.silver) { return a.silver < b.silver ? 1 : -1; } if (a.bronze !== b.bronze) { return a.bronze < b.bronze ? 1 : -1; } return getNOCName(a.noc) > getNOCName(b.noc) ? 1 : -1; }).forEach((noc) => { content.push(`
`); content.push(` `); content.push(`
`); content.push(` ${getNOCFlag(noc.noc)} ${getNOCName(noc.noc)}`); content.push(` ${noc.gold}`); content.push(` ${noc.silver}`); content.push(` ${noc.bronze}`); content.push(` ${noc.gold + noc.silver + noc.bronze}`); content.push(`
`); content.push(`
`) content.push(` `); let lastDate = ""; for (const medal of this.medals .filter((m) => m.noc === noc.noc) .sort((a, b) => { if (a.date !== b.date) { return a.date > b.date ? -1 : 1 } const colors = ["gold", "silver", "bronze"]; if (a.color !== b.color) { return colors.indexOf(a.color) > colors.indexOf(b.color) ? 1 : -1; } return a.name > b.name ? 1 : -1; }) ) { let medalDate = medal.date.substring(0, 10); if (medalDate !== lastDate) { content.push(` `); } lastDate = medalDate; content.push(` `); content.push(` `); content.push(` `); content.push(` `); content.push(` `); } content.push(`
${medalDate}
${medal.name}${medal.sport} - ${medal.unit}
`); content.push(`
`); content.push(`
`); }) const template = readFile(`${__dirname}/medals/template.html`); const output = translate.translate( template.replace("{{medals}}", content.join("\r\n")), this.language, ); saveFile( this.language === "en" ? "docs/medals.html" : `docs/${this.language}/medals.html`, output ); } private generateCSS() { postcss([autoprefixer, tailwindcss]) .process(readFile(`${__dirname}/index/template.css`), { from: "index/template.css", to: "docs/main.css" }) .then((result) => { saveFile("docs/main.css", result.css); }); ; } }