Source: logging/DebugLog.js

/**
 * Copyright (C) 2005-2016 Alfresco Software Limited.
 *
 * This file is part of Alfresco
 *
 * Alfresco is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Alfresco 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 Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
 */

/**
 * This module extends [SubscriptionLog]{@link module:alfresco/logging/SubscriptionLog}
 * to provide a version suitable for debugging purposes.
 *
 * @example <caption>Sample configuration:</caption>
 * {
 *    name: "alfresco/logging/DebugLog",
 *    config: {
 *       payloadConfig: {
 *          maxDepth: 3,
 *          maxChildren: 10,
 *          excludedKeys: ["foo", "bar"]
 *       }
 *    }
 * }
 *
 * @module alfresco/logging/DebugLog
 * @extends external:alfresco/logging/SubscriptionLog
 * @author Martin Doyle
 */
define(["alfresco/core/ObjectTypeUtils",
      "alfresco/logging/SubscriptionLog",
      "dojo/_base/array",
      "dojo/_base/declare",
      "dojo/_base/lang",
      "dojo/date/locale",
      "dojo/dom-class",
      "dojo/dom-construct",
      "dojo/on",
      "dojo/text!./templates/DebugLog.html"
   ],
   function(ObjectTypeUtils, SubscriptionLog, array, declare, lang, dateLocale, domClass, domConstruct, on, template) {
      /*jshint devel:true*/

      return declare([SubscriptionLog], {

         /**
          * The payload config object
          *
          * @instance
          * @typedef {object} payloadConfig
          * @property {number} maxDepth The maximum depth of included objects
          * @property {number} maxChildren The maximum number of child properties to include on a single object
          * @property {string[]} excludedKeys Property names which should not have their values included
          */

         /**
          * An array of the CSS files to use with this widget.
          *
          * @instance
          * @override
          * @type {object[]}
          * @default [{cssFile:"./css/DebugLog.css"}]
          */
         cssRequirements: [{
            cssFile: "./css/DebugLog.css"
         }],

         /**
          * The HTML template to use for the widget.
          *
          * @instance
          * @override
          * @type {string}
          */
         templateString: template,

         /**
          * The root class for this widget
          *
          * @instance
          * @type {string}
          */
         rootClass: "alfresco_logging_DebugLog",

         /**
          * Default payload configuration values
          *
          * @instance
          * @static
          * @type {payloadConfig}
          */
         defaultPayloadConfig: {
            maxChildren: -1,
            maxDepth: -1,
            excludedKeys: ["dojo", "dijit", "dojox", "$", "LiveReload", "Alfresco", "sinon", "dojoConfig", "tinyMCE", "tinymce", "cScope"]
         },

         /**
          * An optional configuration object for controlling payload sanitisation
          *
          * @instance
          * @type {payloadConfig}
          */
         payloadConfig: null,

         /**
          * The log entry nodes
          *
          * @instance
          * @type {Object[]}
          * @default
          */
         _entries: null,

         /**
          * The previous value of the include-filter
          *
          * @instance
          * @type {string}
          * @since 1.0.50
          * @default
          */
         _lastIncludeFilter: null,

         /**
          * The previous value of the exclude-filter
          *
          * @instance
          * @type {string}
          * @since 1.0.50
          * @default
          */
         _lastExcludeFilter: null,

         /**
          * The constructor
          *
          * @instance
          * @override
          */
         constructor: function() {
            this._entries = [];
         },

         /**
          * Called after properties have been mixed into widget
          *
          * @instance
          * @override
          */
         postMixInProperties: function() {
            this.inherited(arguments);
            
            this.topicsToIgnore = this.topicsToIgnore || [];
            this.payloadConfig = lang.mixin({}, this.defaultPayloadConfig, this.payloadConfig);
         },

         /**
          * Update the log with a new log item
          *
          * @instance
          * @override
          * @param {object} logData The details of the publication
          */
         updateLog: function alfresco_logging_DebugLog__updateLog(logData) {

            // Ensure we're not ignoring this topic
            if (array.indexOf(this.topicsToIgnore, logData.topic) !== -1) {
               return;
            }

            // Create data variables
            var hasData = !!logData.data,
               safeData = this._sanitiseData(logData.data),
               simpleData = JSON.stringify(safeData),
               formattedData = JSON.stringify(safeData, null, 2);

            // Setup other vars
            var now = new Date(),
               source,
               entryNode,
               infoNode,
               dataNode;

            // Create the new entry
            entryNode = domConstruct.create("li", {
               "data-aikau-log-type": logData.type,
               "data-aikau-log-topic": logData.topic,
               "data-aikau-log-object": logData.object,
               "data-aikau-log-data": simpleData,
               className: this.rootClass + "__log__entry"
            }, this.logNode, "first");
            if (logData.type === "SUBSCRIBE") {
               domClass.add(entryNode, this.rootClass + "__log__entry--subscribe");
            } else {
               domClass.add(entryNode, this.rootClass + "__log__entry--publish");
            }

            // Add the basic info
            infoNode = domConstruct.create("span", {
               className: this.rootClass + "__log__entry__info"
            }, entryNode);
            domConstruct.create("span", {
               className: this.rootClass + "__log__entry__info__topic",
               innerHTML: logData.topic || "N/A"
            }, infoNode);
            domConstruct.create("span", {
               className: this.rootClass + "__log__entry__info__timestamp",
               innerHTML: dateLocale.format(now, {
                  datePattern: "yyyy-MM-dd",
                  timePattern: "HH:mm:ss.SSS"
               })
            }, infoNode);
            if (logData.object) {
               source = logData.object;
               domConstruct.create("span", {
                  className: this.rootClass + "__log__entry__info__object",
                  innerHTML: "Source: \"" + source + "\""
               }, infoNode);
            }

            // Do we have some data
            if (hasData) {

               // Add the data nodes
               dataNode = domConstruct.create("span", {
                  className: this.rootClass + "__log__entry__data " + this.rootClass + "__log__entry__data--collapsed",
                  innerHTML: "Payload"
               }, entryNode);
               domConstruct.create("span", {
                  className: this.rootClass + "__log__entry__data__collapsed"
               }, dataNode).appendChild(document.createTextNode(simpleData));
               domConstruct.create("span", {
                  className: this.rootClass + "__log__entry__data__full"
               }, dataNode).appendChild(document.createTextNode(formattedData));
            }

            // Add click-handler
            on(entryNode, "click", lang.hitch(this, this._onEntryClick, dataNode));

            // Add the entry to the collection of nodes
            this._entries.push({
               node: entryNode,
               topic: logData.topic || ""
            });

            // Re-apply the filters
            this._applyFilters(true);
         },

         /**
          * Apply the include and exclude filters to the log. Filter values are either
          * comma-separated strings or a regular expression, as determined by the state
          * of the regular expression "checkbox".
          *
          * @instance
          * @param {boolean} [force=false] Whether to force applying the current filters
          * @since 1.0.50
          */
         _applyFilters: function alfresco_logging_DebugLog___applyFilters(force) {
            var filters = {
                  include: this.includeFilter.value,
                  exclude: this.excludeFilter.value,
                  regex: this.filterUsesRegex.checked,
                  includePayload: this.includePayload.checked
               },
               prefix = filters.regex ? "REGEXP_" : "",
               suffix = filters.includePayload ? "_INCLUDE-PAYLOAD" : "",
               newInclude = prefix + filters.include + suffix,
               newExclude = prefix + filters.exclude + suffix,
               filtersChanged = newInclude !== this._lastIncludeFilter || newExclude !== this._lastExcludeFilter;
            if (filtersChanged || force) {
               if (filtersChanged) {
                  this._lastIncludeFilter = newInclude;
                  this._lastExcludeFilter = newExclude;
               }
               this._entries.forEach(lang.hitch(this, this._applyFilter, filters));
            }
         },

         /**
          * Set the visibility of the supplied log entry given the current filters
          *
          * @instance
          * @param {object} filters The filters information
          * @param {string} filters.include The value of the include-filter input
          * @param {string} filters.exclude The value of the exclude-filter input
          * @param {string} filters.includeRegex Whether the include-filter is a regular-expression
          * @param {string} filters.excludeRegex Whether the include-filter is a regular-expression
          * @param {object} entry The log entry
          */
         _applyFilter: function alfresco_logging_DebugLog___applyFilter(filters, entry) {

            // Setup variables
            var topic = lang.getObject("node.dataset.aikauLogTopic", false, entry),
               data = lang.getObject("node.dataset.aikauLogData", false, entry),
               hiddenClass = this.rootClass + "__log__entry--hidden",
               hide = false,
               hiddenByInclude,
               hiddenByExclude,
               testValue;

            // Clean up the data, to ensure we have suitable data (and not a string containing two quotes!)
            data = (data === "undefined") ? undefined : JSON.parse(data);
            data = data ? JSON.stringify(data) : "";
            testValue = topic;
            if (filters.includePayload) {
               testValue += data;
            }

            // Determine whether to hide this node
            if (filters.include || filters.exclude) {
               hiddenByInclude = filters.include && !this._matchesFilter(testValue, "include", filters);
               hiddenByExclude = filters.exclude && this._matchesFilter(testValue, "exclude", filters);
               hide = hiddenByInclude || hiddenByExclude;
            }

            // Hide/show as appropriate
            domClass[hide ? "add" : "remove"](entry.node, hiddenClass);
         },

         /**
          * Clear the exclude filter
          *
          * @instance
          * @since 1.0.51
          */
         _clearFilters: function alfresco_logging_DebugLog___clearFilters() {
            this.includeFilter.value = "";
            this.excludeFilter.value = "";
            this._applyFilters();
         },

         /**
          * Apply the filter value to the test string and return match status
          *
          * @instance
          * @param {string} testString The text to match against
          * @param {string} filter The filter to apply
          * @returns {boolean} Whether the filter matches the test string
          * @since 1.0.50
          */
         _matchesFilter: function alfresco_logging_DebugLog___matchesFilter(testString, filterType, filters) {
            var filter = filters[filterType];
            if (!filter) {
               return false;
            }
            var matches = false,
               filterToUse;
            if (filters.regex) {
               filterToUse = new RegExp(filter, "i");
               matches = filterToUse.test(testString);
            } else {
               filterToUse = this._splitFilters(filter);
               matches = filterToUse.some(function(filterTerm) {
                  return testString.toLowerCase().indexOf(filterTerm.toLowerCase()) !== -1;
               });
            }
            return matches;
         },

         /**
          * Given a variable, make it safe for being JSON.stringified. This means avoiding
          * circular references and respecting maximum sibling quantity and maximum depth.
          *
          * @instance
          * @param   {object} data The data to make safe
          * @returns {object} The safe data (or the original data, if it was falsy)
          */
         _sanitiseData: function alfresco_logging_DebugLog___sanitiseData(data) {

            // Setup variables
            var maxChildren = this.payloadConfig.maxChildren,
               maxDepth = this.payloadConfig.maxDepth,
               excludedKeys = this.payloadConfig.excludedKeys;

            // Create the safe object
            var safeData = (function makeSafe(unsafe, ancestors) {
               /*jshint maxcomplexity:false,maxstatements:false*/

               // Setup return value
               var safeValue = {};

               // Deal with data appropriately
               if (typeof unsafe === "function") {

                  // Ignore functions
                  var functionName = unsafe.name && unsafe.name + "()";
                  safeValue = functionName || "function";

               } else if (!unsafe || typeof unsafe !== "object") {

                  // Falsy values and non-objects are already safe
                  safeValue = unsafe;

               } else if (ObjectTypeUtils.isArray(unsafe)) {

                  // Arrays are safe in themselves, but make their items safe
                  safeValue = array.map(unsafe, lang.hitch(this, function(unsafeChild) {
                     return makeSafe(unsafeChild, ancestors);
                  }));

               } else if (unsafe.nodeType === Node.ELEMENT_NODE) {

                  // Display information about which element this is
                  safeValue = unsafe.tagName.toLowerCase();
                  if (unsafe.id) {
                     safeValue += "#" + unsafe.id;
                  }
                  if (unsafe.className) {
                     safeValue += "." + unsafe.className.split(" ").join(".");
                  }

               } else if (unsafe.nodeType === Node.TEXT_NODE) {

                  // Text nodes are just strings
                  safeValue = unsafe.textContent;

               } else if (unsafe.nodeType) {

                  // Un-handled node type
                  safeValue = "[" + unsafe.nodeName + "]";

               } else if (unsafe._attachPoints) {

                  // Ignore widgets
                  safeValue = "[widget]";

               } else if (ancestors.indexOf(unsafe) !== -1) {

                  // Recursion avoidance!
                  safeValue = "[recursive object";
                  safeValue += unsafe.id ? " id=" + unsafe.id + "]" : "]";

               } else if (maxDepth !== -1 && ancestors.length === maxDepth) {

                  // Handle max-depth exceptions
                  safeValue = "[object beyond max-depth]";

               } else {

                  // A normal object, so recurse through its properties
                  var keys = Object.keys(unsafe),
                     key,
                     value;
                  for (var i = 0; i < keys.length; i++) {

                     // Variables
                     key = keys[i];
                     try {
                        value = unsafe[key];
                     } catch (e) {
                        value = "Error (see console for details): " + e.message;
                        console.warn("Unable to access '" + key + "' property on object: ", unsafe);
                     }

                     // Check against max children
                     if (maxChildren !== -1 && i === maxChildren) {
                        safeValue["MAXIMUM CHILDREN"] = "Maximum child-property count reached";
                        break;
                     }

                     // Ensure key isn't explicitly excluded
                     if (excludedKeys.indexOf(key) !== -1) {
                        safeValue[key] = "[key excluded]";
                        continue;
                     }

                     // Make the value safe before adding to the return object
                     safeValue[key] = makeSafe(value, ancestors.concat(unsafe));
                  }
               }

               // If we end up with a string, make the linebreaks safe
               if (typeof safeValue === "string") {
                  safeValue = safeValue.replace(/\r/g, "").replace(/\n/g, "\\n");
               }

               // Pass back the safe object
               return safeValue;

            })(data, []);

            // Pass back the safe object
            return safeData;
         },

         /**
          * Clear the log
          *
          * @instance
          */
         _onClearButtonClick: function alfresco_logging_DebugLog___onClearButtonClick() {
            domConstruct.empty(this.logNode);
            this._entries = [];
         },

         /**
          * Handle clicks on the log entry
          *
          * @instance
          * @param {Object} dataNode The dataNode, if present
          * @param {Object} evt Dojo-normalised event object
          */
         _onEntryClick: function alfresco_logging_DebugLog___onEntryClick(dataNode, evt) {
            var collapsedClass = this.rootClass + "__log__entry__data--collapsed",
               expandedDataClass = this.rootClass + "__log__entry__data__full",
               clickedOnExpandedData = domClass.contains(evt.target, expandedDataClass);
            if (!clickedOnExpandedData && dataNode) {
               domClass.toggle(dataNode, collapsedClass);
            }
         },

         /**
          * This top-level click handler is to prevent click events on the log bubbling back up to the document.
          *
          * @instance
          * @param {Event} evt Dojo-normalised event object
          * @since 1.0.61
          */
         _onWidgetClick: function alfresco_logging_DebugLog___onWidgetClick(evt) {
            evt.stopPropagation();
         },

         /**
          * Split the terms in a filter string to create an array of filter values. Terms are
          * comma-separated. To include a comma in a search term, double it up (use ,,) and
          * it will be converted into a single comma after the terms have been split.
          *
          * @instance
          * @param {string} filterString The filter string
          * @returns {string[]} The filter values
          * @since 1.0.50
          */
         _splitFilters: function alfresco_logging_DebugLog___splitFilters(filterString) {
            return filterString.split(/,(?!,)/).map(function(filterValue) {
               return filterValue.replace(/,,/, ",");
            });
         }
      });
   });