diff --git a/config/default.yaml b/config/default.yaml index bb205909..ea971664 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -226,6 +226,8 @@ conference: # alias: stands # # The prefixes of rooms which belong in the subspace # prefixes: ["S."] + # # The types of tracks which belong in the subspace + # trackTypes: ["stands"] # Options related to the IRC bridge. Set to null if you don't use an IRC bridge. ircBridge: null diff --git a/src/Conference.ts b/src/Conference.ts index 86837da3..729d4900 100644 --- a/src/Conference.ts +++ b/src/Conference.ts @@ -672,6 +672,19 @@ export class Conference { /** * Determines the space in which an auditorium space or interest room should reside. + * + * For both auditoria and interest rooms, this is based off a set of configured prefixes for the + * auditorium or interest room ID. + * + * For auditoria, there is the additional option to match on the track type (with a set of configured + * mappings). + * + * ## Historical notes + * + * Matching on track types was added for FOSDEM 2025. Previously, only prefixes were available + * as a matching mechanism but the track type support was needed once auditoria were changed to + * represent tracks instead of being 1:1 mapped to physical in-person rooms. + * * @param auditoriumOrInterestRoom The description of the auditorium or interest room. * @returns The space in which the auditorium or interest room should reside. */ @@ -686,12 +699,19 @@ export class Conference { const id = auditoriumOrInterestRoom.id; for (const [subspaceId, subspaceConfig] of Object.entries(this.config.conference.subspaces)) { - for (const prefix of subspaceConfig.prefixes) { - if (id.startsWith(prefix)) { - if (!(subspaceId in this.subspaces)) { - throw new Error(`The ${subspaceId} subspace has not been created yet!`); + if (subspaceConfig.prefixes !== undefined) { + for (const prefix of subspaceConfig.prefixes) { + if (id.startsWith(prefix)) { + if (!(subspaceId in this.subspaces)) { + throw new Error(`The ${subspaceId} subspace has not been created yet!`); + } + return this.subspaces[subspaceId]; } + } + } + if (subspaceConfig.trackTypes !== undefined && 'trackType' in auditoriumOrInterestRoom) { + if (subspaceConfig.trackTypes.includes(auditoriumOrInterestRoom.trackType)) { return this.subspaces[subspaceId]; } } diff --git a/src/__tests__/backends/json/JsonScheduleBackend.test.ts b/src/__tests__/backends/json/JsonScheduleBackend.test.ts new file mode 100644 index 00000000..8025621a --- /dev/null +++ b/src/__tests__/backends/json/JsonScheduleBackend.test.ts @@ -0,0 +1,70 @@ +import { test, expect, afterEach, beforeEach, describe } from "@jest/globals"; +import { Server, createServer } from "node:http"; +import { AddressInfo } from "node:net"; +import path from "node:path"; +import * as fs from "fs"; +import { IConfig, JsonScheduleFormat } from "../../../config"; +import { JsonScheduleBackend } from "../../../backends/json/JsonScheduleBackend"; + +function getFixture(fixtureFile: string) { + return fs.readFileSync(path.join(__dirname, fixtureFile), "utf8"); +} + +function jsonScheduleServer() { + return new Promise((resolve) => { + const server = createServer((req, res) => { + if (req.url === "/schedule.json") { + res.writeHead(200); + const json = getFixture("original_democon.json"); + res.end(json); + } else if (req.url === "/fosdem/p/matrix") { + if (req.headers.authorization !== "Bearer TOKEN") { + res.writeHead(401); + res.end("Not authorised"); + return; + } + res.writeHead(200); + const json = getFixture("fosdem_democon.json"); + res.end(json); + } else { + console.log(req.url); + res.writeHead(404); + res.end("Not found"); + } + }).listen(undefined, "127.0.0.1", undefined, () => { + resolve(server); + }); + }); +} + +describe("JsonScheduleBackend", () => { + let serv; + beforeEach(async () => { + serv = await jsonScheduleServer(); + }); + afterEach(async () => { + serv.close(); + }); + + test("can parse a FOSDEM JSON format", async () => { + const globalConfig = { conference: { name: "DemoCon" } } as IConfig; + const backend = await JsonScheduleBackend.new( + "/dev/null", + { + backend: "json", + scheduleFormat: JsonScheduleFormat.FOSDEM, + scheduleDefinition: `http://127.0.0.1:${ + (serv.address() as AddressInfo).port + }/fosdem/p/matrix`, + scheduleRequestHeaders: { + "Authorization": "Bearer TOKEN" + } + }, + globalConfig + ); + expect(backend.conference.title).toBe("DemoCon"); + expect(backend.auditoriums).toMatchSnapshot("auditoriums"); + expect(backend.talks).toMatchSnapshot("talks"); + expect(backend.interestRooms.size).toBe(0); + }); +}); diff --git a/src/__tests__/backends/json/__snapshots__/JsonScheduleBackend.test.ts.snap b/src/__tests__/backends/json/__snapshots__/JsonScheduleBackend.test.ts.snap new file mode 100644 index 00000000..dd4a2394 --- /dev/null +++ b/src/__tests__/backends/json/__snapshots__/JsonScheduleBackend.test.ts.snap @@ -0,0 +1,222 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`JsonScheduleBackend can parse a FOSDEM JSON format: auditoriums 1`] = ` +Map { + "5" => { + "id": "5", + "isPhysical": true, + "kind": "auditorium", + "name": "Open CAD/CAM devroom", + "slug": "open_cad_cam", + "talks": Map { + "3" => { + "auditoriumId": "5", + "dateTs": 1706745600000, + "endTime": 1706778000000, + "id": "3", + "livestream_endTime": 1706778000000, + "prerecorded": false, + "qa_startTime": 0, + "speakers": [ + { + "email": "ioferrell@localhost", + "id": "4", + "matrix_id": "", + "name": "Iarlaith O'Ferrell", + "role": "speaker", + }, + { + "email": "niamh@localhost", + "id": "5", + "matrix_id": "", + "name": "Niamh O'Donnellan", + "role": "speaker", + }, + { + "email": "conal@example.org", + "id": "1", + "matrix_id": "@conal:example.org", + "name": "Conal Cördinator", + "role": "coordinator", + }, + ], + "startTime": 1706778000000, + "subtitle": "", + "title": "Panacea3D: The Final Word in Open Source 3D Solid Modelling", + }, + }, + "trackType": "devroom", + }, + "6" => { + "id": "6", + "isPhysical": true, + "kind": "auditorium", + "name": "Self-Hosted Software devroom", + "slug": "selfhosting", + "talks": Map { + "14" => { + "auditoriumId": "6", + "dateTs": 1706745600000, + "endTime": 1706779800000, + "id": "14", + "livestream_endTime": 1706779800000, + "prerecorded": false, + "qa_startTime": 0, + "speakers": [ + { + "email": "tysnr@localhost", + "id": "42", + "matrix_id": "", + "name": "Yasunori Takeda", + "role": "speaker", + }, + { + "email": "chloe@example.org", + "id": "246", + "matrix_id": "@chloe:example.org", + "name": "Chloé Coordinator", + "role": "coordinator", + }, + ], + "startTime": 1706779800000, + "subtitle": "", + "title": "A new architecture for Local-First Computing without boundaries", + }, + "19" => { + "auditoriumId": "6", + "dateTs": 1706745600000, + "endTime": 1706783400000, + "id": "19", + "livestream_endTime": 1706783400000, + "prerecorded": false, + "qa_startTime": 0, + "speakers": [ + { + "email": "vivaldo@localhost", + "id": "94", + "matrix_id": "@vivaldo:example.org", + "name": "Vivaldo Corradetti", + "role": "speaker", + }, + { + "email": "chloe@example.org", + "id": "246", + "matrix_id": "@chloe:example.org", + "name": "Chloé Coordinator", + "role": "coordinator", + }, + ], + "startTime": 1706783400000, + "subtitle": "", + "title": "Devising a Blended Federation Topology that reaches Cosmique Perfection", + }, + }, + "trackType": "devroom", + }, + "7" => { + "id": "7", + "isPhysical": true, + "kind": "auditorium", + "name": "Keynotes (TALKS NOT ANNOUNCED YET)", + "slug": "key_notes", + "talks": Map {}, + "trackType": "maintrack", + }, +} +`; + +exports[`JsonScheduleBackend can parse a FOSDEM JSON format: talks 1`] = ` +Map { + "3" => { + "auditoriumId": "5", + "dateTs": 1706745600000, + "endTime": 1706778000000, + "id": "3", + "livestream_endTime": 1706778000000, + "prerecorded": false, + "qa_startTime": 0, + "speakers": [ + { + "email": "ioferrell@localhost", + "id": "4", + "matrix_id": "", + "name": "Iarlaith O'Ferrell", + "role": "speaker", + }, + { + "email": "niamh@localhost", + "id": "5", + "matrix_id": "", + "name": "Niamh O'Donnellan", + "role": "speaker", + }, + { + "email": "conal@example.org", + "id": "1", + "matrix_id": "@conal:example.org", + "name": "Conal Cördinator", + "role": "coordinator", + }, + ], + "startTime": 1706778000000, + "subtitle": "", + "title": "Panacea3D: The Final Word in Open Source 3D Solid Modelling", + }, + "14" => { + "auditoriumId": "6", + "dateTs": 1706745600000, + "endTime": 1706779800000, + "id": "14", + "livestream_endTime": 1706779800000, + "prerecorded": false, + "qa_startTime": 0, + "speakers": [ + { + "email": "tysnr@localhost", + "id": "42", + "matrix_id": "", + "name": "Yasunori Takeda", + "role": "speaker", + }, + { + "email": "chloe@example.org", + "id": "246", + "matrix_id": "@chloe:example.org", + "name": "Chloé Coordinator", + "role": "coordinator", + }, + ], + "startTime": 1706779800000, + "subtitle": "", + "title": "A new architecture for Local-First Computing without boundaries", + }, + "19" => { + "auditoriumId": "6", + "dateTs": 1706745600000, + "endTime": 1706783400000, + "id": "19", + "livestream_endTime": 1706783400000, + "prerecorded": false, + "qa_startTime": 0, + "speakers": [ + { + "email": "vivaldo@localhost", + "id": "94", + "matrix_id": "@vivaldo:example.org", + "name": "Vivaldo Corradetti", + "role": "speaker", + }, + { + "email": "chloe@example.org", + "id": "246", + "matrix_id": "@chloe:example.org", + "name": "Chloé Coordinator", + "role": "coordinator", + }, + ], + "startTime": 1706783400000, + "subtitle": "", + "title": "Devising a Blended Federation Topology that reaches Cosmique Perfection", + }, +} +`; diff --git a/src/__tests__/backends/json/fosdem_democon.json b/src/__tests__/backends/json/fosdem_democon.json new file mode 100644 index 00000000..dcb6cca7 --- /dev/null +++ b/src/__tests__/backends/json/fosdem_democon.json @@ -0,0 +1,142 @@ +{ + "talks": [ + { + "event_id": 3, + "title": "Panacea3D: The Final Word in Open Source 3D Solid Modelling", + "persons": [ + { + "person_id": 4, + "event_role": "speaker", + "name": "Iarlaith O'Ferrell", + "email": "ioferrell@localhost", + "matrix_id": "" + }, + { + "person_id": 5, + "event_role": "speaker", + "name": "Niamh O'Donnellan", + "email": "niamh@localhost", + "matrix_id": "" + }, + { + "person_id": 1, + "event_role": "coordinator", + "name": "Conal Cördinator", + "email": "conal@example.org", + "matrix_id": "@conal:example.org" + } + ], + "conference_room": "C.1.111", + "start_datetime": "2024-02-01T09:30:00+01:00", + "duration": 30, + "track": { + "id": 5, + "slug": "open_cad_cam", + "name": "Open CAD/CAM devroom" + } + }, + { + "event_id": 14, + "title": "A new architecture for Local-First Computing without boundaries", + "persons": [ + { + "person_id": 42, + "event_role": "speaker", + "name": "Yasunori Takeda", + "email": "tysnr@localhost", + "matrix_id": "" + }, + { + "person_id": 246, + "event_role": "coordinator", + "name": "Chloé Coordinator", + "email": "chloe@example.org", + "matrix_id": "@chloe:example.org" + } + ], + "conference_room": "C.4.172", + "start_datetime": "2024-02-01T09:45:00+01:00", + "duration": 45, + "track": { + "id": 6, + "slug": "selfhosting", + "name": "Self-Hosted Software devroom" + } + }, + { + "event_id": 19, + "title": "Devising a Blended Federation Topology that reaches Cosmique Perfection", + "persons": [ + { + "person_id": 94, + "event_role": "speaker", + "name": "Vivaldo Corradetti", + "email": "vivaldo@localhost", + "matrix_id": "@vivaldo:example.org" + }, + { + "person_id": 246, + "event_role": "coordinator", + "name": "Chloé Coordinator", + "email": "chloe@example.org", + "matrix_id": "@chloe:example.org" + } + ], + "conference_room": "C.4.172", + "start_datetime": "2024-02-01T10:45:00+01:00", + "duration": 45, + "track": { + "id": 6, + "slug": "selfhosting", + "name": "Self-Hosted Software devroom" + } + } + ], + "tracks": [ + { + "id": 5, + "slug": "open_cad_cam", + "name": "Open CAD/CAM devroom", + "type": "devroom", + "managers": [ + { + "person_id": 1, + "event_role": "coordinator", + "name": "Conal Coordinator", + "email": "conal@example.org", + "matrix_id": "@conal:example.org" + } + ] + }, + { + "id": 6, + "slug": "selfhosting", + "name": "Self-Hosted Software devroom", + "type": "devroom", + "managers": [ + { + "person_id": 246, + "event_role": "coordinator", + "name": "Chloé Coordinator", + "email": "chloe@example.org", + "matrix_id": "@chloe:example.org" + } + ] + }, + { + "id": 7, + "slug": "key_notes", + "name": "Keynotes (TALKS NOT ANNOUNCED YET)", + "type": "maintrack", + "managers": [ + { + "person_id": 0, + "event_role": "coordinator", + "name": "Bert Boss", + "email": "bert@example.org", + "matrix_id": "@bert:example.org" + } + ] + } + ] +} diff --git a/src/__tests__/backends/penta/__snapshots__/PentabarfParser.test.ts.snap b/src/__tests__/backends/penta/__snapshots__/PentabarfParser.test.ts.snap index 8701cc77..cdcf30dd 100644 --- a/src/__tests__/backends/penta/__snapshots__/PentabarfParser.test.ts.snap +++ b/src/__tests__/backends/penta/__snapshots__/PentabarfParser.test.ts.snap @@ -39,6 +39,7 @@ exports[`parsing pentabarf XML: overview: auditoriums 1`] = ` "title": "Testcon01: Opening Remarks", }, }, + "trackType": "", }, ] `; @@ -83,6 +84,7 @@ exports[`parsing pentabarf XML: overview: conference 1`] = ` "title": "Testcon01: Opening Remarks", }, }, + "trackType": "", }, ], "interestRooms": [], diff --git a/src/backends/json/FosdemJsonScheduleLoader.ts b/src/backends/json/FosdemJsonScheduleLoader.ts new file mode 100644 index 00000000..a8d21455 --- /dev/null +++ b/src/backends/json/FosdemJsonScheduleLoader.ts @@ -0,0 +1,123 @@ +import { RoomKind } from "../../models/room_kinds"; +import { IAuditorium, IConference, IInterestRoom, IPerson, ITalk, Role } from "../../models/schedule"; +import { FOSDEMSpecificJSONSchedule, FOSDEMPerson, FOSDEMTrack, FOSDEMTalk } from "./jsontypes/FosdemJsonSchedule.schema"; +import * as moment from "moment"; +import { AuditoriumId, InterestId, TalkId } from "../IScheduleBackend"; +import { IConfig } from "../../config"; + +/** + * Loader and holder for FOSDEM-specific JSON schedules, acquired from the + * custom `/p/matrix` endpoint on the Pretalx instance. + */ +export class FosdemJsonScheduleLoader { + public readonly conference: IConference; + public readonly auditoriums: Map; + public readonly talks: Map; + public readonly interestRooms: Map; + public readonly conferenceId: string; + + constructor(jsonDesc: object, globalConfig: IConfig) { + // TODO: Validate and give errors. Assuming it's correct is a bit cheeky. + const jsonSchedule = jsonDesc as FOSDEMSpecificJSONSchedule; + + this.auditoriums = new Map(); + + for (let rawTrack of jsonSchedule.tracks) { + // Tracks are now (since 2025) mapped 1:1 to auditoria + const auditorium = this.convertAuditorium(rawTrack); + if (this.auditoriums.has(auditorium.id)) { + throw `Conflict in auditorium ID «${auditorium.id}»!`; + } + this.auditoriums.set(auditorium.id, auditorium); + } + + this.talks = new Map(); + + for (let rawTalk of jsonSchedule.talks) { + const talk = this.convertTalk(rawTalk); + if (this.talks.has(talk.id)) { + const conflictingTalk = this.talks.get(talk.id)!; + throw `Talk ID ${talk.id} is not unique — occupied by both «${talk.title}» and «${conflictingTalk.title}»!`; + } + const auditorium = this.auditoriums.get(talk.auditoriumId); + if (!auditorium) { + throw `Talk ID ${talk.id} relies on non-existent auditorium ${talk.auditoriumId}`; + } + auditorium.talks.set(talk.id, talk); + this.talks.set(talk.id, talk); + } + + // TODO: Interest rooms are currently not supported by the JSON schedule backend. + this.interestRooms = new Map(); + + this.conference = { + title: globalConfig.conference.name, + auditoriums: Array.from(this.auditoriums.values()), + interestRooms: Array.from(this.interestRooms.values()) + }; + } + + private convertPerson(person: FOSDEMPerson): IPerson { + if (! Object.values(Role).includes(person.event_role)) { + throw new Error("unknown role: " + person.event_role); + } + return { + id: person.person_id.toString(), + name: person.name, + matrix_id: person.matrix_id, + email: person.email, + // safety: checked above + role: person.event_role as Role, + }; + } + + private convertTalk(talk: FOSDEMTalk): ITalk { + const auditoriumId = talk.track.id.toString(); + const startMoment = moment.utc(talk.start_datetime, moment.ISO_8601, true); + const endMoment = startMoment.add(talk.duration, "minutes"); + + return { + id: talk.event_id.toString(), + title: talk.title, + + // Pretalx does not support this concept. FOSDEM 2024 ran with empty strings. From 2025 we hardcode this as empty for now. + subtitle: "", + + auditoriumId, + + // Hardcoded: all talks are now live from FOSDEM 2025 + prerecorded: false, + + // This is sketchy, but the QA start-time is not applicable except to prerecorded talks. + // Even then, it's not clear why it would be different from the end of the talk? + // This is overall a messy concept, but the only thing that matters now is whether this is + // null (Q&A disabled) or non-null (Q&A enabled, with reminder 5 minutes before the end of the talk slot). + // TODO overhaul replace with a boolean instead...? + qa_startTime: 0, + + // Since the talks are not pre-recorded, the livestream is considered ended when the event ends. + livestream_endTime: endMoment.valueOf(), + + speakers: talk.persons.map(person => this.convertPerson(person)), + + // Must .clone() here because .startOf() mutates the moment(!) + dateTs: startMoment.clone().startOf("day").valueOf(), + startTime: startMoment.valueOf(), + endTime: endMoment.valueOf(), + }; + } + + private convertAuditorium(track: FOSDEMTrack): IAuditorium { + return { + id: track.id.toString(), + slug: track.slug, + name: track.name, + kind: RoomKind.Auditorium, + // This will be populated afterwards + talks: new Map(), + // Hardcoded: FOSDEM is always physical now. + isPhysical: true, + trackType: track.type, + }; + } +} diff --git a/src/backends/json/JsonScheduleBackend.ts b/src/backends/json/JsonScheduleBackend.ts index 80f05e60..674a62c5 100644 --- a/src/backends/json/JsonScheduleBackend.ts +++ b/src/backends/json/JsonScheduleBackend.ts @@ -1,4 +1,4 @@ -import { IJsonScheduleBackendConfig } from "../../config"; +import { IConfig, IJsonScheduleBackendConfig, JsonScheduleFormat } from "../../config"; import { IConference, ITalk, IAuditorium, IInterestRoom } from "../../models/schedule"; import { AuditoriumId, InterestId, IScheduleBackend, TalkId } from "../IScheduleBackend"; import { JsonScheduleLoader } from "./JsonScheduleLoader"; @@ -6,9 +6,17 @@ import * as fetch from "node-fetch"; import * as path from "path"; import { LogService } from "matrix-bot-sdk"; import { readJsonFileAsync, writeJsonFileAsync } from "../../utils"; +import { FosdemJsonScheduleLoader } from "./FosdemJsonScheduleLoader"; + +interface ILoader { + conference: IConference; + talks: Map; + auditoriums: Map; + interestRooms: Map; +} export class JsonScheduleBackend implements IScheduleBackend { - constructor(private loader: JsonScheduleLoader, private cfg: IJsonScheduleBackendConfig, private wasFromCache: boolean, public readonly dataPath: string) { + constructor(private loader: ILoader, private cfg: IJsonScheduleBackendConfig, private globalConfig: IConfig, private wasFromCache: boolean, public readonly dataPath: string) { } @@ -16,23 +24,30 @@ export class JsonScheduleBackend implements IScheduleBackend { return this.wasFromCache; } - private static async loadConferenceFromCfg(dataPath: string, cfg: IJsonScheduleBackendConfig, allowUseCache: boolean): Promise<{loader: JsonScheduleLoader, cached: boolean}> { - let jsonDesc; + private static async loadConferenceFromCfg(dataPath: string, cfg: IJsonScheduleBackendConfig, globalConfig: IConfig, allowUseCache: boolean): Promise<{loader: ILoader, cached: boolean}> { + let jsonDesc: any; let cached = false; + const cachedSchedulePath = path.join(dataPath, 'cached_schedule.json'); try { if (cfg.scheduleDefinition.startsWith("http")) { + const headers = cfg.scheduleRequestHeaders ?? {}; // Fetch the JSON track over the network - jsonDesc = await fetch(cfg.scheduleDefinition).then(r => r.json()); + jsonDesc = await fetch(cfg.scheduleDefinition, {headers}).then(r => r.json()); } else { // Load the JSON from disk jsonDesc = await readJsonFileAsync(cfg.scheduleDefinition); } // Save a cached copy. - await writeJsonFileAsync(cachedSchedulePath, jsonDesc); + try { + await writeJsonFileAsync(cachedSchedulePath, jsonDesc); + } catch (ex) { + // Allow this to fail + LogService.warn("PretalxScheduleBackend", "Failed to cache copy of schedule.", ex); + } } catch (e) { // Fallback to cache — only if allowed if (! allowUseCache) throw e; @@ -56,16 +71,29 @@ export class JsonScheduleBackend implements IScheduleBackend { } } - return {loader: new JsonScheduleLoader(jsonDesc), cached}; + let loader: ILoader; + switch (cfg.scheduleFormat) { + case JsonScheduleFormat.FOSDEM: + loader = new FosdemJsonScheduleLoader(jsonDesc, globalConfig); + break; + case JsonScheduleFormat.Original: + case undefined: + loader = new JsonScheduleLoader(jsonDesc); + break; + default: + throw new Error(`Unknown JSON schedule format: ${cfg.scheduleFormat}`); + } + + return {loader, cached}; } - static async new(dataPath: string, cfg: IJsonScheduleBackendConfig): Promise { - const loader = await JsonScheduleBackend.loadConferenceFromCfg(dataPath, cfg, true); - return new JsonScheduleBackend(loader.loader, cfg, loader.cached, dataPath); + static async new(dataPath: string, cfg: IJsonScheduleBackendConfig, globalConfig: IConfig): Promise { + const loader = await JsonScheduleBackend.loadConferenceFromCfg(dataPath, cfg, globalConfig, true); + return new JsonScheduleBackend(loader.loader, cfg, globalConfig, loader.cached, dataPath); } async refresh(): Promise { - this.loader = (await JsonScheduleBackend.loadConferenceFromCfg(this.dataPath, this.cfg, false)).loader; + this.loader = (await JsonScheduleBackend.loadConferenceFromCfg(this.dataPath, this.cfg, this.globalConfig, false)).loader; // If we managed to load anything, this isn't from the cache anymore. this.wasFromCache = false; } @@ -87,4 +115,4 @@ export class JsonScheduleBackend implements IScheduleBackend { get interestRooms(): Map { return this.loader.interestRooms; } -} \ No newline at end of file +} diff --git a/src/backends/json/JsonScheduleLoader.ts b/src/backends/json/JsonScheduleLoader.ts index 1cdefb58..da957b5c 100644 --- a/src/backends/json/JsonScheduleLoader.ts +++ b/src/backends/json/JsonScheduleLoader.ts @@ -104,6 +104,7 @@ export class JsonScheduleLoader { talks, // TODO Support physical auditoriums in the JSON schedule backend isPhysical: false, + trackType: '', }; } } diff --git a/src/backends/json/jsonschemas/FosdemJsonSchedule.schema.json b/src/backends/json/jsonschemas/FosdemJsonSchedule.schema.json new file mode 100644 index 00000000..0cdecd13 --- /dev/null +++ b/src/backends/json/jsonschemas/FosdemJsonSchedule.schema.json @@ -0,0 +1,140 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://matrix.org/conference-bot/FosdemJsonSchedule.schema.json", + "title": "FOSDEM-Specific JSON Schedule", + "description": "A simple FOSDEM-specific JSON format to describe the schedule for a FOSDEM conference driven by conference-bot.", + + "type": "object", + + "properties": { + "talks": { + "type": "array", + "items": { + "$ref": "#/definitions/talk" + } + }, + "tracks": { + "type": "array", + "items": { + "$ref": "#/definitions/track" + } + } + }, + + "required": [ "talks", "tracks" ], + + "definitions": { + + "track": { + "title": "FOSDEM Track", + "description": "Information about a sequence of talks", + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Stable ID for the track" + }, + "slug": { + "type": "string", + "description": "Stable semi-human-readable slug for the track" + }, + "name": { + "type": "string", + "description": "Human-readable name of the track" + }, + "type": { + "type": "string", + "description": "'devroom' or 'maintrack' (TODO encode this in schema)" + }, + "managers": { + "type": "array", + "description": "List of staff (co-ordinators right now) that apply to the entire track.", + "items": { + "$ref": "#/definitions/person" + } + } + }, + "required": [ "id", "slug", "name", "type", "managers" ] + }, + + + "talk": { + "title": "FOSDEM Talk", + "description": "Information about a scheduled talk", + "type": "object", + "properties": { + "event_id": { + "description": "Unique ID for the talk", + "type": "integer", + "minimum": 0 + }, + "title": { + "type": "string", + "description": "Human-readable name for the talk" + }, + "start_datetime": { + "type": "string", + "description": "Date and time, in RFC3339 format with Z timezone offset, of the start of the talk", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" + }, + "duration": { + "type": "number", + "description": "Duration of the talk, in minutes" + }, + "persons": { + "type": "array", + "items": { + "$ref": "#/definitions/person" + } + }, + "track": { + "description": "Information about what track the talk is in. N.B. In practice more fields are contained here but only ID is used.", + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The Track ID of the track that the talk is in" + } + }, + "required": ["id"] + }, + "conference_room": { + "type": "string", + "description": "Name of the physical (in real life) room that the talk is held in." + } + }, + "required": [ "event_id", "title", "start_datetime", "duration", "persons", "track", "conference_room" ] + }, + + + "person": { + "title": "FOSDEM Person", + "description": "Information about someone who is giving a talk or is assisting with co-ordination", + "type": "object", + "properties": { + "person_id": { + "type": "number", + "description": "ID of the person" + }, + "event_role": { + "type": "string", + "description": "What kind of role the person has for this talk (speaker/coordinator)" + }, + "name": { + "type": "string", + "description": "The name of the person" + }, + "email": { + "type": "string", + "description": "The e-mail address of the person. May be an empty string if not available." + }, + "matrix_id": { + "type": "string", + "description": "The Matrix User ID of the person. May be an empty string if not available. Has historically not been validated thoroughly." + } + }, + "required": [ "person_id", "event_role", "name", "email", "matrix_id" ] + } + + } +} diff --git a/src/backends/json/jsontypes/FosdemJsonSchedule.schema.d.ts b/src/backends/json/jsontypes/FosdemJsonSchedule.schema.d.ts new file mode 100644 index 00000000..f3e23bc7 --- /dev/null +++ b/src/backends/json/jsontypes/FosdemJsonSchedule.schema.d.ts @@ -0,0 +1,104 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * A simple FOSDEM-specific JSON format to describe the schedule for a FOSDEM conference driven by conference-bot. + */ +export interface FOSDEMSpecificJSONSchedule { + talks: FOSDEMTalk[]; + tracks: FOSDEMTrack[]; + [k: string]: unknown; +} +/** + * Information about a scheduled talk + */ +export interface FOSDEMTalk { + /** + * Unique ID for the talk + */ + event_id: number; + /** + * Human-readable name for the talk + */ + title: string; + /** + * Date and time, in RFC3339 format with Z timezone offset, of the start of the talk + */ + start_datetime: string; + /** + * Duration of the talk, in minutes + */ + duration: number; + persons: FOSDEMPerson[]; + /** + * Information about what track the talk is in. N.B. In practice more fields are contained here but only ID is used. + */ + track: { + /** + * The Track ID of the track that the talk is in + */ + id: number; + [k: string]: unknown; + }; + /** + * Name of the physical (in real life) room that the talk is held in. + */ + conference_room: string; + [k: string]: unknown; +} +/** + * Information about someone who is giving a talk or is assisting with co-ordination + */ +export interface FOSDEMPerson { + /** + * ID of the person + */ + person_id: number; + /** + * What kind of role the person has for this talk (speaker/coordinator) + */ + event_role: string; + /** + * The name of the person + */ + name: string; + /** + * The e-mail address of the person. May be an empty string if not available. + */ + email: string; + /** + * The Matrix User ID of the person. May be an empty string if not available. Has historically not been validated thoroughly. + */ + matrix_id: string; + [k: string]: unknown; +} +/** + * Information about a sequence of talks + */ +export interface FOSDEMTrack { + /** + * Stable ID for the track + */ + id: number; + /** + * Stable semi-human-readable slug for the track + */ + slug: string; + /** + * Human-readable name of the track + */ + name: string; + /** + * 'devroom' or 'maintrack' (TODO encode this in schema) + */ + type: string; + /** + * List of staff (co-ordinators right now) that apply to the entire track. + */ + managers: FOSDEMPerson[]; + [k: string]: unknown; +} diff --git a/src/backends/penta/PentabarfParser.ts b/src/backends/penta/PentabarfParser.ts index 028aba40..e0dcca70 100644 --- a/src/backends/penta/PentabarfParser.ts +++ b/src/backends/penta/PentabarfParser.ts @@ -211,7 +211,8 @@ export class PentabarfParser { name: metadata.name, kind: metadata.kind, talks: new Map(), - isPhysical: isPhysical + isPhysical: isPhysical, + trackType: '', }; const existingAuditorium = this.auditoriums.find(r => r.id === auditorium.id); if (existingAuditorium) { diff --git a/src/backends/pretalx/PretalxParser.ts b/src/backends/pretalx/PretalxParser.ts index 5c017809..23bf3b99 100644 --- a/src/backends/pretalx/PretalxParser.ts +++ b/src/backends/pretalx/PretalxParser.ts @@ -142,6 +142,7 @@ export async function parseFromJSON(rawJson: string, prefixConfig: IPrefixConfig talks: new Map(), isPhysical: isPhysical, qaEnabled: qaEnabled, + trackType: '', }; auditoriums.set(room.name, auditorium); } diff --git a/src/config.ts b/src/config.ts index f3d43548..c161b025 100644 --- a/src/config.ts +++ b/src/config.ts @@ -81,7 +81,8 @@ export interface IConfig { [name: string]: { displayName: string; alias: string; - prefixes: string[]; + prefixes?: string[]; + trackTypes?: string[]; }; }; }; @@ -109,12 +110,39 @@ export interface IPrefixConfig { }; } +export enum JsonScheduleFormat { + /** + * Our original JSON schedule format. + */ + Original = "original", + + /** + * The FOSDEM-specific schedule format, available on the `/p/matrix` endpoint. + */ + FOSDEM = "fosdem", +} + export interface IJsonScheduleBackendConfig { backend: "json"; /** * Path or HTTP(S) URL to schedule. */ scheduleDefinition: string; + + /** + * What JSON schedule format to use. + * Defaults to original. + */ + scheduleFormat?: JsonScheduleFormat; + + /** + * Map of request headers to send when requesting the schedule definition. + * Useful for authenticating requests. + * Required for the FOSDEM-format schedules. + * + * Defaults to no headers. + */ + scheduleRequestHeaders?: {[_: string]: string}; } export enum PretalxScheduleFormat { diff --git a/src/index.ts b/src/index.ts index 3fb94e8f..30cd07dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,7 +72,7 @@ export class ConferenceBot { case "pretalx": return await PretalxScheduleBackend.new(config.dataPath, config.conference.schedule, config.conference.prefixes); case "json": - return await JsonScheduleBackend.new(config.dataPath, config.conference.schedule); + return await JsonScheduleBackend.new(config.dataPath, config.conference.schedule, config); default: throw new Error(`Unknown scheduling backend: choose penta, pretalx or json!`) } diff --git a/src/models/schedule.ts b/src/models/schedule.ts index e5b2f4aa..1bd316d3 100644 --- a/src/models/schedule.ts +++ b/src/models/schedule.ts @@ -79,6 +79,13 @@ export interface IAuditorium { * If true, this auditorium is just a virtual representation of a real-world physical auditorium. */ isPhysical: boolean; + + /** + * A 'type' of track that this auditorium is in. + * May be an empty string if there is no concept of types. + * This string will not be human-readable. + */ + trackType: string; } export interface IConference {