@openbrf/display 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSE-EXCEPTION.md +28 -0
  3. package/README.md +126 -2
  4. package/dist/contract.d.ts +244 -0
  5. package/dist/contract.d.ts.map +1 -0
  6. package/dist/contract.js +54 -0
  7. package/dist/contract.js.map +1 -0
  8. package/dist/fetch-loop.d.ts +87 -0
  9. package/dist/fetch-loop.d.ts.map +1 -0
  10. package/dist/fetch-loop.js +351 -0
  11. package/dist/fetch-loop.js.map +1 -0
  12. package/dist/fit.d.ts +44 -0
  13. package/dist/fit.d.ts.map +1 -0
  14. package/dist/fit.js +257 -0
  15. package/dist/fit.js.map +1 -0
  16. package/dist/index.d.ts +23 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +18 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/parse.d.ts +39 -0
  21. package/dist/parse.d.ts.map +1 -0
  22. package/dist/parse.js +494 -0
  23. package/dist/parse.js.map +1 -0
  24. package/dist/renderer.d.ts +85 -0
  25. package/dist/renderer.d.ts.map +1 -0
  26. package/dist/renderer.js +476 -0
  27. package/dist/renderer.js.map +1 -0
  28. package/dist/rotation.d.ts +129 -0
  29. package/dist/rotation.d.ts.map +1 -0
  30. package/dist/rotation.js +335 -0
  31. package/dist/rotation.js.map +1 -0
  32. package/dist/slides.d.ts +41 -0
  33. package/dist/slides.d.ts.map +1 -0
  34. package/dist/slides.js +341 -0
  35. package/dist/slides.js.map +1 -0
  36. package/dist/styles.d.ts +10 -0
  37. package/dist/styles.d.ts.map +1 -0
  38. package/dist/styles.js +107 -0
  39. package/dist/styles.js.map +1 -0
  40. package/dist/text.d.ts +16 -0
  41. package/dist/text.d.ts.map +1 -0
  42. package/dist/text.js +62 -0
  43. package/dist/text.js.map +1 -0
  44. package/dist/time.d.ts +39 -0
  45. package/dist/time.d.ts.map +1 -0
  46. package/dist/time.js +94 -0
  47. package/dist/time.js.map +1 -0
  48. package/dist/translate.d.ts +18 -0
  49. package/dist/translate.d.ts.map +1 -0
  50. package/dist/translate.js +35 -0
  51. package/dist/translate.js.map +1 -0
  52. package/dist/words.d.ts +41 -0
  53. package/dist/words.d.ts.map +1 -0
  54. package/dist/words.js +73 -0
  55. package/dist/words.js.map +1 -0
  56. package/package.json +58 -3
  57. package/src/contract.ts +279 -0
  58. package/src/fetch-loop.ts +472 -0
  59. package/src/fit.ts +348 -0
  60. package/src/index.ts +77 -0
  61. package/src/parse.ts +605 -0
  62. package/src/renderer.ts +708 -0
  63. package/src/rotation.ts +515 -0
  64. package/src/slides.ts +595 -0
  65. package/src/styles.ts +113 -0
  66. package/src/text.ts +65 -0
  67. package/src/time.ts +147 -0
  68. package/src/translate.ts +56 -0
  69. package/src/words.ts +91 -0
@@ -0,0 +1,515 @@
1
+ /**
2
+ * The rotation builder: the reads of ADR 0032 and the moment of the fetch, in;
3
+ * the `news`, `events`, `meeting` and `availability` slides, out.
4
+ *
5
+ * It reads nothing and keeps no clock, so whatever calls it decides what goes
6
+ * in: the core's display route and the signage plugin's device route hand it
7
+ * the same narrowed reads, and get the same slides back. The input types are
8
+ * structural, and the plugin SDK's `PluginNewsItem`, `PluginOccurrence`,
9
+ * `PluginMeetingNotice`, `PluginBookableResource` and `PluginBusyInterval`
10
+ * satisfy them; a field this module does not name, such as `audience` or
11
+ * `onScreens`, is ignored.
12
+ */
13
+ import {
14
+ SCREEN_CONTENT_LIMITS as LIMITS,
15
+ SLIDE_ID_PATTERN,
16
+ type AgendaItem,
17
+ type AvailabilityResource,
18
+ type BusyInterval,
19
+ type EventsOccurrence,
20
+ type PictureRef,
21
+ type Slide,
22
+ } from "./contract.ts";
23
+ import { shortenText, shortenTexts } from "./text.ts";
24
+ import {
25
+ DAY,
26
+ floorToMinute,
27
+ MINUTE,
28
+ toInstant,
29
+ zonedDayStartAfter,
30
+ } from "./time.ts";
31
+
32
+ /** A published news item, as `PluginNewsItem` has it. */
33
+ export interface NewsItemInput {
34
+ readonly id: string;
35
+ readonly title: string;
36
+ readonly paragraphs: readonly string[];
37
+ readonly pictures: readonly {
38
+ readonly mediaFileId: string;
39
+ readonly alt: string;
40
+ }[];
41
+ /** ISO 8601 instant. */
42
+ readonly publishedAt: string;
43
+ }
44
+
45
+ /** One occurrence of a published event, as `PluginOccurrence` has it. */
46
+ export interface OccurrenceInput {
47
+ readonly eventId: string;
48
+ readonly occurrenceId: string;
49
+ readonly title: string;
50
+ readonly description: string | null;
51
+ readonly location: string | null;
52
+ /** ISO 8601 instant. */
53
+ readonly startsAt: string;
54
+ /** ISO 8601 instant. */
55
+ readonly endsAt: string;
56
+ readonly calledOff: boolean;
57
+ readonly placesLeft: number | null;
58
+ }
59
+
60
+ /** The notice of a general meeting, as `PluginMeetingNotice` has it. */
61
+ export interface MeetingNoticeInput {
62
+ readonly meetingId: string;
63
+ readonly kind: "ordinary" | "extraordinary";
64
+ /** ISO 8601 instant. */
65
+ readonly startsAt: string;
66
+ readonly place: string;
67
+ readonly digitalParticipation: string | null;
68
+ readonly agenda: readonly {
69
+ readonly position: number;
70
+ readonly title: string;
71
+ }[];
72
+ }
73
+
74
+ /** A bookable resource, as `PluginBookableResource` has it. */
75
+ export interface BookableResourceInput {
76
+ readonly id: string;
77
+ readonly name: string;
78
+ readonly mode: "timeSlots" | "wholeDay" | "dateRange";
79
+ readonly slotMinutes: number | null;
80
+ readonly opensAtMinute: number | null;
81
+ readonly closesAtMinute: number | null;
82
+ }
83
+
84
+ /** A period a resource is taken, as `PluginBusyInterval` has it. */
85
+ export interface BusyIntervalInput {
86
+ readonly resourceId: string;
87
+ /** ISO 8601 instant. */
88
+ readonly start: string;
89
+ /** ISO 8601 instant. */
90
+ readonly end: string;
91
+ }
92
+
93
+ /** A picture file the caller resolved, by media file id. */
94
+ export interface PictureFileInput {
95
+ readonly contentType: string;
96
+ readonly byteSize: number;
97
+ readonly version: string;
98
+ }
99
+
100
+ /** What one rotation is built from. Each source is in it only when given. */
101
+ export interface RotationInput {
102
+ /** The moment of the fetch. */
103
+ readonly now: Date;
104
+ /** Newest first, as the caller read them. */
105
+ readonly news?: readonly NewsItemInput[];
106
+ readonly occurrences?: readonly OccurrenceInput[];
107
+ readonly meetingNotices?: readonly MeetingNoticeInput[];
108
+ readonly availability?: {
109
+ /** Clamped to no earlier than `now`, floored to the minute. */
110
+ readonly from: Date;
111
+ /** Clamped to at most 7 days after `from`. */
112
+ readonly to: Date;
113
+ readonly resources: readonly BookableResourceInput[];
114
+ readonly busy: readonly BusyIntervalInput[];
115
+ };
116
+ /**
117
+ * The picture files the caller resolved, by media file id. A news item's
118
+ * picture without an entry here is left out.
119
+ */
120
+ readonly pictures?: ReadonlyMap<string, PictureFileInput>;
121
+ }
122
+
123
+ /** At most this many news items, a slide each. */
124
+ export const NEWS_SLIDES = 10;
125
+ /** How far ahead an events slide looks, in days. */
126
+ export const EVENTS_DAYS = 31;
127
+
128
+ const NEWS_SECONDS = 15;
129
+ const SLIDE_SECONDS = 20;
130
+
131
+ /**
132
+ * The slides of one rotation, in the order news, events, meeting,
133
+ * availability, each only when its input is given and has something to show:
134
+ *
135
+ * - **News**: a slide for each of the first ten items, in the order given,
136
+ * with the first of its pictures that `pictures` resolves.
137
+ * - **Events**: one slide with the soonest eight occurrences that have not
138
+ * ended and start within 31 days of `now`.
139
+ * - **Meeting**: one slide with the soonest notice of a meeting that has not
140
+ * begun, shown until it begins.
141
+ * - **Availability**: one slide with the first twelve resources and when each
142
+ * is busy, merged and clipped to the window.
143
+ *
144
+ * Every text over its limit is shortened with "…", and every slide's id is
145
+ * the same on every fetch while the slide shows the same thing. The slides,
146
+ * put into a document, pass `parseScreenContent`.
147
+ */
148
+ export function buildRotation(input: RotationInput): Slide[] {
149
+ const now = input.now.getTime();
150
+ const slides: Slide[] = [];
151
+ if (input.news !== undefined) {
152
+ slides.push(...newsSlides(input.news, input.pictures));
153
+ }
154
+ if (input.occurrences !== undefined) {
155
+ const slide = eventsSlide(input.occurrences, now);
156
+ if (slide !== null) {
157
+ slides.push(slide);
158
+ }
159
+ }
160
+ if (input.meetingNotices !== undefined) {
161
+ const slide = meetingSlide(input.meetingNotices, now);
162
+ if (slide !== null) {
163
+ slides.push(slide);
164
+ }
165
+ }
166
+ if (input.availability !== undefined) {
167
+ const slide = availabilitySlide(input.availability, now);
168
+ if (slide !== null) {
169
+ slides.push(slide);
170
+ }
171
+ }
172
+ return slides;
173
+ }
174
+
175
+ /**
176
+ * The window of an availability slide: from the minute of the fetch to the end
177
+ * of the next day in the association's time zone, which is 23 or 25 hours
178
+ * long on the days the clocks change.
179
+ */
180
+ export function availabilityWindow(
181
+ now: Date,
182
+ timeZone: string,
183
+ ): { from: Date; to: Date } {
184
+ const time = now.getTime();
185
+ return {
186
+ from: new Date(floorToMinute(time)),
187
+ to: new Date(zonedDayStartAfter(time, 2, timeZone)),
188
+ };
189
+ }
190
+
191
+ function newsSlides(
192
+ items: readonly NewsItemInput[],
193
+ pictures: ReadonlyMap<string, PictureFileInput> | undefined,
194
+ ): Slide[] {
195
+ const slides: Slide[] = [];
196
+ const ids = new Set<string>();
197
+ for (const item of items) {
198
+ if (slides.length === NEWS_SLIDES) {
199
+ break;
200
+ }
201
+ const publishedAt = timeOf(item.publishedAt);
202
+ const id = slideId("news", item.id);
203
+ if (publishedAt === null || ids.has(id)) {
204
+ continue;
205
+ }
206
+ ids.add(id);
207
+ slides.push({
208
+ id,
209
+ seconds: NEWS_SECONDS,
210
+ until: null,
211
+ source: "news",
212
+ addon: null,
213
+ body: {
214
+ kind: "news",
215
+ title: shortenText(item.title, LIMITS.title),
216
+ paragraphs: shortenTexts(item.paragraphs, LIMITS.paragraphs),
217
+ picture: firstPicture(item.pictures, pictures),
218
+ publishedAt: toInstant(publishedAt),
219
+ },
220
+ });
221
+ }
222
+ return slides;
223
+ }
224
+
225
+ function firstPicture(
226
+ candidates: NewsItemInput["pictures"],
227
+ pictures: ReadonlyMap<string, PictureFileInput> | undefined,
228
+ ): PictureRef | null {
229
+ for (const candidate of candidates) {
230
+ const file = pictures?.get(candidate.mediaFileId);
231
+ if (
232
+ file !== undefined &&
233
+ candidate.mediaFileId.length > 0 &&
234
+ Number.isInteger(file.byteSize) &&
235
+ file.byteSize >= 0
236
+ ) {
237
+ return {
238
+ mediaFileId: candidate.mediaFileId,
239
+ contentType: file.contentType,
240
+ byteSize: file.byteSize,
241
+ version: file.version,
242
+ alt: shortenText(candidate.alt, LIMITS.alt),
243
+ };
244
+ }
245
+ }
246
+ return null;
247
+ }
248
+
249
+ function eventsSlide(
250
+ occurrences: readonly OccurrenceInput[],
251
+ now: number,
252
+ ): Slide | null {
253
+ const horizon = now + EVENTS_DAYS * DAY;
254
+ const coming = occurrences
255
+ .map((occurrence) => ({
256
+ occurrence,
257
+ startsAt: timeOf(occurrence.startsAt),
258
+ endsAt: timeOf(occurrence.endsAt),
259
+ }))
260
+ .filter(
261
+ (
262
+ entry,
263
+ ): entry is {
264
+ occurrence: OccurrenceInput;
265
+ startsAt: number;
266
+ endsAt: number;
267
+ } =>
268
+ entry.startsAt !== null &&
269
+ entry.endsAt !== null &&
270
+ entry.endsAt > now &&
271
+ entry.startsAt < horizon,
272
+ )
273
+ .sort(
274
+ (a, b) =>
275
+ a.startsAt - b.startsAt ||
276
+ compareText(a.occurrence.occurrenceId, b.occurrence.occurrenceId),
277
+ )
278
+ .slice(0, LIMITS.occurrencesMax);
279
+ if (coming.length === 0) {
280
+ return null;
281
+ }
282
+ return {
283
+ id: "events",
284
+ seconds: SLIDE_SECONDS,
285
+ until: null,
286
+ source: "events",
287
+ addon: null,
288
+ body: {
289
+ kind: "events",
290
+ occurrences: coming.map(
291
+ ({ occurrence, startsAt, endsAt }): EventsOccurrence => ({
292
+ title: shortenText(occurrence.title, LIMITS.title),
293
+ description: optionalText(occurrence.description, LIMITS.description),
294
+ location: optionalText(occurrence.location, LIMITS.location),
295
+ startsAt: toInstant(startsAt),
296
+ endsAt: toInstant(endsAt),
297
+ calledOff: occurrence.calledOff === true,
298
+ placesLeft:
299
+ typeof occurrence.placesLeft === "number" &&
300
+ Number.isFinite(occurrence.placesLeft)
301
+ ? Math.max(0, Math.floor(occurrence.placesLeft))
302
+ : null,
303
+ }),
304
+ ),
305
+ },
306
+ };
307
+ }
308
+
309
+ function meetingSlide(
310
+ notices: readonly MeetingNoticeInput[],
311
+ now: number,
312
+ ): Slide | null {
313
+ let soonest: { notice: MeetingNoticeInput; startsAt: number } | null = null;
314
+ for (const notice of notices) {
315
+ const startsAt = timeOf(notice.startsAt);
316
+ if (startsAt === null || startsAt <= now) {
317
+ continue;
318
+ }
319
+ if (
320
+ soonest === null ||
321
+ startsAt < soonest.startsAt ||
322
+ (startsAt === soonest.startsAt &&
323
+ compareText(notice.meetingId, soonest.notice.meetingId) < 0)
324
+ ) {
325
+ soonest = { notice, startsAt };
326
+ }
327
+ }
328
+ if (soonest === null) {
329
+ return null;
330
+ }
331
+ const { notice } = soonest;
332
+ const startsAt = toInstant(soonest.startsAt);
333
+ const agenda = notice.agenda
334
+ .map((item, index) => ({ item, index }))
335
+ .sort(
336
+ (a, b) =>
337
+ positionOf(a.item.position, a.index) -
338
+ positionOf(b.item.position, b.index) || a.index - b.index,
339
+ )
340
+ .slice(0, LIMITS.agendaItems)
341
+ .map(({ item, index }): AgendaItem => ({
342
+ position: positionOf(item.position, index),
343
+ title: shortenText(item.title, LIMITS.agendaTitle),
344
+ }));
345
+ return {
346
+ id: slideId("meeting", notice.meetingId),
347
+ seconds: SLIDE_SECONDS,
348
+ until: startsAt,
349
+ source: "meeting",
350
+ addon: null,
351
+ body: {
352
+ kind: "meeting",
353
+ meetingKind:
354
+ notice.kind === "extraordinary" ? "extraordinary" : "ordinary",
355
+ startsAt,
356
+ place: shortenText(notice.place, LIMITS.place),
357
+ digitalParticipation: optionalText(
358
+ notice.digitalParticipation,
359
+ LIMITS.digitalParticipation,
360
+ ),
361
+ agenda,
362
+ },
363
+ };
364
+ }
365
+
366
+ function positionOf(position: number, index: number): number {
367
+ return Number.isInteger(position) ? position : index + 1;
368
+ }
369
+
370
+ const RESOURCE_MODES = new Set(["timeSlots", "wholeDay", "dateRange"]);
371
+
372
+ function availabilitySlide(
373
+ availability: NonNullable<RotationInput["availability"]>,
374
+ now: number,
375
+ ): Slide | null {
376
+ const from = floorToMinute(Math.max(availability.from.getTime(), now));
377
+ const to =
378
+ Math.floor(
379
+ Math.min(
380
+ availability.to.getTime(),
381
+ from + LIMITS.availabilityDays * DAY,
382
+ ) / 1000,
383
+ ) * 1000;
384
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) {
385
+ return null;
386
+ }
387
+ const resources = availability.resources
388
+ .filter((resource) => RESOURCE_MODES.has(resource.mode))
389
+ .slice(0, LIMITS.resourcesMax)
390
+ .map((resource): AvailabilityResource => ({
391
+ name: shortenText(resource.name, LIMITS.resourceName),
392
+ mode: resource.mode,
393
+ slotMinutes: minutesOrNull(resource.slotMinutes),
394
+ opensAtMinute: minutesOrNull(resource.opensAtMinute),
395
+ closesAtMinute: minutesOrNull(resource.closesAtMinute),
396
+ busy: busyOf(resource.id, availability.busy, from, to, now),
397
+ }));
398
+ if (resources.length === 0) {
399
+ return null;
400
+ }
401
+ return {
402
+ id: "availability",
403
+ seconds: SLIDE_SECONDS,
404
+ until: null,
405
+ source: "availability",
406
+ addon: null,
407
+ body: {
408
+ kind: "availability",
409
+ from: toInstant(from),
410
+ to: toInstant(to),
411
+ resources,
412
+ },
413
+ };
414
+ }
415
+
416
+ /**
417
+ * One resource's busy intervals: clipped to [from, to), whole seconds, sorted
418
+ * and merged. An interval that began at or before `now` is under way, and is
419
+ * taken from `from`: the busy read starts it at the moment of the call, so
420
+ * keeping that to the second would change the document on every fetch.
421
+ */
422
+ function busyOf(
423
+ resourceId: string,
424
+ busy: readonly BusyIntervalInput[],
425
+ from: number,
426
+ to: number,
427
+ now: number,
428
+ ): BusyInterval[] {
429
+ const clipped: [number, number][] = [];
430
+ for (const interval of busy) {
431
+ if (interval.resourceId !== resourceId) {
432
+ continue;
433
+ }
434
+ const start = timeOf(interval.start);
435
+ const end = timeOf(interval.end);
436
+ if (start === null || end === null) {
437
+ continue;
438
+ }
439
+ const clippedStart =
440
+ start <= now ? from : Math.max(Math.floor(start / 1000) * 1000, from);
441
+ const clippedEnd = Math.min(Math.ceil(end / 1000) * 1000, to);
442
+ if (clippedStart < clippedEnd) {
443
+ clipped.push([clippedStart, clippedEnd]);
444
+ }
445
+ }
446
+ clipped.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
447
+ const merged: [number, number][] = [];
448
+ for (const [start, end] of clipped) {
449
+ const last = merged[merged.length - 1];
450
+ if (last !== undefined && start <= last[1]) {
451
+ last[1] = Math.max(last[1], end);
452
+ } else {
453
+ merged.push([start, end]);
454
+ }
455
+ }
456
+ return merged.map(([start, end]) => ({
457
+ start: toInstant(start),
458
+ end: toInstant(end),
459
+ }));
460
+ }
461
+
462
+ const MINUTES_IN_A_DAY = DAY / MINUTE;
463
+
464
+ function minutesOrNull(value: number | null): number | null {
465
+ return typeof value === "number" &&
466
+ Number.isInteger(value) &&
467
+ value >= 0 &&
468
+ value <= MINUTES_IN_A_DAY
469
+ ? value
470
+ : null;
471
+ }
472
+
473
+ function optionalText(value: string | null, max: number): string | null {
474
+ return typeof value === "string" && value.trim() !== ""
475
+ ? shortenText(value, max)
476
+ : null;
477
+ }
478
+
479
+ function timeOf(value: string): number | null {
480
+ const time = Date.parse(value);
481
+ return Number.isFinite(time) ? time : null;
482
+ }
483
+
484
+ /** Plain code unit order, the same in every locale and on every runtime. */
485
+ function compareText(a: string, b: string): number {
486
+ if (a === b) {
487
+ return 0;
488
+ }
489
+ return a < b ? -1 : 1;
490
+ }
491
+
492
+ /**
493
+ * `<prefix>.<id>` when that is a slide id, and otherwise `<prefix>.h` and a
494
+ * hash of the id, so that it stays the same on every fetch.
495
+ */
496
+ function slideId(prefix: string, id: string): string {
497
+ const plain = `${prefix}.${id}`;
498
+ return SLIDE_ID_PATTERN.test(plain) ? plain : `${prefix}.h${hash(id)}`;
499
+ }
500
+
501
+ /** A 53-bit hash of a text (cyrb53), in base 36. Not for security. */
502
+ function hash(text: string): string {
503
+ let h1 = 0xdeadbeef;
504
+ let h2 = 0x41c6ce57;
505
+ for (let index = 0; index < text.length; index += 1) {
506
+ const code = text.charCodeAt(index);
507
+ h1 = Math.imul(h1 ^ code, 2654435761);
508
+ h2 = Math.imul(h2 ^ code, 1597334677);
509
+ }
510
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
511
+ h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
512
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
513
+ h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
514
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
515
+ }