mediumroast_js
Version:
A Command Line Interface (CLI) and Javascript SDK to interact with Mediumroast for GitHub.
450 lines (402 loc) • 21.8 kB
JavaScript
/**
* A class used to build CLIs for constructing Interaction objects
* @author Michael Hay <michael.hay@mediumroast.io>
* @file interactionCLIwizard.js
* @copyright 2022 Mediumroast, Inc. All rights reserved.
* @license Apache-2.0
* @version 2.0.0
*/
// Import required modules
import chalk from 'chalk'
import WizardUtils from "./commonWizard.js"
import CLIOutput from "./output.js"
import FilesystemOperators from "./filesystem.js"
import * as progress from 'cli-progress'
import ora from 'ora'
import crypto from 'crypto'
import { resolve } from 'path'
import CLIUtilities from './common.js'
class AddInteraction {
/**
* A class which encodes the steps needed to create an interaction in the mediumroast.io application. There are two
* modes of the automated case and the manual case. For the automated case, the method wizard() will call
* to the mediumroast.io backend to discover companies which will provide information that can be automatically
* filled in. It is generally suggested for the user to run the automated case to minimze the burden of
* adding the attributes. However, in some cases running a manual process isn't avoidable, this could be
* because the interaction name is incorrrect or the company isn't in the backend yet. As a result the user
* can choose to enable a fully manual process to add an interaction object. In this mode the default value
* for each attribute is 'Unknown'.
* @constructor
* @classdesc Construct the object to execute the company wizard
* @param {Object} env - contains key items needed to interact with the mediumroast.io application
* @param {Object} apiController - an object used to interact with the backend for interactions
* @param {Object} companyController - an object used to interact with the backend for companies
* @param {Object} credential - a credential needed to talk to a RESTful service which is the company_dns in this case
* @param {Object} cli - the already constructed CLI object
*/
constructor(env, controllers){
// Set the environment
this.env = env
// Construct commmon utilities
this.cliUtils = new CLIUtilities()
this.wutils = new WizardUtils(this.objectType)
this.output = new CLIOutput(this.env, this.objectType)
this.fileSystem = new FilesystemOperators()
// Splash screen elements
this.name = "Mediumroast for GitHub"
this.version = `version ${this.cliUtils.getVersionFromPackageJson()}`
this.description = "Commandline Interaction wizard"
this.processName = "mrcli-interaction-wizard"
// Class globals
this.defaultValue = "Unknown"
this.objectType = "Interactions"
this.progressBar = new progress.SingleBar(
{format: '\tProgress [{bar}] {percentage}% | ETA: {eta}s | {value}/{total}'},
progress.Presets.rect
)
// Set the controllers
this.companyCtl = controllers.company
this.interactionCtl = controllers.interaction
this.userCtl = controllers.user
this.githubCtl = controllers.github
// NOTE: These follow the APA style guide for references. These will be used to dynamically create
// the interaction_type attribute for the interaction object.
this.interactionTypes = this.fileSystem.importJSONFile('./interactionTypes.json')[2]
}
async getCompany(companies) {
let companyChoices = []
for(const company in companies) {
companyChoices.push({name: companies[company].name})
}
// Define the company type
const companyChoice = await this.wutils.doList(
"Which company are these interaction(s) associated to?", companyChoices
)
return companyChoice
}
async uploadFile (fileName, fileData, branchName, sha) {
let fileBits = fileName.split('/')
const shortFilename = fileBits[fileBits.length - 1]
const myObjectType = this.fileSystem.checkFilesystemObjectType(fileName)
if(myObjectType[2].isFile()) {
try{
const writeResp = await this.githubCtl.writeBlob(this.objectType, fileName, fileData, branchName, sha)
return [true, {status_code: 200, status_msg: `SUCCESS: uploaded file [${fileName}] to GitHub`}, writeResp]
} catch (err) {
return [false, {status_code: 503, status_msg: `ERROR: unable to upload file [${fileName}] to GitHub`}, err]
}
} else {
return [false, {status_code: 503, status_msg: `WARNING: file [${fileName}] is not of a type that can be uploaded`}, myObjectType[2]]
}
}
async getValidPath() {
const pathPrototype = {
path: {consoleString: 'full path to the directory (e.g., /dir/subdir)', value:this.defaultValue}
}
let myPath = await this.wutils.doManual(pathPrototype)
//
const [success, message, result] = this.fileSystem.checkFilesystemObject(myPath.path)
// new URL(path.join(dir, file), import.meta.url)
const myObjectType = this.fileSystem.checkFilesystemObjectType(myPath.path)
if(!success || myObjectType[2].isFile()) {
console.log(chalk.red.bold(`The directory path wasn\'t resolved correctly. Here\'s your input [${myPath.path}]. Let\'s try again.`))
myPath.path = await this.getValidPath()
}
return resolve(myPath.path)
}
// Create a function that computes the hash of base64 encoded data and returns the hash
computeHash(fileData) {
const hash = crypto.createHash('sha256')
hash.update(fileData)
return hash.digest('hex')
}
async ingestInteractions(branchName, branchSha) {
// Pre-define the final object
let myFiles = []
// Get a valid path
const myPath = await this.getValidPath()
// List all files in the directory and process them one at a time
const allFiles = this.fileSystem.listAllFiles(myPath)
// Start the progress bar
const totalInteractions = allFiles[2].length
this.progressBar.start(totalInteractions, 0)
// Iterate through each file in the directory
for(const myIdx in allFiles[2]) {
// Set the file name for easier readability
let fileName = allFiles[2][myIdx]
// Skip files that start with . including present and parent working directories
if(fileName.indexOf('.') === 0) {
// Increment the progress bar
this.progressBar.increment()
continue
}
// Read the blob and return contents base64 encoded
const fileData = this.fileSystem.readBlobFile(`${myPath}/${fileName}`)
// Compute the hash of the file
const fileHash = this.computeHash(fileData[2])
// Check to see if the file is already in the backend by checking the hash
const fileExists = await this.interactionCtl.findByHash(fileHash)
// If the file exists, skip it
if(fileExists[0]) {
// Increment the progress bar
this.progressBar.increment()
// Add the file to the myFiles array, but as a special case that says it already exists
myFiles.push({interactionName: fileName, fileName: fileName, fileExists: true, fileHash: fileHash})
continue
}
// Upload the file to GitHub
const myContents = await this.uploadFile(`${myPath}/${fileName}`, fileData[2], branchName, branchSha)
// Remove the extesion from the file name and save the file name to the myFiles array
const fullFileName = fileName
const fileBits = fileName.split('.')
const shortFilename = fileBits[fileBits.length - 1]
fileName = fileName.replace(`.${shortFilename}`, '')
// We need to same the object name and the actual file name for later retrieval
myFiles.push({interactionName: fileName, fileName: fullFileName, fileExists: false, fileHash: fileHash})
// Increment the progress bar
this.progressBar.increment()
}
// Stop the progress bar
this.progressBar.stop()
// Return the result of uploaded files
return myFiles
}
async getInteractionType () {
// Take all keys of interactionTypes and turn them into a list of objects like {name:
const myInteractionTypes = Object.keys(this.interactionTypes).map(key => ({
name: key })
)
let interactionType = this.defaultValue
const tmpType = await this.wutils.doList(
"What kind of interaction is this?",
myInteractionTypes
)
interactionType = tmpType
return {
interactionType: interactionType,
interactionDetail: this.interactionTypes[interactionType]
}
}
async discoverCompany() {
// Checking to see if the server is ready for adding interactions
process.stdout.write(chalk.blue.bold('Checking if the mediumroast.io app is ready to add interactions ... '))
const companiesResp = await this.companyCtl.getAll()
if(!companiesResp[0]) {
console.log(chalk.red.bold('No companies detected, run [mrcli setup] to add a company'))
process.exit(-1)
} else {
console.log(chalk.green.bold('Ok'))
}
// Convert companies[2] into an object that is keyed by the company name
const companiesArray = companiesResp[2].mrJson
const companiesObjects = companiesArray.reduce((obj, item) => {
obj[item.name] = item
return obj
}, {})
// Call getCompany to get the company object of interest
const companyChoice = await this.getCompany(companiesObjects)
// Get the company object
return companiesObjects[companyChoice]
}
async createInteractionObject(interactionPrototype, myFiles, myCompany) {
this.output.printLine()
// Create a duplicate count
let duplicateCount = 0
// Loop through each file and create an interaction object
let myInteractions = []
for(const myFile in myFiles) {
// If the file already exists, skip it
if(myFiles[myFile].fileExists) {
console.log(chalk.red.bold(`Skipping file [${myFiles[myFile].interactionName}] because it already exists.`))
// Increment the duplicate count
duplicateCount++
continue
}
// Assign each value from the prototype to the interaction object
let myInteraction = {}
// Loop through each attribute in the prototype and assign the value to the interaction object
for(const attribute in interactionPrototype) {
myInteraction[attribute] = interactionPrototype[attribute].value
}
// Set the file hash
myInteraction.file_hash = myFiles[myFile].fileHash
// Set the name of the interaction to the file name
myInteraction.name = myFiles[myFile].interactionName
console.log(chalk.blue.bold(`Setting details for [${myInteraction.name}]`))
// Set the interaction type
const interactionType = await this.getInteractionType()
myInteraction.interaction_type = interactionType.interactionType
// Set the interaction type details
const interactionDetails = await this.wutils.doManual(interactionType.interactionDetail)
myInteraction.interaction_type_detail = interactionDetails
// Set the stored_url
myInteraction.url = `Interactions/${myFiles[myFile].fileName}`
// Set the company
myInteraction.linked_companies = this.companyCtl.linkObj([myCompany])
// Add the interaction to the list of interactions
myInteractions.push(myInteraction)
// Create an interaction link from the company to the interaction and spread it into the linkedInteractions object
myCompany.linked_interactions = {
...myCompany.linked_interactions,
...this.interactionCtl.linkObj([myInteraction])
}
this.output.printLine()
}
return [myInteractions, myCompany.linked_interactions, duplicateCount]
}
/**
* @function wizard
* @description Invoke the text based wizard process to add an interaction to the mediumroast.io application
* @returns {List} - a list containing the result of the interaction with the mediumroast.io backend
*
* @todo Remove properties variable after checking to see if it is needed or not
*/
async wizard() {
// Unless we suppress this print out the splash screen.
if (this.env.splash) {
this.output.splashScreen(
this.name,
this.version,
this.description
)
}
// Choose if we want to run the setup or not, and it not exit the program
const doSetup = await this.wutils.operationOrNot('It appears you\'d like to create a new interaction, right?')
if (!doSetup) {
console.log(chalk.red.bold('\t-> Ok exiting interaction object creation.'))
process.exit()
}
// Capture the current user
const myUserResp = await this.userCtl.getMyself()
const myUser = myUserResp[2]
// Capture the current company
const myCompany = await this.discoverCompany()
// Set the prototype object which can be used for creating a real object.
// Since the backend expects certain attributes that may not be human readable, the
// prototype below contains strings that are easier to read. Additionally, should
// we wish to set some defaults for each one it is also feasible within this
// prototype object to do so.
// let properties = [
// "organization_id", //
// ]
// Capture the current data and converto to an ISO string
const myDate = new Date()
const myDateString = myDate.toISOString()
// NOTE: Define the interaction prototype to be used for establishing default values. Notes are provided for
// each attribute to help the user understand what the attribute is for. Only those attributes that are
// either 'Unknown' or have a derived default value are included in the prototype. Where the user assigns
// the attribute is excluded from the prototype. The prototype is used to create the interaction object and
// then the user is prompted to assign the attributes that are not derived or unknown. The prototype and
// the user assigned attributes are then merged to create the final interaction object.
let interactionPrototype = {
tags: {consoleString: "", value: {}}, // Empty, but assigned by caffeine
topics: {consoleString: "", value: {}}, // Empty, but assigned by caffeine
status: {consoleString: "", value: 0}, // Set to zero, changed by caffeine
organization: {consoleString: "", value: this.env.gitHubOrg}, // Set the organization to the GitHub organization
content_type: {consoleString: "", value: this.defaultValue}, // Unknown assigned by caffeine
file_size: {consoleString: "", value: this.defaultValue}, // Unknown assigned by caffeine
reading_time: {consoleString: "", value: this.defaultValue}, // Unknown assigned by caffeine
word_count: {consoleString: "", value: this.defaultValue}, // Unknown assigned by caffeine
page_count: {consoleString: "", value: this.defaultValue}, // Unknown assigned by caffeine
description: {consoleString: "", value: this.defaultValue}, // Unknown assigned by caffeine
abstract: {consoleString: "", value: this.defaultValue}, // Unknown assigned by caffeine
creator: {consoleString: "", value: myUser.login}, // Set the creator to the GitHub user
creator_id: {consoleString: "", value: myUser.id}, // Set the creator to the GitHub user
creator_name: {consoleString: "", value: myUser.name}, // Set the creator to the GitHub user
linked_companies: {consoleString: "", value: this.companyCtl.linkObj([myCompany])}, // Assigned to the user selected company
linked_studies: {consoleString: "", value: {}}, // Blank for now
street_address: {consoleString: "", value: myCompany.street_address}, // Set to the company street address
zip_postal: {consoleString: "", value: myCompany.zip_postal}, // Set to the company zip code
city: {consoleString: "", value: myCompany.city}, // Set to the company city
state_province: {consoleString: "", value: myCompany.state_province}, // Set to the company state
country: {consoleString: "", value: myCompany.country}, // Set to the company country
latitude: {consoleString: "", value: myCompany.latitude}, // Set to the company latitude
longitude: {consoleString: "", value: myCompany.longitude}, // Set to the company longitude
region: {consoleString: "", value: myCompany.region}, // Set to the company region
public: {consoleString: "", value: true}, // Set to true
groups: {consoleString: "", value: `${this.env.gitHubOrg}:${myUser.login}`}, // Set to the organization and user, reserved for future use
creation_date: {consoleString: "", value: myDateString}, // Set to the current date
modification_date: {consoleString: "", value: myDateString}, // Set to the current date
file_hash: {consoleString: "", value: this.defaultValue}, // Assigned to detect duplicates
}
// Catch the container for updates
let repoMetadata = {
containers: {
'Interactions': {},
'Companies': {},
/* 'Studies': {}, Not needed at this time, will enable later*/
},
branch: {}
}
let mySpinner = new ora('Preparing the repository to ingest interactions ...')
mySpinner.start()
const caught = await this.githubCtl.catchContainer(repoMetadata)
mySpinner.stop()
// Check to see if caught was successful and return an error if not
if(!caught[0]) {
return caught
}
// Prompt the user to ingest one or more files
const files = await this.ingestInteractions(caught[2].branch.name, caught[2].branch.sha)
// Create the interaction object
let [myInteractions, linkedInteractions, duplicateCount] = await this.createInteractionObject(
interactionPrototype,
files,
myCompany
)
// Update the company object with linkedInteractions and updateObject
// NOTE: linkedInteractions is resetting everytime to the new value, this is a bug
const updatedCompany = await this.companyCtl.updateObj(
// NOTE: This follows the structure expected from the CLI --update switch
{
name: myCompany.name,
key: 'linked_interactions',
value: linkedInteractions
},
true, // This means do not execute a write to the backend
true // Set because this is a system update and not a user update
)
// Create the new interactions
mySpinner = new ora('Writing interaction objects ...')
mySpinner.start()
// Capture the number of interactions
const interactionCount = myInteractions.length
// Append the new interactions to the existing interactions
myInteractions = [...myInteractions, ...caught[2].containers.Interactions.objects]
// Write the new interactions to the backend
const createdInteractions = await this.githubCtl.writeObject(
this.objectType,
myInteractions,
caught[2].branch.name,
caught[2].containers.Interactions.objectSha
)
// Check to see if createdInteractions was successful and return an error if not
if(!createdInteractions[0]) {
return createdInteractions
}
mySpinner.stop()
mySpinner = new ora(`Updating company [${myCompany.name}] object ...`)
mySpinner.start()
// Write the updated company object to the backend
const updatedCompanies = await this.githubCtl.writeObject(
'Companies',
updatedCompany[2],
caught[2].branch.name,
caught[2].containers.Companies.objectSha
)
// Check to see if updatedCompanies was successful and return an error if not
if(!updatedCompanies[0]) {
return updatedCompanies
}
mySpinner.stop()
// Release the container
mySpinner = new ora('Releasing the repository ...')
mySpinner.start()
const released = await this.githubCtl.releaseContainer(caught[2])
mySpinner.stop()
// Return the result of the write including the interaction count and duplicate count
return [true, {status_code: 200, status_msg: `created ${interactionCount} interactions with ${duplicateCount} duplicates detected`}, createdInteractions[2]]
}
}
export default AddInteraction