@engine9-io/input-tools
Version:
Tools for dealing with Engine9 inputs
338 lines (293 loc) • 10.9 kB
JavaScript
const fs = require('node:fs');
const path = require('node:path');
const dayjs = require('dayjs');
const debug = require('debug')('@engine9/input-tools');
const unzipper = require('unzipper');
const { v4: uuidv4, v5: uuidv5, v7: uuidv7, validate: uuidIsValid } = require('uuid');
const archiver = require('archiver');
const handlebars = require('handlebars');
const FileUtilities = require('./file/FileUtilities');
const {
appendPostfix,
bool,
getManifest,
getFile,
downloadFile,
getTempFilename,
getTempDir,
isValidDate,
relativeDate,
streamPacket,
getPacketFiles,
getBatchTransform,
getDebatchTransform,
getStringArray,
makeStrings,
writeTempFile
} = require('./file/tools');
const ForEachEntry = require('./ForEachEntry');
const { TIMELINE_ENTRY_TYPES } = require('./timelineTypes');
function getFormattedDate(dateObject, format = 'MMM DD,YYYY') {
let d = dateObject;
if (d === 'now') d = new Date();
if (d) return dayjs(d).format(format);
return '';
}
handlebars.registerHelper('date', (d, f) => {
let format;
if (typeof f === 'string') format = f;
return getFormattedDate(d, format);
});
handlebars.registerHelper('json', (d) => JSON.stringify(d));
handlebars.registerHelper('uuid', () => uuidv7());
handlebars.registerHelper('percent', (a, b) => `${((100 * a) / b).toFixed(2)}%`);
handlebars.registerHelper('or', (a, b, c) => a || b || c);
async function list(_path) {
const directory = await unzipper.Open.file(_path);
return new Promise((resolve, reject) => {
directory.files[0].stream().pipe(fs.createWriteStream('firstFile')).on('error', reject).on('finish', resolve);
});
}
async function extract(_path, _file) {
const directory = await unzipper.Open(_path);
// return directory.files.map((f) => f.path);
const file = directory.files.find((d) => d.path === _file);
const tempFilename = await getTempFilename({ source: _file });
return new Promise((resolve, reject) => {
file.stream().pipe(fs.createWriteStream(tempFilename)).on('error', reject).on('finish', resolve);
});
}
function appendFiles(existingFiles, _newFiles, options) {
const newFiles = getStringArray(_newFiles);
if (newFiles.length === 0) return;
let { type, dateCreated } = options || {};
if (!type) type = 'unknown';
if (!dateCreated) dateCreated = new Date().toISOString();
let arr = newFiles;
if (!Array.isArray(newFiles)) arr = [arr];
arr.forEach((p) => {
const item = {
type,
originalFilename: '',
isNew: true,
dateCreated
};
if (typeof p === 'string') {
item.originalFilename = path.resolve(process.cwd(), p);
} else {
item.originalFilename = path.resolve(process.cwd(), item.originalFilename);
}
const file = item.originalFilename.split(path.sep).pop();
item.path = `${type}/${file}`;
const existingFile = existingFiles.find((f) => f.path === item.path);
if (existingFile) throw new Error('Error adding files, duplicate path found for path:', +item.path);
existingFiles.push(item);
});
}
async function create(options) {
const {
accountId = 'engine9',
pluginId = '',
target = '', // target filename, creates one if not specified
messageFiles = [], // file with contents of message, used for delivery
personFiles = [], // files with data on people
timelineFiles = [], // activity entry
statisticsFiles = [] // files with aggregate statistics
} = options;
if (options.peopleFiles) throw new Error('Unknown option: peopleFiles, did you mean personFiles?');
const files = [];
const dateCreated = new Date().toISOString();
appendFiles(files, messageFiles, { type: 'message', dateCreated });
appendFiles(files, personFiles, { type: 'person', dateCreated });
appendFiles(files, timelineFiles, { type: 'timeline', dateCreated });
appendFiles(files, statisticsFiles, { type: 'statistics', dateCreated });
const zipFilename = target || (await getTempFilename({ postfix: '.packet.zip' }));
const manifest = {
accountId,
source: {
pluginId
},
dateCreated,
files
};
// create a file to stream archive data to.
const output = fs.createWriteStream(zipFilename);
const archive = archiver('zip', {
zlib: { level: 9 } // Sets the compression level.
});
return new Promise((resolve, reject) => {
debug(`Setting up write stream to ${zipFilename}`);
// listen for all archive data to be written
// 'close' event is fired only when a file descriptor is involved
output.on('close', () => {
debug('archiver has been finalized and the output file descriptor has closed, calling success');
debug(zipFilename);
return resolve({
filename: zipFilename,
bytes: archive.pointer()
});
});
// This event is fired when the data source is drained no matter what was the data source.
// It is not part of this library but rather from the NodeJS Stream API.
// @see: https://nodejs.org/api/stream.html#stream_event_end
output.on('end', () => {
// debug('end event -- Data has been drained');
});
// warnings could be file not founds, etc, but we error even on those
archive.on('warning', (err) => {
reject(err);
});
// good practice to catch this error explicitly
archive.on('error', (err) => {
reject(err);
});
archive.pipe(output);
files.forEach(({ path: name, originalFilename }) => archive.file(originalFilename, { name }));
files.forEach((f) => {
delete f.originalFilename;
delete f.isNew;
});
archive.append(Buffer.from(JSON.stringify(manifest, null, 4), 'utf8'), { name: 'manifest.json' });
archive.finalize();
});
}
function intToByteArray(_v) {
// we want to represent the input as a 8-bytes array
const byteArray = [0, 0, 0, 0, 0, 0, 0, 0];
let v = _v;
for (let index = 0; index < byteArray.length; index += 1) {
const byte = v & 0xff;
byteArray[index] = byte;
v = (v - byte) / 256;
}
return byteArray;
}
function getPluginUUID(uniqueNamespaceLikeDomainName, valueWithinNamespace) {
// Random custom namespace for plugins -- not secure, just a namespace:
return uuidv5(`${uniqueNamespaceLikeDomainName}::${valueWithinNamespace}`, 'f9e1024d-21ac-473c-bac6-64796dd771dd');
}
function getInputUUID(a, b) {
let pluginId = a;
let remoteInputId = b;
if (typeof a === 'object') {
pluginId = a.pluginId;
remoteInputId = a.remoteInputId;
}
if (!pluginId) throw new Error('getInputUUID: Cowardly rejecting a blank plugin_id');
if (!uuidIsValid(pluginId)) throw new Error(`Invalid pluginId:${pluginId}, should be a UUID`);
const rid = (remoteInputId || '').trim();
if (!rid) throw new Error('getInputUUID: Cowardly rejecting a blank remote_input_id, set a default');
// Random custom namespace for inputs -- not secure, just a namespace:
// 3d0e5d99-6ba9-4fab-9bb2-c32304d3df8e
return uuidv5(`${pluginId}:${rid}`, '3d0e5d99-6ba9-4fab-9bb2-c32304d3df8e');
}
function getUUIDv7(date, inputUuid) {
/* optional date and input UUID */
const uuid = inputUuid || uuidv7();
const bytes = Buffer.from(uuid.replace(/-/g, ''), 'hex');
if (date !== undefined) {
const d = new Date(date);
// isNaN behaves differently than Number.isNaN -- we're actually going for the
// attempted conversion here
if (isNaN(d)) throw new Error(`getUUIDv7 got an invalid date:${date || '<blank>'}`);
const dateBytes = intToByteArray(d.getTime()).reverse();
dateBytes.slice(2, 8).forEach((b, i) => {
bytes[i] = b;
});
}
return uuidv4({ random: bytes });
}
/* Returns a date from a given uuid (assumed to be a v7, otherwise the results are ... weird */
function getUUIDTimestamp(uuid) {
const ts = parseInt(`${uuid}`.replace(/-/g, '').slice(0, 12), 16);
return new Date(ts);
}
const requiredTimelineEntryFields = ['ts', 'entry_type_id', 'input_id', 'person_id'];
function getTimelineEntryUUID(inputObject, { defaults = {} } = {}) {
const o = { ...defaults, ...inputObject };
/*
Outside systems CAN specify a unique UUID as remote_entry_uuid,
which will be used for updates, etc.
If not, it will be generated using whatever info we have
*/
if (o.remote_entry_uuid) {
if (!uuidIsValid(o.remote_entry_uuid)) throw new Error('Invalid remote_entry_uuid, it must be a UUID');
return o.remote_entry_uuid;
}
/*
Outside systems CAN specify a unique remote_entry_id
If not, it will be generated using whatever info we have
*/
if (o.remote_entry_id) {
// get a temp ID
if (!o.input_id)
throw new Error('Error generating timeline entry uuid -- remote_entry_id specified, but no input_id');
const uuid = uuidv5(o.remote_entry_id, o.input_id);
// Change out the ts to match the v7 sorting.
// But because outside specified remote_entry_uuid
// may not match this standard, uuid sorting isn't guaranteed
return getUUIDv7(o.ts, uuid);
}
const missing = requiredTimelineEntryFields.filter((d) => o[d] === undefined); // 0 could be an entry type value
if (missing.length > 0) throw new Error(`Missing required fields to append an entry_id:${missing.join(',')}`);
const ts = new Date(o.ts);
// isNaN behaves differently than Number.isNaN -- we're actually going for the
// attempted conversion here
if (isNaN(ts)) throw new Error(`getTimelineEntryUUID got an invalid date:${o.ts || '<blank>'}`);
const idString = `${ts.toISOString()}-${o.person_id}-${o.entry_type_id}-${o.source_code_id || 0}`;
if (!uuidIsValid(o.input_id)) {
throw new Error(`Invalid input_id:'${o.input_id}', type ${typeof o.input_id} -- should be a uuid`);
}
// get a temp ID
const uuid = uuidv5(idString, o.input_id);
// Change out the ts to match the v7 sorting.
// But because outside specified remote_entry_uuid
// may not match this standard, uuid sorting isn't guaranteed
return getUUIDv7(ts, uuid);
}
function getEntryTypeId(o, { defaults = {} } = {}) {
let id = o.entry_type_id || defaults.entry_type_id;
if (id) return id;
const etype = o.entry_type || defaults.entry_type;
if (!etype) {
throw new Error('No entry_type, nor entry_type_id specified, specify a defaultEntryType');
}
id = TIMELINE_ENTRY_TYPES[etype];
if (id === undefined) throw new Error(`Invalid entry_type: ${etype}`);
return id;
}
module.exports = {
appendPostfix,
bool,
create,
list,
downloadFile,
extract,
ForEachEntry,
FileUtilities,
getBatchTransform,
getDebatchTransform,
getManifest,
getFile,
getStringArray,
getTempDir,
getTempFilename,
getTimelineEntryUUID,
getPacketFiles,
getPluginUUID,
getInputUUID,
getUUIDv7,
getUUIDTimestamp,
getEntryTypeId,
handlebars,
isValidDate,
makeStrings,
relativeDate,
streamPacket,
TIMELINE_ENTRY_TYPES,
writeTempFile,
uuidIsValid,
uuidv4,
uuidv5,
uuidv7
};