adaptorex
Version:
Connect all your live interactive storytelling devices and software
546 lines (488 loc) • 15.8 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)
}