adaptorex
Version:
Connect all your live interactive storytelling devices and software
564 lines (508 loc) • 15.2 kB
JavaScript
/**
* Contains useful unspecific classes and functions
* may be required by all adaptor modules.
*
* index.js will make all functions and the log available via global namespace
*
* @requires @apidevtools/json-schema-ref-parser
* @requires npmlog
* @requires events
* @requires fs-extra
* @requires nanoid
* @requires os
*
* @requires file
*
* @module adaptor
* @copyright Lasse Marburg 2022
* @license MIT
*/
const { EventEmitter } = require("events")
var npmlog = require("npmlog")
const ref_parser = require("@apidevtools/json-schema-ref-parser")
const { nanoid } = require("nanoid")
const os = require("os")
const fs = require("fs-extra")
const file = require("./file.js")
const { Variables } = require("./variables.js")
const npm_package = require("./package.json")
const { Plugin } = require("./plugins/plugin.js")
const { PluginItem } = require("./plugins/plugin_items.js")
const messenger = require("./plugins/messenger.js")
const logic = require("./plugins/logic/logic.js")
const socketio = require("./plugins/socketio/socketio.js")
global.adaptor = {}
adaptor.info = npm_package
adaptor.locale = Intl.DateTimeFormat().resolvedOptions().locale
adaptor.parseJSON = Variables.parseJSON
adaptor.parseQuery = Variables.parseQuery
adaptor.messenger = messenger
adaptor.logic = logic
adaptor.socketio = socketio
adaptor.Plugin = Plugin
adaptor.PluginItem = PluginItem
/** webhook URL names for this adaptor instance and server */
adaptor.webhooks = []
/**
* logging class based on [npmlog]{@link https://www.npmjs.com/package/npmlog}.
*
* patches unexpected behavior of npmlog: log of Error object results in duplicated output.
* see: https://github.com/npm/npmlog/issues/62 and https://github.com/npm/npmlog/blob/master/log.js#L182
*
* Uses fs stream to write logs to logfile
*/
adaptor.Log = class {
constructor() {
npmlog.addLevel("debug", 1000, { fg: "blue" })
npmlog.addLevel("trace", 500, { fg: "grey" })
Object.assign(this, npmlog)
this.error = function (prefix, message, ...args) {
if (message instanceof Error && message.stack) {
message = message.stack
}
npmlog.error(prefix, message, ...args)
}
}
/**
* initialize log write stream
*/
init(log_level, logfile_dir) {
return new Promise((resolve, reject) => {
npmlog.level = log_level
file.mkdir(logfile_dir)
let date = this.getPrettyDate()
date = date.replace(/\:/gm, "-").slice(0, -4)
let logfile = logfile_dir + "/" + date + ".log"
var logstream = fs.createWriteStream(logfile, { flags: "a" })
logstream.on("open", (res) => {
npmlog.on("log", (l) => {
logstream.write(
this.getPrettyDate() +
" " +
l.level.toUpperCase() +
" " +
l.prefix +
" " +
l.message +
"\n"
)
})
return resolve()
})
})
}
/**
* Allow to connect socket for each of the dynamic namespaces /log/{level}
*
* Publish each new log message if it is in the domain of the level
*
* On connect publishes a list of latest 50 log records in the level domain
*
* @param {Object} socket - socket.io connection object
*/
createSocket(socketio) {
socketio.on("connection", (socket) => {
let level = socket.nsp.name.split("/").pop()
let latest = []
for (let r of npmlog.record) {
if (npmlog.levels[r.level] >= npmlog.levels[level]) {
latest.push({
id: r.id,
level: r.level,
topic: r.prefix,
message: r.message
})
}
}
latest = latest.slice(-50)
if (latest.length) {
socket.emit("log", latest)
}
let log_event = (l) => {
if (npmlog.levels[l.level] >= npmlog.levels[level]) {
socket.emit("log", [
{ id: l.id, level: l.level, topic: l.prefix, message: l.message }
])
}
}
npmlog.on("log", log_event)
socket.on("disconnect", (e) => {
npmlog.off("log", log_event)
})
})
}
/**
* change current log level
* - trace (show everything)
* - debug
* - info
* - warn
* - error
* - silent (logging off)
* @param {string} log_level
*/
setLevel(log_level) {
npmlog.level = log_level
console.log("log level changed to '" + log_level + "'")
}
/**
* return current date time in following string format:
* [year]-[month]-[day] [hour]-[minute]-[second]
*/
getPrettyDate() {
let tzoffset = new Date().getTimezoneOffset() * 60000 //offset in milliseconds
return new Date(Date.now() - tzoffset)
.toISOString()
.slice(0, -1)
.replace("T", " ")
}
/**
* get log functions that prepends context info.
* context log is itself a function that uses 'debug' log
*
* @param {string} context - log context that is prepended to each log message
* @returns {Object} - log functions that aut prepend context
*/
getContextLog(context) {
let context_log = (msg) => {
this.debug(context, msg)
}
;["trace", "debug", "info", "warn", "error"].forEach((log_level) => {
context_log[log_level] = (msg) => {
this[log_level](context, msg)
}
})
return context_log
}
}
adaptor.log = new adaptor.Log()
/**
* Create an automated First in, first out queue. Runs a callback whenever something was added to the queue using the element as callback argument.
*
* When callback is done queue will go on running the callback with the next items in stack until its.
*/
adaptor.Queue = class {
/**
* @param {function} callback - function to call on each element in queue
*/
constructor(callback) {
/** argument item list */
this.items = []
/** function to be called on each item */
this.callback = callback
/** emits empty event */
this.event = new EventEmitter()
/** queue will not accept any more items when joined */
this.joined = false
log.debug("queue", "create queue with function " + callback.name)
}
/**
* add an item to queue. If queue is empty, run callback right away with item as argument.
*
* goto next once callback resolves
*
* use return_callback to feedback the result of queue callback
*
* @param {*} item - argument for callback put into queue
* @param {function} return_callback - is called with result arg once queue callback resolves
*/
put(item, return_callback) {
let elem = {
arg: item,
call: return_callback
}
if (this.joined) {
return_callback(undefined)
return
}
if (this.items.length == 0) {
this.execute(elem)
}
this.items.push(elem)
}
execute(elem) {
Promise.resolve(this.callback(elem["arg"]))
.then((result) => {
elem.call(result)
this.next()
})
.catch((err) => {
this.items = []
elem.call({ error: err })
})
}
/**
* release latest element from queue. If there is another, run callback with next element.
*
* When callback resolves, proceed with next element if any.
*
* Emit empty event once there are no more elements enqueued
*/
next() {
if (this.items.length > 0) {
this.items.shift()
}
if (this.items.length > 0) {
this.execute(this.items[0])
} else {
this.event.emit("empty")
}
}
/**
* wait for queue to finish. Block adding of further elements until finished.
*
* @returns {Promise} - resolves once queue is done and empty
*/
join() {
return new Promise((resolve, reject) => {
this.joined = true
if (this.items.length > 0) {
this.event.on("empty", (e) => {
this.joined = false
return resolve()
})
} else {
this.joined = false
return resolve()
}
})
}
/**
* join queue but only wait for current item to finish.
* Remove all leftover items.
*/
cancel() {
if (this.items.length > 1) {
this.items = [this.items.shift()]
log.debug("queue", "cancel and drop " + this.items.length + " items")
}
return this.join()
}
}
/**
*
* @returns object of local ip4 IP adresses (name:ip)
*/
adaptor.getIPs = function () {
const interfaces = os.networkInterfaces()
const results = Object.create(null)
for (const name of Object.keys(interfaces)) {
for (const net of interfaces[name]) {
if (net.family === "IPv4" && !net.internal) {
if (!results[name]) {
results[name] = []
}
results[name].push(net.address)
}
}
}
return results
}
/**
* Generate short Unique Identifier String
*
* @returns {string} - uid string
*/
adaptor.createId = function () {
return nanoid(8)
}
/**
* Find out if a string or number is an integer value
*
* @param {string|number} value
* @returns {boolean} - true if value is int or string that can be parsed to int
*/
adaptor.isInt = function (value) {
return (
!isNaN(value) &&
parseInt(Number(value)) == value &&
!isNaN(parseInt(value, 10))
)
}
/**
* check if Object has any properties
* Checkout this post:
* https://coderwall.com/p/_g3x9q/how-to-check-if-javascript-object-is-empty
*
* @param {Object} obj - object to check on emptyness
*
* @returns {boolean} false if object is not empty
*/
adaptor.isEmpty = function (obj) {
for (var key in obj) {
if (obj.hasOwnProperty(key)) return false
}
return true
}
/**
* see if a list of properties are all own properties of an object
*
* @param {Object} obj - Object to review
* @param {Array} properties - list of property names (each a string)
*
* @returns {boolean} - true if all properties are own properties of object. Otherwise false
*/
adaptor.hasOwnProperties = function (obj, properties) {
for (var i = 0; i < properties.length; i++) {
if (!obj.hasOwnProperty(properties[i])) return false
}
return true
}
/**
* address object property with '.' seperated string
* Comes in handy especially when working with Mongodb and nested document find queries.
* Mongo uses [dot notation]{@link https://docs.mongodb.com/manual/core/document/#document-dot-notation}
* Inspired by:
* {@link https://stackoverflow.com/questions/6491463/accessing-nested-javascript-objects-with-string-key/6491621#6491621}
*
* @param {Object} o - some Object
* @param {string} s - some string that finds nested properties with '.' notation
*
* @returns {*} - value or object that was found in '.' noted path. undefined if path doesn't exist in o
*/
adaptor.getPath = function (o, s) {
s = s.replace(/\[(\w+)\]/g, ".$1") // convert indexes to properties
s = s.replace(/^\./, "") // strip a leading dot
var a = s.split(".")
for (var i = 0, n = a.length; i < n; ++i) {
var k = a[i]
if (typeof o !== "object" || Array.isArray(o)) {
log.warn("getPath", o + " is not a valid object")
return
}
if (k in o) {
o = o[k]
} else {
return
}
}
return o
}
/**
* assign object property with '.' seperated string path
*
* Inspired by:
* {@link https://stackoverflow.com/questions/13719593/how-to-set-object-property-of-object-property-of-given-its-string-name-in-ja/13719799#13719799}
*
* @param {Object} obj - object to assign value to
* @param {string} prop - path
* @param {*} value - value to assign to object at path
*/
adaptor.assignPath = function (obj, prop, value) {
if (typeof prop === "string") prop = prop.split(".")
if (prop.length > 1) {
var e = prop.shift()
adaptor.assignPath(
(obj[e] =
Object.prototype.toString.call(obj[e]) === "[object Object]"
? obj[e]
: {}),
prop,
value
)
} else {
obj[prop[0]] = value
}
}
/**
* check if Objects have the same key/value pairs (keys also have to be in the same Order)
* Simple string comparison
* @todo find a more efficient and safe way then JSON.stringify comparison
* @param {Object} objA - compare Object A
* @param {Object} objB - compare Object B
*/
adaptor.equalValues = function (objA, objB) {
if (JSON.stringify(objA) == JSON.stringify(objB)) {
return true
} else {
return false
}
}
/**
* compare two objects and return what changes happened during the transition from
* one object to the other. Checks for equality in each property and recognizes if
* a property has changed (updated), is new (updated) or was removed.
*
* @param {Object} original - Original object
* @param {Object} changed - Object that derived from original Object
*
* @returns {Object} 'updated' contains all keys and their values that are not equal or have
* no equal key - value pair in the original object. 'removed' contains a list of all missing properties
* in the changed object
*/
adaptor.changes = function (original_object, changed_object) {
let changes = { updated: {}, removed: [] }
for (let key in original_object) {
if (changed_object.hasOwnProperty(key)) {
if (!adaptor.equalValues(changed_object[key], original_object[key])) {
changes.updated[key] = changed_object[key]
}
} else {
changes.removed.push(key)
}
}
for (let key in changed_object) {
if (!original_object.hasOwnProperty(key)) {
changes.updated[key] = changed_object[key]
}
}
return changes
}
/**
* make an object clone using serialization. Leave no references even in nested properties.
* This does not work with complex object properties like functions, classes, dates etc.!
*
* @param {Object} source - Object to be deep cloned
*
* @returns {Object} - clone of object
*/
adaptor.deepClone = function (source) {
return JSON.parse(JSON.stringify(source))
}
/**
* perform a function on all Object properties, including nested properties.
*
* is not being used to date.
*
* similar to, though way more primitive than, lodash {@link https://lodash.com/docs/4.17.15#transform|_transform}
* @param {Object} obj - Object to be transformed
* @param {function} funct - function to perform on Object properties
*/
adaptor.transform = function (obj, funct) {
var result = null
if (obj instanceof Array) {
for (var i = 0; i < theObject.length; i++) {
result = transform(obj[i], funct)
if (result) {
break
}
}
} else {
for (var prop in obj) {
funct(prop, obj)
if (obj[prop] instanceof Object || obj[prop] instanceof Array) {
result = transform(obj[prop], funct)
if (result) {
break
}
}
}
}
return result
}
/**
* Use json-schema-ref-parser to resolve $ref references in json schema
*
* @param {Object} obj - json schema with references to be resolved
*/
adaptor.dereference = async function (obj) {
return ref_parser.dereference(obj)
}
/**
* check if Objects is of type object and not an array or null
* @param {*} obj Object to check
* @return {Boolean}
*/
adaptor.isObject = function (value) {
return typeof value === "object" && !Array.isArray(value) && value !== null
}