1// Copyright 2013 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4/**
5 * This class stores the filter queries as history and highlight the log text
6 * to increase the readability of the log
7 *
8 *   - Enable / Disable highlights
9 *   - Highlights text with multiple colors
10 *   - Resolve hghlight conficts (A text highlighted by multiple colors) so that
11 *     the latest added highlight always has highest priority to display.
12 *
13 */
14var CrosLogMarker = (function() {
15  'use strict';
16
17  // Special classes (defined in log_analyzer_view.css)
18  var LOG_MARKER_HIGHLIGHT_CLASS = 'cros-log-analyzer-marker-highlight';
19  var LOG_MARKER_CONTAINER_ID = 'cros-log-analyzer-marker-container';
20  var LOG_MARKER_HISTORY_ENTRY_CLASS = 'cros-log-analyzer-marker-history-entry';
21  var LOG_MARKER_HISTORY_COLOR_TAG_CLASS =
22          'cros-log-analyzer-marker-history-color-tag';
23
24  /**
25   * Colors used for highlighting. (Current we support 6 colors)
26   * TODO(shinfan): Add more supoorted colors.
27   */
28  var COLOR_USAGE_SET = {
29    'Crimson': false,
30    'DeepSkyBlue': false,
31    'DarkSeaGreen': false,
32    'GoldenRod': false,
33    'IndianRed': false,
34    'Orange': false
35  };
36  var COLOR_NUMBER = Object.keys(COLOR_USAGE_SET).length;
37
38
39  /**
40   * CrosHighlightTag represents a single highlight tag in text.
41   */
42  var CrosHighlightTag = (function() {
43    /**
44     * @constructor
45     */
46    function CrosHighlightTag(color, field, range, priority) {
47      this.color = color;
48      this.field = field;
49      this.range = range;
50      this.priority = priority;
51      this.enabled = true;
52    }
53
54    return CrosHighlightTag;
55  })();
56
57  /**
58   * @constructor
59   * @param {CrosLogAnalyzerView} logAnalyzerView A reference to
60   *     CrosLogAnalyzerView.
61   */
62  function CrosLogMarker(logAnalyzerView) {
63    this.container = $(LOG_MARKER_CONTAINER_ID);
64    // Stores highlight objects for each entry.
65    this.entryHighlights = [];
66    // Stores all the filter queries.
67    this.markHistory = {};
68    // Object references from CrosLogAnalyzerView.
69    this.logEntries = logAnalyzerView.logEntries;
70    this.logAnalyzerView = logAnalyzerView;
71    // Counts how many highlights are created.
72    this.markCount = 0;
73    for (var i = 0; i < this.logEntries.length; i++) {
74      this.entryHighlights.push([]);
75    }
76  }
77
78  CrosLogMarker.prototype = {
79    /**
80     * Saves the query to the mark history and highlights the text
81     * based on the query.
82     */
83    addMarkHistory: function(query) {
84      // Increases the counter
85      this.markCount += 1;
86
87      // Find an avaiable color.
88      var color = this.pickColor();
89      if (!color) {
90        // If all colors are occupied.
91        alert('You can only add at most ' + COLOR_NUMBER + 'markers.');
92        return;
93      }
94
95      // Updates HTML elements.
96      var historyEntry = addNode(this.container, 'div');
97      historyEntry.className = LOG_MARKER_HISTORY_ENTRY_CLASS;
98
99      // A color tag that indicats the color used.
100      var colorTag = addNode(historyEntry, 'div');
101      colorTag.className = LOG_MARKER_HISTORY_COLOR_TAG_CLASS;
102      colorTag.style.background = color;
103
104      // Displays the query text.
105      var queryText = addNodeWithText(historyEntry, 'p', query);
106      queryText.style.color = color;
107
108      // Adds a button to remove the marker.
109      var removeBtn = addNodeWithText(historyEntry, 'a', 'Remove');
110      removeBtn.addEventListener(
111          'click', this.onRemoveBtnClicked_.bind(this, historyEntry, color));
112
113      // A checkbox that lets user enable and disable the marker.
114      var enableCheckbox = addNode(historyEntry, 'input');
115      enableCheckbox.type = 'checkbox';
116      enableCheckbox.checked = true;
117      enableCheckbox.color = color;
118      enableCheckbox.addEventListener('change',
119          this.onEnableCheckboxChange_.bind(this, enableCheckbox), false);
120
121      // Searches log text for matched patterns and highlights them.
122      this.patternMatch(query, color);
123    },
124
125    /**
126     * Search the text for matched strings
127     */
128    patternMatch: function(query, color) {
129      var pattern = new RegExp(query, 'i');
130      for (var i = 0; i < this.logEntries.length; i++) {
131        var entry = this.logEntries[i];
132        // Search description of each log entry
133        // TODO(shinfan): Add more search fields
134        var positions = this.findPositions(
135            pattern, entry.description);
136        for (var j = 0; j < positions.length; j++) {
137            var pos = positions[j];
138            this.mark(entry, pos, 'description', color);
139        }
140        this.sortHighlightsByStartPosition_(this.entryHighlights[i]);
141      }
142    },
143
144    /**
145     * Highlights the text.
146     * @param {CrosLogEntry} entry The log entry to be highlighted
147     * @param {int|Array} position [start, end]
148     * @param {string} field The field of entry to be highlighted
149     * @param {string} color color used for highlighting
150     */
151    mark: function(entry, position, field, color) {
152      // Creates the highlight object
153      var tag = new CrosHighlightTag(color, field, position, this.markCount);
154      // Add the highlight into entryHighlights
155      this.entryHighlights[entry.rowNum].push(tag);
156    },
157
158    /**
159     * Find the highlight objects that covers the given position
160     * @param {CrosHighlightTag|Array} highlights highlights of a log entry
161     * @param {int} position The target index
162     * @param {string} field The target field
163     * @return {CrosHighlightTag|Array} Highlights that cover the position
164     */
165    getHighlight: function(highlights, index, field) {
166      var res = [];
167      for (var j = 0; j < highlights.length; j++) {
168        var highlight = highlights[j];
169        if (highlight.range[0] <= index &&
170            highlight.range[1] > index &&
171            highlight.field == field &&
172            highlight.enabled) {
173          res.push(highlight);
174        }
175      }
176      /**
177       * Sorts the result by priority so that the highlight with
178       * highest priority comes first.
179       */
180      this.sortHighlightsByPriority_(res);
181      return res;
182    },
183
184    /**
185     * This function highlights the entry by going through the text from left
186     * to right and searching for "key" positions.
187     * A "key" position is a position that one (or more) highlight
188     * starts or ends. We only care about "key" positions because this is where
189     * the text highlight status changes.
190     * At each key position, the function decides if the text between this
191     * position and previous position need to be highlighted and resolves
192     * highlight conflicts.
193     *
194     * @param {CrosLogEntry} entry The entry going to be highlighted.
195     * @param {string} field The specified field of the entry.
196     * @param {DOMElement} parent Parent node.
197     */
198    getHighlightedEntry: function(entry, field, parent) {
199      var rowNum = entry.rowNum;
200      // Get the original text content of the entry (without any highlights).
201      var content = this.logEntries[rowNum][field];
202      var index = 0;
203      while (index < content.length) {
204        var nextIndex = this.getNextIndex(
205            this.entryHighlights[rowNum], index, field, content);
206        // Searches for highlights that have the position in range.
207        var highlights = this.getHighlight(
208            this.entryHighlights[rowNum], index, field);
209        var text = content.substr(index, nextIndex - index);
210        if (highlights.length > 0) {
211          // Always picks the highlight with highest priority.
212          this.addSpan(text, highlights[0].color, parent);
213        } else {
214          addNodeWithText(parent, 'span', text);
215        }
216        index = nextIndex;
217      }
218    },
219
220    /**
221     * A helper function that is used by this.getHightlightedEntry
222     * It returns the first index where a highlight begins or ends from
223     * the given index.
224     * @param {CrosHighlightTag|Array} highlights An array of highlights
225     *     of a log entry.
226     * @param {int} index The start position.
227     * @param {string} field The specified field of entry.
228     *     Other fields are ignored.
229     * @param {string} content The text content of the log entry.
230     * @return {int} The first index where a highlight begins or ends.
231     */
232    getNextIndex: function(highlights, index, field, content) {
233      var minGap = Infinity;
234      var res = -1;
235      for (var i = 0; i < highlights.length; i++) {
236        if (highlights[i].field != field || !highlights[i].enabled)
237          continue;
238        // Distance between current index and the start index of highlight.
239        var gap1 = highlights[i].range[0] - index;
240        // Distance between current index and the end index of highlight.
241        var gap2 = highlights[i].range[1] - index;
242        if (gap1 > 0 && gap1 < minGap) {
243          minGap = gap1;
244          res = highlights[i].range[0];
245        }
246        if (gap2 > 0 && gap2 < minGap) {
247          minGap = gap2;
248          res = highlights[i].range[1];
249        }
250      }
251      // Returns |res| if found. Otherwise returns the end position of the text.
252      return res > 0 ? res : content.length;
253    },
254
255    /**
256     * A helper function that is used by this.getHightlightedEntry.
257     * It adds the HTML label to the text.
258     */
259    addSpan: function(text, color, parent) {
260      var span = addNodeWithText(parent, 'span', text);
261      span.style.color = color;
262      span.className = LOG_MARKER_HIGHLIGHT_CLASS;
263    },
264
265    /**
266     * A helper function that is used by this.getHightlightedEntry.
267     * It adds the HTML label to the text.
268     */
269    pickColor: function() {
270      for (var color in COLOR_USAGE_SET) {
271        if (!COLOR_USAGE_SET[color]) {
272          COLOR_USAGE_SET[color] = true;
273          return color;
274        }
275      }
276      return false;
277    },
278
279    /**
280     * A event handler that enables and disables the corresponding marker.
281     * @private
282     */
283    onEnableCheckboxChange_: function(checkbox) {
284      for (var i = 0; i < this.entryHighlights.length; i++) {
285        for (var j = 0; j < this.entryHighlights[i].length; j++) {
286          if (this.entryHighlights[i][j].color == checkbox.color) {
287            this.entryHighlights[i][j].enabled = checkbox.checked;
288           }
289        }
290      }
291      this.refreshLogTable();
292    },
293
294    /**
295     * A event handlier that removes the marker from history.
296     * @private
297     */
298    onRemoveBtnClicked_: function(entry, color) {
299      entry.parentNode.removeChild(entry);
300      COLOR_USAGE_SET[color] = false;
301      for (var i = 0; i < this.entryHighlights.length; i++) {
302        var highlights = this.entryHighlights[i];
303        while (true) {
304          var index = this.findHighlightByColor_(highlights, color);
305          if (index == -1)
306            break;
307          highlights.splice(index, 1);
308        }
309      }
310      this.refreshLogTable();
311    },
312
313    /**
314     * A helper function that returns the index of first highlight that
315     * has the target color. Otherwise returns -1.
316     * @private
317     */
318    findHighlightByColor_: function(highlights, color) {
319      for (var i = 0; i < highlights.length; i++) {
320        if (highlights[i].color == color)
321          return i;
322      }
323      return -1;
324    },
325
326    /**
327     * Refresh the log table in the CrosLogAnalyzerView.
328     */
329    refreshLogTable: function() {
330      this.logAnalyzerView.populateTable();
331      this.logAnalyzerView.filterLog();
332    },
333
334    /**
335     * A pattern can appear multiple times in a string.
336     * Returns positions of all the appearance.
337     */
338    findPositions: function(pattern, str) {
339      var res = [];
340      str = str.toLowerCase();
341      var match = str.match(pattern);
342      if (!match)
343        return res;
344      for (var i = 0; i < match.length; i++) {
345        var index = 0;
346        while (true) {
347          var start = str.indexOf(match[i].toLowerCase(), index);
348          if (start == -1)
349            break;
350          var end = start + match[i].length;
351          res.push([start, end]);
352          index = end + 1;
353        }
354      }
355      return res;
356    },
357
358    /**
359     * A helper function used in sorting highlights by start position.
360     * @param {HighlightTag} h1, h2 Two highlight tags in the array.
361     * @private
362     */
363    compareStartPosition_: function(h1, h2) {
364      return h1.range[0] - h2.range[0];
365    },
366
367    /**
368     * A helper function used in sorting highlights by priority.
369     * @param {HighlightTag} h1, h2 Two highlight tags in the array.
370     * @private
371     */
372    comparePriority_: function(h1, h2) {
373      return h2.priority - h1.priority;
374    },
375
376    /**
377     * A helper function that sorts the highlights array by start position.
378     * @private
379     */
380    sortHighlightsByStartPosition_: function(highlights) {
381      highlights.sort(this.compareStartPosition_);
382    },
383
384    /**
385     * A helper function that sorts the highlights array by priority.
386     * @private
387     */
388    sortHighlightsByPriority_: function(highlights) {
389      highlights.sort(this.comparePriority_);
390    }
391  };
392
393  return CrosLogMarker;
394})();
395