-
Notifications
You must be signed in to change notification settings - Fork 0
/
openlp.mjs
193 lines (191 loc) · 12.3 KB
/
openlp.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
export class Slide {
api; item; index; id; text; html;
constructor(api, item, index) {
/* structure of slide objects from controller/live-items - https://gitlab.com/openlp/wiki/-/wikis/Documentation/HTTP-API#controllerlive-items-get
chords: HTML formatted string containing chords
footer: HTML formatted footer text for this slide
html: HTML formatted slide with no chords
text: Plain text slide with no chords
selected: If this is the selected slide
tag: The tag used to identify this verse
title: Same as the service item title
*/
this.api = api; this.item = item; this.index = index; this.id = this.item.id + '|' + this.index; // format = [ItemGuid]|[slideIndexInt]
this.text = this.api.text; this.html = this.api.html; // TODO: take verse reference off .html
}
// show()
// getters (isActive, title, previous, next, type, song, scripture) - use getters for anything that changes, and any references to item (this class doesn't have control over that, so we shouldn't assume item data is static)
get isActive() { return this.id === this.item.service.openLP.lastSlideID; } // use a getter because active slide changes, and this prevents us from having to unmark previous active and mark new active when slide changes
get title() { return this.item.title || this.api.title; } // prefer item title, so that slide title shows item title processing. Documentation states that slide title should be the same as item title, so any changes should flow down
// not including footer, because it should be merely a concatenated string of data available in the slide and item objects. Let consumers of this data concat for themselves, or go to slide.api for it
get previous() { return this.item.slides[this.index - 1]; } // can't set at construction because item.slides isn't populated by .map() return until ALL new Slide() calls have completed for an item. Even if we had access to slides created before, in forward-iteration there is no way to reference the object to be created in the next iteration of .map()
get next() { return this.item.slides[this.index + 1]; } // can't set at construction because item.slides isn't populated by .map() return until ALL new Slide() calls have completed for an item. Even if we had access to slides created before, in forward-iteration there is no way to reference the object to be created in the next iteration of .map()
get type() { return this.item.type; }
get song() { return this.item.song ? { ...this.item.song, tag: this.api.tag, chords: this.api.chords } : undefined; }
get scripture() {
if (!this.item.scripture) { return; }
let [, slideChapter, slideVerse] = this.text.match(/^(\d+):(\d+)/) || [];
slideChapter ??= this.previous?.scripture.slideChapter; // when a scripture is too long for OpenLP to display it on one slide, it arbitrarily breaks the text into two slides. The second slide does not start with chapter and verse, since it starts mid-verse. In cases where the slide doesn't start with chapter and verse, we should be able to assume the previous slide shows the same chapter and verse (and that it has already found values, if there is more than one break)
slideVerse ??= this.previous?.scripture.slideVerse;
return { ...this.item.scripture, slideChapter, slideVerse };
}
}
export class Item {
api = { liveItem: null, serviceItem: null }; service; slides = [];
constructor(service, api = {}) {
/* service/items item structure - https://gitlab.com/openlp/wiki/-/wikis/Documentation/HTTP-API#serviceitems-get
ccli_number: CCLI number, empty string if not applicable or missing
id: Unique service item id
is_valid: False if a required file is missing
notes: Notes for the service item
plugin: The plugin that provides this service item
selected: If this service item is currently live
title: The service item's title
*/
/* controller/live-items item structure - https://gitlab.com/openlp/wiki/-/wikis/Documentation/HTTP-API#controllerlive-items-get
audit: Array of legal information about the service item
[<title>, <authors_all>, <copyright>, <ccli_number>]
[] backgroundAudio: List of audio files
[] capabilities: List of capabilities (number indexes)
{} data: The data associated with the service item
This can contain anything but most commonly contains these attributes
"alternate_title", "authors", "ccli_number", "copyright", "title"
[] footer: Footer strings, line by line
fromPlugin: If this service item came from a plugin
isThemeOverwritten: If this item provides it's own theme
name: The name of the plugin that created this service item
notes: Notes for this service item
slides: Array of slide objects (see documentation within transformSlide for structure of slide object)
theme: This service item's selected theme, null if missing
title: Service item title
type: Display type.
"ServiceItemType.Text", "ServiceItemType.Image", "ServiceItemType.Command"
id: is not listed in the documentation, but is present in response
*/
this.service = service;
if (api.serviceItem) { this.api.serviceItem = api.serviceItem; }
if (api.liveItem) { this.api.liveItem = api.liveItem; }
if (api.liveItem?.slides) { this.slides = api.liveItem.slides.map((slide, idx) => new Slide(slide, this, idx)); } // service/items doesn't return slides in item data
}
get type() {
const plugin = this.api.serviceItem?.plugin || this.api.liveItem?.name;
return { songs: 'song', bibles: 'scripture' }[plugin] || plugin;
}
get index() { return this.service.items?.findIndex(i => i.id === this.api.liveItem?.id); }
get id() { return this.api.liveItem?.id || this.api.serviceItem?.id; }
get notes() { return this.api.liveItem?.notes || this.api.serviceItem?.notes; }
get theme() { return this.api.liveItem?.theme; } // TODO: confirm serviceItem does not have theme
get content() { return this.api.liveItem?.type; } // TODO: confirm serviceItem does not have type
get title() { return this.api.liveItem?.title || this.api.serviceItem?.title; }
get isActive() { return this.id === this.openLP?.item.id; }
get previous() { return this.service[this.index - 1]; }
get next() { return this.service[this.index - 1]; }
get song() {
if (this.type !== 'song') { return; }
return {
ccli: this.api.serviceItem?.ccli_number,
authors: this.api.liveItem?.audit[1],
}
}
get scripture() { // parsing the title is the only universal way to derive this info. Scripture info also exists in liveItem.footer (broken into chapter/verse and translation/copyright), and liveItem.data.bibles (version and copyright). But, if you wanted to get parsed info about all scriptures in the service, you can't use liveItem without selecting each item to first population that data
if (this.type !== 'scripture') { return; }
const titleRx = /^(\d?[A-z\s]+)\s([\d:,\s-]+)\s([A-z\s]+)\s\(([A-Z]+)\), ([^]+)/,
[, book, verse, translation, abbreviation, copyright] = this.title.match(titleRx),
verseRx = /^(\d+):(\d+)-?(\d+)?$/,
[
[, chapterStart, chapterStartVerseStart, chapterStartVerseEnd = chapterStartVerseStart],
[, chapterEnd = chapterStart,
chapterEndVerseStart = chapterStart !== chapterEnd ? 1 : chapterStartVerseStart,
chapterEndVerseEnd = chapterStart !== chapterEnd ? chapterEndVerseStart : chapterStartVerseEnd
] = []
] = verse.split(', ').map(s => s.match(verseRx).map(v => v && parseInt(v, 10)));
return {
reference: book + ' ' + verse,
chapterStart, chapterStartVerseStart, chapterStartVerseEnd,
chapterEnd, chapterEndVerseStart, chapterEndVerseEnd,
book, translation, abbreviation, copyright
}
}
}
export class Service {
items = []; id; openLP;
constructor(openLP, id, items) {
this.openLP = openLP;
this.id = id;
this.items = items;
Object.defineProperty(this, 'active', { get() { return this.items.find(i => i.isActive); } });
}
get scriptures() { return this.items.filter(i => i.$plugin === 'bible'); }
get songs() { return this.items.filter(i => i.$plugin === 'song'); }
}
export default class OpenLP {
#ws; #hostname; #lastFullID; #lastSlideID; #events = {}; mode;
item; service; // NOTE: item is not stored exclusively inside service, because item can be live without being present in the service. One can put a song or scripture on the screen, in the "Live" selector, without adding to the service. So, while an item MAY be in a service (and often will be in "production") it is not necessarily so (eg. testing, impromptu changes during the service, etc)
// static (apiPort, wsPort, getMode, fileAsJSON) -
static apiPort = 4316;
static wsPort = 4317;
static getMode(data) {
if (data.blank) { return 'blank'; }
if (data.display) { return 'desktop'; }
if (data.theme) { return 'theme'; }
return 'presentation';
}
static async fileAsJSON(file) {
return new Promise(res => {
const reader = new FileReader();
reader.onload = _ => res(JSON.parse(reader.result.toString()).results);
reader.readAsText(file);
});
}
// private (api, newSocket, connect)
async #api(path, post) {
return (await (await fetch(`http://${this.#hostname}:${this.constructor.apiPort}/api/v2/${path}`)).json());
}
async #newSocket(uri) { return new Promise((res, rej) => {
const ws = new WebSocket(uri);
ws.addEventListener('open', _ => { ws.removeEventListener('error', rej); res(ws) }, { once: true });
ws.addEventListener('error', err => { ws.removeEventListener('open', res); rej(err) }, { once: true });
}); }
async #connect(hostnames) {
async function wait(ms) { return new Promise(res => setTimeout(res, ms)); }
const [hostname, ...fallbacks] = hostnames;
try {
this.#ws = await this.#newSocket(`ws://${hostname}:${this.constructor.wsPort}`);
this.#hostname = hostname;
console.log('OpenLP: connected to ' + hostname);
}
catch (error) {
console.error('OpenLP: Failed to connect to ' + hostname, error);
if (!fallbacks.length) { await wait(5000); return await this.#connect(hostnames); }
return this.#connect(fallbacks);
}
}
constructor(hostnames) { this.#connect(hostnames).then(_ => this.#ws.addEventListener('message', evt => this.socketMessage(evt))); }
async socketMessage(evt) {
const data = await this.constructor.fileAsJSON(evt.data);
/* structure of data - https://gitlab.com/openlp/wiki/-/wikis/Documentation/websockets#program-state
counter: "Number incremented if the live display has changed"
service: "Number incremented if the service is modified"
slide: "Number incremented if the service is modified" - SH: this description from documentation doesn't sound right. This should be current slide index within current item
item: "The service unique identifier for the current service item" (GUID)
twelve: "Is using twelve hour time"
blank: "Is display blank"
theme: "Is display showing the theme"
display: "Is display showing the desktop"
version: "Version number (not sure what for, hard coded as 3 atm)""
isSecure: "Is authentication enabled for login required HTTP API endpoints"
chordNotation: "Notation used for chords, can be `english`, `german` or `neo-latin`"
*/
// console.log('OpenLP: socket message', data);
const mode = this.constructor.getMode(data);
if (this.mode !== mode) { this.mode = mode; this.#events.mode?.forEach(fn => fn(mode)); }
const slideID = data.item + '|' + data.slide, fullID = data.service + '|' + slideID, dirty = {}; // markers of whether the event data has changed. Fire events after all state has been updated, so that there isn't any confusion in event handlers as to why an item change event shows something different than the accompanying slide change event - for example
if (this.#lastFullID === fullID) { return console.log('OpenLP: websocket sent positional duplicate'); }
this.#lastFullID = fullID;
if (data.service && this.service?.id !== data.service) { dirty.service = this.service = new Service(this, data.service, (await this.#api('service/items')).map(serviceItem => new Item({ serviceItem }))); }
if (data.item && this.item?.id !== data.item) { dirty.item = this.item = new Item(this.service, { liveItem: await this.#api('controller/live-items') }); }
if (data.item && this.#lastSlideID !== slideID) { this.#lastSlideID = slideID; dirty.slide = this.item.slides.active = this.item.slides[data.slide]; }
Object.entries(dirty).forEach(([key, val]) => this.#events[key]?.forEach(fn => fn(val)));
}
on(type, fn) { (this.#events[type] ??= []).push(fn); return this; }
}