Home Manual Reference Source Test

src/transform/ScriptTransformer.js

import Logger from 'gulplog';
import { DataType, VariantArrayType } from 'node-opcua/lib/datamodel/variant';
import {
  findChild,
  findChildren,
  textContent,
  createElement,
  createTextNode,
  createCDataNode,
  prependChild,
  appendChild,
  isElement,
  attributeValues,
} from 'modify-xml';
import XMLTransformer from '../lib/transform/XMLTransformer';

/**
 * A transformer that splits atvise scripts and quick dynamics into a code file and a .json file
 * containing parameters and metadata.
 */
export class AtviseScriptTransformer extends XMLTransformer {
  /**
   * The source file extension to allow for scripts.
   */
  static get scriptSourceExtension() {
    return '.js';
  }

  /**
   * The source file extensions to allow.
   * @type {string[]}
   */
  static get sourceExtensions() {
    return ['.json', this.scriptSourceExtension];
  }

  /**
   * Extracts a script's metadata.
   * @param {Object} document The parsed xml document to process.
   * @return {Object} The metadata found.
   */
  processMetadata(document) {
    const config = {};

    const metaTag = findChild(document, 'metadata');
    // console.error('Meta', metaTag);
    if (!metaTag || !metaTag.childNodes) {
      return config;
    }

    metaTag.childNodes.forEach((child) => {
      if (child.type !== 'element') {
        return;
      }

      switch (child.name) {
        case 'icon':
          config.icon = Object.assign(
            {
              content: textContent(child) || '',
            },
            attributeValues(child)
          );
          break;
        case 'visible':
          config.visible = Boolean(parseInt(textContent(child), 10));
          break;
        case 'title':
          config.title = textContent(child);
          break;
        case 'description':
          config.description = textContent(child);
          break;
        default: {
          if (!config.metadata) {
            config.metadata = {};
          }

          const value = textContent(child);

          if (config.metadata[child.name]) {
            if (!Array.isArray(config.metadata[child.name])) {
              config.metadata[child.name] = [config.metadata[child.name]];
            }

            config.metadata[child.name].push(value);
          } else {
            config.metadata[child.name] = textContent(child);
          }

          if (!['longrunning'].includes(child.name)) {
            Logger.debug(`Generic metadata element '${child.name}'`); // FIXME:  at ${node.nodeId}
          }
          break;
        }
      }
    });

    return config;
  }

  /**
   * Extracts a script's parameters.
   * @param {Object} document The parsed xml document to process.
   * @return {Object[]} The parameters found.
   */
  processParameters(document) {
    const paramTags = findChildren(document, 'parameter');
    if (!paramTags.length) {
      return undefined;
    }

    return paramTags.map((node) => {
      const { childNodes } = node;
      const attributes = attributeValues(node);
      const param = Object.assign({}, attributes);

      // Handle relative parameter targets
      if (attributes.relative === 'true') {
        param.target = {};

        const target = findChild(childNodes.find(isElement), [
          'Elements',
          'RelativePathElement',
          'TargetName',
        ]);

        if (target) {
          const [index, name] = ['NamespaceIndex', 'Name'].map((tagName) =>
            textContent(findChild(target, tagName))
          );

          const parsedIndex = parseInt(index, 10);

          param.target = { namespaceIndex: isNaN(parsedIndex) ? 1 : parsedIndex, name };
        }
      }

      return param;
    });
  }

  /**
   * Splits a node into multiple source nodes.
   * @param {Node} node A server node.
   * @param {Object} context The current transform context.
   */
  async transformFromDB(node, context) {
    if (!this.shouldBeTransformed(node)) {
      return undefined;
    }

    if (node.arrayType !== VariantArrayType.Scalar) {
      // FIXME: Instead of throwing we could simply pass the original node to the callback
      throw new Error('Array of scripts not supported');
    }

    // If scripts are empty (e.g. created by atvise builder, but never edited) we display a warning and ignore them.
    if (!node.value.value) {
      Logger.warn(`The script '${node.id.value}' is empty, skipping...`);
      return context.remove(node);
    }

    const xml = this.decodeContents(node);
    if (!xml) {
      throw new Error('Error parsing script');
    }

    const document = findChild(xml, 'script');
    if (!document) {
      throw new Error(`Empty document at ${node.id.value}`);
    }

    // Extract metadata and parameters
    const config = {
      ...this.processMetadata(document),
      parameters: this.processParameters(document),
    };

    // Write config file
    const configFile = this.constructor.splitFile(node, '.json');
    configFile.value = {
      dataType: DataType.String,
      arrayType: VariantArrayType.Scalar,
      value: JSON.stringify(config, null, '  '),
    };
    context.addNode(configFile);

    // Write JavaScript file
    const codeNode = findChild(document, 'code');
    const scriptFile = this.constructor.splitFile(node, '.js');
    scriptFile.value = {
      dataType: DataType.String,
      arrayType: VariantArrayType.Scalar,
      value: textContent(codeNode) || '',
    };
    context.addNode(scriptFile);

    return super.transformFromDB(node);
  }

  /**
   * Inlines the passed source nodes to the given container node.
   * @param {Node} node The container node.
   * @param {{ [ext: string]: Node }} sources The source nodes to inline.
   */
  combineNodes(node, sources) {
    const configFile = sources['.json'];
    let config = {};

    if (configFile) {
      try {
        config = JSON.parse(configFile.stringValue);
      } catch (e) {
        throw new Error(`Error parsing JSON in ${configFile.relative}: ${e.message}`);
      }
    }

    const scriptFile = sources[this.constructor.scriptSourceExtension];
    let code = '';

    if (scriptFile) {
      code = scriptFile.stringValue;
    }

    const document = createElement('script', []);

    const result = {
      childNodes: [
        { type: 'directive', value: '<?xml version="1.0" encoding="UTF-8"?>' },
        document,
      ],
    };

    // Insert metadata
    const meta = [];

    if (node.isQuickDynamic) {
      // - Icon
      if (config.icon) {
        const icon = config.icon.content;
        delete config.icon.content;

        meta.push(createElement('icon', [createTextNode(icon)], config.icon));
      }

      // - Other fields
      if (config.visible !== undefined) {
        meta.push(createElement('visible', [createTextNode(`${config.visible ? 1 : 0}`)]));
      }

      if (config.title !== undefined) {
        meta.push(createElement('title', [createTextNode(config.title)]));
      }

      if (config.description !== undefined) {
        meta.push(createElement('description', [createTextNode(config.description)]));
      }
    }

    // - Additional fields
    if (config.metadata !== undefined) {
      Object.entries(config.metadata).forEach(([name, value]) => {
        (Array.isArray(value) ? value : [value]).forEach((v) =>
          meta.push(createElement(name, [createTextNode(v)]))
        );
      });
    }

    if (node.isQuickDynamic || meta.length) {
      prependChild(document, createElement('metadata', meta));
    }

    // Insert parameters
    if (config.parameters) {
      config.parameters.forEach((attributes) => {
        let elements;

        // Handle relative parameter targets
        if (attributes.relative === 'true' && attributes.target) {
          const { namespaceIndex, name } = attributes.target;
          const targetElements = createElement('Elements');

          elements = [createElement('RelativePath', [targetElements])];

          if (name !== undefined) {
            targetElements.childNodes = [
              createElement('RelativePathElement', [
                createElement('TargetName', [
                  createElement('NamespaceIndex', [createTextNode(`${namespaceIndex}`)]),
                  createElement('Name', [createTextNode(`${name}`)]),
                ]),
              ]),
            ];
          }

          // eslint-disable-next-line no-param-reassign
          delete attributes.target;
        }

        appendChild(document, createElement('parameter', elements, attributes));
      });
    }

    // Insert script code
    appendChild(document, createElement('code', [createCDataNode(code)]));

    // eslint-disable-next-line no-param-reassign
    node.value.value = this.encodeContents(result);
  }
}

/**
 * A transformer that splits atvise server scripts into multiple files.
 */
export class ServerscriptTransformer extends AtviseScriptTransformer {
  /** The container's extension. */
  static get extension() {
    return '.script';
  }

  /**
   * Returns `true` for all script nodes.
   * @param {Node} node The node to check.
   * @return {boolean} If the node is a server script.
   */
  shouldBeTransformed(node) {
    return node.isVariable && node.isScript;
  }
}

/**
 * A transformer that splits atvise quickdynamics into multiple files.
 */
export class QuickDynamicTransformer extends AtviseScriptTransformer {
  /** The container's extension. */
  static get extension() {
    return '.qd';
  }

  /**
   * Returns `true` for all nodes containing quick dynamics.
   * @param {Node} node The node to check.
   * @return {boolean} If the node is a quick dynamic.
   */
  shouldBeTransformed(node) {
    return node.isVariable && node.isQuickDynamic;
  }
}