Home Manual Reference Source Test

src/transform/DisplayTransformer.js

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

const rootMetaTags = [{ tag: 'title' }, { tag: 'desc', key: 'description' }];

/**
 * Splits read atvise display XML nodes into their SVG and JavaScript sources,
 * alongside with a .json file containing the display's parameters.
 */
export default class DisplayTransformer extends XMLTransformer {
  /**
   * The extension to add to display container node names when they are pulled.
   * @type {string}
   */
  static get extension() {
    return '.display';
  }

  /**
   * 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', '.svg', this.scriptSourceExtension];
  }

  /**
   * Returns `true` for all display nodes.
   * @param {Node} node The node to check.
   */
  shouldBeTransformed(node) {
    return node.hasTypeDefinition('VariableTypes.ATVISE.Display');
  }

  /**
   * Splits any read files containing atvise displays into their SVG and JavaScript sources,
   * alongside with a json file containing the display's parameters.
   * @param {BrowsedNode} node The node to split.
   * @param {Object} context The 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 displays not supported');
    }

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

    const document = findChild(xml, 'svg');
    if (!document) {
      throw new Error('Error parsing display: No `svg` tag');
    }

    const config = {};
    const scriptTags = removeChildren(document, 'script');
    let inlineScript;

    // Extract JavaScript
    if (scriptTags.length) {
      scriptTags.forEach((script) => {
        const attributes = attributeValues(script);
        if (attributes && (attributes.src || attributes['xlink:href'])) {
          if (!config.dependencies) {
            config.dependencies = [];
          }

          config.dependencies.push(attributes.src || attributes['xlink:href']);
        } else {
          // Warn on multiple inline scripts
          if (inlineScript) {
            Logger[node.id.value.startsWith('SYSTEM.LIBRARY.ATVISE') ? 'debug' : 'warn'](
              `'${node.id.value}' contains multiple inline scripts.`
            );
            document.childNodes.push(inlineScript);
          }
          inlineScript = script;
        }
      });
    }
    if (inlineScript) {
      const scriptFile = this.constructor.splitFile(node, '.js');
      const scriptText = textContent(inlineScript);

      scriptFile.value = {
        dataType: DataType.String,
        arrayType: VariantArrayType.Scalar,
        value: scriptText,
      };
      context.addNode(scriptFile);
    }

    rootMetaTags.forEach(({ tag, key }) => {
      const [element, ...additional] = removeChildren(document, tag);
      if (!element) return;

      config[key || tag] = textContent(element);

      if (additional.length) {
        Logger.warn(`Removed additional <${tag} /> element inside ${node.nodeId}`);
      }
    });

    // Extract metadata
    const metaTag = findChild(document, 'metadata');
    if (metaTag && metaTag.childNodes) {
      // TODO: Warn on multiple metadata tags

      // - Parameters
      const paramTags = removeChildren(metaTag, 'atv:parameter');
      if (paramTags.length) {
        config.parameters = [];

        paramTags.forEach((n) => config.parameters.push(attributeValues(n)));
      }
    }

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

    const svgFile = this.constructor.splitFile(node, '.svg');
    svgFile.value = {
      dataType: DataType.String,
      arrayType: VariantArrayType.Scalar,
      value: this.encodeContents(xml),
    };
    context.addNode(svgFile);

    // equals: node.renameTo(`${node.name}.display`);
    return super.transformFromDB(node);
  }

  /**
   * Creates a display from the collected nodes.
   * @param {BrowsedNode} node The container node.
   * @param {Map<string, BrowsedNode>} sources The collected files, stored against their
   * extension.
   */
  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 svgFile = sources['.svg'];
    if (!svgFile) {
      throw new Error(`No display SVG for ${node.nodeId}`);
    }

    const scriptFile = sources[this.constructor.scriptSourceExtension];
    let inlineScript = '';
    if (scriptFile) {
      inlineScript = scriptFile.stringValue;
    }

    const xml = this.decodeContents(svgFile);
    const result = xml;
    const svg = findChild(result, 'svg');

    if (!svg) {
      throw new Error('Error parsing display SVG: No `svg` tag');
    }

    // Insert dependencies
    if (config.dependencies) {
      config.dependencies.forEach((s) => {
        appendChild(svg, createElement('script', undefined, { 'xlink:href': s }));
      });
    }

    // Insert script
    // FIXME: Import order is not preserved!
    if (scriptFile) {
      appendChild(
        svg,
        createElement('script', [createCDataNode(inlineScript)], {
          type: 'text/ecmascript',
        })
      );
    }

    // Insert metadata
    // - Parameters
    if (config.parameters && config.parameters.length > 0) {
      let [metaTag] = removeChildren(svg, 'metadata');

      // FIXME: Warn on multiple metadata tags

      if (!metaTag) {
        metaTag = createElement('metadata');
      }

      // Parameters should come before other atv attributes, e.g. `atv:gridconfig`
      for (let i = config.parameters.length - 1; i >= 0; i--) {
        prependChild(metaTag, createElement('atv:parameter', undefined, config.parameters[i]));
      }

      // Insert <metadata> as first element in the resulting svg, after <defs>, <desc> and
      // <title> if defined (nothing to do, they are ordered inside #encodeContents)
      prependChild(svg, metaTag);
    }

    // - Title and description
    rootMetaTags.reverse().forEach(({ tag, key }) => {
      const value = config[key || tag];

      if (value !== undefined) {
        prependChild(svg, createElement(tag, [createTextNode(value)]));
      }
    });

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