srveit/insteon-hub2

View on GitHub
tools/decodeDevelopersGuide.js

Summary

Maintainability
A
0 mins
Test Coverage
'use strict'

const { readFile } = require('fs/promises')
const path = require('path')
const URL = 'http://cache.insteon.com/pdf/INSTEON_DevCats_and_Product_Keys_20081008.pdf'
const CATEGORY_BOUNDARIES_FILE = path.join(__dirname, 'categoryBoundaries.json')

async function readJsonFile (filename) {
  const contents = await readFile(filename)
  return JSON.parse(contents.toString())
}

async function parseCategoriesTable (contents) {
  const PDFParser = (await import('pdf2json')).default

  return new Promise(function (resolve, reject) {
    const pdfParser = new PDFParser()

    pdfParser.on('pdfParser_dataError', errData => reject(errData.parserError))
    pdfParser.on('pdfParser_dataReady', pdfData => {
      resolve(pdfData.Pages.map(page => page.Texts.reduce(
        (rows, item) => {
          rows[item.y] = rows[item.y] || {}
          rows[item.y][item.x] = decodeURIComponent(item.R[0].T)
          return rows
        },
        {}
      )))
    })
    pdfParser.parseBuffer(contents)
  })
}

function extractDevices (rows) {
  let previousDevice
  return Object.entries(rows).reduce(
    (devices, [pageNumber, page]) => Object.entries(page).reduce(
      (devices, [y, columns]) => {
        const xs = Object.keys(columns).map(x => parseFloat(x)).sort((x1, x2) => x1 - x2)
        const values = Object.values(columns)

        if (xs[0] > 14 && values.length >= 3) {
          const description = values.slice(2).map(w => w.trim()).join(' ')
            .replace('Di mmer', 'Dimmer')
            .replace('Count-down', 'Countdown')
            .replace('Tr ansmitter', 'Transmitter')
            .replace(
              '2412N INSTEON Central Controller',
              'INSTEON Central Controller [2412N]'
            )
            .replace('c ontroller', 'controller')
            .replace('Doors’', 'Doors\'')
            .replace('INSTEO N', 'INSTEON')
          const device = {
            location: pageNumber * 100 + parseFloat(y),
            pageNumber,
            y: parseFloat(y),
            subcategoryId: values[0].trim().slice(-2),
            productKey: values[1].trim().slice(-6)
              .replace(/legacy/i, '000000'),
            description,
          }
          devices.push(device)
          previousDevice = device
        }
        if (values.length === 1 && xs[0] > 20 && previousDevice) {
          previousDevice.description += ' ' + values[0].trim()
        }
        return devices
      },
      devices
    ),
    []
  )
}

function extractCategories (rows) {
  return rows.reduce(
    (categories, page, pageNumber) => Object.entries(page).reduce(
      (categories, [y, columns]) => {
        const xs = Object.keys(columns).map(x => parseFloat(x)).sort((x1, x2) => x1 - x2)
        const values = Object.values(columns)

        if (values.length === 1 && xs[0] > 5.7 && xs[0] < 6) {
          categories.push({
            location: pageNumber * 100 + parseFloat(y),
            pageNumber,
            y: parseFloat(y),
            categoryId: values[0].trim().slice(-2),
          })
        }
        return categories
      },
      categories
    ),
    []
  )
}

function extractCategoryDescriptions (rows) {
  return Object.entries(rows).reduce(
    (categoryDescriptions, [pageNumber, page]) => Object.entries(page).reduce(
      (categoryDescriptions, [y, columns]) => {
        const xs = Object.keys(columns).map(x => parseFloat(x)).sort((x1, x2) => x1 - x2)
        const values = Object.values(columns)

        if (xs[0] > 8 && xs[0] < 9) {
          categoryDescriptions.push({
            location: pageNumber * 100 + parseFloat(y),
            pageNumber,
            y: parseFloat(y),
            values: values.map(v => v.trim()).join(' '),
          })
        }
        return categoryDescriptions
      },
      categoryDescriptions
    ),
    []
  ).sort((cd1, cd2) => {
    if (cd1.location === cd2.location) {
      return 0
    }
    if (cd1.location > cd2.location) {
      return 1
    }
    return -1
  })
}

function addCategoryBoundaries (categories, categoryBoundaries) {
  let i = 1
  return categories.reduce(
    (categories, { categoryId }) => {
      categories[categoryId] = {
        categoryId,
        subcategories: {},
        top: categoryBoundaries[i - 1][0] * 100 +
          categoryBoundaries[i - 1][1],
        bottom: categoryBoundaries[i][0] * 100 +
          categoryBoundaries[i][1],
      }
      i += 1
      return categories
    },
    {}
  )
}

function findCategory (categories, { pageNumber, y }) {
  const location = pageNumber * 100 + y
  return Object.values(categories).find(category => category.top < location &&
                         category.bottom >= location)
}

function addCategoryDescriptions (categories, categoryDescriptions) {
  for (const categoryDescription of categoryDescriptions) {
    const category = findCategory(categories, categoryDescription)
    if (!category.categoryName) {
      category.categoryName = categoryDescription.values
    } else {
      if (!category.examplesOfDevices) {
        category.examplesOfDevices = categoryDescription.values
      } else {
        category.examplesOfDevices = (
          category.examplesOfDevices + ' ' + categoryDescription.values
        )
          .replace('Plug- In', 'Plug-In')
          .replace('Time- lapse', 'Time-lapse')
      }
    }
  }
}

function addDevices (categories, devices) {
  for (const device of devices) {
    const [deviceDescription, modelNumber] =
      device.description.split(/ *\[/)
    const category = findCategory(categories, device)
    const subcategory = {
      categoryId: category.categoryId,
      subcategoryId: device.subcategoryId,
      productKey: device.productKey,
      deviceDescription,
    }
    if (modelNumber) {
      subcategory.modelNumber = modelNumber
        .replace(']', '')
        .replace('????', '')
        .replace('new smaller board', '')
    }
    category.subcategories[device.subcategoryId] = subcategory
  }
}

function removeTopBottom (categories) {
  for (const category of Object.values(categories)) {
    delete category.top
    delete category.bottom
  }
}

const getDevelopersGuide = async () => {
  const fetch = (await import('node-fetch')).default

  try {
    const response = await fetch(URL)
    return response.buffer()
  } catch (error) {
    console.warn(error)
    return undefined
  }
}

async function decodeDevelopersGuide (filename) {
  const categoryBoundaries =
    await readJsonFile(CATEGORY_BOUNDARIES_FILE)
  const contents = await getDevelopersGuide()
  const rows = await parseCategoriesTable(contents)
  const categories = extractCategories(rows)
  const categoryDescriptions = extractCategoryDescriptions(rows)
  const devices = extractDevices(rows)

  const categories1 = addCategoryBoundaries(categories, categoryBoundaries)
  addCategoryDescriptions(categories1, categoryDescriptions)
  addDevices(categories1, devices)
  removeTopBottom(categories1)
  return categories1
}

module.exports = decodeDevelopersGuide