diff --git a/config/default.yaml b/config/default.yaml index 120a9456..c2194321 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -122,6 +122,11 @@ conference: name: "FOSDEM 2021" + # You must specifiy a backend for your schedule. + # It may either be "json", "penta", or "pretalx". Remember to only + # specify *one*. + + # sample schedule configuration for using the json backend. schedule: # the backend to pull the schedule from - this can either be a JSON schedule file, or a URL to pull XML from # Possible values are "json" or "penta". If JSON is chosen, the path to the file must be provided via the @@ -131,8 +136,7 @@ conference: # the JSON schema of the JSON schedule format. scheduleDefinition: "path/to/local/file" - # sample schedule configuration for using the penta backend. When using this configuration ensure that the json - # example above is commented out + # sample schedule configuration for using the penta backend. # schedule: # backend: "penta" # The URL to the XML which is updated with conference information. @@ -140,6 +144,23 @@ conference: # setting up the conference. # scheduleDefinition: "https://fosdem.org/2021/schedule/xml" + # sample schedule configuration for using the pretalx backend. + # schedule: + # backend: "pretalx" + # The format the scheduleDefinition is in. Use "pretalx" if you are loading the + # JSON export from pretalx directly, or "fosdem" for the specific pentabarf-xml-format + # they use instead. + # scheduleFormat: "fosdem"|"pretalx" + # The URL to the XML which is updated with conference information. + # This is read and parsed by the bot during the early stages of + # setting up the conference. + # scheduleDefinition: "https://fosdem.org/2021/schedule/xml" + # The endpoint to reach the base API path of the conference. + # pretalxApiEndpoint: "https://pretalx.example.com/api/events/example-2021/" + # Access token for accessing the Pretalx API + # pretalxAccessToken: "secret!" + + # The timezone that the the bot's database is operating off of. timezone: "Europe/Brussels" diff --git a/package.json b/package.json index a53f7ad3..0a7a3550 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "await-lock": "^2.1.0", "config": "^3.3.3", "express": "^4.17.1", - "fast-xml-parser": "^3.17.6", + "fast-xml-parser": "^4.3.2", "hls.js": "^0.14.17", "irc-upd": "^0.11.0", "js-yaml": "^3.14.1", diff --git a/src/Scheduler.ts b/src/Scheduler.ts index f687527f..a3b1a6b9 100644 --- a/src/Scheduler.ts +++ b/src/Scheduler.ts @@ -206,7 +206,7 @@ export class Scheduler { // Rationale: Sometimes schedules get changed at short notice, so we try our best to accommodate that. // Rationale for adding 1 minute: so we don't cut it too close to the wire; whilst processing the refresh, // time may slip forward. - await this.conference.backend.refreshShortTerm((minVar + 1) * 60); + await this.conference.backend.refreshShortTerm?.((minVar + 1) * 60); } catch (e) { LogService.error("Scheduler", `Failed short-term schedule refresh: ${e.message ?? e}\n${e.stack ?? '?'}`); } diff --git a/src/__tests__/backends/penta/CachingBackend.test.ts b/src/__tests__/backends/penta/CachingBackend.test.ts index c2153a73..b97751d8 100644 --- a/src/__tests__/backends/penta/CachingBackend.test.ts +++ b/src/__tests__/backends/penta/CachingBackend.test.ts @@ -1,7 +1,7 @@ import { PentaDb } from "../../../backends/penta/db/PentaDb"; import { test, expect, jest } from "@jest/globals"; import { PentabarfParser } from "../../../backends/penta/PentabarfParser"; -import { IPentaDbConfig, IPentaScheduleBackendConfig, IPrefixConfig } from "../../../config"; +import { IPrefixConfig } from "../../../config"; import { PentaBackend } from "../../../backends/penta/PentaBackend"; import { Role } from "../../../models/schedule"; import { CachingBackend } from "../../../backends/CachingBackend"; diff --git a/src/__tests__/backends/penta/PentaBackend.test.ts b/src/__tests__/backends/penta/PentaBackend.test.ts index 452be583..709c9205 100644 --- a/src/__tests__/backends/penta/PentaBackend.test.ts +++ b/src/__tests__/backends/penta/PentaBackend.test.ts @@ -1,7 +1,7 @@ import { PentaDb } from "../../../backends/penta/db/PentaDb"; import { test, expect, jest } from "@jest/globals"; import { PentabarfParser } from "../../../backends/penta/PentabarfParser"; -import { IPentaDbConfig, IPentaScheduleBackendConfig, IPrefixConfig } from "../../../config"; +import { IPrefixConfig } from "../../../config"; import { PentaBackend } from "../../../backends/penta/PentaBackend"; import { Role } from "../../../models/schedule"; diff --git a/src/__tests__/backends/penta/PentabarfParser.test.ts b/src/__tests__/backends/penta/PentabarfParser.test.ts index adea7104..3683cbfd 100644 --- a/src/__tests__/backends/penta/PentabarfParser.test.ts +++ b/src/__tests__/backends/penta/PentabarfParser.test.ts @@ -24,12 +24,16 @@ const prefixConfig: IPrefixConfig = { }, }; +function getFixture(fixtureFile: string) { + return fs.readFileSync(path.join(__dirname, fixtureFile), 'utf8') +} + /* * A somewhat vague test that just loads in a basic file and checks a few things, comparing against the snapshots in * __snapshots__. */ test('parsing pentabarf XML: overview', () => { - const xml = fs.readFileSync(path.join(__dirname, "pentabarf01_overview.xml"), 'utf8'); + const xml = getFixture("pentabarf01_overview.xml"); const p = new PentabarfParser(xml, prefixConfig); // TODO the auditorium id and slug look dodgy and not id-like or slug-like... @@ -49,7 +53,7 @@ test('parsing pentabarf XML: overview', () => { test('duplicate events lead to errors', () => { - const xml = fs.readFileSync(path.join(__dirname, "pentabarf03_duplicate_talk.xml"), 'utf8'); + const xml = getFixture("pentabarf03_duplicate_talk.xml"); expect(() => new PentabarfParser(xml, prefixConfig)).toThrow( "Auditorium A.01 (Someroom): Talk E001: this talk already exists and is defined a second time." @@ -57,10 +61,16 @@ test('duplicate events lead to errors', () => { }); test("unrecognised prefixes don't create rooms", () => { - const xml = fs.readFileSync(path.join(__dirname, "pentabarf04_unrecognised_prefix.xml"), 'utf8'); + const xml = getFixture("pentabarf04_unrecognised_prefix.xml"); const p = new PentabarfParser(xml, prefixConfig); expect(p.auditoriums.length).toEqual(0); expect(p.talks.length).toEqual(0); expect(p.interestRooms.length).toEqual(0); +}); + +test("tracks that set a online qa value correctly apply to talks", () => { + const xml = getFixture("pentabarf05_online_qa.xml"); + const p = new PentabarfParser(xml, prefixConfig); + expect(p.talks).toMatchSnapshot("talks"); }); \ No newline at end of file diff --git a/src/__tests__/backends/penta/__snapshots__/PentabarfParser.test.ts.snap b/src/__tests__/backends/penta/__snapshots__/PentabarfParser.test.ts.snap index c2d83611..97faf454 100644 --- a/src/__tests__/backends/penta/__snapshots__/PentabarfParser.test.ts.snap +++ b/src/__tests__/backends/penta/__snapshots__/PentabarfParser.test.ts.snap @@ -16,6 +16,7 @@ exports[`parsing pentabarf XML: overview: auditoriums 1`] = ` "id": "E001", "livestream_endTime": 0, "prerecorded": true, + "pretalxCode": undefined, "qa_startTime": null, "slug": "testcon_opening_remarks", "speakers": [ @@ -61,6 +62,7 @@ exports[`parsing pentabarf XML: overview: conference 1`] = ` "id": "E001", "livestream_endTime": 0, "prerecorded": true, + "pretalxCode": undefined, "qa_startTime": null, "slug": "testcon_opening_remarks", "speakers": [ @@ -122,6 +124,7 @@ exports[`parsing pentabarf XML: overview: talks 1`] = ` "id": "E001", "livestream_endTime": 0, "prerecorded": true, + "pretalxCode": undefined, "qa_startTime": null, "slug": "testcon_opening_remarks", "speakers": [ @@ -147,3 +150,132 @@ exports[`parsing pentabarf XML: overview: talks 1`] = ` }, ] `; + +exports[`tracks that set a online qa value correctly apply to talks: talks 1`] = ` +[ + { + "auditoriumId": "A.01 (Someroom)", + "dateTs": 1675468800000, + "endTime": 1675502100000, + "id": "E001", + "livestream_endTime": 0, + "prerecorded": true, + "pretalxCode": undefined, + "qa_startTime": 0, + "slug": "testcon_opening_remarks", + "speakers": [ + { + "email": "", + "id": "9012", + "matrix_id": "", + "name": "John Teststone", + "role": "speaker", + }, + { + "email": "", + "id": "90367", + "matrix_id": "", + "name": "Evå 完牲 Exampleson", + "role": "speaker", + }, + ], + "startTime": 1675501200000, + "subtitle": "", + "title": "Testcon01: Opening Remarks", + "track": "TrackWithQA", + }, + { + "auditoriumId": "A.01 (Someroom)", + "dateTs": 1675468800000, + "endTime": 1675503900000, + "id": "E002", + "livestream_endTime": 0, + "prerecorded": true, + "pretalxCode": undefined, + "qa_startTime": 0, + "slug": "testcon_opening_remarks", + "speakers": [ + { + "email": "", + "id": "9012", + "matrix_id": "", + "name": "John Teststone", + "role": "speaker", + }, + { + "email": "", + "id": "90367", + "matrix_id": "", + "name": "Evå 完牲 Exampleson", + "role": "speaker", + }, + ], + "startTime": 1675502100000, + "subtitle": "", + "title": "Testcon01: Opening Remarks", + "track": "TrackWithoutQA", + }, + { + "auditoriumId": "A.01 (Someroom)", + "dateTs": 1675468800000, + "endTime": 1675504800000, + "id": "E003", + "livestream_endTime": 0, + "prerecorded": true, + "pretalxCode": undefined, + "qa_startTime": null, + "slug": "testcon_opening_remarks", + "speakers": [ + { + "email": "", + "id": "9012", + "matrix_id": "", + "name": "John Teststone", + "role": "speaker", + }, + { + "email": "", + "id": "90367", + "matrix_id": "", + "name": "Evå 完牲 Exampleson", + "role": "speaker", + }, + ], + "startTime": 1675503000000, + "subtitle": "", + "title": "Testcon01: Opening Remarks", + "track": "UnspecifiedTrack", + }, + { + "auditoriumId": "AQ.5 (QA Room)", + "dateTs": 1675468800000, + "endTime": 1675505700000, + "id": "E004", + "livestream_endTime": 0, + "prerecorded": true, + "pretalxCode": undefined, + "qa_startTime": 0, + "slug": "testcon_opening_remarks", + "speakers": [ + { + "email": "", + "id": "9012", + "matrix_id": "", + "name": "John Teststone", + "role": "speaker", + }, + { + "email": "", + "id": "90367", + "matrix_id": "", + "name": "Evå 完牲 Exampleson", + "role": "speaker", + }, + ], + "startTime": 1675503900000, + "subtitle": "", + "title": "Testcon01: Opening Remarks", + "track": "UnspecifiedTrack", + }, +] +`; diff --git a/src/__tests__/backends/penta/pentabarf05_online_qa.xml b/src/__tests__/backends/penta/pentabarf05_online_qa.xml new file mode 100644 index 00000000..9f2d0b29 --- /dev/null +++ b/src/__tests__/backends/penta/pentabarf05_online_qa.xml @@ -0,0 +1,113 @@ + + + + Testcon01 + + + + TrackWithQA + TrackWithoutQA + + + + + 09:00 + 00:15 + A.01 (Someroom) + testcon_opening_remarks + Testcon01: Opening Remarks + + TrackWithQA + devroom + + + <p>Opening remarks</p> + + + + John Teststone + Evå 完牲 Exampleson + + + + + Submit feedback + + + + 09:15 + 00:30 + A.01 (Someroom) + testcon_opening_remarks + Testcon01: Opening Remarks + + TrackWithoutQA + devroom + + + <p>Opening remarks</p> + + + + John Teststone + Evå 完牲 Exampleson + + + + + Submit feedback + + + + 09:30 + 00:30 + A.01 (Someroom) + testcon_opening_remarks + Testcon01: Opening Remarks + + UnspecifiedTrack + devroom + + + <p>Opening remarks</p> + + + + John Teststone + Evå 完牲 Exampleson + + + + + Submit feedback + + + + + + 09:45 + 00:30 + AQ.5 (QA Room) + testcon_opening_remarks + Testcon01: Opening Remarks + + UnspecifiedTrack + devroom + + + <p>Opening remarks</p> + + + + John Teststone + Evå 完牲 Exampleson + + + + + Submit feedback + + + + + diff --git a/src/__tests__/backends/pretalx/PretalxBackend.test.ts b/src/__tests__/backends/pretalx/PretalxBackend.test.ts new file mode 100644 index 00000000..c1b19652 --- /dev/null +++ b/src/__tests__/backends/pretalx/PretalxBackend.test.ts @@ -0,0 +1,171 @@ +import { test, expect, afterEach, beforeEach, describe } from "@jest/globals"; +import { PretalxScheduleBackend } from "../../../backends/pretalx/PretalxBackend"; +import { Server, createServer } from "node:http"; +import { AddressInfo } from "node:net"; +import path from "node:path"; +import { PretalxScheduleFormat } from "../../../config"; + +const pretalxSpeakers = [{ + code: "37RA83", + name: "AnyConf Staff", + biography: null, + avatar: "", + email: "staff@anyconf.example.com", +},{ + code: "YT3EFD", + name: "Alice AnyConf", + biography: "Alice is a test user with a big robotic brain.", + avatar: "", + email: "alice@anyconf.example.com", +}]; + +const pretalxTalks = [{ + "code": "GK99DE", + "speakers": pretalxSpeakers, +}, { + "code": "ABCDEF", + "speakers": pretalxSpeakers, +}]; + +const matrixPersons = [{ + person_id: 2, + name: "AnyConf Staff", + email: "staff@anyconf.example.com", + matrix_id: "", + event_role: "coordinator", +},{ + person_id: 1324, + name: "Alice AnyConf", + email: "alice@anyconf.example.com", + matrix_id: "@alice:anyconf.example.com", + event_role: "host", +}]; + +const matrixTalks = [{ + event_id: 1235, + title: "Welcome to AnyConf 2024", + persons: matrixPersons, + conference_room: "janson", + start_datetime: "2024-02-03T09:00:00+01:00", + duration: 1500.00, + track_id: 325, +},{ + event_id: 1234, + title: "Welcome to AnyConf 2024", + persons: matrixPersons, + conference_room: "janson", + start_datetime: "2024-02-04T09:00:00+01:00", + duration: 1500.00, + track_id: 325, +}]; + +function fakePretalxServer() { + return new Promise(resolve => { const server = createServer((req, res) => { + if (req.url?.startsWith('/talks/?')) { + res.writeHead(200); + res.end(JSON.stringify({ + count: pretalxTalks.length, + next: null, + previous: null, + results: pretalxTalks, + })); + } else if (req.url?.startsWith('/talks/')) { + const talkCode = req.url.slice('/talks/'.length); + const talk = pretalxTalks.find(s => s.code === talkCode); + if (talk) { + res.writeHead(200); + res.end(talk); + } else { + res.writeHead(404); + res.end(`Talk "${talkCode}" not found`); + } + } else if (req.url === '/p/matrix/') { + res.writeHead(200); + res.end(JSON.stringify({talks: matrixTalks})); + } else { + console.log(req.url); + res.writeHead(404); + res.end("Not found"); + } + }).listen(undefined, '127.0.0.1', undefined, () => { + resolve(server); + })}); +} + +const prefixConfig = { + // Unused here. + aliases: "", displayNameSuffixes: {}, suffixes: {}, physicalAuditoriumRooms: [], + + auditoriumRooms: [ + "AW1.", + "D.", + "H.", + "Janson", + "K.", + "M.", + "UA2.", + "UB2.252A", + "UB4.", + "UB5.", + "UD2." + ], + qaAuditoriumRooms: [ + "AQ.", + ], + interestRooms: [ + "I." + ], + nameOverrides: { + "A.special": "special-room", + }, +}; +describe('PretalxBackend', () => { + let pretalxServ; + beforeEach(async () => { + pretalxServ = await fakePretalxServer(); + }); + afterEach(async () => { + pretalxServ.close(); + }); + + test("can parse a standard JSON format", async () => { + const pretalxServ = await fakePretalxServer(); + const backend = await PretalxScheduleBackend.new("/dev/null", { + backend: "pretalx", + scheduleFormat: PretalxScheduleFormat.Pretalx, + scheduleDefinition: path.join(__dirname, 'anyconf.json'), + pretalxAccessToken: "123456", + pretalxApiEndpoint: `http://127.0.0.1:${(pretalxServ.address() as AddressInfo).port}`, + }, prefixConfig); + expect(backend.conference.title).toBe('AnyConf 2024'); + expect(backend.conference.auditoriums).toHaveLength(7); + }); + + test.only("can parse a FOSDEM format", async () => { + const pretalxServ = await fakePretalxServer(); + const backend = await PretalxScheduleBackend.new("/dev/null", { + backend: "pretalx", + scheduleDefinition: path.join(__dirname, 'fosdemformat.xml'), + scheduleFormat: PretalxScheduleFormat.FOSDEM, + pretalxAccessToken: "123456", + pretalxApiEndpoint: `http://127.0.0.1:${(pretalxServ.address() as AddressInfo).port}`, + }, prefixConfig); + expect(backend.conference.title).toBe('AnyConf 2024'); + expect(backend.conference.auditoriums).toHaveLength(1); + const talks = [...backend.conference.auditoriums[0].talks.values()]; + // Check that we got the information correctly. + expect(talks[0].speakers).toEqual([{ + "email": "staff@anyconf.example.com", + "id": "2", + "matrix_id": "", + "name": "AnyConf Staff", + "role": "coordinator" + }, { + "email": "alice@anyconf.example.com", + "id": "1324", + "matrix_id": "@alice:anyconf.example.com", + "name": "Alice AnyConf", + "role": "host" + }]); + }); +}); diff --git a/src/__tests__/backends/pretalx/PretalxParser.test.ts b/src/__tests__/backends/pretalx/PretalxParser.test.ts new file mode 100644 index 00000000..975d458d --- /dev/null +++ b/src/__tests__/backends/pretalx/PretalxParser.test.ts @@ -0,0 +1,40 @@ +import path from "node:path"; +import { test, expect } from "@jest/globals"; +import { parseFromJSON } from "../../../backends/pretalx/PretalxParser"; +import { readFile } from "node:fs/promises"; +import { IPrefixConfig } from "../../../config"; + +const prefixConfig: IPrefixConfig = { + // Unused here. + aliases: "", displayNameSuffixes: {}, suffixes: {}, physicalAuditoriumRooms: [], + + auditoriumRooms: [ + "AW1.", + "D.", + "H.", + "Janson", + "K.", + "M.", + "UA2.", + "UB2.252A", + "UB4.", + "UB5.", + "UD2." + ], + qaAuditoriumRooms: [ + "AQ.", + ], + interestRooms: [ + "I." + ], + nameOverrides: { + "A.special": "special-room", + }, +}; + +test("can parse a standard XML format", async () => { + const xml = await readFile(path.join(__dirname, "anyconf.json"), 'utf-8'); + const { title, talks } = await parseFromJSON(xml, prefixConfig); + expect(talks.size).toBe(2); + expect(title).toBe('AnyConf 2024'); +}); diff --git a/src/__tests__/backends/pretalx/anyconf.json b/src/__tests__/backends/pretalx/anyconf.json new file mode 100644 index 00000000..bc51377f --- /dev/null +++ b/src/__tests__/backends/pretalx/anyconf.json @@ -0,0 +1,168 @@ +{ + "_note": "This a snapshot of the FOSDEM 2024 schedule schema, with fake data.", + "schedule": { + "version": "2024-01-02 20:35", + "base_url": "https://pretalx.example.com/conf/schedule/", + "conference": { + "acronym": "anyconf-2024", + "title": "AnyConf 2024", + "start": "2024-02-03", + "end": "2024-02-04", + "daysCount": 2, + "timeslot_duration": "00:05", + "time_zone_name": "Europe/Brussels", + "rooms": [ + { + "name": "janson", + "guid": null, + "description": "Janson", + "capacity": 1415 + }, + { + "name": "j1106", + "guid": null, + "description": "J.1.106", + "capacity": 70 + }, + { + "name": "k1105", + "guid": null, + "description": "K.1.105 (A room)", + "capacity": 805 + }, + { + "name": "h1301", + "guid": null, + "description": "H.1301 (Another room)", + "capacity": 189 + }, + { + "name": "aw1120", + "guid": null, + "description": "AW1.120", + "capacity": 74 + }, + { + "name": "ua2114", + "guid": null, + "description": "UA2.114 (Wow)", + "capacity": 207 + }, + { + "name": "ub2252a", + "guid": null, + "description": "UB2.252A (More)", + "capacity": 532 + }, + { + "name": "ud2120", + "guid": null, + "description": "UD2.120 (Rooms)", + "capacity": 446 + }, + { + "name": "a01", + "guid": null, + "description": "A.01 (Someroom)", + "capacity": 175 + } + ], + "days": [ + { + "index": 1, + "date": "2024-02-03", + "day_start": "2024-02-03T04:00:00+01:00", + "day_end": "2024-02-04T03:59:00+01:00", + "rooms":{ + "janson": [{ + "id": 1234, + "guid": "5bb0aa4e-0c0e-5ccd-9523-0ed1ca679039", + "logo": "", + "date": "2024-02-03T09:00:00+01:00", + "start": "09:00", + "duration": "00:25", + "room": "janson", + "slug": "welcome talk", + "url": "https://pretalx.example.com/conf/talk/GK99DE/", + "title": "Welcome to AnyConf 2024", + "subtitle": "", + "track": "Keynotes", + "type": "Talk", + "language": "en", + "abstract": "AnyConf welcome and opening talk.", + "description": null, + "recording_license": "", + "do_not_record": false, + "persons": [ + { + "id": 2, + "code": "37RA83", + "public_name": "AnyConf Staff", + "biography": null, + "answers": [] + }, + { + "id": 1324, + "code": "YT3EFD", + "public_name": "Alice AnyConf", + "biography": "Alice is a test user with a big robotic brain.", + "answers": [] + } + ], + "links": [], + "attachments": [], + "answers": [] + }] + } + }, + { + "index": 2, + "date": "2024-02-04", + "day_start": "2024-02-04T04:00:00+01:00", + "day_end": "2024-02-05T03:59:00+01:00", + "rooms":{ + "janson": [{ + "id": 1235, + "guid": "b8325b99-84af-45a7-806b-5819c79b6dc5", + "logo": "", + "date": "2024-02-04T09:00:00+01:00", + "start": "09:00", + "duration": "00:25", + "room": "janson", + "slug": "welcome talk", + "url": "https://pretalx.example.com/conf/talk/ABCDEF/", + "title": "Welcome to AnyConf 2024", + "subtitle": "", + "track": "Keynotes", + "type": "Talk", + "language": "en", + "abstract": "AnyConf welcome and opening talk.", + "description": null, + "recording_license": "", + "do_not_record": false, + "persons": [ + { + "id": 2, + "code": "37RA83", + "public_name": "AnyConf Staff", + "biography": null, + "answers": [] + }, + { + "id": 1324, + "code": "YT3EFD", + "public_name": "Alice AnyConf", + "biography": "Alice is a test user with a big robotic brain.", + "answers": [] + } + ], + "links": [], + "attachments": [], + "answers": [] + }] + } + } + ] + } + } + } \ No newline at end of file diff --git a/src/__tests__/backends/pretalx/fosdemformat.xml b/src/__tests__/backends/pretalx/fosdemformat.xml new file mode 100644 index 00000000..6e1a3c81 --- /dev/null +++ b/src/__tests__/backends/pretalx/fosdemformat.xml @@ -0,0 +1,63 @@ + + latest + + anyconf-2024 + AnyConf 2024 + 2024-02-03 + 2024-02-04 + 2 + 09:00:00 + 00:05:00 + https://example.com/2024/schedule/ + Europe/Brussels + + + Keynotes + + + + + 2024-02-03T09:00:00+01:00 + 09:00 + 00:25 + janson + welcome talk + https://pretalx.example.com/conf/talk/GK99DE/ + Welcome to AnyConf 2024 + Keynotes + keynote + en + +

AnyConf welcome and opening talk.

+
+ + AnyConf Staff + Alice AnyConf + +
+
+
+ + + + 2024-02-04T09:00:00+01:00 + 09:00 + 00:25 + janson + welcome talk + https://pretalx.example.com/conf/talk/GK99DE/ + Welcome to AnyConf 2024 + Keynotes + keynote + en + +

AnyConf welcome and opening talk.

+
+ + AnyConf Staff + Alice AnyConf + +
+
+
+
\ No newline at end of file diff --git a/src/backends/CachingBackend.ts b/src/backends/CachingBackend.ts index aa246cc6..23faa1db 100644 --- a/src/backends/CachingBackend.ts +++ b/src/backends/CachingBackend.ts @@ -1,6 +1,4 @@ -import { rename } from "fs"; import { LogService } from "matrix-bot-sdk"; -import { config } from "process"; import { RoomKind } from "../models/room_kinds"; import { IConference, ITalk, IAuditorium, IInterestRoom } from "../models/schedule"; import { jsonReplacerMapToObject, readJsonFileAsync, writeJsonFileAsync } from "../utils"; @@ -103,7 +101,7 @@ export class CachingBackend implements IScheduleBackend { // It's notable that we don't save any changes to disk. // It wouldn't be a bad idea to persist the changes, but introducing a lot of disk I/O into this frequent operation // made me uncomfortable. - await this.lastUsedBackend.refreshShortTerm(lookaheadSeconds); + await this.lastUsedBackend.refreshShortTerm?.(lookaheadSeconds); } } diff --git a/src/backends/IScheduleBackend.ts b/src/backends/IScheduleBackend.ts index 43cf8a3e..a11f7561 100644 --- a/src/backends/IScheduleBackend.ts +++ b/src/backends/IScheduleBackend.ts @@ -25,8 +25,10 @@ export interface IScheduleBackend { * * This is an ugly hack to support short-notice changes to the conference schedule, as happens in real life. * It is principally expected to be called by the Scheduler when scheduling tasks in the short-term future. + * + * This may not be defined if the backend has no way to do short term refreshes. */ - refreshShortTerm(lookaheadSeconds: number): Promise; + refreshShortTerm?(lookaheadSeconds: number): Promise; /** * Returns true iff the current schedule was loaded from cache, rather than from the intended source. diff --git a/src/backends/common.ts b/src/backends/common.ts new file mode 100644 index 00000000..0dcf88b9 --- /dev/null +++ b/src/backends/common.ts @@ -0,0 +1,20 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export function simpleTimeParse(str: string): { hours: number, minutes: number } { + const parts = str.split(':'); + return {hours: Number(parts[0]), minutes: Number(parts[1])}; +} \ No newline at end of file diff --git a/src/backends/json/JsonScheduleBackend.ts b/src/backends/json/JsonScheduleBackend.ts index 6eb53812..80f05e60 100644 --- a/src/backends/json/JsonScheduleBackend.ts +++ b/src/backends/json/JsonScheduleBackend.ts @@ -70,11 +70,7 @@ export class JsonScheduleBackend implements IScheduleBackend { this.wasFromCache = false; } - async refreshShortTerm(_lookaheadSeconds: number): Promise { - // NOP: There's no way to partially refresh a JSON schedule. - // Short-term changes to a JSON schedule are therefore currently unimplemented. - // This hack was intended for Penta anyway. - } + // refreshShortTerm() not implemented - There's no way to partially refresh a JSON schedule. get conference(): IConference { return this.loader.conference; diff --git a/src/backends/penta/PentabarfParser.ts b/src/backends/penta/PentabarfParser.ts index 59c2edbc..195306ab 100644 --- a/src/backends/penta/PentabarfParser.ts +++ b/src/backends/penta/PentabarfParser.ts @@ -14,24 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -import * as parser from 'fast-xml-parser'; +import { XMLParser } from "fast-xml-parser"; import { IAuditorium, IConference, IInterestRoom, IPerson, ITalk, Role } from "../../models/schedule"; import moment from "moment"; import { RoomKind } from "../../models/room_kinds"; import { IPrefixConfig } from "../../config"; import { LogService } from 'matrix-bot-sdk'; import { slugify } from '../../utils/aliases'; +import { simpleTimeParse } from "../common"; function arrayLike(val: T | T[]): T[] { if (Array.isArray(val)) return val; return [val]; } -function simpleTimeParse(str: string): { hours: number, minutes: number } { - const parts = str.split(':'); - return {hours: Number(parts[0]), minutes: Number(parts[1])}; -} - /** * Given a event-room ID (not a Matrix ID; a room from the Pentabarf XML), * decodes that to figure out what type of event-room it is @@ -64,6 +60,8 @@ export function decodePrefix(id: string, prefixConfig: IPrefixConfig): {kind: Ro export interface IPentabarfEvent { attr: { "@_id": string; // number + // Extension from FOSDEM to map to Pretalx codes. + "@_code": string; // number }; start: string; duration: string; @@ -95,6 +93,26 @@ export interface IPentabarfEvent { }; } +interface Track { + attr: { + "@_online_qa": string; + }; + "#text": string; +} + +interface Day { + attr: { + "@_index": string; // number + "@_date": string; + }; + room: { + attr: { + "@_name": string; + }; + event: IPentabarfEvent[]; + }[]; +} + export interface IPentabarfSchedule { schedule: { conference: { @@ -108,36 +126,41 @@ export interface IPentabarfSchedule { day_change: string; timeslot_duration: string; }; - day: { - attr: { - "@_index": string; // number - "@_date": string; - }; - room: { - attr: { - "@_name": string; - }; - event: IPentabarfEvent[]; - }[]; - }[]; + /** + * This is an extension from FOSDEM. + */ + tracks?: { + track: Track|Track[]; + }, + day: Day|Day[]; }; } +export interface IPentabarfTalk extends ITalk { + /** + * For the FOSDEM extended version of this format, + * a "code" is provided. + */ + pretalxCode?: string; +} + export class PentabarfParser { private readonly parsed: IPentabarfSchedule; public readonly conference: IConference; public readonly auditoriums: IAuditorium[]; - public readonly talks: ITalk[]; + public readonly talks: IPentabarfTalk[]; public readonly speakers: IPerson[]; public readonly interestRooms: IInterestRoom[]; constructor(rawXml: string, prefixConfig: IPrefixConfig) { - this.parsed = parser.parse(rawXml, { - attrNodeName: "attr", + const parser = new XMLParser({ + attributesGroupName: "attr", + attributeNamePrefix : "@_", textNodeName: "#text", ignoreAttributes: false, }); + this.parsed = parser.parse(rawXml); this.auditoriums = []; this.talks = []; @@ -148,6 +171,11 @@ export class PentabarfParser { auditoriums: this.auditoriums, interestRooms: this.interestRooms, }; + const trackHasOnlineQA = new Map(); + + for (const track of arrayLike(this.parsed.schedule.tracks?.track ?? [])) { + trackHasOnlineQA.set(track["#text"], Boolean(track.attr["@_online_qa"])); + } for (const day of arrayLike(this.parsed.schedule?.day)) { if (!day) continue; @@ -192,24 +220,29 @@ export class PentabarfParser { this.auditoriums.push(auditorium); } - const qaEnabled = prefixConfig.qaAuditoriumRooms.find(p => auditorium.id.startsWith(p)) !== undefined; - + const roomQaEnabled = prefixConfig.qaAuditoriumRooms.find(p => auditorium.id.startsWith(p)) !== undefined; + for (const pEvent of arrayLike(pRoom.event)) { if (!pEvent) continue; + const talkId = pEvent.attr?.["@_id"]; if (pEvent.title.startsWith("CANCELLED ")) { // FOSDEM represents cancelled talks with a title prefix. // There is currently no more 'proper' way to get this information. - LogService.info("PentabarfParser", `Talk '${pEvent.attr?.["@_id"]}' has CANCELLED in prefix of title: ignoring.`) + LogService.info("PentabarfParser", `Talk '${talkId}' has CANCELLED in prefix of title: ignoring.`) continue; } + // The schedule is authorative, but we fall back to the manual list. + let qaEnabled = trackHasOnlineQA.get(pEvent.track) ?? roomQaEnabled; + const parsedStartTime = simpleTimeParse(pEvent.start); const parsedDuration = simpleTimeParse(pEvent.duration); const startTime = moment(dateTs).add(parsedStartTime.hours, 'hours').add(parsedStartTime.minutes, 'minutes'); const endTime = moment(startTime).add(parsedDuration.hours, 'hours').add(parsedDuration.minutes, 'minutes'); - let talk: ITalk = { - id: pEvent.attr?.["@_id"], + let talk: IPentabarfTalk = { + pretalxCode: pEvent.attr?.["@_code"], + id: talkId, dateTs: dateTs, startTime: startTime.valueOf(), endTime: endTime.valueOf(), diff --git a/src/backends/pretalx/FOSDEMPretalxApiClient.ts b/src/backends/pretalx/FOSDEMPretalxApiClient.ts new file mode 100644 index 00000000..7abfb362 --- /dev/null +++ b/src/backends/pretalx/FOSDEMPretalxApiClient.ts @@ -0,0 +1,30 @@ +import { Role } from "../../models/schedule"; +import { PretalxApiClient } from "./PretalxApiClient"; + +export interface FOSDEMTalk { + event_id: number, + title: string, + conference_room: string, + start_datetime: string, + duration: number, + track_id: number, + persons: [{ + person_id: number, + event_role: Role, + name: string, + email: string, + matrix_id: string, + }] +} + +export class FOSDEMPretalxApiClient extends PretalxApiClient { + async getFOSDEMTalks(): Promise { + const url = new URL(this.baseUri + `/p/matrix/`); + const req = await fetch(url, this.requestInit); + if (!req.ok) { + const reason = await req.text(); + throw Error(`Failed to request events from Pretalx: ${req.status} ${reason}`); + } + return (await req.json()).talks as FOSDEMTalk[]; + } +} \ No newline at end of file diff --git a/src/backends/pretalx/PretalxApiClient.ts b/src/backends/pretalx/PretalxApiClient.ts new file mode 100644 index 00000000..5933e918 --- /dev/null +++ b/src/backends/pretalx/PretalxApiClient.ts @@ -0,0 +1,170 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export interface PretalxSpeaker { + code: string, + name: string, + biography: string|null, + submissions: string[], + avatar: string, + answers: [], + email: string, +} + +interface PretalxResultsResponse { + count: number; + next: string; + previous: string|null, + results: T[] +} + +export interface PretalxTalk { + "code": string, + "speakers": Omit[], + "title": string, + "submission_type": string, + "submission_type_id": number + "track": { + "en": string, + }, + "track_id": number, + "state": "confirmed", + "abstract": string, + "description": null, + "duration": number, + "slot_count": number, + "do_not_record": boolean, + "is_featured": boolean, + "content_locale": "en", + "slot": { + "room_id": string, + "room": string, + "start": string, + "end": string, + }, + "image": string, + "resources": {resource: string, description: string}[] + "created": string, + "pending_state": string|null, + answers: { + "id": number, + "question": { + "id": 1, + "question": { + "en": string, + }, + "required": false, + "target": "submission", + "options": { + id: string, + option: string, + }[], + }, + "answer": string, + "answer_file": string|null, + "submission": string, + "person": string|null, + "options": { + id: string, + option: string, + }[], + }[], + "notes": string, + "internal_notes": string, + "tags": string[], + "tag_ids": string[], +} + +export class PretalxApiClient { + constructor(protected readonly baseUri: string, private readonly token: string) { + if (baseUri.endsWith('/')) { this.baseUri = baseUri.slice(0, -1)} + } + + protected get requestInit(): RequestInit { + return { + headers: { + 'Authorization': `Token ${this.token}` + }, + } + } + + async getSpeaker(code: string) { + const url = new URL(this.baseUri + `/speakers/${code}/`); + const req = await fetch(url, this.requestInit); + if (!req.ok) { + const reason = await req.text(); + throw Error(`Failed to request speakers from Pretalx: ${req.status} ${reason}`); + } + const result = await req.json() as PretalxSpeaker; + return result; + } + + async getSpeakers(offset: number, limit: number) { + const url = new URL(this.baseUri + '/speakers/'); + url.searchParams.set('offset', offset.toString()); + url.searchParams.set('limit', limit.toString()); + const req = await fetch(url, this.requestInit); + if (!req.ok) { + const reason = await req.text(); + throw Error(`Failed to request speakers from Pretalx: ${req.status} ${reason}`); + } + const result = await req.json() as PretalxResultsResponse; + const nextValue = result.next && new URL(result.next).searchParams.get('offset'); + return { + speakers: result.results, + next: nextValue ? parseInt(nextValue) : null, + }; + } + + async *getAllSpeakers(): AsyncGenerator { + let offset: number|null = 0; + do { + const { next: newOffset, speakers } = await this.getSpeakers(offset, 100); + for (const speaker of speakers) { + yield speaker; + } + offset = newOffset + } while (offset !== null) + } + + async getTalks(offset: number, limit: number) { + const url = new URL(this.baseUri + '/talks/'); + url.searchParams.set('offset', offset.toString()); + url.searchParams.set('limit', limit.toString()); + const req = await fetch(url, this.requestInit); + if (!req.ok) { + const reason = await req.text(); + throw Error(`Failed to request talks from Pretalx: ${req.status} ${reason}`); + } + const result = await req.json() as PretalxResultsResponse; + const nextValue = result.next && new URL(result.next).searchParams.get('offset'); + return { + talks: result.results, + next: nextValue ? parseInt(nextValue) : null, + }; + } + + async *getAllTalks(): AsyncGenerator { + let offset: number|null = 0; + do { + const { next: newOffset, talks } = await this.getTalks(offset, 100); + for (const talk of talks) { + yield talk; + } + offset = newOffset + } while (offset !== null) + } +} \ No newline at end of file diff --git a/src/backends/pretalx/PretalxBackend.ts b/src/backends/pretalx/PretalxBackend.ts new file mode 100644 index 00000000..8de3110e --- /dev/null +++ b/src/backends/pretalx/PretalxBackend.ts @@ -0,0 +1,201 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IPrefixConfig, IPretalxScheduleBackendConfig, PretalxScheduleFormat } from "../../config"; +import { IConference, ITalk, IAuditorium, IInterestRoom, Role } from "../../models/schedule"; +import { AuditoriumId, InterestId, IScheduleBackend, TalkId } from "../IScheduleBackend"; +import * as fetch from "node-fetch"; +import * as path from "path"; +import { LogService } from "matrix-bot-sdk"; +import { PretalxSchema as PretalxData, parseFromJSON } from "./PretalxParser"; +import { readFile, writeFile } from "fs/promises"; +import { PretalxApiClient } from "./PretalxApiClient"; +import { PentabarfParser } from "../penta/PentabarfParser"; +import { FOSDEMPretalxApiClient } from "./FOSDEMPretalxApiClient"; + + +const MIN_TIME_BEFORE_REFRESH_MS = 60000; + +export class PretalxScheduleBackend implements IScheduleBackend { + private readonly apiClient: PretalxApiClient; + private lastRefresh: number; + private constructor( + private readonly cfg: IPretalxScheduleBackendConfig, + private readonly prefixCfg: IPrefixConfig, + private data: PretalxData, + private wasFromCache: boolean, + private readonly dataPath: string) { + if (cfg.scheduleFormat === PretalxScheduleFormat.FOSDEM) { + this.apiClient = new FOSDEMPretalxApiClient(cfg.pretalxApiEndpoint, cfg.pretalxAccessToken); + } else { + this.apiClient = new PretalxApiClient(cfg.pretalxApiEndpoint, cfg.pretalxAccessToken); + } + + } + + wasLoadedFromCache(): boolean { + return this.wasFromCache; + } + + private static async loadConferenceFromCfg(dataPath: string, cfg: IPretalxScheduleBackendConfig, prefixCfg: IPrefixConfig, allowUseCache: boolean): Promise<{data: PretalxData, cached: boolean}> { + let jsonOrXMLDesc; + let cached = false; + + const cachedSchedulePath = path.join(dataPath, 'cached_schedule.json'); + + try { + if (cfg.scheduleDefinition.startsWith("http")) { + // Fetch the JSON track over the network + jsonOrXMLDesc = await fetch(cfg.scheduleDefinition).then(r => r.text()); + } else { + // Load the JSON from disk + jsonOrXMLDesc = await readFile(cfg.scheduleDefinition, 'utf-8'); + } + + // Save a cached copy. + try { + await writeFile(cachedSchedulePath, jsonOrXMLDesc); + } 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; + + cached = true; + + LogService.error("PretalxScheduleBackend", "Unable to load XML schedule, will use cached copy if available.", e.body ?? e); + try { + jsonOrXMLDesc = await readFile(cachedSchedulePath, 'utf-8'); + } catch (e) { + if (e.code === 'ENOENT') { + // No file + LogService.error("PretalxScheduleBackend", "Double fault: Unable to load schedule and unable to load cached schedule (cached file doesn't exist)"); + } else if (e instanceof SyntaxError) { + LogService.error("PretalxScheduleBackend", "Double fault: Unable to load schedule and unable to load cached schedule (cached file has invalid JSON)"); + } else { + LogService.error("PretalxScheduleBackend", "Double fault: Unable to load schedule and unable to load cached schedule: ", e); + } + + throw "Double fault whilst trying to load JSON schedule"; + } + } + let data: PretalxData; + // For FOSDEM we prefer to use the pentabarf format as it contains + // extra information not found in the JSON format. This may change + // in the future. + if (cfg.scheduleFormat === PretalxScheduleFormat.FOSDEM) { + const pentaData = new PentabarfParser(jsonOrXMLDesc, prefixCfg); + data = { + talks: new Map(pentaData.talks.map(v => [v.id, v])), + auditoriums: new Map(pentaData.auditoriums.map(v => [v.name, v])), + interestRooms: new Map(pentaData.interestRooms.map(v => [v.id, v])), + title: pentaData.conference.title, + } + } else { + data = await parseFromJSON(jsonOrXMLDesc, prefixCfg); + } + + return {data, cached}; + } + + static async new(dataPath: string, cfg: IPretalxScheduleBackendConfig, prefixCfg: IPrefixConfig): Promise { + const loader = await PretalxScheduleBackend.loadConferenceFromCfg(dataPath, cfg, prefixCfg, true); + const backend = new PretalxScheduleBackend(cfg, prefixCfg, loader.data, loader.cached, dataPath); + await backend.hydrateFromApi(); + return backend; + } + + private async hydrateFromApi() { + if (this.apiClient instanceof FOSDEMPretalxApiClient) { + for (const apiTalk of await this.apiClient.getFOSDEMTalks()) { + const localTalk = this.talks.get(apiTalk.event_id.toString()); + if (!localTalk) { + LogService.warn("PretalxScheduleBackend", `Talk missing from public schedule ${apiTalk.event_id}.`); + continue; + } + localTalk.speakers = apiTalk.persons.map(speaker => ({ + id: speaker.person_id.toString(), + // Set emails for all the speakers. + email: speaker.email, + matrix_id: speaker.matrix_id, + name: speaker.name, + role: speaker.event_role, + })); + } + return; + } + // Otherwise, use standard API. + for await (const apiTalk of this.apiClient.getAllTalks()) { + if (apiTalk.state !== "confirmed") { + continue; + } + const localTalk = this.talks.get(apiTalk.code); + if (!localTalk) { + LogService.warn("PretalxScheduleBackend", `Talk missing from public schedule ${apiTalk.code}.`); + continue; + } + localTalk.speakers = apiTalk.speakers.map(speaker => ({ + id: speaker.code, + email: speaker.email, + // Pretalx has no matrix ID field. + matrix_id: '', + name: speaker.name, + role: Role.Speaker, + })); + } + } + + async refresh(): Promise { + this.lastRefresh = Date.now(); + this.data = (await PretalxScheduleBackend.loadConferenceFromCfg(this.dataPath, this.cfg, this.prefixCfg, false)).data; + await this.hydrateFromApi(); + // If we managed to load anything, this isn't from the cache anymore. + this.wasFromCache = false; + } + + async refreshShortTerm(): Promise { + // We don't have a way to partially refresh yet, so do a full refresh since + // it's currently only two API calls. + + // We still want to prevent rapid refreshing, so this should only happen periodically. + if (Date.now() - this.lastRefresh < MIN_TIME_BEFORE_REFRESH_MS) { + return; + } + await this.refresh(); + } + + get conference(): IConference { + return { + title: this.data.title, + interestRooms: [...this.data.interestRooms.values()], + auditoriums: [...this.data.auditoriums.values()] + }; + }; + + get talks(): Map { + return this.data.talks; + } + + get auditoriums(): Map { + return this.data.auditoriums; + } + + get interestRooms(): Map { + return this.data.interestRooms; + } +} \ No newline at end of file diff --git a/src/backends/pretalx/PretalxParser.ts b/src/backends/pretalx/PretalxParser.ts new file mode 100644 index 00000000..e00be2f5 --- /dev/null +++ b/src/backends/pretalx/PretalxParser.ts @@ -0,0 +1,213 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IInterestRoom, IAuditorium, ITalk, Role } from "../../models/schedule"; +import { decodePrefix } from "../penta/PentabarfParser"; +import { IPrefixConfig } from "../../config"; +import { simpleTimeParse } from "../common"; +import { RoomKind } from "../../models/room_kinds"; +import { slugify } from "../../utils/aliases"; +import { LogService } from "matrix-bot-sdk"; + +interface PretalxRoom { + name: string, + guid: null, + description: string, + capacity: number, +} + +interface PretalxPerson { + id: number, + code: string, + public_name: string, + biography: string|null, + answers: string[], +} + +interface PretalxTalk { + id: number, + date: string, + start: string, + duration: string, + room: string, + slug: string, + url: string, + recording_license: string, + do_not_record: boolean, + title: string, + subtitle: string, + track: string, + type: string, + language: string, + abstract: string, + description: string, + logo: string, + persons: PretalxPerson[], + links: string[], + attachments: string[], +} + +interface PretalxDay { + index: number, + date: string, + day_start: string, + day_end: string, + rooms: Record, +} + +/** + * Schema for an exported Pretalx schedule. + * @note May differ from standard format as based on https://pretalx.fosdem.org/fosdem-2024/schedule/export/schedule.xml + */ +export interface PretalxData { + schedule: { + version: string, + base_url: string, + conference: { + acronym: string, + title: string, + start: string, + end: string, + daysCount: string, + timeslot_duration: string, + time_zone_name: string, + rooms: PretalxRoom[], + days: PretalxDay[] + }, + } +} + + +export interface PretalxSchema { + /** + * room.id -> IInterestRoom + */ + interestRooms: Map; + /** + * room.name -> IAuditorium + */ + auditoriums: Map; + /** + * eventId -> ITalk + */ + talks: Map; + title: string; +} + +/** + * This will parse a schedule JSON file and attempt to fill in some of the fields. As the JSON + * only contains some (public) data, consumers should expect to find additional information through + * the API. + * @param rawXml + * @returns + */ +export async function parseFromJSON(rawJson: string, prefixConfig: IPrefixConfig): Promise { + const { conference } = (JSON.parse(rawJson) as PretalxData).schedule; + const interestRooms = new Map(); + const auditoriums = new Map(); + const talks = new Map(); + + for (const room of conference.rooms) { + const {kind, name: description} = decodePrefix(room.description, prefixConfig) ?? { kind: null, name: null }; + if (kind === null) { + LogService.info("PretalxParser", "Ignoring unrecognised room name from schedule", room); + } + if (kind === RoomKind.SpecialInterest) { + const spiRoom: IInterestRoom = { + id: room.name, + name: description, + kind: kind, + }; + interestRooms.set(spiRoom.id, spiRoom); + } else if (kind === RoomKind.Auditorium) { + const isPhysical = prefixConfig.physicalAuditoriumRooms.some(p => room.description.startsWith(p)); + const qaEnabled = prefixConfig.qaAuditoriumRooms.some(p => room.description.startsWith(p)); + const auditorium = { + id: room.name, + slug: slugify(room.name), + name: description, + kind: kind, + talks: new Map(), + isPhysical: isPhysical, + qaEnabled: qaEnabled, + }; + auditoriums.set(room.name, auditorium); + } + } + + for (const day of conference.days) { + const dayStart = new Date(day.day_start); + for (const [roomName, events] of Object.entries(day.rooms)) { + const auditorium = auditoriums.get(roomName); + if (!auditorium) { + // Skipping event, not mapped to an auditorium. + continue; + } + for (const event of events) { + const eventDate = new Date(event.date); + // This assumes your event does not span multiple days + const { hours: durationHours, minutes:durationMinutes } = simpleTimeParse(event.duration); + const endTime = new Date( + eventDate.getTime() + + (durationHours * 1000 * 60 * 60) + + (durationMinutes * 1000 * 60) + ); + + if (event.type === 'Talk') { + // Tediously, we need the "code" to map to pretalx. The "code" + // is only available via the URL. + const eventCode = event.url.split('/').reverse()[1]; + if (!eventCode) { + throw Error('Could not determine code for event'); + } + const talk: ITalk = { + dateTs: dayStart.getTime(), + endTime: endTime.getTime(), + id: eventCode, + livestream_endTime: 0, + qa_startTime: auditorium.qaEnabled ? 0 : null, + startTime: eventDate.getTime(), + subtitle: event.subtitle, + title: event.title, + track: event.track, + prerecorded: false, + speakers: event.persons.map(p => ({ + id: p.code, + name: p.public_name, + // TODO: Assuming speaker, + role: Role.Speaker, + // TODO: Lookup + email: '', + matrix_id: '', + })), //event.persons, + // TODO: Unsure? + auditoriumId: roomName, + slug: event.slug, + }; + talks.set(eventCode, talk); + auditorium?.talks.set(eventCode, talk); + } + } + } + } + + return { + title: conference.title, + interestRooms, + auditoriums, + talks, + } +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index c182d60a..aa10844f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -125,7 +125,36 @@ export interface IPentaScheduleBackendConfig { database: IPentaDbConfig; } -export type ScheduleBackendConfig = IJsonScheduleBackendConfig | IPentaScheduleBackendConfig; +export enum PretalxScheduleFormat { + /** + * Standard pretalx support, uses no custom extensions. + */ + Pretalx = "pretalx", + /** + * Expects a pentabarf (+ extensions) + * format schedule. Extends standard pretalx API client. + */ + FOSDEM = "fosdem", +} + +export interface IPretalxScheduleBackendConfig { + backend: "pretalx"; + /** + * Is the schedule in fosdem or pretalx format? For legacy reasons + * some conferences prefer "fosdem" which can contain extensions. + * Defaults to "pretalx". + */ + scheduleFormat?: PretalxScheduleFormat; + /** + * HTTP(S) URL to schedule XML. + */ + scheduleDefinition: string; + pretalxAccessToken: string; + pretalxApiEndpoint: string; +} + + +export type ScheduleBackendConfig = IJsonScheduleBackendConfig | IPentaScheduleBackendConfig | IPretalxScheduleBackendConfig; export interface IPentaDbConfig { host: string; diff --git a/src/index.ts b/src/index.ts index b33d4a9d..e72f0898 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,6 +62,7 @@ import { CachingBackend } from "./backends/CachingBackend"; import { ConferenceMatrixClient } from "./ConferenceMatrixClient"; import { Server } from "http"; import { collectDefaultMetrics, register } from "prom-client"; +import { PretalxScheduleBackend } from "./backends/pretalx/PretalxBackend"; LogService.setLogger(new CustomLogger()); LogService.setLevel(LogLevel.DEBUG); @@ -72,10 +73,12 @@ export class ConferenceBot { switch (config.conference.schedule.backend) { case "penta": return await CachingBackend.new(() => PentaBackend.new(config), path.join(config.dataPath, "penta_cache.json")); + 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); default: - throw new Error(`Unknown scheduling backend: choose penta or json!`) + throw new Error(`Unknown scheduling backend: choose penta, pretalx or json!`) } } diff --git a/yarn.lock b/yarn.lock index e80e21e9..256646ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2506,12 +2506,12 @@ fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-sta resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== -fast-xml-parser@^3.17.6: - version "3.21.1" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-3.21.1.tgz#152a1d51d445380f7046b304672dd55d15c9e736" - integrity sha512-FTFVjYoBOZTJekiUsawGsSYV9QL0A+zDYCRj7y34IO6Jg+2IMYEtQa+bbictpdpV8dHxXywqU7C0gRDEOFtBFg== +fast-xml-parser@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.3.2.tgz#761e641260706d6e13251c4ef8e3f5694d4b0d79" + integrity sha512-rmrXUXwbJedoXkStenj1kkljNF7ugn5ZjR9FJcwmCfcCbtOMDghPajbc+Tck6vE6F5XsDmx+Pr2le9fw8+pXBg== dependencies: - strnum "^1.0.4" + strnum "^1.0.5" fastest-levenshtein@^1.0.12: version "1.0.16" @@ -5537,7 +5537,7 @@ strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -strnum@^1.0.4: +strnum@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db" integrity sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==