From 05f75180335c3414c69e308f6a273aaa722169d9 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Wed, 5 Feb 2025 10:19:28 +0100 Subject: [PATCH] Refactor API and implement DI (#53) --- api/browser/client.js | 494 +++++++------- api/browser/transport.js | 100 ++- api/client.js | 9 +- api/commands.js | 244 +++---- api/events.js | 478 ++++++++------ api/events.unit.test.js | 34 + api/index.js | 80 ++- api/items.js | 610 +++++++++--------- api/node/client.js | 171 ++--- api/node/transport.js | 20 +- api/server.js | 104 +-- api/settings.js | 31 +- api/shortcuts.js | 187 +++--- api/state.js | 289 +++++---- api/transport.js | 14 +- api/types.js | 172 ++--- api/variables.js | 128 ++-- api/variables.unit.test.js | 12 +- api/widgets.js | 49 +- app/components/Frame/index.jsx | 13 +- app/components/FrameComponent/index.jsx | 13 +- app/components/Header/style.css | 4 +- app/components/Onboarding/index.jsx | 4 +- docs/plugins/README.md | 4 + lib/Workspace.js | 2 +- lib/plugin/PluginLoader.js | 1 + lib/{template => }/template.json | 0 lib/template/TemplateLoader.js | 53 -- package-lock.json | 84 +-- package.json | 2 +- .../inspector/app/components/Form/index.jsx | 48 +- .../inspector/app/components/Form/style.css | 11 - .../app/components/StringInput/index.jsx | 2 + .../app/components/VariableHint/index.jsx | 8 + .../app/components/VariableHint/style.css | 10 + .../components/VariableStringInput/index.jsx | 101 +++ .../components/VariableStringInput/style.css | 19 + shared/DIController.js | 54 ++ shared/DIController.unit.test.js | 27 + shared/DIControllerError.js | 8 + 40 files changed, 2059 insertions(+), 1635 deletions(-) create mode 100644 api/events.unit.test.js rename lib/{template => }/template.json (100%) delete mode 100644 lib/template/TemplateLoader.js create mode 100644 plugins/inspector/app/components/VariableHint/index.jsx create mode 100644 plugins/inspector/app/components/VariableHint/style.css create mode 100644 plugins/inspector/app/components/VariableStringInput/index.jsx create mode 100644 plugins/inspector/app/components/VariableStringInput/style.css create mode 100644 shared/DIController.js create mode 100644 shared/DIController.unit.test.js create mode 100644 shared/DIControllerError.js diff --git a/api/browser/client.js b/api/browser/client.js index 4a156eb..d5ac316 100644 --- a/api/browser/client.js +++ b/api/browser/client.js @@ -5,77 +5,8 @@ const MissingIdentityError = require('../error/MissingIdentityError') const InvalidArgumentError = require('../error/InvalidArgumentError') -const state = require('../state') -const events = require('../events') -const commands = require('../commands') - const LazyValue = require('../classes/LazyValue') - -/** - * @typedef {{ - * id: String, - * role: Number, - * heartbeat: Number, - * isPersistent: Boolean, - * isEditingLayout: Boolean - * }} Connection - */ - -/** - * Roles that a - * client can assume - */ -const ROLES = { - satellite: 0, - main: 1 -} - -/** - * The client's - * current identity - * @type { LazyValue } - */ -const _identity = new LazyValue() - -/** - * @private - * Set the client's identity - * @param { String } identity - */ -function setIdentity (identity) { - _identity.set(identity) -} - -/** - * Get the current identity - * @returns { String? } - */ -function getIdentity () { - return _identity.get() -} - -/** - * Await the identity to be set, - * will return immediately if an - * identity is already set - * or otherwise return a - * Promise - * @returns { String | Promise. } - */ -function awaitIdentity () { - return _identity.getLazy() -} - -/** - * @private - * Assert that an identity is set, - * will throw an error if not - */ -function assertIdentity () { - if (!getIdentity()) { - throw new MissingIdentityError() - } -} +const DIController = require('../../shared/DIController') /** * @private @@ -95,219 +26,284 @@ function ensureArray (thing) { } /** - * Select an item, - * will replace the - * current selection - * @param { String } item A string to select - *//** - * Select multiple items, - * will replace the - * current selection - * @param { String[] } item Multiple items to select + * @typedef {{ + * id: String, + * role: Number, + * heartbeat: Number, + * isPersistent: Boolean, + * isEditingLayout: Boolean + * }} Connection */ -async function setSelection (item) { - assertIdentity() - - const items = ensureArray(item) - await state.apply({ - _connections: { - [getIdentity()]: { - selection: { $replace: items } - } - } - }) - events.emitLocally('selection', items) -} +class Client { + #props + + /** + * Roles that a + * client can assume + */ + get ROLES () { + return Object.freeze({ + satellite: 0, + main: 1 + }) + } -/** - * Select an item by adding to the - * client's already existing selection - * @param { String } item The id of an item to add - *//** - * Select multiple items by adding to - * the client's already existing selection - * @param { String[] } item An array of ids for - * the items to add - */ -async function addSelection (item) { - assertIdentity() + constructor (props) { + this.#props = props + } - const currentSelection = await getSelection() - const newSelectionSet = new Set(Array.isArray(currentSelection) ? currentSelection : []) - const newItems = ensureArray(item) + /** + * The client's + * current identity + * @type { LazyValue } + */ + #identity = new LazyValue() + + /** + * @private + * Set the client's identity + * @param { String } identity + */ + setIdentity (identity) { + this.#identity.set(identity) + } - for (const item of newItems) { - newSelectionSet.add(item) + /** + * Get the current identity + * @returns { String? } + */ + getIdentity () { + return this.#identity.get() } - const newSelection = Array.from(newSelectionSet.values()) + /** + * Await the identity to be set, + * will return immediately if an + * identity is already set + * or otherwise return a + * Promise + * @returns { String | Promise. } + */ + awaitIdentity () { + return this.#identity.getLazy() + } - await state.apply({ - _connections: { - [getIdentity()]: { - selection: { $replace: newSelection } - } + /** + * @private + * Assert that an identity is set, + * will throw an error if not + */ + assertIdentity () { + if (!this.getIdentity()) { + throw new MissingIdentityError() } - }) - events.emitLocally('selection', newSelection) -} + } -/** - * Subtract an item from - * the current selection - * @param { String } item The id of an item to subtract - *//** - * Subtract multiple items - * from the current selection - * @param { String[] } item An array of ids of items to subtract - */ -function subtractSelection (item) { - assertIdentity() + /** + * Select an item, + * will replace the + * current selection + * @param { String } item A string to select + *//** + * Select multiple items, + * will replace the + * current selection + * @param { String[] } item Multiple items to select + */ + async setSelection (item) { + this.assertIdentity() + + const items = ensureArray(item) + await this.#props.State.apply({ + _connections: { + [this.getIdentity()]: { + selection: { $replace: items } + } + } + }) - const selection = state.getLocalState()?._connections?.[getIdentity()]?.selection - if (!selection) { - return + this.#props.Events.emitLocally('selection', items) } - const items = new Set(ensureArray(item)) - const newSelection = selection.filter(id => !items.has(id)) + /** + * Select an item by adding to the + * client's already existing selection + * @param { String } item The id of an item to add + *//** + * Select multiple items by adding to + * the client's already existing selection + * @param { String[] } item An array of ids for + * the items to add + */ + async addSelection (item) { + this.assertIdentity() - setSelection(newSelection, newSelection) -} + const currentSelection = await this.getSelection() + const newSelectionSet = new Set(Array.isArray(currentSelection) ? currentSelection : []) + const newItems = ensureArray(item) -/** - * Check whether or not an - * item is in the selection - * @param { String } item The id of an item to check - * @returns { Boolean } - */ -async function isSelected (item) { - assertIdentity() - const selection = await state.get(`_connections.${getIdentity()}.selection`) - if (!selection) { - return false - } - return selection.includes(item) -} + for (const item of newItems) { + newSelectionSet.add(item) + } -/** - * Clear the current selection - */ -async function clearSelection () { - assertIdentity() + const newSelection = Array.from(newSelectionSet.values()) - await state.apply({ - _connections: { - [getIdentity()]: { - selection: { $delete: true } + await this.#props.State.apply({ + _connections: { + [this.getIdentity()]: { + selection: { $replace: newSelection } + } } - } - }) + }) + this.#props.Events.emitLocally('selection', newSelection) + } - events.emitLocally('selection', []) -} + /** + * Subtract an item from + * the current selection + * @param { String } item The id of an item to subtract + *//** + * Subtract multiple items + * from the current selection + * @param { String[] } item An array of ids of items to subtract + */ + subtractSelection (item) { + this.assertIdentity() -/** - * Get the current selection - * @returns { Promise. } - */ -async function getSelection () { - assertIdentity() - return (await state.get(`_connections.${getIdentity()}.selection`)) || [] -} + const selection = this.#props.State.getLocalState()?._connections?.[this.getIdentity()]?.selection + if (!selection) { + return + } -/** - * Get all clients - * from the state - * @returns { Promise. } - */ -async function getAllConnections () { - return Object.entries((await state.get('_connections')) || {}) - .map(([id, connection]) => ({ - id, - ...connection, - role: (connection.role == null ? ROLES.satellite : connection.role) - })) -} + const items = new Set(ensureArray(item)) + const newSelection = selection.filter(id => !items.has(id)) -/** - * Set the role of a - * client by its id - * @param { String } id - * @param { Number } role - */ -async function setRole (id, role) { - if (!id || typeof id !== 'string') { - throw new InvalidArgumentError('Invalid argument \'id\', must be a string') + this.setSelection(newSelection, newSelection) } - if (!Object.values(ROLES).includes(role)) { - throw new InvalidArgumentError('Invalid argument \'role\', must be a valid role') + /** + * Check whether or not an + * item is in the selection + * @param { String } item The id of an item to check + * @returns { Boolean } + */ + async isSelected (item) { + this.assertIdentity() + const selection = await this.#props.State.get(`_connections.${this.getIdentity()}.selection`) + if (!selection) { + return false + } + return selection.includes(item) } - const set = { - _connections: { - [id]: { - role + /** + * Clear the current selection + */ + async clearSelection () { + this.assertIdentity() + + await this.#props.State.apply({ + _connections: { + [this.getIdentity()]: { + selection: { $delete: true } + } } - } + }) + + this.#props.Events.emitLocally('selection', []) } - /* - There can only be one client with the main role, - if set, demote all other mains to satellite - */ - if (role === ROLES.main) { - (await getConnectionsByRole(ROLES.main)) - /* - Don't reset the role of the - connection we're currently setting - */ - .filter(connection => connection.id !== id) - .forEach(connection => { set._connections[connection.id] = { role: ROLES.satellite } }) + /** + * Get the current selection + * @returns { Promise. } + */ + async getSelection () { + this.assertIdentity() + return (await this.#props.State.get(`_connections.${this.getIdentity()}.selection`)) || [] } - state.apply(set) -} + /** + * Get all clients + * from the this.#props.State + * @returns { Promise. } + */ + async getAllConnections () { + return Object.entries((await this.#props.State.get('_connections')) || {}) + .map(([id, connection]) => ({ + id, + ...connection, + role: (connection.role == null ? this.ROLES.satellite : connection.role) + })) + } -/** - * Get an array of all clients that - * have assumed a certain role - * @param { Number } role A valid role - * @returns { Promise. } - */ -async function getConnectionsByRole (role) { - if (!Object.values(ROLES).includes(role)) { - throw new InvalidArgumentError('Invalid argument \'role\', must be a valid role') + /** + * Set the role of a + * client by its id + * @param { String } id + * @param { Number } role + */ + async setRole (id, role) { + if (!id || typeof id !== 'string') { + throw new InvalidArgumentError('Invalid argument \'id\', must be a string') + } + + if (!Object.values(this.ROLES).includes(role)) { + throw new InvalidArgumentError('Invalid argument \'role\', must be a valid role') + } + + const set = { + _connections: { + [id]: { + role + } + } + } + + /* + There can only be one client with the main role, + if set, demote all other mains to satellite + */ + if (role === this.ROLES.main) { + (await this.getConnectionsByRole(this.ROLES.main)) + /* + Don't reset the role of the + connection we're currently setting + */ + .filter(connection => connection.id !== id) + .forEach(connection => { set._connections[connection.id] = { role: this.ROLES.satellite } }) + } + + this.#props.State.apply(set) } - return (await getAllConnections()) - .filter(connection => connection.role === role) -} + /** + * Get an array of all clients that + * have assumed a certain role + * @param { Number } role A valid role + * @returns { Promise. } + */ + async getConnectionsByRole (role) { + if (!Object.values(this.ROLES).includes(role)) { + throw new InvalidArgumentError('Invalid argument \'role\', must be a valid role') + } -/** - * Send a heartbeat - * for this client - */ -async function heartbeat () { - const id = await awaitIdentity() - commands.executeRawCommand('client.heartbeat', id) -} + return (await this.getAllConnections()) + .filter(connection => connection.role === role) + } -module.exports = { - roles: ROLES, - setIdentity, - getIdentity, - awaitIdentity, - setSelection, - getSelection, - addSelection, - subtractSelection, - clearSelection, - isSelected, - setRole, - getAllConnections, - getConnectionsByRole, - heartbeat + /** + * Send a heartbeat + * for this client + */ + async heartbeat () { + const id = await this.awaitIdentity() + this.#props.Commands.executeRawCommand('client.heartbeat', id) + } } + +DIController.main.register('Client', Client, [ + 'State', + 'Events', + 'Commands' +]) diff --git a/api/browser/transport.js b/api/browser/transport.js index 38bf2d1..63bf33e 100644 --- a/api/browser/transport.js +++ b/api/browser/transport.js @@ -1,67 +1,57 @@ -// SPDX-FileCopyrightText: 2022 Sveriges Television AB +// SPDX-FileCopyrightText: 2025 Sveriges Television AB // // SPDX-License-Identifier: MIT -const handlers = [] +const DIController = require('../../shared/DIController') -let sendQueue = [] +class Transport { + #handlers = [] + #queue = [] -/** - * Replay the send queue and - * sequentially call send for - * every message - */ -function replayQueue () { - const tmpQueue = sendQueue - sendQueue = [] + /** + * Replay the send queue and + * sequentially call send for + * every message + */ + replayQueue () { + const tmpQueue = this.#queue + this.#queue = [] - for (const message of tmpQueue) { - this.send(message) + for (const message of tmpQueue) { + this.send(message) + } } -} -/** - * Send a message with the - * transport, for messages - * leaving the api - * - * Unless overridden - * messages will be queued - * - * @param { Object } message - */ -function send (message) { - sendQueue.push(message) -} + /** + * The entrypoint for messages + * coming into the api + * + * This should be called by the application + * when a new message is to be handled + * + * @param { Object } msg + */ + receive (msg) { + this.#handlers.forEach(handler => handler(msg)) + } -/** - * Add a handler to be called when - * this transport receives a message - * @param { (Any) -> Void } handler - */ -function onMessage (handler) { - handlers.push(handler) -} + onMessage (handler) { + this.#handlers.push(handler) + } -/** - * The entrypoint for messages - * coming into the api - * - * This should be called by the application - * when a new message is to be handled - * - * @param { Object } message - */ -function receive (message) { - handlers.forEach(handler => handler(message)) + /** + * Send a message with the + * transport, for messages + * leaving the api + * + * Unless overridden + * messages will be queued + * + * @param { Object } msg + */ + send (msg) { + this.#queue.push(msg) + } } -/** - * @type { import('../transport').Communicator } - */ -module.exports = { - send, - receive, - replayQueue, - onMessage -} +DIController.main.register('Transport', Transport) diff --git a/api/client.js b/api/client.js index 1f743af..8e7cee0 100644 --- a/api/client.js +++ b/api/client.js @@ -2,13 +2,12 @@ // // SPDX-License-Identifier: MIT -const client = (function () { +;(function () { if (module.parent) { - return require('./node/client') + require('./node/client') + return } if (typeof window !== 'undefined') { - return require('./browser/client') + require('./browser/client') } })() - -module.exports = client diff --git a/api/commands.js b/api/commands.js index f085346..33923bd 100644 --- a/api/commands.js +++ b/api/commands.js @@ -2,10 +2,9 @@ // // SPDX-License-Identifier: MIT -const transport = require('./transport') const random = require('./random') -const handlers = new Map() +const DIController = require('../shared/DIController') const NoLocalHandlerError = require('./error/NoLocalHandlerError') const InvalidArgumentError = require('./error/InvalidArgumentError') @@ -31,128 +30,141 @@ function handlerFactory (fn, returns) { } } -/* -Handle incoming messages and -call the registered handler -with the provided arguments - -This code is also responsible -for executing any transactions -if the handler provides a -return value -*/ -transport.onMessage(async message => { - const handler = handlers.get(message.command) - if (!handler) return - - const args = message.args || [] - - if (!handler.returns) { - handler.call(...args) - } else { - const transaction = args.shift() - try { - const res = await handler.call(...args) - executeRawCommand(transaction, res) - } catch (err) { - executeRawCommand(transaction, undefined, { - message: err.message, - cause: err.cause, - stack: err.stack, - name: err.name - }) - } +class Commands { + #props + + #handlers = new Map() + + constructor (props) { + this.#props = props + this.#setup() } -}) -/** - * Execute a command - * @param { String } command A command to execute - * @param { ...any } args Any arguments to pass to the handler, must be serializable - * @returns { Promise. } - */ -function executeCommand (command, ...args) { - return new Promise((resolve, reject) => { - const transactionId = random.string(12) - const transaction = `transaction:${transactionId}:${command}` - - registerCommand(transaction, (res, err) => { - removeCommand(transaction) - - if (err) { - const error = new Error(err.message) - error.stack = err.stack - error.cause = err.cause - error.name = err.name - return reject(error) + #setup () { + /* + Handle incoming messages and + call the registered handler + with the provided arguments + + This code is also responsible + for executing any transactions + if the handler provides a + return value + */ + this.#props.Transport.onMessage(async message => { + const handler = this.#handlers.get(message.command) + if (!handler) return + + const args = message.args || [] + + if (!handler.returns) { + handler.call(...args) + } else { + const transaction = args.shift() + try { + const res = await handler.call(...args) + this.executeRawCommand(transaction, res) + } catch (err) { + this.executeRawCommand(transaction, undefined, { + message: err.message, + cause: err.cause, + stack: err.stack, + name: err.name + }) + } } - resolve(res) - }, false) - - executeRawCommand(command, transaction, ...args) - }) -} -exports.executeCommand = executeCommand + }) + } -/** - * Execute a command without - * creating a transaction - * - * No return values are available - * from commands called with this - * function, use executeCommand if - * return values are required - * @param { String } command The command to execute - * @param { ...any } args Arguments to pass to the command - */ -function executeRawCommand (command, ...args) { - transport.send({ command, args: [...args] }) -} -exports.executeRawCommand = executeRawCommand + /** + * Execute a command + * @param { String } command A command to execute + * @param { ...any } args Any arguments to pass to the handler, must be serializable + * @returns { Promise. } + */ + executeCommand (command, ...args) { + return new Promise((resolve, reject) => { + const transactionId = random.string(12) + const transaction = `transaction:${transactionId}:${command}` + + this.registerCommand(transaction, (res, err) => { + this.removeCommand(transaction) + + if (err) { + const error = new Error(err.message) + error.stack = err.stack + error.cause = err.cause + error.name = err.name + return reject(error) + } + resolve(res) + }, false) + + this.executeRawCommand(command, transaction, ...args) + }) + } -/** - * Register a command - * with a handler - * @param { String } command The command to register, should be scoped - * @param { - * (...Any) => Promise. | (...Any) => Void - * } handler A handler to invoke whenever - * the command is run - * @param { Boolean } returns Indicate whether or not - * the handler returns a value, - * defaults to true - */ -function registerCommand (command, handler, returns = true) { - if (typeof handler !== 'function') { - throw new InvalidArgumentError('Parameter \'handler\' must be a function') + /** + * Execute a command without + * creating a transaction + * + * No return values are available + * from commands called with this + * function, use executeCommand if + * return values are required + * @param { String } command The command to execute + * @param { ...any } args Arguments to pass to the command + */ + executeRawCommand (command, ...args) { + this.#props.Transport.send({ command, args: [...args] }) } - handlers.set(command, handlerFactory(handler, returns)) - transport.send({ - command: 'commands.registerCommand', - args: [command] - }) -} -exports.registerCommand = registerCommand + /** + * Register a command + * with a handler + * @param { String } command The command to register, should be scoped + * @param { + * (...Any) => Promise. | (...Any) => Void + * } handler A handler to invoke whenever + * the command is run + * @param { Boolean } returns Indicate whether or not + * the handler returns a value, + * defaults to true + */ + registerCommand (command, handler, returns = true) { + if (typeof handler !== 'function') { + throw new InvalidArgumentError('Parameter \'handler\' must be a function') + } -/** - * Remove a command - * and remove its handler - * @param { String } command The command to remove - */ -function removeCommand (command) { - /* - A plugin can only remove - its own commands - */ - if (!handlers.has(command)) { - throw new NoLocalHandlerError('Command cannot be removeed as it wasn\'t created by this plugin') + this.#handlers.set(command, handlerFactory(handler, returns)) + this.#props.Transport.send({ + command: 'commands.registerCommand', + args: [command] + }) } - handlers.delete(command) - transport.send({ - command: 'commands.removeCommand', - args: [command] - }) + /** + * Remove a command + * and remove its handler + * @param { String } command The command to remove + */ + removeCommand (command) { + /* + A plugin can only remove + its own commands + */ + if (!this.#handlers.has(command)) { + throw new NoLocalHandlerError('Command cannot be removeed as it wasn\'t created by this plugin') + } + + this.#handlers.delete(command) + this.#props.Transport.send({ + command: 'commands.removeCommand', + args: [command] + }) + } } -exports.removeCommand = removeCommand + +DIController.main.register('Commands', Commands, [ + 'Transport' +]) diff --git a/api/events.js b/api/events.js index cfae548..219b114 100644 --- a/api/events.js +++ b/api/events.js @@ -1,18 +1,16 @@ -// SPDX-FileCopyrightText: 2022 Sveriges Television AB +// SPDX-FileCopyrightText: 2025 Sveriges Television AB // // SPDX-License-Identifier: MIT -const commands = require('./commands') const random = require('./random') -const remoteHandlers = new Map() -const localHandlers = new Map() -const intercepts = new Map() +const DIController = require('../shared/DIController') /** * @typedef {{ * callee: String * }} EventHandlerOpts + * * @property { String } callee An optional identifier for the * callee of the function, * this is used to clean up handlers @@ -36,233 +34,321 @@ function appendToMapArray (map, key, item) { map.set(key, [...items, item]) } -/** - * @private - * - * Call all local handlers for an event, - * this should be called whenever an event - * is emitted so that all listeners - * are executed - * - * @param { String } event The name of the event to emit - * @param { ...any } args Any data to pass along with the event - */ -async function callLocalHandlers (event, ...args) { - let _args = args - - const handlers = localHandlers.get(event) - if (!handlers) { - return - } - +class Events { /* - Let any intercepts do their thing - before calling the event handlers + These would usually be declared private + but since we're creating proxies + of this class' instances we cannot + do that in an easy manner here */ - const interceptFns = intercepts.get(event) || [] - for (const { fn } of interceptFns) { - _args = await fn(..._args) - } + props + remoteHandlers = new Map() + localHandlers = new Map() + intercepts = new Map() - for (const { handler } of handlers) { - handler(..._args) + opts = {} + + constructor (props, opts) { + this.props = props + this.opts = opts } -} -/** - * Emit an event - * @param { String } event The name of the event to emit - * @param { ...any } args Any data to pass along with the event - */ -function emit (event, ...args) { - commands.executeRawCommand('events.emit', event, ...args) -} -exports.emit = emit + /** + * @private + * + * Call all local handlers for an event, + * this should be called whenever an event + * is emitted so that all listeners + * are executed + * + * @param { String } event The name of the event to emit + * @param { ...any } args Any data to pass along with the event + */ + async callLocalHandlers (event, ...args) { + let _args = args -/** - * Emit an event but only call local handlers - * - * This will NOT trigger any listeners outside - * this client. If that is what you want, - * use the standard 'emit' function instead. - * - * @param { String } event The name of the event to emit - * @param { ...any } args Any data to pass along with the event - */ -function emitLocally (event, ...args) { - callLocalHandlers(event, ...args) -} -exports.emitLocally = emitLocally + const handlers = this.localHandlers.get(event) + if (!handlers) { + return + } -/** - * Register a function that intercepts a certain event - * before calling any handlers with its result - * @param { String } event The name of the event to intercept - * @param { (any[]) => any[] } handler A function intercepting the event, - * it must resolve to an array of values - * @param { EventHandlerOpts } opts - */ -function intercept (event, handler, opts) { - const fn = async function (...args) { - const res = await handler(...args) - if (Array.isArray(res)) return res - return [res] + /* + Let any intercepts do their thing + before calling the event handlers + */ + const interceptFns = this.intercepts.get(event) || [] + for (const { fn } of interceptFns) { + _args = await fn(..._args) + } + + for (const { handler } of handlers) { + handler(..._args) + } } - appendToMapArray(intercepts, event, { fn, callee: opts?.callee }) -} -exports.intercept = intercept -/** - * Remove an intercepting function - * from an event - * @param { String } event - * @param { (any[]) => any[] } handler - */ -function removeIntercept (event, handler) { - const handlers = intercepts.get(event) - if (!handlers) { - return + /** + * Emit an event + * @param { String } event The name of the event to emit + * @param { ...any } args Any data to pass along with the event + */ + emit (event, ...args) { + this.props.Commands.executeRawCommand('events.emit', event, ...args) } - const index = handlers.findIndex(({ fn }) => fn === handler) - handlers.splice(index, 1) + /** + * Emit an event but only call local handlers + * + * This will NOT trigger any listeners outside + * this client. If that is what you want, + * use the standard 'emit' function instead. + * + * @param { String } event The name of the event to emit + * @param { ...any } args Any data to pass along with the event + */ + emitLocally (event, ...args) { + this.callLocalHandlers(event, ...args) + } - if (handlers.length === 0) { - intercepts.delete(event) - } else { - intercepts.set(event, handlers) + /** + * Register a function that this.intercepts a certain event + * before calling any handlers with its result + * @param { String } event The name of the event to intercept + * @param { (any[]) => any[] } handler A function intercepting the event, + * it must resolve to an array of values + * @param { EventHandlerOpts } opts + */ + intercept (event, handler, opts) { + const fn = async function (...args) { + const res = await handler(...args) + if (Array.isArray(res)) return res + return [res] + } + appendToMapArray(this.intercepts, event, { fn, callee: opts?.callee || this.opts?.callee }) } -} -exports.removeIntercept = removeIntercept -/** - * Add a handler for an event - * @param { String } event An event to listen to - * @param { EventHandler } handler A handler to be called - * when the event is received - * @param { EventHandlerOpts } opts - * @returns { Promise. } - */ -async function on (event, handler, opts) { - appendToMapArray(localHandlers, event, { handler, callee: opts?.callee }) + /** + * Remove an intercepting function + * from an event + * @param { String } event + * @param { (any[]) => any[] } handler + */ + removeIntercept (event, handler) { + const handlers = this.intercepts.get(event) + if (!handlers) { + return + } - /* - Only setup the command if - we have just one listener - */ - if (localHandlers.get(event).length === 1) { - const command = `event:${random.string(12)}:${event}` + const index = handlers.findIndex(({ fn }) => fn === handler) + handlers.splice(index, 1) + + if (handlers.length === 0) { + this.intercepts.delete(event) + } else { + this.intercepts.set(event, handlers) + } + } + + /** + * Add a handler for an event + * @param { String } event An event to listen to + * @param { EventHandler } handler A handler to be called + * when the event is received + * @param { EventHandlerOpts } opts + * @returns { Promise. } + */ + async on (event, handler, opts) { + appendToMapArray(this.localHandlers, event, { handler, callee: opts?.callee || this.opts?.callee }) /* - Register a command handle that will trigger - all local handlers of the event + Only setup the command if + we have just one listener */ - commands.registerCommand(command, async (...args) => { - callLocalHandlers(event, ...args) - }, false) + if (this.localHandlers.get(event).length === 1) { + const command = `event:${random.string(12)}:${event}` + + /* + Register a command handle that will trigger + all local handlers of the event + */ + this.props.Commands.registerCommand(command, async (...args) => { + this.callLocalHandlers(event, ...args) + }, false) - const handlerId = await commands.executeCommand('events.triggerCommand', event, command) - remoteHandlers.set(event, [command, handlerId]) + const handlerId = await this.props.Commands.executeCommand('events.triggerCommand', event, command) + this.remoteHandlers.set(event, [command, handlerId]) + } } -} -exports.on = on -/** - * Add a handler for an event but only - * call it the first time the event is - * received - * @param { String } event An event to listen to - * @param { EventHandler } handler A handler to call - * @param { EventHandlerOpts } opts - */ -function once (event, handler, opts) { - function handle (...args) { - off(event, handle) - handler(...args) + /** + * Add a handler for an event but only + * call it the first time the event is + * received + * @param { String } event An event to listen to + * @param { EventHandler } handler A handler to call + * @param { EventHandlerOpts } opts + */ + once (event, handler, opts) { + const off = this.off + function handle (...args) { + off(event, handle) + handler(...args) + } + this.on(event, handle, opts) } - on(event, handle, opts) -} -exports.once = once -/** - * Remove a handler for an event - * @param { String } event An event name - * @param { EventHandler } handler A handler to remove - */ -function off (event, handler) { - if (!localHandlers.has(event)) return + /** + * Remove a handler for an event + * @param { String } event An event name + * @param { EventHandler } handler A handler to remove + */ + off (event, handler) { + if (!this.localHandlers.has(event)) return - const handlers = localHandlers.get(event) - const index = handlers.findIndex(({ handler: _handler }) => _handler === handler) - handlers.splice(index, 1) + const handlers = this.localHandlers.get(event) + const index = handlers.findIndex(({ handler: _handler }) => _handler === handler) + handlers.splice(index, 1) - if (handlers.length === 0) { - localHandlers.delete(event) + if (handlers.length === 0) { + this.localHandlers.delete(event) - if (!remoteHandlers.has(event)) { - return - } + if (!this.remoteHandlers.has(event)) { + return + } - /* - Remove the command completely as we don't - have any handlers for this event anymore - */ - const [command, handlerId] = remoteHandlers.get(event) - commands.removeCommand(command) - commands.executeRawCommand('events.off', event, handlerId) + /* + Remove the command completely as we don't + have any handlers for this event anymore + */ + const [command, handlerId] = this.remoteHandlers.get(event) + this.props.Commands.removeCommand(command) + this.props.Commands.executeRawCommand('events.off', event, handlerId) - remoteHandlers.delete(event) - } else { - localHandlers.set(event, handlers) + this.remoteHandlers.delete(event) + } else { + this.localHandlers.set(event, handlers) + } } -} -exports.off = off -/** - * Remove all listeners - *//** - * Remove all listeners associated - * with the specified callee - * @param { String } callee - */ -function removeAllListeners (callee) { - for (const event of localHandlers.keys()) { - for (const { handler, callee: _callee } of localHandlers.get(event)) { - if (callee && _callee !== callee) { - continue + /** + * Remove all listeners + *//** + * Remove all listeners associated + * with the specified callee + * @param { String } callee + * @returns { Number } The number of listeners that were removed + */ + removeAllListeners (callee) { + let count = 0 + for (const event of this.localHandlers.keys()) { + for (const { handler, callee: _callee } of this.localHandlers.get(event)) { + if (callee && _callee !== callee) { + continue + } + this.off(event, handler) + count++ } - off(event, handler) } + return count } -} -exports.removeAllListeners = removeAllListeners -/** - * Remove all intercepts - *//** - * Remove all intercepts associated - * with the specified callee - * @param { String } callee - */ -function removeAllIntercepts (callee) { - for (const event of intercepts.keys()) { - for (const { fn, callee: _callee } of intercepts.get(event)) { - if (callee && _callee !== callee) { - continue + /** + * Remove all this.intercepts + *//** + * Remove all this.intercepts associated + * with the specified callee + * @param { String } callee + * @returns { Number } The number of intercepts that were removed + */ + removeAllIntercepts (callee) { + let count = 0 + for (const event of this.intercepts.keys()) { + for (const { fn, callee: _callee } of this.intercepts.get(event)) { + if (callee && _callee !== callee) { + continue + } + this.removeIntercept(event, fn) + count++ } - removeIntercept(event, fn) } + return count } -} -exports.removeAllIntercepts = removeAllIntercepts -/** - * Check if there is a registered - * remote handler for an event - * @param { String } event - * @returns { Boolean } - */ -function hasRemoteHandler (event) { - return remoteHandlers.has(event) + /** + * Check if there is a registered + * remote handler for an event + * @param { String } event + * @returns { Boolean } + */ + hasRemoteHandler (event) { + return this.remoteHandlers.has(event) + } + + /** + * Create a scoped instance + * of the event api + * + * This is used for being able to do + * clean up tasks in batch using the + * 'removeAllListeners' and 'removeAllIntercepts' + * methods + * + * @param { String } callee A unique id that can be associated + * with calls made by the scope + * + * @returns { Proxy. } + */ + createScope (callee) { + /* + Create a scope object with methods + that will override the original + instance's methods + + Any methods not defined in the scope + object will be instead directed to the + original instance + */ + const scope = {} + scope.id = callee + + scope.intercept = (event, handler, opts) => { + return this.intercept(event, handler, { + ...opts, + callee + }) + } + + scope.on = (event, handler, opts) => { + return this.on(event, handler, { + ...opts, + callee + }) + } + + scope.once = (event, handler, opts) => { + return this.once(event, handler, { + ...opts, + callee + }) + } + + /* + Create and return a proxy object + that will forward any calls implemented + in the scope-object there rather than to + the original instance + */ + const intercept = { + get: (target, prop, receiver) => { + if (scope[prop]) { + return scope[prop] + } + return Reflect.get(target, prop, receiver) + } + } + return new Proxy(this, intercept) + } } -exports.hasRemoteHandler = hasRemoteHandler + +DIController.main.register('Events', Events, [ + 'Commands' +]) diff --git a/api/events.unit.test.js b/api/events.unit.test.js new file mode 100644 index 0000000..f74ad4f --- /dev/null +++ b/api/events.unit.test.js @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2023 Sveriges Television AB +// +// SPDX-License-Identifier: MIT + +require('./events') + +const DIController = require('../shared/DIController') + +let events +beforeAll(() => { + events = DIController.main.instantiate('Events', { + Commands: { + registerCommand: () => {}, + executeCommand: () => {} + } + }) +}) + +test('create a new callee scope', () => { + const scope = events.createScope('myCallee') + expect(scope.id).toEqual('myCallee') +}) + +test('remove all listeners for a callee', () => { + const scope = events.createScope('mySecondCallee') + scope.on('test', () => {}) + expect(events.removeAllListeners('mySecondCallee')).toEqual(1) +}) + +test('remove all intercepts for a callee', () => { + const scope = events.createScope('myThirdCallee') + scope.intercept('test', () => {}) + expect(events.removeAllIntercepts('myThirdCallee')).toEqual(1) +}) diff --git a/api/index.js b/api/index.js index 503cd2e..449a169 100644 --- a/api/index.js +++ b/api/index.js @@ -2,45 +2,61 @@ // // SPDX-License-Identifier: MIT -const transport = require('./transport') -const variables = require('./variables') -const shortcuts = require('./shortcuts') -const commands = require('./commands') -const settings = require('./settings') -const widgets = require('./widgets') -const client = require('./client') -const events = require('./events') -const server = require('./server') -const state = require('./state') -const types = require('./types') -const items = require('./items') +const DIController = require('../shared/DIController') -/** - * The api entrypoint - * exposed to plugins - * @type { Api } - */ -const api = { - transport, - variables, - shortcuts, - commands, - settings, - widgets, - client, - events, - server, - state, - types, - items +require('./transport') +require('./variables') +require('./shortcuts') +require('./commands') +require('./settings') +require('./widgets') +require('./client') +require('./events') +require('./server') +require('./state') +require('./types') +require('./items') + +class API { + constructor (props) { + this.transport = props.Transport + this.variables = props.Variables + this.shortcuts = props.Shortcuts + this.commands = props.Commands + this.settings = props.Settings + this.widgets = props.Widgets + this.client = props.Client + this.events = props.Events + this.server = props.Server + this.state = props.State + this.types = props.Types + this.items = props.Items + } } -module.exports = api +DIController.main.register('API', API, [ + 'Transport', + 'Variables', + 'Shortcuts', + 'Commands', + 'Settings', + 'Widgets', + 'Client', + 'Events', + 'Server', + 'State', + 'Types', + 'Items' +]) + +const main = DIController.main.instantiate('API') + +module.exports = main /* Expose the api as window.bridge if we're running in a browser */ if (typeof window !== 'undefined') { - window.bridge = api + window.bridge = main } diff --git a/api/items.js b/api/items.js index db3590d..0c69933 100644 --- a/api/items.js +++ b/api/items.js @@ -15,374 +15,378 @@ const objectPath = require('object-path') -const state = require('./state') -const types = require('./types') -const client = require('./client') -const events = require('./events') const random = require('./random') -const commands = require('./commands') -const variables = require('./variables') const MissingArgumentError = require('./error/MissingArgumentError') const InvalidArgumentError = require('./error/InvalidArgumentError') const Cache = require('./classes/Cache') +const DIController = require('../shared/DIController') const CACHE_MAX_ENTRIES = 10 -const cache = new Cache(CACHE_MAX_ENTRIES) - -/* -Intercept the item.change event -to always include the full item -*/ -;(function () { - events.intercept('item.change', async itemId => { - return await getItem(itemId) - }) -})() /** - * Create a new id for an item - * that is unique and doesn't - * already exist - * - * It's kept short to be - * easy to work with - * - * @returns { String } + * Perform a deep clone + * of an object + * @param { any } obj An object to clone + * @returns { any } */ -function createUniqueId () { - let proposal - while (!proposal || state.getLocalState()?.items?.[proposal]) { - proposal = random.string(4) +function deepClone (obj) { + if (typeof window !== 'undefined' && window.structuredClone) { + return window.structuredClone(obj) } - return proposal + return JSON.parse(JSON.stringify(obj)) } -/** - * Create an item from of a specific - * type and store it in the state - * @param { String } type A type identifier to create an item from - * @returns { Promise. } A Promise resolving to the id of the created item - */ -async function createItem (type) { - const _type = await types.getType(type) - if (!_type) { - throw new InvalidArgumentError('Received an invalid value for the argument \'type\', no such type exist') - } +class Items { + #props - const item = { - id: createUniqueId(), - type: _type.id, - data: {} - } + #cache = new Cache(CACHE_MAX_ENTRIES) - for (const [key, def] of Object.entries(_type.properties)) { - objectPath.set(item.data, key, def.default || undefined) + constructor (props) { + this.#props = props + this.#setup() } - applyItem(item.id, item) - return item.id -} -exports.createItem = createItem - -/** - * Apply changes to an - * item in the state - * - * @param { String } id The id of an item to update - * @param { Item } set An item object to apply - */ -async function applyItem (id, set = {}) { - if (typeof id !== 'string') { - throw new MissingArgumentError('Invalid value for item id, must be a string') + #setup () { + /* + Intercept the item.change event + to always include the full item + */ + this.#props.Events.intercept('item.change', async itemId => { + return await this.getItem(itemId) + }) } - if (typeof set !== 'object' || Array.isArray(set)) { - throw new InvalidArgumentError('Argument \'item\' must be a valid object that\'s not an array') + /** + * Create a new id for an item + * that is unique and doesn't + * already exist + * + * It's kept short to be + * easy to work with + * + * @returns { String } + */ + createUniqueId () { + let proposal + while (!proposal || this.#props.State.getLocalState()?.items?.[proposal]) { + proposal = random.string(4) + } + return proposal } - await state.apply({ - items: { - [id]: set + /** + * Create an item from of a specific + * type and store it in the this.#props.State + * @param { String } type A type identifier to create an item from + * @returns { Promise. } A Promise resolving to the id of the created item + */ + async createItem (type) { + const _type = await this.#props.Types.getType(type) + if (!_type) { + throw new InvalidArgumentError('Received an invalid value for the argument \'type\', no such type exist') } - }) -} -exports.applyItem = applyItem -/** - * Apply changes to an - * already existing item - * in the state - * - * This function checks if the - * item exists before applying - * the data - * - * @param { String } id The id of an item to update - * @param { Item } set An item object to apply - */ -async function applyExistingItem (id, set = {}) { - if (typeof id !== 'string') { - throw new MissingArgumentError('Invalid value for item id, must be a string') - } + const item = { + id: this.createUniqueId(), + type: _type.id, + data: {} + } - const item = await getItem(id) - if (!item) { - throw new InvalidArgumentError('Invalid item id, item does not exist') + for (const [key, def] of Object.entries(_type.properties)) { + objectPath.set(item.data, key, def.default || undefined) + } + + this.applyItem(item.id, item) + return item.id } - await applyItem(id, set) -} -exports.applyExistingItem = applyExistingItem + /** + * Apply changes to an + * item in the this.#props.State + * + * @param { String } id The id of an item to update + * @param { Item } set An item object to apply + */ + async applyItem (id, set = {}) { + if (typeof id !== 'string') { + throw new MissingArgumentError('Invalid value for item id, must be a string') + } -/** - * Get an item object by its id - * @param { String } id The id for the item to get - * @returns { Promise. } - */ -function getItem (id) { - /* - Use caching if it's safe to do so - - The cache key must depend on the local state revision - in order to not get out of date, and that will only - get updated if the client is listening for the - 'state.change' event - */ - if ( - events.hasRemoteHandler('state.change') && - state.getLocalRevision() !== 0 - ) { - return cache.cache(`${id}::${state.getLocalRevision()}`, () => commands.executeCommand('items.getItem', id)) + if (typeof set !== 'object' || Array.isArray(set)) { + throw new InvalidArgumentError('Argument \'item\' must be a valid object that\'s not an array') + } + + await this.#props.State.apply({ + items: { + [id]: set + } + }) } - return commands.executeCommand('items.getItem', id) -} -exports.getItem = getItem -/** - * Get the local representation of an item - * @param { String } id The id of the item to get - * @returns { Item } - */ -function getLocalItem (id) { - const curState = state.getLocalState() - return curState?.items?.[id] -} -exports.getLocalItem = getLocalItem + /** + * Apply changes to an + * already existing item + * in the this.#props.State + * + * This function checks if the + * item exists before applying + * the data + * + * @param { String } id The id of an item to update + * @param { Item } set An item object to apply + */ + async applyExistingItem (id, set = {}) { + if (typeof id !== 'string') { + throw new MissingArgumentError('Invalid value for item id, must be a string') + } -/** - * Delete an item by its id - * - * Will trigger the - * items.delete event - * - * @param { String } id - */ -function deleteItem (id) { - deleteItems([id]) -} -exports.deleteItem = deleteItem + const item = await this.getItem(id) + if (!item) { + throw new InvalidArgumentError('Invalid item id, item does not exist') + } -/** - * Delete multiple - * items by their ids - * - * Will trigger the - * items.delete event - * - * @param { String[] } ids - */ -async function deleteItems (ids) { - /* - Make sure any deleted items - are no longer selected if available - in the current context - */ - if (typeof client?.subtractSelection === 'function') { - client.subtractSelection(ids) + await this.applyItem(id, set) } - return commands.executeCommand('items.deleteItems', ids) -} -exports.deleteItems = deleteItems -/** - * Perform a deep clone - * of an object - * @param { any } obj An object to clone - * @returns { any } - */ -function deepClone (obj) { - if (typeof window !== 'undefined' && window.structuredClone) { - return window.structuredClone(obj) + /** + * Get an item object by its id + * @param { String } id The id for the item to get + * @returns { Promise. } + */ + getItem (id) { + /* + Use caching if it's safe to do so + + The cache key must depend on the local this.#props.State revision + in order to not get out of date, and that will only + get updated if the this.#props.Client is listening for the + 'this.#props.State.change' event + */ + if ( + this.#props.Events.hasRemoteHandler('this.#props.State.change') && + this.#props.State.getLocalRevision() !== 0 + ) { + return this.#cache.cache(`${id}::${this.#props.State.getLocalRevision()}`, () => { + return this.#props.Commands.executeCommand('items.getItem', id) + }) + } + return this.#props.Commands.executeCommand('items.getItem', id) } - return JSON.parse(JSON.stringify(obj)) -} -/** - * Populate any variable placeholders - * in an item's properties - in place - * - * @param { any } item - * @param { any } type - * @param { any } values - * @returns { any } The item with modified property values - */ -function populateVariablesMutable (item, type, values) { - if (!item.data) { - item.data = {} + /** + * Get the local representation of an item + * @param { String } id The id of the item to get + * @returns { Item } + */ + getLocalItem (id) { + const curState = this.#props.State.getLocalState() + return curState?.items?.[id] } - for (const key of Object.keys(type.properties)) { - if (!type.properties[key].allowsVariables) { - continue - } - const currentValue = objectPath.get(item.data, key) + /** + * Delete an item by its id + * + * Will trigger the + * items.delete event + * + * @param { String } id + */ + deleteItem (id) { + this.deleteItems([id]) + } - if (currentValue != null) { - objectPath.set(item.data, key, JSON.parse(variables.substituteInString(JSON.stringify(currentValue), values))) + /** + * Delete multiple + * items by their ids + * + * Will trigger the + * items.delete event + * + * @param { String[] } ids + */ + async deleteItems (ids) { + /* + Make sure any deleted items + are no longer selected if available + in the current context + */ + if (typeof this.#props.Client?.subtractSelection === 'function') { + this.#props.Client.subtractSelection(ids) } + return this.#props.Commands.executeCommand('items.deleteItems', ids) } - return item -} + /** + * Populate any variable placeholders + * in an item's properties - in place + * + * @param { any } item + * @param { any } type + * @param { any } values + * @returns { any } The item with modified property values + */ + populateVariablesMutable (item, type, values) { + if (!item.data) { + item.data = {} + } -/** - * Play the item and emit - * the 'playing' event` - * @param { String } id - */ -async function playItem (id) { - const item = await getItem(id) + for (const key of Object.keys(type.properties)) { + if (!type.properties[key].allowsVariables) { + continue + } + const currentValue = objectPath.get(item.data, key) - if (!item) { - return - } + if (currentValue != null) { + objectPath.set(item.data, key, JSON.parse(this.#props.Variables.substituteInString(JSON.stringify(currentValue), values))) + } + } - if (item?.data?.disabled) { - return + return item } - const type = await types.getType(item.type) - const vars = await variables.getAllVariables() - const clone = populateVariablesMutable(deepClone(item), type, vars) + /** + * Play the item and emit + * the 'playing' event` + * @param { String } id + */ + async playItem (id) { + const item = await this.getItem(id) - const delay = parseInt(clone?.data?.delay) + if (!item) { + return + } - if (delay && !Number.isNaN(delay)) { - commands.executeCommand('items.scheduleItem', clone, delay) - } else { - commands.executeCommand('items.playItem', clone) - } -} -exports.playItem = playItem + if (item?.data?.disabled) { + return + } -/** - * Play the item and emit - * the 'stop' event - * @param { String } id - */ -async function stopItem (id) { - const item = await getItem(id) + const type = await this.#props.Types.getType(item.type) + const vars = await this.#props.Variables.getAllVariables() + const clone = this.populateVariablesMutable(deepClone(item), type, vars) - if (!item) { - return - } + const delay = parseInt(clone?.data?.delay) - if (item?.data?.disabled) { - return + if (delay && !Number.isNaN(delay)) { + this.#props.Commands.executeCommand('items.scheduleItem', clone, delay) + } else { + this.#props.Commands.executeCommand('items.playItem', clone) + } } - const type = await types.getType(item.type) - const vars = await variables.getAllVariables() - const clone = populateVariablesMutable(deepClone(item), type, vars) + /** + * Play the item and emit + * the 'stop' event + * @param { String } id + */ + async stopItem (id) { + const item = await this.getItem(id) - commands.executeCommand('items.stopItem', clone) -} -exports.stopItem = stopItem + if (!item) { + return + } -/** - * Add or update an - * issue by its id - * - * An issue indicates a problem with an item - * and may be reflected in the interface - * - * @param { String } itemId - * @param { String } issueId - * @param { ItemIssue } issueSpec - */ -async function applyIssue (itemId, issueId, issueSpec) { - if (typeof itemId !== 'string') { - throw new MissingArgumentError('Invalid value for item id, must be a string') - } + if (item?.data?.disabled) { + return + } - if (typeof issueId !== 'string') { - throw new MissingArgumentError('Invalid value for issue id, must be a string') - } + const type = await this.#props.Types.getType(item.type) + const vars = await this.#props.Variables.getAllVariables() + const clone = this.populateVariablesMutable(deepClone(item), type, vars) - if (typeof issueSpec !== 'object' || Array.isArray(issueSpec)) { - throw new InvalidArgumentError('Argument \'issueSpec\' must be a valid object that\'s not an array') + this.#props.Commands.executeCommand('items.stopItem', clone) } - await applyExistingItem(itemId, { - issues: { - [issueId]: { - ts: Date.now(), - ...issueSpec - } + /** + * Add or update an + * issue by its id + * + * An issue indicates a problem with an item + * and may be reflected in the interface + * + * @param { String } itemId + * @param { String } issueId + * @param { ItemIssue } issueSpec + */ + async applyIssue (itemId, issueId, issueSpec) { + if (typeof itemId !== 'string') { + throw new MissingArgumentError('Invalid value for item id, must be a string') } - }) -} -exports.applyIssue = applyIssue -/** - * Remove an issue by its - * id from an item - * - * @param { String } itemId - * @param { String } issueId - */ -async function removeIssue (itemId, issueId) { - if (typeof itemId !== 'string') { - throw new MissingArgumentError('Invalid value for item id, must be a string') - } + if (typeof issueId !== 'string') { + throw new MissingArgumentError('Invalid value for issue id, must be a string') + } - if (typeof issueId !== 'string') { - throw new MissingArgumentError('Invalid value for issue id, must be a string') + if (typeof issueSpec !== 'object' || Array.isArray(issueSpec)) { + throw new InvalidArgumentError('Argument \'issueSpec\' must be a valid object that\'s not an array') + } + + await this.applyExistingItem(itemId, { + issues: { + [issueId]: { + ts: Date.now(), + ...issueSpec + } + } + }) } - applyExistingItem(itemId, { - issues: { - [issueId]: { $delete: true } + /** + * Remove an issue by its + * id from an item + * + * @param { String } itemId + * @param { String } issueId + */ + async removeIssue (itemId, issueId) { + if (typeof itemId !== 'string') { + throw new MissingArgumentError('Invalid value for item id, must be a string') } - }) -} -exports.removeIssue = removeIssue -/** - * Render a value for an item by its id, - * this will replace any variable placeholders - * - * @example - * - * Item { - * id: '1234', - * data: { - * name: '$(this.data.myValue)', - * myValue: 'Hello World' - * } - * } - * - * renderValue('1234', 'data.name') -> 'Hello World' - * - * @param { String } itemId The id of the item - * @param { String } path The path to the value to render - * @returns { Promise. } - */ -async function renderValue (itemId, path) { - const item = await getItem(itemId) - const currentValue = objectPath.get(item || {}, path) - return variables.substituteInString(currentValue, undefined, { this: item }) + if (typeof issueId !== 'string') { + throw new MissingArgumentError('Invalid value for issue id, must be a string') + } + + this.applyExistingItem(itemId, { + issues: { + [issueId]: { $delete: true } + } + }) + } + + /** + * Render a value for an item by its id, + * this will replace any variable placeholders + * + * @example + * + * Item { + * id: '1234', + * data: { + * name: '$(this.data.myValue)', + * myValue: 'Hello World' + * } + * } + * + * renderValue('1234', 'data.name') -> 'Hello World' + * + * @param { String } itemId The id of the item + * @param { String } path The path to the value to render + * @returns { Promise. } + */ + async renderValue (itemId, path) { + const item = await this.getItem(itemId) + const currentValue = objectPath.get(item || {}, path) + return this.#props.Variables.substituteInString(currentValue, undefined, { this: item }) + } } -exports.renderValue = renderValue + +DIController.main.register('Items', Items, [ + 'State', + 'Types', + 'Client', + 'Events', + 'Commands', + 'Variables' +]) diff --git a/api/node/client.js b/api/node/client.js index d860482..4a73021 100644 --- a/api/node/client.js +++ b/api/node/client.js @@ -4,7 +4,8 @@ const MissingArgumentError = require('../error/MissingArgumentError') const InvalidArgumentError = require('../error/InvalidArgumentError') -const state = require('../state') + +const DIController = require('../../shared/DIController') /** * @typedef {{ @@ -16,101 +17,107 @@ const state = require('../state') * }} Connection */ -/** - * Roles that a - * client can assume - */ -const ROLES = { - satellite: 0, - main: 1 -} +class Client { + #props -/** - * Get all clients - * from the state - * @returns { Promise. } - */ -async function getAllConnections () { - return Object.entries((await state.get('_connections')) || {}) - .map(([id, connection]) => ({ - id, - ...connection, - role: (connection.role == null ? ROLES.satellite : connection.role) - })) -} + /** + * Roles that a + * client can assume + */ + get ROLES () { + return Object.freeze({ + satellite: 0, + main: 1 + }) + } -/** - * Set the role of a - * client by its id - * @param { String } id - * @param { Number } role - */ -async function setRole (id, role) { - if (!id || typeof id !== 'string') { - throw new InvalidArgumentError('Invalid argument \'id\', must be a string') + constructor (props) { + this.#props = props } - if (!Object.values(ROLES).includes(role)) { - throw new InvalidArgumentError('Invalid argument \'role\', must be a valid role') + /** + * Get all clients + * from the state + * @returns { Promise. } + */ + async getAllConnections () { + return Object.entries((await this.#props.State.get('_connections')) || {}) + .map(([id, connection]) => ({ + id, + ...connection, + role: (connection.role == null ? this.ROLES.satellite : connection.role) + })) } - const set = { - _connections: { - [id]: { - role + /** + * Set the role of a + * client by its id + * @param { String } id + * @param { Number } role + */ + async setRole (id, role) { + if (!id || typeof id !== 'string') { + throw new InvalidArgumentError('Invalid argument \'id\', must be a string') + } + + if (!Object.values(this.ROLES).includes(role)) { + throw new InvalidArgumentError('Invalid argument \'role\', must be a valid role') + } + + const set = { + _connections: { + [id]: { + role + } } } - } - /* - There can only be one client with the main role, - if set, demote all other mains to satellite - */ - if (role === ROLES.main) { - (await getConnectionsByRole(ROLES.main)) - /* - Don't reset the role of the - connection we're currently setting - */ - .filter(connection => connection.id !== id) - .forEach(connection => { set._connections[connection.id] = { role: ROLES.satellite } }) + /* + There can only be one client with the main role, + if set, demote all other mains to satellite + */ + if (role === this.ROLES.main) { + (await this.getConnectionsByRole(this.ROLES.main)) + /* + Don't reset the role of the + connection we're currently setting + */ + .filter(connection => connection.id !== id) + .forEach(connection => { set._connections[connection.id] = { role: this.ROLES.satellite } }) + } + + this.#props.State.apply(set) } - state.apply(set) -} + /** + * Get an array of all clients that + * have assumed a certain role + * @param { Number } role A valid role + * @returns { Promise. } + */ + async getConnectionsByRole (role) { + if (!Object.values(this.ROLES).includes(role)) { + throw new InvalidArgumentError('Invalid argument \'role\', must be a valid role') + } -/** - * Get an array of all clients that - * have assumed a certain role - * @param { Number } role A valid role - * @returns { Promise. } - */ -async function getConnectionsByRole (role) { - if (!Object.values(ROLES).includes(role)) { - throw new InvalidArgumentError('Invalid argument \'role\', must be a valid role') + return (await this.getAllConnections()) + .filter(connection => connection.role === role) } - return (await getAllConnections()) - .filter(connection => connection.role === role) -} - -/** - * Get the current selection - * of a connection by its id - * @param { String } connectionId - * @returns { Promise. } - */ -async function getSelection (connectionId) { - if (!connectionId) { - throw new MissingArgumentError('Missing required argument \'connectionId\'') + /** + * Get the current selection + * of a connection by its id + * @param { String } connectionId + * @returns { Promise. } + */ + async getSelection (connectionId) { + if (!connectionId) { + throw new MissingArgumentError('Missing required argument \'connectionId\'') + } + return (await this.#props.State.get(`_connections.${connectionId}.selection`)) || [] } - return (await state.get(`_connections.${connectionId}.selection`)) || [] } -module.exports = { - roles: ROLES, - setRole, - getSelection, - getAllConnections, - getConnectionsByRole -} +DIController.main.register('Client', Client, [ + 'State' +]) diff --git a/api/node/transport.js b/api/node/transport.js index bd4b9f9..35c6551 100644 --- a/api/node/transport.js +++ b/api/node/transport.js @@ -1,13 +1,19 @@ -// SPDX-FileCopyrightText: 2022 Sveriges Television AB +// SPDX-FileCopyrightText: 2025 Sveriges Television AB // // SPDX-License-Identifier: MIT const { parentPort } = require('worker_threads') -/** - * @type { import('../transport').Communicator } - */ -module.exports = { - onMessage: handler => parentPort.on('message', handler), - send: msg => parentPort.postMessage(msg) +const DIController = require('../../shared/DIController') + +class Transport { + onMessage (handler) { + parentPort.on('message', handler) + } + + send (msg) { + parentPort.postMessage(msg) + } } + +DIController.main.register('Transport', Transport) diff --git a/api/server.js b/api/server.js index 5da4d6b..beaf580 100644 --- a/api/server.js +++ b/api/server.js @@ -1,49 +1,63 @@ -// SPDX-FileCopyrightText: 2022 Sveriges Television AB +// SPDX-FileCopyrightText: 2025 Sveriges Television AB // // SPDX-License-Identifier: MIT -const commands = require('./commands') - -/** - * Access uris for - * static assets - * @type { String } - */ -exports.uris = Object.freeze({ - STYLE_RESET: '/bridge.bundle.css' -}) - -/** - * Serve a static file - * through the web server - * @param { String } filePath An absolute path to the file to serve - * @returns { Promise. } A path to the file as served by the web server - */ -function serveFile (filePath) { - return commands.executeCommand('server.serveFile', filePath) - .then(hash => `/api/v1/serve/${hash}`) -} -exports.serveFile = serveFile - -/** - * Serve a string as a static file - * through the web server - * @param { String } str A string to serve as a file - * @returns { Promise. } A path to the file as served by the web server - */ -function serveString (str) { - return commands.executeCommand('server.serveString', str) - .then(hash => `/api/v1/serve/${hash}`) -} -exports.serveString = serveString - -/** - * Stop serving a file through - * the web server by its id - * @param { String } id - * @returns { Promise. } - */ -function unserve (id) { - return commands.executeCommand('server.unserve', id) +const DIController = require('../shared/DIController') + +class Server { + #props + + /** + * Uris for + * static assets + * + * @type {{ + * STYLE_RESET: String + * }} + */ + get uris () { + return Object.freeze({ + STYLE_RESET: '/bridge.bundle.css' + }) + } + + constructor (props) { + this.#props = props + } + + /** + * Serve a static file + * through the web server + * @param { String } filePath An absolute path to the file to serve + * @returns { Promise. } A path to the file as served by the web server + */ + serveFile (filePath) { + return this.#props.Commands.executeCommand('server.serveFile', filePath) + .then(hash => `/api/v1/serve/${hash}`) + } + + /** + * Serve a string as a static file + * through the web server + * @param { String } str A string to serve as a file + * @returns { Promise. } A path to the file as served by the web server + */ + serveString (str) { + return this.#props.Commands.executeCommand('server.serveString', str) + .then(hash => `/api/v1/serve/${hash}`) + } + + /** + * Stop serving a file through + * the web server by its id + * @param { String } id + * @returns { Promise. } + */ + unserve (id) { + return this.#props.Commands.executeCommand('server.unserve', id) + } } -exports.unserve = unserve + +DIController.main.register('Server', Server, [ + 'Commands' +]) diff --git a/api/settings.js b/api/settings.js index c644383..1c08d56 100644 --- a/api/settings.js +++ b/api/settings.js @@ -10,15 +10,26 @@ * }} SettingSpecification See lib/schemas/setting.schema.json for complete spec */ -const commands = require('./commands') +const DIController = require('../shared/DIController') -/** - * Register a setting - * by its specification - * @param { SettingSpecification } specification A setting specification - * @returns { Promise. } - */ -function registerSetting (specification) { - return commands.executeCommand('settings.registerSetting', specification) +class Settings { + #props + + constructor (props) { + this.#props = props + } + + /** + * Register a setting + * by its specification + * @param { SettingSpecification } specification A setting specification + * @returns { Promise. } + */ + registerSetting (specification) { + return this.#props.Commands.executeCommand('settings.registerSetting', specification) + } } -exports.registerSetting = registerSetting + +DIController.main.register('Settings', Settings, [ + 'Commands' +]) diff --git a/api/shortcuts.js b/api/shortcuts.js index 577d758..1be7f01 100644 --- a/api/shortcuts.js +++ b/api/shortcuts.js @@ -15,110 +15,115 @@ * }} ShortcutOverrideSpec */ -const state = require('./state') -const commands = require('./commands') - const InvalidArgumentError = require('./error/InvalidArgumentError') +const DIController = require('../shared/DIController') -/** - * Make a shortcut available - * to the application - * @param { ShortcutSpec } spec - */ -function registerShortcut (spec = {}) { - return commands.executeCommand('shortcuts.registerShortcut', spec) -} -exports.registerShortcut = registerShortcut - -/** - * Get a shortcut's - * specification - * @param { String } id - * @returns { Promise. } - */ -function getShortcut (id) { - return state.getLocalState()?._shortcuts?.[id] -} -exports.getShortcut = getShortcut +class Shortcuts { + #props -/** - * Get all shortcuts' - * specifications - * @returns { Promise. } - */ -async function getShortcuts () { - const index = state.getLocalState()?._shortcuts - const overrides = state.getLocalState()?._userDefaults?.shortcuts || {} - - return Object.values(index || {}) - .map(shortcut => { - return { - ...shortcut, - ...(overrides[shortcut.id] || {}) - } - }) -} -exports.getShortcuts = getShortcuts + constructor (props) { + this.#props = props + } -/** - * Register a new shortcut override - * - * Note that the override will be registered - * to the user defaults state for the current - * main process and not necessarily the local - * user - * - * @param { String } id An identifier of the shortcut to override - * @param { ShortcutOverrideSpec } spec A specification to use as an override - * @returns { Promise. } - */ -async function registerShortcutOverride (id, spec) { - if (typeof spec !== 'object') { - throw new InvalidArgumentError('The provided \'spec\' must be a shortcut override specification') + /** + * Make a shortcut available + * to the application + * @param { ShortcutSpec } spec + */ + registerShortcut (spec = {}) { + return this.#props.Commands.executeCommand('shortcuts.registerShortcut', spec) } - if (typeof id !== 'string') { - throw new InvalidArgumentError('The provided \'id\' must be a string') + /** + * Get a shortcut's + * specification + * @param { String } id + * @returns { Promise. } + */ + getShortcut (id) { + return this.#props.State.getLocalState()?._shortcuts?.[id] } - const currentOverride = await state.get(`_userDefaults.shortcuts.${id}`) - const set = { [id]: spec } + /** + * Get all shortcuts' + * specifications + * @returns { Promise. } + */ + async getShortcuts () { + const index = this.#props.State.getLocalState()?._shortcuts + const overrides = this.#props.State.getLocalState()?._userDefaults?.shortcuts || {} - if (currentOverride) { - set[id] = { $replace: spec } + return Object.values(index || {}) + .map(shortcut => { + return { + ...shortcut, + ...(overrides[shortcut.id] || {}) + } + }) } - state.apply({ - _userDefaults: { - shortcuts: set + /** + * Register a new shortcut override + * + * Note that the override will be registered + * to the user defaults this.#props.State for the current + * main process and not necessarily the local + * user + * + * @param { String } id An identifier of the shortcut to override + * @param { ShortcutOverrideSpec } spec A specification to use as an override + * @returns { Promise. } + */ + async registerShortcutOverride (id, spec) { + if (typeof spec !== 'object') { + throw new InvalidArgumentError('The provided \'spec\' must be a shortcut override specification') } - }) -} -exports.registerShortcutOverride = registerShortcutOverride -/** - * Clear any override for a - * specific shortcut by its id - * @param { String } id - */ -async function clearShortcutOverride (id) { - state.apply({ - _userDefaults: { - shortcuts: { - [id]: { $delete: true } - } + if (typeof id !== 'string') { + throw new InvalidArgumentError('The provided \'id\' must be a string') } - }) -} -exports.clearShortcutOverride = clearShortcutOverride -/** - * Get a shortcut override specification - * for a shortcut by its id - * @param { String } id - * @returns { Promise. } - */ -async function getShortcutOverride (id) { - return state.get(`_userDefaults.shortcuts.${id}`) + const currentOverride = await this.#props.State.get(`_userDefaults.shortcuts.${id}`) + const set = { [id]: spec } + + if (currentOverride) { + set[id] = { $replace: spec } + } + + this.#props.State.apply({ + _userDefaults: { + shortcuts: set + } + }) + } + + /** + * Clear any override for a + * specific shortcut by its id + * @param { String } id + */ + async clearShortcutOverride (id) { + this.#props.State.apply({ + _userDefaults: { + shortcuts: { + [id]: { $delete: true } + } + } + }) + } + + /** + * Get a shortcut override specification + * for a shortcut by its id + * @param { String } id + * @returns { Promise. } + */ + async getShortcutOverride (id) { + return this.#props.State.get(`_userDefaults.shortcuts.${id}`) + } } -exports.getShortcutOverride = getShortcutOverride + +DIController.main.register('Shortcuts', Shortcuts, [ + 'State', + 'Commands' +]) diff --git a/api/state.js b/api/state.js index 3af77e8..8b87225 100644 --- a/api/state.js +++ b/api/state.js @@ -4,157 +4,166 @@ const merge = require('../shared/merge') -const commands = require('./commands') -const events = require('./events') - const Cache = require('./classes/Cache') +const DIController = require('../shared/DIController') const CACHE_MAX_ENTRIES = 10 -const cache = new Cache(CACHE_MAX_ENTRIES) - -/** - * Keep a local - * copy of the state - * @type { State } - */ -let state - -/** - * The state's current - * revision number, - * this is used to ensure - * that the state is kept - * up-to-date - * - * Please note that this - * value only will be updated - * if there are listeners for - * state changes attached by - * the current process - * - * @type { Number } - */ -let revision = 0 - -/** - * Get the local revision - * @returns { Number } - */ -function getLocalRevision () { - return revision -} -exports.getLocalRevision = getLocalRevision - -/** - * Get the full remote state - * @returns { Promise. } - *//** - * Get a part of the remote state - * specified by a dot notated path - * @param { String } path - * @returns { Promise. } - */ -function getRemoteState (path) { - return commands.executeCommand('state.get', path) -} -/** - * Apply state changes to - * the local copy of the state - * @param { Object[] } set An array of objects to set - *//** - * Apply a single change to - * the local copy of the state - * @param { Object } set An object to set - */ -function applyLocally (set) { - if (Array.isArray(set)) { - for (const change of set) { - state = merge.deep(state, change) - } - } else { - state = merge.deep(state, set) - } -} +class State { + #props + + #cache = new Cache(CACHE_MAX_ENTRIES) + + /** + * Keep a local + * copy of the state + * @type { State } + */ + #state -/* -Intercept the state.change event -to always include the full calculated -state -*/ -;(function () { - events.intercept('state.change', async (set, remoteRevision, transparent) => { - revision += 1 + /** + * The state's current + * revision number, + * this is used to ensure + * that the state is kept + * up-to-date + * + * Please note that this + * value only will be updated + * if there are listeners for + * state changes attached by + * the current process + * + * @type { Number } + */ + #revision = 0 + constructor (props) { + this.#props = props + this.#setup() + } + + #setup () { /* - Make sure the revision numbers match, and if not, - update the local state from the remote state + Intercept the state.change event + to always include the full calculated + state */ - if (revision !== remoteRevision) { - const newState = await getRemoteState() - revision = newState._revision - state = newState + this.#props.Events.intercept('state.change', async (set, remoteRevision, transparent) => { + this.#revision += 1 + + /* + Make sure the revision numbers match, and if not, + update the local state from the remote state + */ + if (this.#revision !== remoteRevision) { + const newState = await this.getRemoteState() + this.#revision = newState._revision + this.#state = newState + } else { + this.applyLocally(set) + } + + return this.#state + }) + } + + /** + * Get the local revision + * @returns { Number } + */ + getLocalRevision () { + return this.#revision + } + + /** + * Get the current local + * copy of the state + * @returns { Object } + */ + getLocalState () { + return this.#state + } + + /** + * Get the full remote state + * @returns { Promise. } + *//** + * Get a part of the remote state + * specified by a dot notated path + * @param { String } path + * @returns { Promise. } + */ + getRemoteState (path) { + return this.#props.Commands.executeCommand('state.get', path) + } + + /** + * Apply state changes to + * the local copy of the state + * @param { Object[] } set An array of objects to set + *//** + * Apply a single change to + * the local copy of the state + * @param { Object } set An object to set + */ + applyLocally (set) { + if (Array.isArray(set)) { + for (const change of set) { + this.#state = merge.deep(this.#state, change) + } } else { - applyLocally(set) + this.#state = merge.deep(this.#state, set) } + } - return state - }) -})() - -/** - * Apply some data to the state, - * most often this function shouldn't - * be called directly - there's probably - * a command for what you want to do - * @param { Object } set Data to apply to the state - *//** - * Apply some data to the state, - * most often this function shouldn't - * be called directly - there's probably - * a command for what you want to do - * @param { Object[] } set An array of data objects to - * apply to the state in order - */ -function apply (set) { - commands.executeRawCommand('state.apply', set) -} -exports.apply = apply - -/** - * Get the full current state - * @returns { Promise. } - *//** - * Get part of the current state - * specified by a dot-notated path - * @param { String } path - * @returns { Promise. } - */ -async function get (path) { - if (!path) { - const newState = await getRemoteState() - revision = newState._revision - state = newState - return state - } else { - /* - If we can expect the revision to be updated, - use the caching layer to bundle calls together - */ - if (events.hasRemoteHandler('state.change') && revision !== 0) { - return cache.cache(`${path}::${revision}`, () => getRemoteState(path)) + /** + * Apply some data to the state, + * most often this function shouldn't + * be called directly - there's probably + * a command for what you want to do + * @param { Object } set Data to apply to the state + *//** + * Apply some data to the state, + * most often this function shouldn't + * be called directly - there's probably + * a command for what you want to do + * @param { Object[] } set An array of data objects to + * apply to the state in order + */ + apply (set) { + this.#props.Commands.executeRawCommand('state.apply', set) + } + + /** + * Get the full current state + * @returns { Promise. } + *//** + * Get part of the current state + * specified by a dot-notated path + * @param { String } path + * @returns { Promise. } + */ + async get (path) { + if (!path) { + const newState = await this.getRemoteState() + this.#revision = newState._revision + this.#state = newState + return this.#state + } else { + /* + If we can expect the revision to be updated, + use the caching layer to bundle calls together + */ + if (this.#props.Events.hasRemoteHandler('state.change') && this.#revision !== 0) { + return this.#cache.cache(`${path}::${this.#revision}`, () => this.getRemoteState(path)) + } + return this.getRemoteState(path) } - return getRemoteState(path) } } -exports.get = get - -/** - * Get the current local - * copy of the state - * @returns { Object } - */ -function getLocalState () { - return state -} -exports.getLocalState = getLocalState + +DIController.main.register('State', State, [ + 'Events', + 'Commands' +]) diff --git a/api/transport.js b/api/transport.js index dcec8d3..1493a73 100644 --- a/api/transport.js +++ b/api/transport.js @@ -1,23 +1,23 @@ -// SPDX-FileCopyrightText: 2022 Sveriges Television AB +// SPDX-FileCopyrightText: 2025 Sveriges Television AB // // SPDX-License-Identifier: MIT -const transport = (function () { +;(function () { /* Use a dummy transport for unit-tests */ if (typeof process !== 'undefined' && process.env.JEST_WORKER_ID) { - return require('./dummy/transport') + require('./dummy/transport') + return } if (module.parent) { console.log('[API] Using node transport') - return require('./node/transport') + require('./node/transport') + return } if (typeof window !== 'undefined') { console.log('[API] Using browser transport') - return require('./browser/transport') + require('./browser/transport') } })() - -module.exports = transport diff --git a/api/types.js b/api/types.js index e2c686e..5ca03e3 100644 --- a/api/types.js +++ b/api/types.js @@ -2,14 +2,10 @@ // // SPDX-License-Identifier: MIT -const state = require('./state') -const events = require('./events') -const commands = require('./commands') - const Cache = require('./classes/Cache') +const DIController = require('../shared/DIController') const CACHE_MAX_ENTRIES = 100 -const cache = new Cache(CACHE_MAX_ENTRIES) /** * Perform a deep clone @@ -24,92 +20,106 @@ function deepClone (obj) { return JSON.parse(JSON.stringify(obj)) } -/** - * Render a complete type - * from a set of type definitions - * - * This will make sure that - * inheritance relations are - * kept - * @param { String } id - * @param { Object. } typesDict - * @returns { Type } - */ -function renderType (id, typesDict = {}) { - if (!typesDict[id]) return undefined +class Types { + #props + + #cache = new Cache(CACHE_MAX_ENTRIES) + + constructor (props) { + this.#props = props + } - const type = deepClone(typesDict[id]) + /** + * Render a complete type + * from a set of type definitions + * + * This will make sure that + * inheritance relations are + * kept + * @param { String } id + * @param { Object. } typesDict + * @returns { Type } + */ + renderType (id, typesDict = {}) { + if (!typesDict[id]) return undefined - /* - Render the ancestor if this - type inherits properties - */ - if (type.inherits) { - const ancestor = renderType(type.inherits, typesDict) + const type = deepClone(typesDict[id]) - type.properties = { - ...ancestor?.properties || {}, - ...type.properties || {} + /* + Render the ancestor if this + type inherits properties + */ + if (type.inherits) { + const ancestor = this.renderType(type.inherits, typesDict) + + type.properties = { + ...ancestor?.properties || {}, + ...type.properties || {} + } } + + return type } - return type -} + /** + * @private + * + * Get the full specification + * for a type by its id without + * going through the cache + * + * This is only to + * be used internally, + * always prefer the + * cached version + * + * @param { String } id The id of a type + */ + async getTypeUncached (id) { + const types = this.#props.State.getLocalState()?._types || + await this.#props.State.get('_types') -/** - * @private - * - * Get the full specification - * for a type by its id without - * going through the cache - * - * This is only to - * be used internally, - * always prefer the - * cached version - * - * @param { String } id The id of a type - */ -async function getTypeUncached (id) { - const types = state.getLocalState()?._types || - await state.get('_types') + return this.renderType(id, types) + } - return renderType(id, types) -} + /** + * Get the full specification + * for a type by its id + * + * @param { String } id The id of a type + */ + async getType (id) { + /* + Use caching if it's safe to do so -/** - * Get the full specification - * for a type by its id - * - * @param { String } id The id of a type - */ -async function getType (id) { - /* - Use caching if it's safe to do so - - The cache key must depend on the local state revision - in order to not get out of date, and that will only - get updated if the client is listening for the - 'state.change' event - */ - if ( - events.hasRemoteHandler('state.change') && - state.getLocalRevision() !== 0 - ) { - return cache.cache(`${id}::${state.getLocalRevision()}`, async () => getTypeUncached(id)) + The cache key must depend on the local this.#props.State revision + in order to not get out of date, and that will only + get updated if the client is listening for the + 'this.#props.State.change' event + */ + if ( + this.#props.Events.hasRemoteHandler('this.#props.State.change') && + this.#props.State.getLocalRevision() !== 0 + ) { + return this.#cache.cache(`${id}::${this.#props.State.getLocalRevision()}`, async () => this.getTypeUncached(id)) + } + + return this.getTypeUncached(id) } - return getTypeUncached(id) + /** + * Register a type + * by its specification + * @param { TypeSpecification } spec A type specification + * @returns { Promise. } + */ + registerType (spec) { + return this.#props.Commands.executeCommand('types.registerType', spec) + } } -exports.getType = getType -/** - * Register a type - * by its specification - * @param { TypeSpecification } spec A type specification - * @returns { Promise. } - */ -function registerType (spec) { - return commands.executeCommand('types.registerType', spec) -} -exports.registerType = registerType +DIController.main.register('Types', Types, [ + 'State', + 'Events', + 'Commands' +]) diff --git a/api/variables.js b/api/variables.js index 1266e9f..43b3c7f 100644 --- a/api/variables.js +++ b/api/variables.js @@ -1,77 +1,85 @@ -// SPDX-FileCopyrightText: 2023 Sveriges Television AB +// SPDX-FileCopyrightText: 2025 Sveriges Television AB // // SPDX-License-Identifier: MIT const objectPath = require('object-path') -const commands = require('./commands') -const state = require('./state') +const DIController = require('../shared/DIController') const VARIABLE_REGEX = /\$\((.*?)\)/g -/** - * Set a variable's value - * @param { String } key - * @param { any } value - */ -function setVariable (key, value) { - return commands.executeCommand('variables.setVariable', key, value) -} -exports.setVariable = setVariable +class Variables { + #props -/** - * Get a variable's value - * @param { String } key - * @returns { Promise. } - */ -function getVariable (key) { - return commands.executeCommand('variables.getVariable', key) -} -exports.getVariable = getVariable + constructor (props) { + this.#props = props + } -/** - * Get all variables' values - * @returns { Promise. } - */ -async function getAllVariables () { - return state.get('variables') -} -exports.getAllVariables = getAllVariables + /** + * Set a variable's value + * @param { String } key + * @param { any } value + */ + setVariable (key, value) { + return this.#props.Commands.executeCommand('variables.setVariable', key, value) + } -/** - * Substitute variables for their - * values in a string - * - * @example - * "Hello $(my variable)" -> "Hello world" - * - * @param { String } str - * @param { any } data Data to substitute variables for, - * defaults to the local state - * @param { any } overrideData Data that will override the - * default data rather than replace - * @returns { String } - */ -function substituteInString (str, data = (state.getLocalState()?.variables || {}), overrideData = {}) { - const text = str.split(VARIABLE_REGEX) - const values = { - ...data, - ...overrideData + /** + * Get a variable's value + * @param { String } key + * @returns { Promise. } + */ + getVariable (key) { + return this.#props.Commands.executeCommand('variables.getVariable', key) } - let out = '' - let i = 0 + /** + * Get all variables' values + * @returns { Promise. } + */ + async getAllVariables () { + return this.#props.State.get('variables') + } - while (text.length > 0) { - if (i % 2 === 0) { - out += text.shift() - } else { - const path = text.shift() - const value = objectPath.get(values, path) - out += value || '' + /** + * Substitute variables for their + * values in a string + * + * @example + * "Hello $(my variable)" -> "Hello world" + * + * @param { String } str + * @param { any } data Data to substitute variables for, + * defaults to the local this.#props.State + * @param { any } overrideData Data that will override the + * default data rather than replace + * @returns { String } + */ + substituteInString (str, data = (this.#props.State.getLocalState()?.variables || {}), overrideData = {}) { + const text = str.split(VARIABLE_REGEX) + const values = { + ...data, + ...overrideData } - i++ + + let out = '' + let i = 0 + + while (text.length > 0) { + if (i % 2 === 0) { + out += text.shift() + } else { + const path = text.shift() + const value = objectPath.get(values, path) + out += value || '' + } + i++ + } + return out } - return out } -exports.substituteInString = substituteInString + +DIController.main.register('Variables', Variables, [ + 'State', + 'Commands' +]) diff --git a/api/variables.unit.test.js b/api/variables.unit.test.js index e85dc07..7fcea18 100644 --- a/api/variables.unit.test.js +++ b/api/variables.unit.test.js @@ -2,7 +2,17 @@ // // SPDX-License-Identifier: MIT -const variables = require('./variables') +require('./variables') + +const DIController = require('../shared/DIController') + +let variables +beforeAll(() => { + variables = DIController.main.instantiate('Variables', { + State: {}, + Commands: {} + }) +}) test('substitutes variables in a string', () => { const str = 'This is a $(var 1) with multiple $(var2)' diff --git a/api/widgets.js b/api/widgets.js index 3cb17d7..d7e8e84 100644 --- a/api/widgets.js +++ b/api/widgets.js @@ -1,24 +1,35 @@ -// SPDX-FileCopyrightText: 2022 Sveriges Television AB +// SPDX-FileCopyrightText: 2025 Sveriges Television AB // // SPDX-License-Identifier: MIT -const state = require('./state') +const DIController = require('../shared/DIController') -/** - * Make a widget available - * to the application - * @param { - * id: String, - * name: String, - * uri: String, - * description: String - * } spec - */ -function registerWidget (spec) { - state.apply({ - _widgets: { - [spec.id]: spec - } - }) +class Widgets { + #props + + constructor (props) { + this.#props = props + } + + /** + * Make a widget available + * to the application + * @param { + * id: String, + * name: String, + * uri: String, + * description: String + * } spec + */ + registerWidget (spec) { + this.#props.State.apply({ + _widgets: { + [spec.id]: spec + } + }) + } } -exports.registerWidget = registerWidget + +DIController.main.register('Widgets', Widgets, [ + 'State' +]) diff --git a/app/components/Frame/index.jsx b/app/components/Frame/index.jsx index eb63d60..d8826af 100644 --- a/app/components/Frame/index.jsx +++ b/app/components/Frame/index.jsx @@ -104,18 +104,7 @@ export function Frame ({ src, api, doUpdateTheme = 1 }) { */ return { ...api, - events: { - ...api.events, - on: (arg0, arg1, opts = {}) => { - return api.events.on(arg0, arg1, { ...opts, callee: opts.callee || callee }) - }, - once: (arg0, arg1, opts = {}) => { - return api.events.once(arg0, arg1, { ...opts, callee: opts.callee || callee }) - }, - intercept: (arg0, arg1, opts = {}) => { - return api.events.intercept(arg0, arg1, { ...opts, callee: opts.callee || callee }) - } - } + events: api.events.createScope(callee) } } return {} diff --git a/app/components/FrameComponent/index.jsx b/app/components/FrameComponent/index.jsx index 48cb883..06a3320 100644 --- a/app/components/FrameComponent/index.jsx +++ b/app/components/FrameComponent/index.jsx @@ -111,18 +111,7 @@ export function FrameComponent ({ data, onUpdate }) { */ return { ...bridge, - events: { - ...bridge.events, - on: (arg0, arg1, opts = {}) => { - return bridge.events.on(arg0, arg1, { ...opts, callee: opts.callee || callee }) - }, - once: (arg0, arg1, opts = {}) => { - return bridge.events.once(arg0, arg1, { ...opts, callee: opts.callee || callee }) - }, - intercept: (arg0, arg1, opts = {}) => { - return bridge.events.intercept(arg0, arg1, { ...opts, callee: opts.callee || callee }) - } - } + events: bridge.events.createScope(callee) } } return {} diff --git a/app/components/Header/style.css b/app/components/Header/style.css index 6152980..c45a66d 100644 --- a/app/components/Header/style.css +++ b/app/components/Header/style.css @@ -12,7 +12,7 @@ align-items: center; justify-content: space-between; - + -webkit-app-region: drag; z-index: 2; @@ -91,6 +91,8 @@ for the traffic light align-items: center; justify-content: center; + -webkit-app-region: no-drag; + border-radius: 7px; overflow: hidden; } diff --git a/app/components/Onboarding/index.jsx b/app/components/Onboarding/index.jsx index 2b81def..a281eef 100644 --- a/app/components/Onboarding/index.jsx +++ b/app/components/Onboarding/index.jsx @@ -39,9 +39,9 @@ export function Onboarding ({ onClose = () => {} }) {

{content.heading}

{ - content.paragraphs.map(paragraph => { + content.paragraphs.map((paragraph, i) => { return ( -
+
{ paragraph.icon && diff --git a/docs/plugins/README.md b/docs/plugins/README.md index 8a6895f..5a4a1a0 100644 --- a/docs/plugins/README.md +++ b/docs/plugins/README.md @@ -95,6 +95,10 @@ The name of the plugin. *See [https://docs.npmjs.com/cli/v8/configuring-npm/package-json#main](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#main)* The package's main file which will be run in the main process. If not specified Bridge won't run any script on plugin initialization. +#### disabled +**Optional** +A boolean indicating whether this plugin is disabled or not, this is useful for toggling plugins during development + #### engines **Required** *See [https://docs.npmjs.com/cli/v8/configuring-npm/package-json#engines](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#engines)* diff --git a/lib/Workspace.js b/lib/Workspace.js index df2872e..9426880 100644 --- a/lib/Workspace.js +++ b/lib/Workspace.js @@ -18,7 +18,7 @@ const UserDefaults = require('./UserDefaults') const paths = require('./paths') const api = require('./api') -const template = require('./template/template.json') +const template = require('./template.json') const SOCKET_KEEPALIVE_TIMEOUT_MS = 20000 const SOCKET_CLEANUP_INTERVAL_MS = 1000 diff --git a/lib/plugin/PluginLoader.js b/lib/plugin/PluginLoader.js index 90f4d70..e036dc2 100644 --- a/lib/plugin/PluginLoader.js +++ b/lib/plugin/PluginLoader.js @@ -158,6 +158,7 @@ class PluginLoader { return bundles .filter(bundle => bundle) + .filter(manifest => !manifest.disabled) } /** diff --git a/lib/template/template.json b/lib/template.json similarity index 100% rename from lib/template/template.json rename to lib/template.json diff --git a/lib/template/TemplateLoader.js b/lib/template/TemplateLoader.js deleted file mode 100644 index b2491d2..0000000 --- a/lib/template/TemplateLoader.js +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Sveriges Television AB -// -// SPDX-License-Identifier: MIT - -const fs = require('fs') - -const Logger = require('../Logger') -const logger = new Logger({ name: 'TemplateLoader' }) - -class TemplateLoader { - /** - * Get the singleton - * instance of this class - * @returns { TemplateLoader } - */ - static getInstance () { - if (!this._instance) { - this._instance = new TemplateLoader() - } - return this._instance - } - - constructor (path) { - /** - * @private - * @type { String } - */ - this._path = path - } - - /** - * Set the directory path for this - * loader to look for templates in - * @param { String } dirpath - */ - setPath (dirpath) { - this._path = dirpath - } - - /** - * List all available templates - */ - async list () { - if (!this._path) { - throw new Error('No template path is set') - } - - logger.debug('Listing templates in directory', this._path) - const files = fs.promises.readdir(this._path) - console.log(files) - } -} -module.exports = TemplateLoader diff --git a/package-lock.json b/package-lock.json index 688ff74..f2c215b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "@electron/packager": "^18.3.3", "babel-loader": "^8.2.2", "css-loader": "^5.2.0", - "electron": "^22.3.6", + "electron": "^33.2.1", "eslint": "^8.38.0", "eslint-config-standard": "^17.0.0", "eslint-plugin-import": "^2.27.5", @@ -1856,10 +1856,11 @@ } }, "node_modules/@electron/get": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.2.tgz", - "integrity": "sha512-eFZVFoRXb3GFGd7Ak7W4+6jBl9wBtiZ4AaYOse97ej6mKj5tkyO0dUnUChs1IhJZtx1BENo4/p4WUTXpi6vT+g==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", @@ -1881,6 +1882,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" } @@ -3479,10 +3481,14 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.16.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.0.tgz", - "integrity": "sha512-BsAaKhB+7X+H4GnSjGhJG9Qi8Tw+inU9nJDwmD5CgOmBLEI6ArdhikpLX7DjbjDRDTbqZzU2LSQNZg8WGPiSZQ==", - "dev": true + "version": "20.17.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.11.tgz", + "integrity": "sha512-Ept5glCK35R8yeyIeYlRIZtX6SLRyqMhOFTgj5SOkMpLTdw3SEHI9fHx60xaUZ+V1aJxQJODE+7/j5ocZydYTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } }, "node_modules/@types/node-forge": { "version": "1.3.11", @@ -5647,14 +5653,15 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron": { - "version": "22.3.27", - "resolved": "https://registry.npmjs.org/electron/-/electron-22.3.27.tgz", - "integrity": "sha512-7Rht21vHqj4ZFRnKuZdFqZFsvMBCmDqmjetiMqPtF+TmTBiGne1mnstVXOA/SRGhN2Qy5gY5bznJKpiqogjM8A==", + "version": "33.3.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-33.3.0.tgz", + "integrity": "sha512-316ZlFUHJmzGrhRj87tVStxyYvknDqVR9eYSsGKAHY7auhVWFLIcPPGxcnbD/H1mez8CpDjXvEjcz76zpWxsXw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "dependencies": { "@electron/get": "^2.0.0", - "@types/node": "^16.11.26", + "@types/node": "^20.9.0", "extract-zip": "^2.0.1" }, "bin": { @@ -5670,12 +5677,6 @@ "integrity": "sha512-/bKPPcgZVUziECqDc+0HkT87+0zhaWSZHNXqF8FLd2lQcptpmUFwoCSWjCdOng9Gdq+afKArPdEg/0ZW461Eng==", "dev": true }, - "node_modules/electron/node_modules/@types/node": { - "version": "16.18.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.24.tgz", - "integrity": "sha512-zvSN2Esek1aeLdKDYuntKAYjti9Z2oT4I8bfkLLhIxHlv3dwZ5vvATxOc31820iYm4hQRCwjUgDpwSMFjfTUnw==", - "dev": true - }, "node_modules/emittery": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz", @@ -7242,6 +7243,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", @@ -13911,6 +13913,13 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -16029,9 +16038,9 @@ } }, "@electron/get": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.2.tgz", - "integrity": "sha512-eFZVFoRXb3GFGd7Ak7W4+6jBl9wBtiZ4AaYOse97ej6mKj5tkyO0dUnUChs1IhJZtx1BENo4/p4WUTXpi6vT+g==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", "dev": true, "requires": { "debug": "^4.1.1", @@ -17305,10 +17314,13 @@ "dev": true }, "@types/node": { - "version": "18.16.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.0.tgz", - "integrity": "sha512-BsAaKhB+7X+H4GnSjGhJG9Qi8Tw+inU9nJDwmD5CgOmBLEI6ArdhikpLX7DjbjDRDTbqZzU2LSQNZg8WGPiSZQ==", - "dev": true + "version": "20.17.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.11.tgz", + "integrity": "sha512-Ept5glCK35R8yeyIeYlRIZtX6SLRyqMhOFTgj5SOkMpLTdw3SEHI9fHx60xaUZ+V1aJxQJODE+7/j5ocZydYTg==", + "dev": true, + "requires": { + "undici-types": "~6.19.2" + } }, "@types/node-forge": { "version": "1.3.11", @@ -18987,22 +18999,14 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "electron": { - "version": "22.3.27", - "resolved": "https://registry.npmjs.org/electron/-/electron-22.3.27.tgz", - "integrity": "sha512-7Rht21vHqj4ZFRnKuZdFqZFsvMBCmDqmjetiMqPtF+TmTBiGne1mnstVXOA/SRGhN2Qy5gY5bznJKpiqogjM8A==", + "version": "33.3.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-33.3.0.tgz", + "integrity": "sha512-316ZlFUHJmzGrhRj87tVStxyYvknDqVR9eYSsGKAHY7auhVWFLIcPPGxcnbD/H1mez8CpDjXvEjcz76zpWxsXw==", "dev": true, "requires": { "@electron/get": "^2.0.0", - "@types/node": "^16.11.26", + "@types/node": "^20.9.0", "extract-zip": "^2.0.1" - }, - "dependencies": { - "@types/node": { - "version": "16.18.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.24.tgz", - "integrity": "sha512-zvSN2Esek1aeLdKDYuntKAYjti9Z2oT4I8bfkLLhIxHlv3dwZ5vvATxOc31820iYm4hQRCwjUgDpwSMFjfTUnw==", - "dev": true - } } }, "electron-to-chromium": { @@ -25132,6 +25136,12 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, + "undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true + }, "unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", diff --git a/package.json b/package.json index 521c87b..d4b16de 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@electron/packager": "^18.3.3", "babel-loader": "^8.2.2", "css-loader": "^5.2.0", - "electron": "^22.3.6", + "electron": "^33.2.1", "eslint": "^8.38.0", "eslint-config-standard": "^17.0.0", "eslint-plugin-import": "^2.27.5", diff --git a/plugins/inspector/app/components/Form/index.jsx b/plugins/inspector/app/components/Form/index.jsx index ba7c372..86edbd5 100644 --- a/plugins/inspector/app/components/Form/index.jsx +++ b/plugins/inspector/app/components/Form/index.jsx @@ -36,14 +36,14 @@ import { Notification } from '../../../../../app/components/Notification' import { TextInput } from '../TextInput' import { ColorInput } from '../ColorInput' -import { StringInput } from '../StringInput' import { SelectInput } from '../SelectInput' import { BooleanInput } from '../BooleanInput' - +import { VariableHint } from '../VariableHint' +import { VariableStringInput } from '../VariableStringInput' const INPUT_COMPONENTS = { boolean: BooleanInput, - string: StringInput, + string: VariableStringInput, color: ColorInput, enum: SelectInput, text: TextInput, @@ -128,6 +128,20 @@ function orderByGroups (properties) { export function Form () { const [store] = React.useContext(StoreContext) const [groups, setGroups] = React.useState({}) + const [globalVariableContext, setGlobalVariableContext] = React.useState({}) + + /** + * Get all global variables when the selection + * changes in order to populate the context and + * provide completions + */ + React.useEffect(() => { + async function get () { + const vars = await bridge.variables.getAllVariables() + setGlobalVariableContext(vars) + } + get() + }, [store?.selection]) /* Store the value that's currently being edited @@ -137,6 +151,18 @@ export function Form () { */ const [localData, setLocalData] = React.useState({}) + /** + * A reference to the + * first selected item + */ + const firstItem = store?.items?.[0] + + /** + * The context used for + * variable suggestions + */ + const variableContext = {this: firstItem, ...globalVariableContext} + /* Find out what the common properties are to sort them into groups and @@ -209,13 +235,19 @@ export function Form () { */ function renderProperty (property, id) { const Component = INPUT_COMPONENTS[property.type] + + function handleVariableHintClick () { + const currentVal = getValue(property.key) + handleDataChange(property.key, `${currentVal || ''}$(`) + } + return (
{ property.allowsVariables && -
=
+ handleVariableHintClick()} /> }
{ @@ -232,6 +264,7 @@ export function Form () { htmlFor={id} data={property} value={getValue(property.key)} + variableContext={property.allowsVariables && variableContext} onChange={value => handleDataChange(property.key, value)} /> { @@ -270,8 +303,11 @@ export function Form () {
- - handleDataChange('name', value)} large /> +
+ + { handleDataChange('name', `${getValue('name') || ''}$(`) }}/> +
+ handleDataChange('name', value)} large />
diff --git a/plugins/inspector/app/components/Form/style.css b/plugins/inspector/app/components/Form/style.css index 46bea6c..5b5781e 100644 --- a/plugins/inspector/app/components/Form/style.css +++ b/plugins/inspector/app/components/Form/style.css @@ -127,17 +127,6 @@ border-top: 1px solid var(--base-color--shade); } -.Form-inputVariableHint { - margin-bottom: 2px; - padding: 1px 5px; - - border-radius: 7px; - background: var(--base-color--shade); - font-size: 0.9em; - - box-sizing: border-box; -} - .Form-notifications .Notification { margin-top: 5px; margin-bottom: 15px; diff --git a/plugins/inspector/app/components/StringInput/index.jsx b/plugins/inspector/app/components/StringInput/index.jsx index 53f5162..2f54592 100644 --- a/plugins/inspector/app/components/StringInput/index.jsx +++ b/plugins/inspector/app/components/StringInput/index.jsx @@ -11,6 +11,7 @@ export function StringInput ({ htmlFor, value = '', onChange = () => {}, + onKeyDown = () => {}, large }) { return ( @@ -20,6 +21,7 @@ export function StringInput ({ className={`StringInput ${large ? 'StringInput--large' : ''}`} value={value || ''} onChange={e => onChange(e.target.value)} + onKeyDown={e => onKeyDown(e)} /> ) } diff --git a/plugins/inspector/app/components/VariableHint/index.jsx b/plugins/inspector/app/components/VariableHint/index.jsx new file mode 100644 index 0000000..2298e16 --- /dev/null +++ b/plugins/inspector/app/components/VariableHint/index.jsx @@ -0,0 +1,8 @@ +import React from 'react' +import './style.css' + +export function VariableHint ({ onClick = () => {} }) { + return ( +
onClick()}>VAR
+ ) +} diff --git a/plugins/inspector/app/components/VariableHint/style.css b/plugins/inspector/app/components/VariableHint/style.css new file mode 100644 index 0000000..b61c4db --- /dev/null +++ b/plugins/inspector/app/components/VariableHint/style.css @@ -0,0 +1,10 @@ +.VariableHint { + margin-bottom: 2px; + padding: 2px 5px; + + border-radius: 7px; + background: var(--base-color--shade); + font-size: 0.8em; + + box-sizing: border-box; +} \ No newline at end of file diff --git a/plugins/inspector/app/components/VariableStringInput/index.jsx b/plugins/inspector/app/components/VariableStringInput/index.jsx new file mode 100644 index 0000000..02e5b63 --- /dev/null +++ b/plugins/inspector/app/components/VariableStringInput/index.jsx @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2024 Sveriges Television AB + * + * SPDX-License-Identifier: MIT + */ + +import React from 'react' +import './style.css' + +import { StringInput } from '../StringInput' + +const VARIABLE_REGEX = /\$\(/g + +function stringEndsWithUnclosedVariable (str) { + if (!str) { + return false + } + const parts = `${str}`.split(VARIABLE_REGEX) + const lastPart = parts[parts.length - 1] + return lastPart.indexOf(')') === -1 && parts.length > 1 +} + +function getLastUnfinishedVariable (str) { + const parts = str.split(VARIABLE_REGEX) + return parts[parts.length - 1] +} + +function getCompletion (str, completions) { + const matches = completions + .filter(completion => completion.indexOf(str) === 0) + .sort((a, b) => a.length - b.length) + + return matches[0] +} + +function getPathsFromObject (obj) { + const out = [] + for (const key of Object.keys(obj)) { + out.push(key) + + if (typeof obj[key] === 'object') { + const subpaths = getPathsFromObject(obj[key]) + .map(subpath => `${key}.${subpath}`) + out.push(...subpaths) + } + } + return out +} + +export function VariableStringInput ({ + htmlFor, + value = '', + onChange = () => {}, + variableContext = {}, + large +}) { + const [suggestion, setSuggestion] = React.useState() + const paths = React.useMemo(() => { + return getPathsFromObject(variableContext) + }, [variableContext]) + + React.useEffect(() => { + if (!stringEndsWithUnclosedVariable(value)) { + setSuggestion('') + return + } + + const lastUnfinishedVariable = getLastUnfinishedVariable(value) + const suggestion = getCompletion(lastUnfinishedVariable, paths) + + if (!suggestion) { + setSuggestion('') + return + } + + setSuggestion(suggestion.substring(lastUnfinishedVariable.length)) + }, [value, paths]) + + function handleKeyDown (e) { + if (!suggestion) { + return + } + if (e.key === 'ArrowRight' || e.key === 'Tab') { + e.preventDefault() + onChange(value += suggestion) + } + } + + return ( +
+
{value}{suggestion}
+ onChange(newValue)} + onKeyDown={e => handleKeyDown(e)} + large={large} + /> +
+ ) +} diff --git a/plugins/inspector/app/components/VariableStringInput/style.css b/plugins/inspector/app/components/VariableStringInput/style.css new file mode 100644 index 0000000..46a376f --- /dev/null +++ b/plugins/inspector/app/components/VariableStringInput/style.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2022 Sveriges Television AB + * + * SPDX-License-Identifier: MIT + */ + +.VariableStringInput { + position: relative; + width: 100%; +} + +.VariableStringInput-suggestion { + position: absolute; + padding: 0.75em 0.6em; + pointer-events: none; + opacity: 0.5; + + white-space: nowrap; +} \ No newline at end of file diff --git a/shared/DIController.js b/shared/DIController.js new file mode 100644 index 0000000..999be58 --- /dev/null +++ b/shared/DIController.js @@ -0,0 +1,54 @@ +const DIControllerError = require('./DIControllerError') + +class DIController { + static main = new DIController() + + #index = new Map() + + register (name, object, requirements = []) { + if (this.#index.has(name)) { + throw new DIControllerError('Entry has already been registered') + } + this.#index.set(name, { + object, + requirements + }) + } + + instantiate (name, scope = {}, ...args) { + const requirements = this.#getRequirements(name) + const props = {} + + for (const requirement of requirements) { + if (!scope[requirement]) { + scope[requirement] = this.instantiate(requirement, scope) + } + props[requirement] = scope[requirement] + } + + return new (this.#getObject(name))(props, ...(args || [])) + } + + #getEntry (name) { + const entry = this.#index.get(name) + if (!entry) { + throw new DIControllerError('No registered entry was found with the provided name') + } + return entry + } + + #getObject (name) { + const entry = this.#getEntry(name) + if (!entry?.object) { + throw new DIControllerError('Missing object for entry') + } + return entry.object + } + + #getRequirements (name) { + const entry = this.#getEntry(name) + return entry?.requirements || [] + } +} + +module.exports = DIController diff --git a/shared/DIController.unit.test.js b/shared/DIController.unit.test.js new file mode 100644 index 0000000..2cb92ae --- /dev/null +++ b/shared/DIController.unit.test.js @@ -0,0 +1,27 @@ +const DIController = require('./DIController') + +class A { + constructor (props) { + this.B = props.B + } + + foo () { + return this.B.foo() + } +} + +class B { + foo () { + return 'bar' + } +} + +beforeAll (() => { + DIController.main.register('A', A, ['B']) + DIController.main.register('B', B) +}) + +test('instantiate an object with a requirement', () => { + const a = DIController.main.instantiate('A') + expect(a.foo()).toBe('bar') +}) diff --git a/shared/DIControllerError.js b/shared/DIControllerError.js new file mode 100644 index 0000000..f8625c3 --- /dev/null +++ b/shared/DIControllerError.js @@ -0,0 +1,8 @@ +class DIControllerError extends Error { + constructor (msg) { + super(msg) + this.name = DIControllerError + } +} + +module.exports = DIControllerError