Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a FOSDEM JSON schedule loader for the /p/matrix schema #240

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add a FOSDEM JSON schedule loader
reivilibre committed Dec 17, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
commit 5ac1b524f0a2e734ead7c224b79ce95223c4a5a3
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');
@@ -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;
@@ -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;
}
@@ -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