Skip to content

Commit

Permalink
Add a FOSDEM JSON schedule loader
Browse files Browse the repository at this point in the history
  • Loading branch information
reivilibre committed Dec 17, 2024
1 parent 9d3d191 commit 5ac1b52
Show file tree
Hide file tree
Showing 6 changed files with 422 additions and 12 deletions.
122 changes: 122 additions & 0 deletions src/backends/json/FosdemJsonScheduleLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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<AuditoriumId, IAuditorium>;
public readonly talks: Map<TalkId, ITalk>;
public readonly interestRooms: Map<InterestId, IInterestRoom>;
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<string>(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,
};
}
}
48 changes: 37 additions & 11 deletions src/backends/json/JsonScheduleBackend.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
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";
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<TalkId, ITalk>;
auditoriums: Map<AuditoriumId, IAuditorium>;
interestRooms: Map<InterestId, IInterestRoom>;
}

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) {

}

wasLoadedFromCache(): boolean {
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');
Expand All @@ -32,7 +40,12 @@ export class JsonScheduleBackend implements IScheduleBackend {
}

// 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;
Expand All @@ -56,16 +69,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<JsonScheduleBackend> {
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<JsonScheduleBackend> {
const loader = await JsonScheduleBackend.loadConferenceFromCfg(dataPath, cfg, globalConfig, true);
return new JsonScheduleBackend(loader.loader, cfg, globalConfig, loader.cached, dataPath);
}

async refresh(): Promise<void> {
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;
}
Expand All @@ -87,4 +113,4 @@ export class JsonScheduleBackend implements IScheduleBackend {
get interestRooms(): Map<InterestId, IInterestRoom> {
return this.loader.interestRooms;
}
}
}
140 changes: 140 additions & 0 deletions src/backends/json/jsonschemas/FosdemJsonSchedule.schema.json
Original file line number Diff line number Diff line change
@@ -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" ]
}

}
}
Loading

0 comments on commit 5ac1b52

Please sign in to comment.