Source: KiCad_BOM_Wizard.js

#!/usr/bin/env node

/**
*   @license
*   This is the source code file KiCad_BOM_Wizard.js, this is a free KiCad BOM plugin.
*   Copyright (C) 2016  Ronald A. N. Sousa www.hashdefineelectronics.com/
*
*   This program is free software: you can redistribute it and/or modify
*   it under the terms of the GNU General Public License as published by
*   the Free Software Foundation, either version 3 of the License, or
*   (at your option) any later version.
*
*   This program is distributed in the hope that it will be useful,
*   but WITHOUT ANY WARRANTY; without even the implied warranty of
*   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
*   GNU General Public License for more details.
*
*   You should have received a copy of the GNU General Public License
*   along with this program.  If not, see {@link http://www.gnu.org/licenses/}.
*
*   @file KiCad_BOM_Wizard.js
*
*   @author Ronald Sousa http://hashdefineelectronics.com/kicad-bom-wizard/
*
*   @version 0.0.8
*
*   @fileoverview This KiCad plugin can be used to create custom BOM files based on easy
*   configurable templates files. The plugin is writing in JavaScript and has been
*   designed to integrate into KiCad’s BOM plugin manager.
*
*   {@link http://hashdefineelectronics.com/kicad-bom-wizard/|Project Page}
*
*   {@link https://github.com/HashDefineElectronics/KiCad_BOM_Wizard.git|Repository Page}
*
*   @requires xml2js
*
*/

/**
*   Defines the plugin revision number
*/
var PluginRevisionNumber = '0.0.7'

/**
*   Defines KiCad Revision number
*/
var KiCadXMLRevision = 'D'

/**
*   Defines the minimum number of arguments this plugins takes
*/
var MinmumNumOfExpectedArguments = 4

/**
*   holds the processed header data
*/
var OutputHeader = ''

/**
*   holds the template data for main body template should be KiCadXmlFilePath/template.conf
*/
var Template = null

/**
*   holds the template data for table group should be KiCadXmlFilePath/group.conf
*/
var GroupTemplate = null

/**
*   holds the template data for table rows should be KiCadXmlFilePath/row.conf
*/
var RowTemplate = null

/**
*   holds the template data for table headers
*   should be KiCadXmlFilePath/headers.conf
*/
var HeadersTemplate = null

/**
*   holds the template data for fields
*   should be KiCadXmlFilePath/fields.conf
*/
var FieldsTemplate = null

/**
*   This is the project KiCad XML file to use to
*   extract the BOM information
*/
var KiCadXmlFilePath = ''

/**
*   the path and file name to use to create the output BOM
*/
var OutputFilePath = ''

/**
*   Path is used to handle parsing system path urls
*/
var Path = require('path')

/**
*   This is the path to the template files to use
*   when creating the BOM
*/
var TemplateFolder = Path.join(__dirname, '/Template/')

/**
*   javascript object class of the KiCadXmlFilePath file
*/
var UserProjectNetData = null

/**
*   keep track of the number of unique parts found while
*   creating the BOM
*/
var NumberOfUniqueParts = 0

/**
*   keep track of the number of parts found while
*   creating the BOM
*/
var TotalNumberOfParts = 0

// Get cli user arguments
GetArguments()

// print system information
PluginDetails()

// Run the first task.
Task('STATE_GET_XML_DATA')

/**
*   This will check the entire part list for a matching
*   value and fields and return the part's index number that matches
*
*   @param source holds the original list of unsorted parts
*   @param searchTerm the part information to search for
*   @param listOfGroups holds the list of groups
*
*   @returns -1 = no match else the index number of the found item
*/
function SearchUniquePartIndex (source, searchTerm, listOfGroups) {
  for (var Index = 0; Index < source.length; Index++) {
    // reset the filed test flag. this will ensure that we check the next part that might have all the matching fields
    var FieldsTestResult = true
    // part value matches
    if (searchTerm.Value[0] === source[Index].Value[0] && searchTerm.Footprint === source[Index].Footprint) {
      for (var FieldIndex = 0; FieldIndex < listOfGroups.length; FieldIndex++) {
        // If either one is true
        if (listOfGroups[ FieldIndex ] in searchTerm.Fields || listOfGroups[ FieldIndex ] in source[Index].Fields) {
          // If either one is true then both have to be set
          if (listOfGroups[ FieldIndex ] in searchTerm.Fields &&
                listOfGroups[ FieldIndex ] in source[Index].Fields &&
                searchTerm.Fields[ listOfGroups[ FieldIndex ] ] === source[Index].Fields[ listOfGroups[ FieldIndex ] ]) {
            // Do nothing
          } else {
            FieldsTestResult = false
          }
        }
      }

      // We have a match
      if (FieldsTestResult) {
        return Index
      }
    }
  }

  return -1
}

/**
*   creates the table
*
*   @param fieldsList the array that has all the various filed names
*   @param GroupedList the array that has all the parts grouped by the ref prefix
*   @param partGroupedList the array that actually contains all the parts data
*
*   @returns the output
*/
function GenerateTable (fieldsList, groupedList, partGroupedList) {
  var ReturnOutput = ''
  var FieldIndex = 0

  var ListOfheaders = RowTemplate.split('<!--ROW_PART_')
  OutputHeader = ''

  // now go through each element output the header
  if (ListOfheaders.length > 0) {
    for (var HeaderIndex = 0; HeaderIndex < ListOfheaders.length; HeaderIndex++) {
      if (ListOfheaders[HeaderIndex].indexOf('REF-->') !== -1) {
        OutputHeader += HeadersTemplate.replace(/<!--HEADER_ROW-->/g, 'Ref')
        OutputHeader = OutputHeader.replace(/<!--HEADER_CLASS_REF_TAG-->/g, 'HeadRefTag')
      } else if (ListOfheaders[HeaderIndex].indexOf('QTY-->') !== -1) {
        OutputHeader += HeadersTemplate.replace(/<!--HEADER_ROW-->/g, 'Qty')
        OutputHeader = OutputHeader.replace(/<!--HEADER_CLASS_QTY_TAG-->/g, 'HeadQtyTag')
      } else if (ListOfheaders[HeaderIndex].indexOf('VALUE-->') !== -1) {
        OutputHeader += HeadersTemplate.replace(/<!--HEADER_ROW-->/g, 'Value')
        OutputHeader = OutputHeader.replace(/<!--HEADER_CLASS_VALUE_TAG-->/g, 'HeadValueTag')
      } else if (ListOfheaders[HeaderIndex].indexOf('FOOTPRINT-->') !== -1) {
        OutputHeader += HeadersTemplate.replace(/<!--HEADER_ROW-->/g, 'Footprint')
        OutputHeader = OutputHeader.replace(/<!--HEADER_CLASS_FOOTPRINT_TAG-->/g, 'HeadFootprintTag')
      } else if (ListOfheaders[HeaderIndex].indexOf('FIELDS-->') !== -1) {
        // this will help us place the fileds header
        OutputHeader += '<!--FIELDS_HEADER_PLACEHOLDER-->'
      } else {
        // this is an unknown column
        continue
      }

      // this will ensure that all other tags are cleared from this column
      OutputHeader = OutputHeader.replace(/<!--HEADER_CLASS_REF_TAG-->/g, '')
      OutputHeader = OutputHeader.replace(/<!--HEADER_CLASS_QTY_TAG-->/g, '')
      OutputHeader = OutputHeader.replace(/<!--HEADER_CLASS_VALUE_TAG-->/g, '')
      OutputHeader = OutputHeader.replace(/<!--HEADER_CLASS_FOOTPRINT_TAG-->/g, '')
    }
  }

  fieldsList.sort()
  var TempFieldHeader = ''

  for (FieldIndex = 0; FieldIndex < fieldsList.length; FieldIndex++) {
    TempFieldHeader += HeadersTemplate.replace(/<!--HEADER_ROW-->/g, fieldsList[ FieldIndex ])
    TempFieldHeader = TempFieldHeader.replace(/<!--HEADER_CLASS_REF_TAG-->/g, '')
    TempFieldHeader = TempFieldHeader.replace(/<!--HEADER_CLASS_QTY_TAG-->/g, '')
    TempFieldHeader = TempFieldHeader.replace(/<!--HEADER_CLASS_VALUE_TAG-->/g, '')
  }

  // now place it where it needs to be
  OutputHeader = OutputHeader.replace(/<!--FIELDS_HEADER_PLACEHOLDER-->/g, TempFieldHeader)

  groupedList.sort()

  // keep track if the table row is odd or even. true = even else is odd
  var RowIsEvenFlag = false

  for (var Group in groupedList) {
    // take a copy of the table template
    var TableTemp = GroupTemplate
    var GroupdName = groupedList[Group]

    TableTemp = TableTemp.replace(/<!--GROUP_CLASS_TAG-->/g, 'group_' + GroupdName)
    TableTemp = TableTemp.replace(/<!--GROUP_TITLE_TEXT-->/g, GroupdName)

    var TableRowAll = ''
    for (var Item in partGroupedList[GroupdName]) {
      var TempRow = RowTemplate
      var RefTemp = ''

      for (var Ref in partGroupedList[GroupdName][Item].Ref) {
        RefTemp += Ref + ' '
      }

      if (RowIsEvenFlag) {
        TempRow = TempRow.replace(/<!--ROW_CLASS_ODD_EVEN_TAG-->/g, 'RowEvenTag')
      } else {
        TempRow = TempRow.replace(/<!--ROW_CLASS_ODD_EVEN_TAG-->/g, 'RowOddTag')
      }
      TempRow = TempRow.replace(/<!--ROW_PART_REF-->/g, RefTemp)
      TempRow = TempRow.replace(/<!--ROW_PART_QTY-->/g, partGroupedList[GroupdName][Item].Count)
      TempRow = TempRow.replace(/<!--ROW_PART_VALUE-->/g, partGroupedList[GroupdName][Item].Value)
      TempRow = TempRow.replace(/<!--ROW_PART_FOOTPRINT-->/g, partGroupedList[GroupdName][Item].Footprint)

      TempRow = TempRow.replace(/<!--HEADER_CLASS_REF_TAG-->/g, 'HeadRefTag')
      TempRow = TempRow.replace(/<!--HEADER_CLASS_QTY_TAG-->/g, 'HeadQtyTag')
      TempRow = TempRow.replace(/<!--HEADER_CLASS_VALUE_TAG-->/g, 'HeadValueTag')
      TempRow = TempRow.replace(/<!--HEADER_CLASS_FOOTPRINT_TAG-->/g, 'HeadFootprintTag')

      var FieldsTemp = ''

      for (FieldIndex = 0; FieldIndex < fieldsList.length; FieldIndex++) {
        var SingleFieldTemp = FieldsTemplate

        SingleFieldTemp = SingleFieldTemp.replace(/<!--FIELD_CLASS_TAG-->/g, 'Field_' + fieldsList[ FieldIndex ])

        if (partGroupedList[ GroupdName ][ Item ].Fields[ fieldsList[ FieldIndex ] ]) {
          SingleFieldTemp = SingleFieldTemp.replace(/<!--FIELD-->/g, partGroupedList[ GroupdName ][ Item ].Fields[ fieldsList[ FieldIndex ] ].replace(/,/g, ' '))
        } else {
          SingleFieldTemp = SingleFieldTemp.replace(/<!--FIELD-->/g, ' ')
        }

        FieldsTemp += SingleFieldTemp
      }

      TableRowAll += TempRow.replace(/<!--ROW_PART_FIELDS-->/g, FieldsTemp)

      RowIsEvenFlag = !RowIsEvenFlag
    }

    TableTemp = TableTemp.replace(/<!--GROUP_ROW_DATA-->/g, TableRowAll)

    ReturnOutput += TableTemp
  }
  return ReturnOutput
}

/**
*   return the generated part table
*
*   @returns the output
*/
function ExtractAndGenerateDataForThePart () {
  var PartGroupedList = []
  // holds the list of groups. This is used to make sorting easier
  var GroupedList = []
  var UniquePartList = []
  var ListOfFields = []
  NumberOfUniqueParts = 0
  TotalNumberOfParts = 0
  var PartIndex = 0

  // Get the list of groups we are going to use
  UserProjectNetData.export.components[0].comp.forEach(function (Part) {
    if (Part.fields) {
      Part.fields.forEach(function (value) {
        value.field.forEach(function (value) {
          if (ListOfFields.indexOf(value.$.name) === -1) {
            // if the returned index is -1 then we know  that we know we don't have this item
            ListOfFields.push(value.$.name)
          }
        })
      })
    }
  })

  // get the list of fields and grouped the component with the same value
  UserProjectNetData.export.components[0].comp.forEach(function (Part) {
    var TempFieldHolder = []

    if (Part.fields) {
      Part.fields.forEach(function (value) {
        value.field.forEach(function (value) {
          TempFieldHolder[value.$.name] = value['_']
        })
      })
    }

    var FootprintValue = ''

    // get the component footprint if its not been defined or left empty
    if (typeof Part.footprint !== 'undefined' && typeof Part.footprint[0] !== 'undefined') {
      FootprintValue = Part.footprint[0]
    }

    var TempPart = {'Value': Part.value, 'Count': 1, 'Ref': [], 'Fields': TempFieldHolder, 'Footprint': FootprintValue, 'RefPrefix': Part.$.ref.replace(/[0-9]/g, '')}

    PartIndex = SearchUniquePartIndex(UniquePartList, TempPart, ListOfFields)

    // Do we have this part?
    if (PartIndex === -1) {
      UniquePartList.push(TempPart)
      PartIndex = UniquePartList.length
      PartIndex--

      UniquePartList[PartIndex].Ref[Part.$.ref] = Part.$.ref

      if (Part.fields) {
        Part.fields.forEach(function (value) {
          value.field.forEach(function (value) {
            if (ListOfFields.indexOf(value.$.name) === -1) {
              // if the returned index is -1 then we know  that we know we don't have this item
              ListOfFields.push(value.$.name)
            }
          })
        })
      }

      if (typeof PartGroupedList[UniquePartList[PartIndex].RefPrefix] === 'undefined') {
        GroupedList.push(UniquePartList[PartIndex].RefPrefix)
        PartGroupedList[UniquePartList[PartIndex].RefPrefix] = []
        PartGroupedList[UniquePartList[PartIndex].RefPrefix].push(UniquePartList[PartIndex])
      } else {
        PartGroupedList[UniquePartList[PartIndex].RefPrefix].push(UniquePartList[PartIndex])
      }

      NumberOfUniqueParts++
    } else {
      UniquePartList[PartIndex].Count++
      UniquePartList[PartIndex].Ref[Part.$.ref] = Part.$.ref
    }

    TotalNumberOfParts++
  })

  return GenerateTable(ListOfFields, GroupedList, PartGroupedList)
}

/**
*   This will generate the Bill of material based on the
*   template given
*/
function GenerateBOM () {
  if (UserProjectNetData != null && Template != null) {
    Message('Generating BOM [ ' + OutputFilePath + ' ]')

    var Result = ExtractAndGenerateDataForThePart()

    Template = Template.replace(/<!--DATE_GENERATED-->/g, UserProjectNetData.export.design[0].date)
    Template = Template.replace(/<!--TITLE-->/g, UserProjectNetData.export.design[0].sheet[0].title_block[0].title)
    Template = Template.replace(/<!--DATE-->/g, UserProjectNetData.export.design[0].sheet[0].title_block[0].date)
    Template = Template.replace(/<!--COMPANY-->/g, UserProjectNetData.export.design[0].sheet[0].title_block[0].company)
    Template = Template.replace(/<!--REVISON-->/g, UserProjectNetData.export.design[0].sheet[0].title_block[0].rev)
    Template = Template.replace(/<!--COMMENT_1-->/g, UserProjectNetData.export.design[0].sheet[0].title_block[0].comment[0].$.value)
    Template = Template.replace(/<!--COMMENT_2-->/g, UserProjectNetData.export.design[0].sheet[0].title_block[0].comment[1].$.value)
    Template = Template.replace(/<!--COMMENT_3-->/g, UserProjectNetData.export.design[0].sheet[0].title_block[0].comment[2].$.value)
    Template = Template.replace(/<!--COMMENT_4-->/g, UserProjectNetData.export.design[0].sheet[0].title_block[0].comment[3].$.value)
    Template = Template.replace(/<!--TOTAL_NUM_OF_PARTS-->/g, TotalNumberOfParts)
    Template = Template.replace(/<!--TOTAL_NUM_OF_UNIQUE_PARTS-->/g, NumberOfUniqueParts)
    Template = Template.replace(/<!--CLASS_HEADER_TAG-->/g, OutputHeader)
    Template = Template.replace(/<!--BOM_TABLE-->/g, Result)
    // output BOM
    var OutputFilePathWrite = require('fs')

    OutputFilePathWrite.writeFile(OutputFilePath, Template, function (returnError) {
      if (returnError) {
        ErrorMessage(returnError)
      }

      Message('BOM created')
    })
  } else {
    ErrorMessage('Error generating BOM')
  }
}

/**
*   read the user KiCad file. This will also convert the
*   the xml data to javascript object.
*/
function ReadXmlFile () {
  var xml2js = require('xml2js')
  var parser = new xml2js.Parser()

  var XMLFile = require('fs')

  Message('reading KiCad XML file [ ' + KiCadXmlFilePath + ' ]')

  XMLFile.readFile(KiCadXmlFilePath, function (returnError, output) {
    // returnError should return null if the file was read correctly
    if (returnError === null) {
      // Convert kicad XML data to javascript object class
      parser.parseString(output, function (returnError, result) {
        // returnError should return null if the data was converted correctly
        if (returnError === null) {
          UserProjectNetData = result

          if (UserProjectNetData.export.$.version !== KiCadXMLRevision) {
            ErrorMessage('Incompatible KiCad XML version: Expected ' + KiCadXMLRevision + ' Found ' + UserProjectNetData.export.$.version)
          }

          Task('STATE_READ_TEMPLATE')
        } else {
          ErrorMessage(returnError)
        }
      })
    } else {
      ErrorMessage(returnError)
    }
  })
}

/**
*   read template.conf
*/
function ReadTemplateFile () {
  Message('Reading Template [ ' + TemplateFolder + ' ]')

  var FileTemp = require('fs')

  FileTemp.readFile(TemplateFolder + '/template.conf', 'utf8', function (returnError, output) {
    // returnError should return null if the data was read correctly
    if (returnError === null) {
      Template = output
      Task('STATE_READ_TABLE_TEMPLATE')
    } else {
      ErrorMessage('Error reading template.conf')
    }
  })
}

/**
*   read group.conf
*/
function ReadGroupFile () {
  var FileTemp = require('fs')

  FileTemp.readFile(TemplateFolder + '/group.conf', 'utf8', function (returnError, output) {
    // returnError should return null if the data was read correctly
    if (returnError === null) {
      GroupTemplate = output
      Task('STATE_READ_TABLE_ROW_HEADER_TEMPLATE')
    } else {
      ErrorMessage('Error reading group.conf')
    }
  })
}

/**
*   read headers.conf
*/
function ReadHeadersFile () {
  var FileTemp = require('fs')

  FileTemp.readFile(TemplateFolder + '/headers.conf', 'utf8', function (returnError, output) {
    // returnError should return null if the data was read correctly
    if (returnError === null) {
      HeadersTemplate = output
      Task('STATE_READ_TABLE_ROW_TEMPLATE')
    } else {
      ErrorMessage('Error reading headers.conf')
    }
  })
}

/**
*   read row.conf
*/
function ReadRowFile () {
  var FileTemp = require('fs')

  FileTemp.readFile(TemplateFolder + '/row.conf', 'utf8', function (returnError, output) {
    // returnError should return null if the data was read correctly
    if (returnError === null) {
      RowTemplate = output
      Task('STATE_READ_Field_TEMPLATE')
    } else {
      ErrorMessage('Error reading row.conf')
    }
  })
}

/**
*   read fields.conf
*/
function ReadFieldFile () {
  var FileTemp = require('fs')

  FileTemp.readFile(TemplateFolder + '/fields.conf', 'utf8', function (returnError, output) {
    // returnError should return null if the data was read correctly
    if (returnError === null) {
      FieldsTemplate = output
      Task('STATE_GENERATE_BOM')
    } else {
      ErrorMessage('Error reading fields.conf')
    }
  })
}

/**
*   Handles getting the arguments pass to the plugin
*/
function GetArguments () {
  // make sure that we have enough parameter to continue
  if (process.argv.length < MinmumNumOfExpectedArguments) {
    ErrorMessage('Too few arguments. Found ' + process.argv.length + ' Expected at least ' + MinmumNumOfExpectedArguments)
  }

  KiCadXmlFilePath = process.argv[2]
  OutputFilePath = process.argv[3]

  if (process.argv.length > MinmumNumOfExpectedArguments) {
    // the user has specified template they wish to use.

    if (PathExist(process.argv[4])) {
      // check if use template path exist

      TemplateFolder = process.argv[4]
    } else if (PathExist(TemplateFolder + process.argv[4])) {
      // now check if the user is wanting to use a  template in KiCad_BOM_Wizard/Template

      TemplateFolder += process.argv[4]
    } else {
      ErrorMessage('Template directory not found: [ ' + process.argv[4] + ' ]')
    }
  } else {
    TemplateFolder += 'HTML'
  }
}

/**
*   This function can be used to check if the given path
*   exist
*
*   @returns true on success else false false
*/
function PathExist (path) {
  // first check if directory exist
  var FileSystem = require('fs')
  try {
    if (FileSystem.statSync(path).isDirectory()) {
      return true
    }
  } catch (ex) {
    // we can ignore the error message
  }

  return false
}

/**
*   Handles the machine state.
*/
function Task (state) {
  switch (state) {
    case 'STATE_GET_XML_DATA':
      ReadXmlFile()
      break

    case 'STATE_READ_TEMPLATE':
      ReadTemplateFile()
      break

    case 'STATE_READ_TABLE_TEMPLATE':
      ReadGroupFile()
      break

    case 'STATE_READ_TABLE_ROW_HEADER_TEMPLATE':
      ReadHeadersFile()
      break

    case 'STATE_READ_TABLE_ROW_TEMPLATE':
      ReadRowFile()
      break

    case 'STATE_READ_Field_TEMPLATE':
      ReadFieldFile()
      break

    case 'STATE_GENERATE_BOM':
      GenerateBOM()
      break

    default:
      ErrorMessage('Task() default error')
      break
  }
}

/*
*   This function will display the plugin information
*   and the data pass by user.
*/
function PluginDetails () {
  console.log('KiCad_BOM_Wizard Rev: ' + PluginRevisionNumber)
}

/**
*   this function is used to make a standard format
*   for error messages.
*   this also handle exiting the program
*/
function ErrorMessage (message) {
  console.log('\n\n')
  console.log('Error *****')
  console.log(message)
  console.log('\n\n')
  process.exit(1)
}

/*
*   this function is used to make a standard format
*   for error messages.
*   this also handle exiting the program
*/
function Message (message) {
  console.log(message)
}