Upstream version 8.37.180.0
[platform/framework/web/crosswalk.git] / src / chrome / browser / resources / history / history.js
1 // Copyright (c) 2012 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 <include src="../uber/uber_utils.js">
6 <include src="history_focus_manager.js">
7
8 ///////////////////////////////////////////////////////////////////////////////
9 // Globals:
10 /** @const */ var RESULTS_PER_PAGE = 150;
11
12 // Amount of time between pageviews that we consider a 'break' in browsing,
13 // measured in milliseconds.
14 /** @const */ var BROWSING_GAP_TIME = 15 * 60 * 1000;
15
16 // The largest bucket value for UMA histogram, based on entry ID. All entries
17 // with IDs greater than this will be included in this bucket.
18 /** @const */ var UMA_MAX_BUCKET_VALUE = 1000;
19
20 // The largest bucket value for a UMA histogram that is a subset of above.
21 /** @const */ var UMA_MAX_SUBSET_BUCKET_VALUE = 100;
22
23 // TODO(glen): Get rid of these global references, replace with a controller
24 //     or just make the classes own more of the page.
25 var historyModel;
26 var historyView;
27 var pageState;
28 var selectionAnchor = -1;
29 var activeVisit = null;
30
31 /** @const */ var Command = cr.ui.Command;
32 /** @const */ var Menu = cr.ui.Menu;
33 /** @const */ var MenuButton = cr.ui.MenuButton;
34
35 /**
36  * Enum that shows the filtering behavior for a host or URL to a managed user.
37  * Must behave like the FilteringBehavior enum from managed_mode_url_filter.h.
38  * @enum {number}
39  */
40 ManagedModeFilteringBehavior = {
41   ALLOW: 0,
42   WARN: 1,
43   BLOCK: 2
44 };
45
46 MenuButton.createDropDownArrows();
47
48 /**
49  * Returns true if the mobile (non-desktop) version is being shown.
50  * @return {boolean} true if the mobile version is being shown.
51  */
52 function isMobileVersion() {
53   return !document.body.classList.contains('uber-frame');
54 }
55
56 /**
57  * Record an action in UMA.
58  * @param {string} actionDesc The name of the action to be logged.
59  */
60 function recordUmaAction(actionDesc) {
61   chrome.send('metricsHandler:recordAction', [actionDesc]);
62 }
63
64 /**
65  * Record a histogram value in UMA. If specified value is larger than the max
66  * bucket value, record the value in the largest bucket.
67  * @param {string} histogram The name of the histogram to be recorded in.
68  * @param {integer} maxBucketValue The max value for the last histogram bucket.
69  * @param {integer} value The value to record in the histogram.
70  */
71
72 function recordUmaHistogram(histogram, maxBucketValue, value) {
73   chrome.send('metricsHandler:recordInHistogram',
74               [histogram,
75               ((value > maxBucketValue) ? maxBucketValue : value),
76               maxBucketValue]);
77 }
78
79 ///////////////////////////////////////////////////////////////////////////////
80 // Visit:
81
82 /**
83  * Class to hold all the information about an entry in our model.
84  * @param {Object} result An object containing the visit's data.
85  * @param {boolean} continued Whether this visit is on the same day as the
86  *     visit before it.
87  * @param {HistoryModel} model The model object this entry belongs to.
88  * @constructor
89  */
90 function Visit(result, continued, model) {
91   this.model_ = model;
92   this.title_ = result.title;
93   this.url_ = result.url;
94   this.domain_ = result.domain;
95   this.starred_ = result.starred;
96
97   // These identify the name and type of the device on which this visit
98   // occurred. They will be empty if the visit occurred on the current device.
99   this.deviceName = result.deviceName;
100   this.deviceType = result.deviceType;
101
102   // The ID will be set according to when the visit was displayed, not
103   // received. Set to -1 to show that it has not been set yet.
104   this.id_ = -1;
105
106   this.isRendered = false;  // Has the visit already been rendered on the page?
107
108   // All the date information is public so that owners can compare properties of
109   // two items easily.
110
111   this.date = new Date(result.time);
112
113   // See comment in BrowsingHistoryHandler::QueryComplete - we won't always
114   // get all of these.
115   this.dateRelativeDay = result.dateRelativeDay || '';
116   this.dateTimeOfDay = result.dateTimeOfDay || '';
117   this.dateShort = result.dateShort || '';
118
119   // Shows the filtering behavior for that host (only used for managed users).
120   // A value of |ManagedModeFilteringBehavior.ALLOW| is not displayed so it is
121   // used as the default value.
122   this.hostFilteringBehavior = ManagedModeFilteringBehavior.ALLOW;
123   if (typeof result.hostFilteringBehavior != 'undefined')
124     this.hostFilteringBehavior = result.hostFilteringBehavior;
125
126   this.blockedVisit = result.blockedVisit || false;
127
128   // Whether this is the continuation of a previous day.
129   this.continued = continued;
130
131   this.allTimestamps = result.allTimestamps;
132 }
133
134 // Visit, public: -------------------------------------------------------------
135
136 /**
137  * Returns a dom structure for a browse page result or a search page result.
138  * @param {Object} propertyBag A bag of configuration properties, false by
139  * default:
140  *  - isSearchResult: Whether or not the result is a search result.
141  *  - addTitleFavicon: Whether or not the favicon should be added.
142  *  - useMonthDate: Whether or not the full date should be inserted (used for
143  * monthly view).
144  * @return {Node} A DOM node to represent the history entry or search result.
145  */
146 Visit.prototype.getResultDOM = function(propertyBag) {
147   var isSearchResult = propertyBag.isSearchResult || false;
148   var addTitleFavicon = propertyBag.addTitleFavicon || false;
149   var useMonthDate = propertyBag.useMonthDate || false;
150   var node = createElementWithClassName('li', 'entry');
151   var time = createElementWithClassName('div', 'time');
152   var entryBox = createElementWithClassName('label', 'entry-box');
153   var domain = createElementWithClassName('div', 'domain');
154
155   this.id_ = this.model_.nextVisitId_++;
156
157   // Only create the checkbox if it can be used either to delete an entry or to
158   // block/allow it.
159   if (this.model_.editingEntriesAllowed) {
160     var checkbox = document.createElement('input');
161     checkbox.type = 'checkbox';
162     checkbox.id = 'checkbox-' + this.id_;
163     checkbox.time = this.date.getTime();
164     checkbox.addEventListener('click', checkboxClicked);
165     entryBox.appendChild(checkbox);
166
167     // Clicking anywhere in the entryBox will check/uncheck the checkbox.
168     entryBox.setAttribute('for', checkbox.id);
169     entryBox.addEventListener('mousedown', entryBoxMousedown);
170     entryBox.addEventListener('click', entryBoxClick);
171   }
172
173   // Keep track of the drop down that triggered the menu, so we know
174   // which element to apply the command to.
175   // TODO(dubroy): Ideally we'd use 'activate', but MenuButton swallows it.
176   var self = this;
177   var setActiveVisit = function(e) {
178     activeVisit = self;
179     var menu = $('action-menu');
180     menu.dataset.devicename = self.deviceName;
181     menu.dataset.devicetype = self.deviceType;
182   };
183   domain.textContent = this.domain_;
184
185   entryBox.appendChild(time);
186
187   var bookmarkSection = createElementWithClassName('div', 'bookmark-section');
188   if (this.starred_) {
189     bookmarkSection.classList.add('starred');
190     bookmarkSection.addEventListener('click', function f(e) {
191       recordUmaAction('HistoryPage_BookmarkStarClicked');
192       bookmarkSection.classList.remove('starred');
193       chrome.send('removeBookmark', [self.url_]);
194       bookmarkSection.removeEventListener('click', f);
195       e.preventDefault();
196     });
197   }
198   entryBox.appendChild(bookmarkSection);
199
200   var visitEntryWrapper = entryBox.appendChild(document.createElement('div'));
201   if (addTitleFavicon || this.blockedVisit)
202     visitEntryWrapper.classList.add('visit-entry');
203   if (this.blockedVisit) {
204     visitEntryWrapper.classList.add('blocked-indicator');
205     visitEntryWrapper.appendChild(this.getVisitAttemptDOM_());
206   } else {
207     visitEntryWrapper.appendChild(this.getTitleDOM_(isSearchResult));
208     if (addTitleFavicon)
209       this.addFaviconToElement_(visitEntryWrapper);
210     visitEntryWrapper.appendChild(domain);
211   }
212
213   if (isMobileVersion()) {
214     var removeButton = createElementWithClassName('button', 'remove-entry');
215     removeButton.setAttribute('aria-label',
216                               loadTimeData.getString('removeFromHistory'));
217     removeButton.classList.add('custom-appearance');
218     removeButton.addEventListener('click', function(e) {
219       self.removeFromHistory();
220       e.stopPropagation();
221       e.preventDefault();
222     });
223     entryBox.appendChild(removeButton);
224
225     // Support clicking anywhere inside the entry box.
226     entryBox.addEventListener('click', function(e) {
227       e.currentTarget.querySelector('a').click();
228     });
229   } else {
230     var dropDown = createElementWithClassName('button', 'drop-down');
231     dropDown.value = 'Open action menu';
232     dropDown.title = loadTimeData.getString('actionMenuDescription');
233     dropDown.setAttribute('menu', '#action-menu');
234     dropDown.setAttribute('aria-haspopup', 'true');
235     cr.ui.decorate(dropDown, MenuButton);
236
237     dropDown.addEventListener('mousedown', setActiveVisit);
238     dropDown.addEventListener('focus', setActiveVisit);
239
240     // Prevent clicks on the drop down from affecting the checkbox.  We need to
241     // call blur() explicitly because preventDefault() cancels any focus
242     // handling.
243     dropDown.addEventListener('click', function(e) {
244       e.preventDefault();
245       document.activeElement.blur();
246     });
247     entryBox.appendChild(dropDown);
248   }
249
250   // Let the entryBox be styled appropriately when it contains keyboard focus.
251   entryBox.addEventListener('focus', function() {
252     this.classList.add('contains-focus');
253   }, true);
254   entryBox.addEventListener('blur', function() {
255     this.classList.remove('contains-focus');
256   }, true);
257
258   var entryBoxContainer =
259       createElementWithClassName('div', 'entry-box-container');
260   node.appendChild(entryBoxContainer);
261   entryBoxContainer.appendChild(entryBox);
262
263   if (isSearchResult || useMonthDate) {
264     // Show the day instead of the time.
265     time.appendChild(document.createTextNode(this.dateShort));
266   } else {
267     time.appendChild(document.createTextNode(this.dateTimeOfDay));
268   }
269
270   this.domNode_ = node;
271   node.visit = this;
272
273   return node;
274 };
275
276 /**
277  * Remove this visit from the history.
278  */
279 Visit.prototype.removeFromHistory = function() {
280   recordUmaAction('HistoryPage_EntryMenuRemoveFromHistory');
281   var self = this;
282   this.model_.removeVisitsFromHistory([this], function() {
283     removeEntryFromView(self.domNode_);
284   });
285 };
286
287 // Visit, private: ------------------------------------------------------------
288
289 /**
290  * Add child text nodes to a node such that occurrences of the specified text is
291  * highlighted.
292  * @param {Node} node The node under which new text nodes will be made as
293  *     children.
294  * @param {string} content Text to be added beneath |node| as one or more
295  *     text nodes.
296  * @param {string} highlightText Occurences of this text inside |content| will
297  *     be highlighted.
298  * @private
299  */
300 Visit.prototype.addHighlightedText_ = function(node, content, highlightText) {
301   var i = 0;
302   if (highlightText) {
303     var re = new RegExp(Visit.pregQuote_(highlightText), 'gim');
304     var match;
305     while (match = re.exec(content)) {
306       if (match.index > i)
307         node.appendChild(document.createTextNode(content.slice(i,
308                                                                match.index)));
309       i = re.lastIndex;
310       // Mark the highlighted text in bold.
311       var b = document.createElement('b');
312       b.textContent = content.substring(match.index, i);
313       node.appendChild(b);
314     }
315   }
316   if (i < content.length)
317     node.appendChild(document.createTextNode(content.slice(i)));
318 };
319
320 /**
321  * Returns the DOM element containing a link on the title of the URL for the
322  * current visit.
323  * @param {boolean} isSearchResult Whether or not the entry is a search result.
324  * @return {Element} DOM representation for the title block.
325  * @private
326  */
327 Visit.prototype.getTitleDOM_ = function(isSearchResult) {
328   var node = createElementWithClassName('div', 'title');
329   var link = document.createElement('a');
330   link.href = this.url_;
331   link.id = 'id-' + this.id_;
332   link.target = '_top';
333   var integerId = parseInt(this.id_, 10);
334   link.addEventListener('click', function() {
335     recordUmaAction('HistoryPage_EntryLinkClick');
336     // Record the ID of the entry to signify how many entries are above this
337     // link on the page.
338     recordUmaHistogram('HistoryPage.ClickPosition',
339                        UMA_MAX_BUCKET_VALUE,
340                        integerId);
341     if (integerId <= UMA_MAX_SUBSET_BUCKET_VALUE) {
342       recordUmaHistogram('HistoryPage.ClickPositionSubset',
343                          UMA_MAX_SUBSET_BUCKET_VALUE,
344                          integerId);
345     }
346   });
347   link.addEventListener('contextmenu', function() {
348     recordUmaAction('HistoryPage_EntryLinkRightClick');
349   });
350
351   if (isSearchResult) {
352     link.addEventListener('click', function() {
353       recordUmaAction('HistoryPage_SearchResultClick');
354     });
355   }
356
357   // Add a tooltip, since it might be ellipsized.
358   // TODO(dubroy): Find a way to show the tooltip only when necessary.
359   link.title = this.title_;
360
361   this.addHighlightedText_(link, this.title_, this.model_.getSearchText());
362   node.appendChild(link);
363
364   return node;
365 };
366
367 /**
368  * Returns the DOM element containing the text for a blocked visit attempt.
369  * @return {Element} DOM representation of the visit attempt.
370  * @private
371  */
372 Visit.prototype.getVisitAttemptDOM_ = function() {
373   var node = createElementWithClassName('div', 'title');
374   node.innerHTML = loadTimeData.getStringF('blockedVisitText',
375                                            this.url_,
376                                            this.id_,
377                                            this.domain_);
378   return node;
379 };
380
381 /**
382  * Set the favicon for an element.
383  * @param {Element} el The DOM element to which to add the icon.
384  * @private
385  */
386 Visit.prototype.addFaviconToElement_ = function(el) {
387   var url = isMobileVersion() ?
388       getFaviconImageSet(this.url_, 32, 'touch-icon') :
389       getFaviconImageSet(this.url_);
390   el.style.backgroundImage = url;
391 };
392
393 /**
394  * Launch a search for more history entries from the same domain.
395  * @private
396  */
397 Visit.prototype.showMoreFromSite_ = function() {
398   recordUmaAction('HistoryPage_EntryMenuShowMoreFromSite');
399   historyView.setSearch(this.domain_);
400   $('search-field').focus();
401 };
402
403 // Visit, private, static: ----------------------------------------------------
404
405 /**
406  * Quote a string so it can be used in a regular expression.
407  * @param {string} str The source string.
408  * @return {string} The escaped string.
409  * @private
410  */
411 Visit.pregQuote_ = function(str) {
412   return str.replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1');
413 };
414
415 ///////////////////////////////////////////////////////////////////////////////
416 // HistoryModel:
417
418 /**
419  * Global container for history data. Future optimizations might include
420  * allowing the creation of a HistoryModel for each search string, allowing
421  * quick flips back and forth between results.
422  *
423  * The history model is based around pages, and only fetching the data to
424  * fill the currently requested page. This is somewhat dependent on the view,
425  * and so future work may wish to change history model to operate on
426  * timeframe (day or week) based containers.
427  *
428  * @constructor
429  */
430 function HistoryModel() {
431   this.clearModel_();
432 }
433
434 // HistoryModel, Public: ------------------------------------------------------
435
436 /** @enum {number} */
437 HistoryModel.Range = {
438   ALL_TIME: 0,
439   WEEK: 1,
440   MONTH: 2
441 };
442
443 /**
444  * Sets our current view that is called when the history model changes.
445  * @param {HistoryView} view The view to set our current view to.
446  */
447 HistoryModel.prototype.setView = function(view) {
448   this.view_ = view;
449 };
450
451 /**
452  * Reload our model with the current parameters.
453  */
454 HistoryModel.prototype.reload = function() {
455   // Save user-visible state, clear the model, and restore the state.
456   var search = this.searchText_;
457   var page = this.requestedPage_;
458   var range = this.rangeInDays_;
459   var offset = this.offset_;
460   var groupByDomain = this.groupByDomain_;
461
462   this.clearModel_();
463   this.searchText_ = search;
464   this.requestedPage_ = page;
465   this.rangeInDays_ = range;
466   this.offset_ = offset;
467   this.groupByDomain_ = groupByDomain;
468   this.queryHistory_();
469 };
470
471 /**
472  * @return {string} The current search text.
473  */
474 HistoryModel.prototype.getSearchText = function() {
475   return this.searchText_;
476 };
477
478 /**
479  * Tell the model that the view will want to see the current page. When
480  * the data becomes available, the model will call the view back.
481  * @param {number} page The page we want to view.
482  */
483 HistoryModel.prototype.requestPage = function(page) {
484   this.requestedPage_ = page;
485   this.updateSearch_();
486 };
487
488 /**
489  * Receiver for history query.
490  * @param {Object} info An object containing information about the query.
491  * @param {Array} results A list of results.
492  */
493 HistoryModel.prototype.addResults = function(info, results) {
494   // If no requests are in flight then this was an old request so we drop the
495   // results. Double check the search term as well.
496   if (!this.inFlight_ || info.term != this.searchText_)
497     return;
498
499   $('loading-spinner').hidden = true;
500   this.inFlight_ = false;
501   this.isQueryFinished_ = info.finished;
502   this.queryStartTime = info.queryStartTime;
503   this.queryEndTime = info.queryEndTime;
504
505   var lastVisit = this.visits_.slice(-1)[0];
506   var lastDay = lastVisit ? lastVisit.dateRelativeDay : null;
507
508   for (var i = 0, result; result = results[i]; i++) {
509     var thisDay = result.dateRelativeDay;
510     var isSameDay = lastDay == thisDay;
511     this.visits_.push(new Visit(result, isSameDay, this));
512     lastDay = thisDay;
513   }
514
515   if (loadTimeData.getBoolean('isUserSignedIn')) {
516     var message = loadTimeData.getString(
517         info.hasSyncedResults ? 'hasSyncedResults' : 'noSyncedResults');
518     this.view_.showNotification(message);
519   }
520
521   this.updateSearch_();
522 };
523
524 /**
525  * @return {number} The number of visits in the model.
526  */
527 HistoryModel.prototype.getSize = function() {
528   return this.visits_.length;
529 };
530
531 /**
532  * Get a list of visits between specified index positions.
533  * @param {number} start The start index.
534  * @param {number} end The end index.
535  * @return {Array.<Visit>} A list of visits.
536  */
537 HistoryModel.prototype.getNumberedRange = function(start, end) {
538   return this.visits_.slice(start, end);
539 };
540
541 /**
542  * Return true if there are more results beyond the current page.
543  * @return {boolean} true if the there are more results, otherwise false.
544  */
545 HistoryModel.prototype.hasMoreResults = function() {
546   return this.haveDataForPage_(this.requestedPage_ + 1) ||
547       !this.isQueryFinished_;
548 };
549
550 /**
551  * Removes a list of visits from the history, and calls |callback| when the
552  * removal has successfully completed.
553  * @param {Array<Visit>} visits The visits to remove.
554  * @param {Function} callback The function to call after removal succeeds.
555  */
556 HistoryModel.prototype.removeVisitsFromHistory = function(visits, callback) {
557   var toBeRemoved = [];
558   for (var i = 0; i < visits.length; i++) {
559     toBeRemoved.push({
560       url: visits[i].url_,
561       timestamps: visits[i].allTimestamps
562     });
563   }
564   chrome.send('removeVisits', toBeRemoved);
565   this.deleteCompleteCallback_ = callback;
566 };
567
568 /**
569  * Called when visits have been succesfully removed from the history.
570  */
571 HistoryModel.prototype.deleteComplete = function() {
572   // Call the callback, with 'this' undefined inside the callback.
573   this.deleteCompleteCallback_.call();
574   this.deleteCompleteCallback_ = null;
575 };
576
577 // Getter and setter for HistoryModel.rangeInDays_.
578 Object.defineProperty(HistoryModel.prototype, 'rangeInDays', {
579   get: function() {
580     return this.rangeInDays_;
581   },
582   set: function(range) {
583     this.rangeInDays_ = range;
584   }
585 });
586
587 /**
588  * Getter and setter for HistoryModel.offset_. The offset moves the current
589  * query 'window' |range| days behind. As such for range set to WEEK an offset
590  * of 0 refers to the last 7 days, an offset of 1 refers to the 7 day period
591  * that ended 7 days ago, etc. For MONTH an offset of 0 refers to the current
592  * calendar month, 1 to the previous one, etc.
593  */
594 Object.defineProperty(HistoryModel.prototype, 'offset', {
595   get: function() {
596     return this.offset_;
597   },
598   set: function(offset) {
599     this.offset_ = offset;
600   }
601 });
602
603 // Setter for HistoryModel.requestedPage_.
604 Object.defineProperty(HistoryModel.prototype, 'requestedPage', {
605   set: function(page) {
606     this.requestedPage_ = page;
607   }
608 });
609
610 // HistoryModel, Private: -----------------------------------------------------
611
612 /**
613  * Clear the history model.
614  * @private
615  */
616 HistoryModel.prototype.clearModel_ = function() {
617   this.inFlight_ = false;  // Whether a query is inflight.
618   this.searchText_ = '';
619   // Whether this user is a managed user.
620   this.isManagedProfile = loadTimeData.getBoolean('isManagedProfile');
621   this.deletingHistoryAllowed = loadTimeData.getBoolean('allowDeletingHistory');
622
623   // Only create checkboxes for editing entries if they can be used either to
624   // delete an entry or to block/allow it.
625   this.editingEntriesAllowed = this.deletingHistoryAllowed;
626
627   // Flag to show that the results are grouped by domain or not.
628   this.groupByDomain_ = false;
629
630   this.visits_ = [];  // Date-sorted list of visits (most recent first).
631   this.nextVisitId_ = 0;
632   selectionAnchor = -1;
633
634   // The page that the view wants to see - we only fetch slightly past this
635   // point. If the view requests a page that we don't have data for, we try
636   // to fetch it and call back when we're done.
637   this.requestedPage_ = 0;
638
639   // The range of history to view or search over.
640   this.rangeInDays_ = HistoryModel.Range.ALL_TIME;
641
642   // Skip |offset_| * weeks/months from the begining.
643   this.offset_ = 0;
644
645   // Keeps track of whether or not there are more results available than are
646   // currently held in |this.visits_|.
647   this.isQueryFinished_ = false;
648
649   if (this.view_)
650     this.view_.clear_();
651 };
652
653 /**
654  * Figure out if we need to do more queries to fill the currently requested
655  * page. If we think we can fill the page, call the view and let it know
656  * we're ready to show something. This only applies to the daily time-based
657  * view.
658  * @private
659  */
660 HistoryModel.prototype.updateSearch_ = function() {
661   var doneLoading = this.rangeInDays_ != HistoryModel.Range.ALL_TIME ||
662                     this.isQueryFinished_ ||
663                     this.canFillPage_(this.requestedPage_);
664
665   // Try to fetch more results if more results can arrive and the page is not
666   // full.
667   if (!doneLoading && !this.inFlight_)
668     this.queryHistory_();
669
670   // Show the result or a message if no results were returned.
671   this.view_.onModelReady(doneLoading);
672 };
673
674 /**
675  * Query for history, either for a search or time-based browsing.
676  * @private
677  */
678 HistoryModel.prototype.queryHistory_ = function() {
679   var maxResults =
680       (this.rangeInDays_ == HistoryModel.Range.ALL_TIME) ? RESULTS_PER_PAGE : 0;
681
682   // If there are already some visits, pick up the previous query where it
683   // left off.
684   var lastVisit = this.visits_.slice(-1)[0];
685   var endTime = lastVisit ? lastVisit.date.getTime() : 0;
686
687   $('loading-spinner').hidden = false;
688   this.inFlight_ = true;
689   chrome.send('queryHistory',
690       [this.searchText_, this.offset_, this.rangeInDays_, endTime, maxResults]);
691 };
692
693 /**
694  * Check to see if we have data for the given page.
695  * @param {number} page The page number.
696  * @return {boolean} Whether we have any data for the given page.
697  * @private
698  */
699 HistoryModel.prototype.haveDataForPage_ = function(page) {
700   return page * RESULTS_PER_PAGE < this.getSize();
701 };
702
703 /**
704  * Check to see if we have data to fill the given page.
705  * @param {number} page The page number.
706  * @return {boolean} Whether we have data to fill the page.
707  * @private
708  */
709 HistoryModel.prototype.canFillPage_ = function(page) {
710   return ((page + 1) * RESULTS_PER_PAGE <= this.getSize());
711 };
712
713 /**
714  * Enables or disables grouping by domain.
715  * @param {boolean} groupByDomain New groupByDomain_ value.
716  */
717 HistoryModel.prototype.setGroupByDomain = function(groupByDomain) {
718   this.groupByDomain_ = groupByDomain;
719   this.offset_ = 0;
720 };
721
722 /**
723  * Gets whether we are grouped by domain.
724  * @return {boolean} Whether the results are grouped by domain.
725  */
726 HistoryModel.prototype.getGroupByDomain = function() {
727   return this.groupByDomain_;
728 };
729
730 ///////////////////////////////////////////////////////////////////////////////
731 // HistoryView:
732
733 /**
734  * Functions and state for populating the page with HTML. This should one-day
735  * contain the view and use event handlers, rather than pushing HTML out and
736  * getting called externally.
737  * @param {HistoryModel} model The model backing this view.
738  * @constructor
739  */
740 function HistoryView(model) {
741   this.editButtonTd_ = $('edit-button');
742   this.editingControlsDiv_ = $('editing-controls');
743   this.resultDiv_ = $('results-display');
744   this.pageDiv_ = $('results-pagination');
745   this.model_ = model;
746   this.pageIndex_ = 0;
747   this.lastDisplayed_ = [];
748
749   this.model_.setView(this);
750
751   this.currentVisits_ = [];
752
753   // If there is no search button, use the search button label as placeholder
754   // text in the search field.
755   if ($('search-button').offsetWidth == 0)
756     $('search-field').placeholder = $('search-button').value;
757
758   var self = this;
759
760   $('clear-browsing-data').addEventListener('click', openClearBrowsingData);
761   $('remove-selected').addEventListener('click', removeItems);
762
763   // Add handlers for the page navigation buttons at the bottom.
764   $('newest-button').addEventListener('click', function() {
765     recordUmaAction('HistoryPage_NewestHistoryClick');
766     self.setPage(0);
767   });
768   $('newer-button').addEventListener('click', function() {
769     recordUmaAction('HistoryPage_NewerHistoryClick');
770     self.setPage(self.pageIndex_ - 1);
771   });
772   $('older-button').addEventListener('click', function() {
773     recordUmaAction('HistoryPage_OlderHistoryClick');
774     self.setPage(self.pageIndex_ + 1);
775   });
776
777   var handleRangeChange = function(e) {
778     // Update the results and save the last state.
779     self.setRangeInDays(parseInt(e.target.value, 10));
780   };
781
782   // Add handlers for the range options.
783   $('timeframe-filter-all').addEventListener('change', handleRangeChange);
784   $('timeframe-filter-week').addEventListener('change', handleRangeChange);
785   $('timeframe-filter-month').addEventListener('change', handleRangeChange);
786
787   $('range-previous').addEventListener('click', function(e) {
788     if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME)
789       self.setPage(self.pageIndex_ + 1);
790     else
791       self.setOffset(self.getOffset() + 1);
792   });
793   $('range-next').addEventListener('click', function(e) {
794     if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME)
795       self.setPage(self.pageIndex_ - 1);
796     else
797       self.setOffset(self.getOffset() - 1);
798   });
799   $('range-today').addEventListener('click', function(e) {
800     if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME)
801       self.setPage(0);
802     else
803       self.setOffset(0);
804   });
805 }
806
807 // HistoryView, public: -------------------------------------------------------
808 /**
809  * Do a search on a specific term.
810  * @param {string} term The string to search for.
811  */
812 HistoryView.prototype.setSearch = function(term) {
813   window.scrollTo(0, 0);
814   this.setPageState(term, 0, this.getRangeInDays(), this.getOffset());
815 };
816
817 /**
818  * Reload the current view.
819  */
820 HistoryView.prototype.reload = function() {
821   this.model_.reload();
822   this.updateSelectionEditButtons();
823   this.updateRangeButtons_();
824 };
825
826 /**
827  * Sets all the parameters for the history page and then reloads the view to
828  * update the results.
829  * @param {string} searchText The search string to set.
830  * @param {number} page The page to be viewed.
831  * @param {HistoryModel.Range} range The range to view or search over.
832  * @param {number} offset Set the begining of the query to the specific offset.
833  */
834 HistoryView.prototype.setPageState = function(searchText, page, range, offset) {
835   this.clear_();
836   this.model_.searchText_ = searchText;
837   this.pageIndex_ = page;
838   this.model_.requestedPage_ = page;
839   this.model_.rangeInDays_ = range;
840   this.model_.groupByDomain_ = false;
841   if (range != HistoryModel.Range.ALL_TIME)
842     this.model_.groupByDomain_ = true;
843   this.model_.offset_ = offset;
844   this.reload();
845   pageState.setUIState(this.model_.getSearchText(),
846                        this.pageIndex_,
847                        this.getRangeInDays(),
848                        this.getOffset());
849 };
850
851 /**
852  * Switch to a specified page.
853  * @param {number} page The page we wish to view.
854  */
855 HistoryView.prototype.setPage = function(page) {
856   // TODO(sergiu): Move this function to setPageState as well and see why one
857   // of the tests fails when using setPageState.
858   this.clear_();
859   this.pageIndex_ = parseInt(page, 10);
860   window.scrollTo(0, 0);
861   this.model_.requestPage(page);
862   pageState.setUIState(this.model_.getSearchText(),
863                        this.pageIndex_,
864                        this.getRangeInDays(),
865                        this.getOffset());
866 };
867
868 /**
869  * @return {number} The page number being viewed.
870  */
871 HistoryView.prototype.getPage = function() {
872   return this.pageIndex_;
873 };
874
875 /**
876  * Set the current range for grouped results.
877  * @param {string} range The number of days to which the range should be set.
878  */
879 HistoryView.prototype.setRangeInDays = function(range) {
880   // Set the range, offset and reset the page.
881   this.setPageState(this.model_.getSearchText(), 0, range, 0);
882 };
883
884 /**
885  * Get the current range in days.
886  * @return {number} Current range in days from the model.
887  */
888 HistoryView.prototype.getRangeInDays = function() {
889   return this.model_.rangeInDays;
890 };
891
892 /**
893  * Set the current offset for grouped results.
894  * @param {number} offset Offset to set.
895  */
896 HistoryView.prototype.setOffset = function(offset) {
897   // If there is another query already in flight wait for that to complete.
898   if (this.model_.inFlight_)
899     return;
900   this.setPageState(this.model_.getSearchText(),
901                     this.pageIndex_,
902                     this.getRangeInDays(),
903                     offset);
904 };
905
906 /**
907  * Get the current offset.
908  * @return {number} Current offset from the model.
909  */
910 HistoryView.prototype.getOffset = function() {
911   return this.model_.offset;
912 };
913
914 /**
915  * Callback for the history model to let it know that it has data ready for us
916  * to view.
917  * @param {boolean} doneLoading Whether the current request is complete.
918  */
919 HistoryView.prototype.onModelReady = function(doneLoading) {
920   this.displayResults_(doneLoading);
921
922   // Allow custom styling based on whether there are any results on the page.
923   // To make this easier, add a class to the body if there are any results.
924   if (this.model_.visits_.length)
925     document.body.classList.add('has-results');
926   else
927     document.body.classList.remove('has-results');
928
929   this.updateNavBar_();
930
931   if (isMobileVersion()) {
932     // Hide the search field if it is empty and there are no results.
933     var hasResults = this.model_.visits_.length > 0;
934     var isSearch = this.model_.getSearchText().length > 0;
935     $('search-field').hidden = !(hasResults || isSearch);
936   }
937 };
938
939 /**
940  * Enables or disables the buttons that control editing entries depending on
941  * whether there are any checked boxes.
942  */
943 HistoryView.prototype.updateSelectionEditButtons = function() {
944   if (loadTimeData.getBoolean('allowDeletingHistory')) {
945     var anyChecked = document.querySelector('.entry input:checked') != null;
946     $('remove-selected').disabled = !anyChecked;
947   } else {
948     $('remove-selected').disabled = true;
949   }
950 };
951
952 /**
953  * Shows the notification bar at the top of the page with |innerHTML| as its
954  * content.
955  * @param {string} innerHTML The HTML content of the warning.
956  * @param {boolean} isWarning If true, style the notification as a warning.
957  */
958 HistoryView.prototype.showNotification = function(innerHTML, isWarning) {
959   var bar = $('notification-bar');
960   bar.innerHTML = innerHTML;
961   bar.hidden = false;
962   if (isWarning)
963     bar.classList.add('warning');
964   else
965     bar.classList.remove('warning');
966
967   // Make sure that any links in the HTML are targeting the top level.
968   var links = bar.querySelectorAll('a');
969   for (var i = 0; i < links.length; i++)
970     links[i].target = '_top';
971
972   this.positionNotificationBar();
973 };
974
975 /**
976  * Adjusts the position of the notification bar based on the size of the page.
977  */
978 HistoryView.prototype.positionNotificationBar = function() {
979   var bar = $('notification-bar');
980
981   // If the bar does not fit beside the editing controls, put it into the
982   // overflow state.
983   if (bar.getBoundingClientRect().top >=
984       $('editing-controls').getBoundingClientRect().bottom) {
985     bar.classList.add('alone');
986   } else {
987     bar.classList.remove('alone');
988   }
989 };
990
991 // HistoryView, private: ------------------------------------------------------
992
993 /**
994  * Clear the results in the view.  Since we add results piecemeal, we need
995  * to clear them out when we switch to a new page or reload.
996  * @private
997  */
998 HistoryView.prototype.clear_ = function() {
999   var alertOverlay = $('alertOverlay');
1000   if (alertOverlay && alertOverlay.classList.contains('showing'))
1001     hideConfirmationOverlay();
1002
1003   this.resultDiv_.textContent = '';
1004
1005   this.currentVisits_.forEach(function(visit) {
1006     visit.isRendered = false;
1007   });
1008   this.currentVisits_ = [];
1009
1010   document.body.classList.remove('has-results');
1011 };
1012
1013 /**
1014  * Record that the given visit has been rendered.
1015  * @param {Visit} visit The visit that was rendered.
1016  * @private
1017  */
1018 HistoryView.prototype.setVisitRendered_ = function(visit) {
1019   visit.isRendered = true;
1020   this.currentVisits_.push(visit);
1021 };
1022
1023 /**
1024  * Generates and adds the grouped visits DOM for a certain domain. This
1025  * includes the clickable arrow and domain name and the visit entries for
1026  * that domain.
1027  * @param {Element} results DOM object to which to add the elements.
1028  * @param {string} domain Current domain name.
1029  * @param {Array} domainVisits Array of visits for this domain.
1030  * @private
1031  */
1032 HistoryView.prototype.getGroupedVisitsDOM_ = function(
1033     results, domain, domainVisits) {
1034   // Add a new domain entry.
1035   var siteResults = results.appendChild(
1036       createElementWithClassName('li', 'site-entry'));
1037
1038   // Make a wrapper that will contain the arrow, the favicon and the domain.
1039   var siteDomainWrapper = siteResults.appendChild(
1040       createElementWithClassName('div', 'site-domain-wrapper'));
1041
1042   if (this.model_.editingEntriesAllowed) {
1043     var siteDomainCheckbox =
1044         createElementWithClassName('input', 'domain-checkbox');
1045
1046     siteDomainCheckbox.type = 'checkbox';
1047     siteDomainCheckbox.addEventListener('click', domainCheckboxClicked);
1048     siteDomainCheckbox.domain_ = domain;
1049
1050     siteDomainWrapper.appendChild(siteDomainCheckbox);
1051   }
1052
1053   var siteArrow = siteDomainWrapper.appendChild(
1054       createElementWithClassName('div', 'site-domain-arrow collapse'));
1055   var siteDomain = siteDomainWrapper.appendChild(
1056       createElementWithClassName('div', 'site-domain'));
1057   var siteDomainLink = siteDomain.appendChild(
1058       createElementWithClassName('button', 'link-button'));
1059   siteDomainLink.addEventListener('click', function(e) { e.preventDefault(); });
1060   siteDomainLink.textContent = domain;
1061   var numberOfVisits = createElementWithClassName('span', 'number-visits');
1062   var domainElement = document.createElement('span');
1063
1064   numberOfVisits.textContent = loadTimeData.getStringF('numberVisits',
1065                                                        domainVisits.length);
1066   siteDomain.appendChild(numberOfVisits);
1067
1068   domainVisits[0].addFaviconToElement_(siteDomain);
1069
1070   siteDomainWrapper.addEventListener('click', toggleHandler);
1071
1072   if (this.model_.isManagedProfile) {
1073     siteDomainWrapper.appendChild(
1074         getManagedStatusDOM(domainVisits[0].hostFilteringBehavior));
1075   }
1076
1077   siteResults.appendChild(siteDomainWrapper);
1078   var resultsList = siteResults.appendChild(
1079       createElementWithClassName('ol', 'site-results'));
1080   resultsList.classList.add('grouped');
1081
1082   // Collapse until it gets toggled.
1083   resultsList.style.height = 0;
1084
1085   // Add the results for each of the domain.
1086   var isMonthGroupedResult = this.getRangeInDays() == HistoryModel.Range.MONTH;
1087   for (var j = 0, visit; visit = domainVisits[j]; j++) {
1088     resultsList.appendChild(visit.getResultDOM({
1089       useMonthDate: isMonthGroupedResult
1090     }));
1091     this.setVisitRendered_(visit);
1092   }
1093 };
1094
1095 /**
1096  * Enables or disables the time range buttons.
1097  * @private
1098  */
1099 HistoryView.prototype.updateRangeButtons_ = function() {
1100   // The enabled state for the previous, today and next buttons.
1101   var previousState = false;
1102   var todayState = false;
1103   var nextState = false;
1104   var usePage = (this.getRangeInDays() == HistoryModel.Range.ALL_TIME);
1105
1106   // Use pagination for most recent visits, offset otherwise.
1107   // TODO(sergiu): Maybe send just one variable in the future.
1108   if (usePage) {
1109     if (this.getPage() != 0) {
1110       nextState = true;
1111       todayState = true;
1112     }
1113     previousState = this.model_.hasMoreResults();
1114   } else {
1115     if (this.getOffset() != 0) {
1116       nextState = true;
1117       todayState = true;
1118     }
1119     previousState = !this.model_.isQueryFinished_;
1120   }
1121
1122   $('range-previous').disabled = !previousState;
1123   $('range-today').disabled = !todayState;
1124   $('range-next').disabled = !nextState;
1125 };
1126
1127 /**
1128  * Groups visits by domain, sorting them by the number of visits.
1129  * @param {Array} visits Visits received from the query results.
1130  * @param {Element} results Object where the results are added to.
1131  * @private
1132  */
1133 HistoryView.prototype.groupVisitsByDomain_ = function(visits, results) {
1134   var visitsByDomain = {};
1135   var domains = [];
1136
1137   // Group the visits into a dictionary and generate a list of domains.
1138   for (var i = 0, visit; visit = visits[i]; i++) {
1139     var domain = visit.domain_;
1140     if (!visitsByDomain[domain]) {
1141       visitsByDomain[domain] = [];
1142       domains.push(domain);
1143     }
1144     visitsByDomain[domain].push(visit);
1145   }
1146   var sortByVisits = function(a, b) {
1147     return visitsByDomain[b].length - visitsByDomain[a].length;
1148   };
1149   domains.sort(sortByVisits);
1150
1151   for (var i = 0; i < domains.length; ++i) {
1152     var domain = domains[i];
1153     this.getGroupedVisitsDOM_(results, domain, visitsByDomain[domain]);
1154   }
1155 };
1156
1157 /**
1158  * Adds the results for a month.
1159  * @param {Array} visits Visits returned by the query.
1160  * @param {Element} parentElement Element to which to add the results to.
1161  * @private
1162  */
1163 HistoryView.prototype.addMonthResults_ = function(visits, parentElement) {
1164   if (visits.length == 0)
1165     return;
1166
1167   var monthResults = parentElement.appendChild(
1168       createElementWithClassName('ol', 'month-results'));
1169   // Don't add checkboxes if entries can not be edited.
1170   if (!this.model_.editingEntriesAllowed)
1171     monthResults.classList.add('no-checkboxes');
1172
1173   this.groupVisitsByDomain_(visits, monthResults);
1174 };
1175
1176 /**
1177  * Adds the results for a certain day. This includes a title with the day of
1178  * the results and the results themselves, grouped or not.
1179  * @param {Array} visits Visits returned by the query.
1180  * @param {Element} parentElement Element to which to add the results to.
1181  * @private
1182  */
1183 HistoryView.prototype.addDayResults_ = function(visits, parentElement) {
1184   if (visits.length == 0)
1185     return;
1186
1187   var firstVisit = visits[0];
1188   var day = parentElement.appendChild(createElementWithClassName('h3', 'day'));
1189   day.appendChild(document.createTextNode(firstVisit.dateRelativeDay));
1190   if (firstVisit.continued) {
1191     day.appendChild(document.createTextNode(' ' +
1192                                             loadTimeData.getString('cont')));
1193   }
1194   var dayResults = parentElement.appendChild(
1195       createElementWithClassName('ol', 'day-results'));
1196
1197   // Don't add checkboxes if entries can not be edited.
1198   if (!this.model_.editingEntriesAllowed)
1199     dayResults.classList.add('no-checkboxes');
1200
1201   if (this.model_.getGroupByDomain()) {
1202     this.groupVisitsByDomain_(visits, dayResults);
1203   } else {
1204     var lastTime;
1205
1206     for (var i = 0, visit; visit = visits[i]; i++) {
1207       // If enough time has passed between visits, indicate a gap in browsing.
1208       var thisTime = visit.date.getTime();
1209       if (lastTime && lastTime - thisTime > BROWSING_GAP_TIME)
1210         dayResults.appendChild(createElementWithClassName('li', 'gap'));
1211
1212       // Insert the visit into the DOM.
1213       dayResults.appendChild(visit.getResultDOM({ addTitleFavicon: true }));
1214       this.setVisitRendered_(visit);
1215
1216       lastTime = thisTime;
1217     }
1218   }
1219 };
1220
1221 /**
1222  * Adds the text that shows the current interval, used for week and month
1223  * results.
1224  * @param {Element} resultsFragment The element to which the interval will be
1225  *     added to.
1226  * @private
1227  */
1228 HistoryView.prototype.addTimeframeInterval_ = function(resultsFragment) {
1229   if (this.getRangeInDays() == HistoryModel.Range.ALL_TIME)
1230     return;
1231
1232   // If this is a time range result add some text that shows what is the
1233   // time range for the results the user is viewing.
1234   var timeFrame = resultsFragment.appendChild(
1235       createElementWithClassName('h2', 'timeframe'));
1236   // TODO(sergiu): Figure the best way to show this for the first day of
1237   // the month.
1238   timeFrame.appendChild(document.createTextNode(loadTimeData.getStringF(
1239       'historyInterval',
1240       this.model_.queryStartTime,
1241       this.model_.queryEndTime)));
1242 };
1243
1244 /**
1245  * Update the page with results.
1246  * @param {boolean} doneLoading Whether the current request is complete.
1247  * @private
1248  */
1249 HistoryView.prototype.displayResults_ = function(doneLoading) {
1250   // Either show a page of results received for the all time results or all the
1251   // received results for the weekly and monthly view.
1252   var results = this.model_.visits_;
1253   if (this.getRangeInDays() == HistoryModel.Range.ALL_TIME) {
1254     var rangeStart = this.pageIndex_ * RESULTS_PER_PAGE;
1255     var rangeEnd = rangeStart + RESULTS_PER_PAGE;
1256     results = this.model_.getNumberedRange(rangeStart, rangeEnd);
1257   }
1258   var searchText = this.model_.getSearchText();
1259   var groupByDomain = this.model_.getGroupByDomain();
1260
1261   if (searchText) {
1262     // Add a header for the search results, if there isn't already one.
1263     if (!this.resultDiv_.querySelector('h3')) {
1264       var header = document.createElement('h3');
1265       header.textContent = loadTimeData.getStringF('searchResultsFor',
1266                                                    searchText);
1267       this.resultDiv_.appendChild(header);
1268     }
1269
1270     this.addTimeframeInterval_(this.resultDiv_);
1271
1272     var searchResults = createElementWithClassName('ol', 'search-results');
1273
1274     // Don't add checkboxes if entries can not be edited.
1275     if (!this.model_.editingEntriesAllowed)
1276       searchResults.classList.add('no-checkboxes');
1277
1278     if (results.length == 0 && doneLoading) {
1279       var noSearchResults = searchResults.appendChild(
1280           createElementWithClassName('div', 'no-results-message'));
1281       noSearchResults.textContent = loadTimeData.getString('noSearchResults');
1282     } else {
1283       for (var i = 0, visit; visit = results[i]; i++) {
1284         if (!visit.isRendered) {
1285           searchResults.appendChild(visit.getResultDOM({
1286             isSearchResult: true,
1287             addTitleFavicon: true
1288           }));
1289           this.setVisitRendered_(visit);
1290         }
1291       }
1292     }
1293     this.resultDiv_.appendChild(searchResults);
1294   } else {
1295     var resultsFragment = document.createDocumentFragment();
1296
1297     this.addTimeframeInterval_(resultsFragment);
1298
1299     if (results.length == 0 && doneLoading) {
1300       var noResults = resultsFragment.appendChild(
1301           createElementWithClassName('div', 'no-results-message'));
1302       noResults.textContent = loadTimeData.getString('noResults');
1303       this.resultDiv_.appendChild(resultsFragment);
1304       return;
1305     }
1306
1307     if (this.getRangeInDays() == HistoryModel.Range.MONTH &&
1308         groupByDomain) {
1309       // Group everything together in the month view.
1310       this.addMonthResults_(results, resultsFragment);
1311     } else {
1312       var dayStart = 0;
1313       var dayEnd = 0;
1314       // Go through all of the visits and process them in chunks of one day.
1315       while (dayEnd < results.length) {
1316         // Skip over the ones that are already rendered.
1317         while (dayStart < results.length && results[dayStart].isRendered)
1318           ++dayStart;
1319         var dayEnd = dayStart + 1;
1320         while (dayEnd < results.length && results[dayEnd].continued)
1321           ++dayEnd;
1322
1323         this.addDayResults_(
1324             results.slice(dayStart, dayEnd), resultsFragment, groupByDomain);
1325       }
1326     }
1327
1328     // Add all the days and their visits to the page.
1329     this.resultDiv_.appendChild(resultsFragment);
1330   }
1331   // After the results have been added to the DOM, determine the size of the
1332   // time column.
1333   this.setTimeColumnWidth_(this.resultDiv_);
1334 };
1335
1336 /**
1337  * Update the visibility of the page navigation buttons.
1338  * @private
1339  */
1340 HistoryView.prototype.updateNavBar_ = function() {
1341   this.updateRangeButtons_();
1342
1343   // Managed users have the control bar on top, don't show it on the bottom
1344   // as well.
1345   if (!loadTimeData.getBoolean('isManagedProfile')) {
1346     $('newest-button').hidden = this.pageIndex_ == 0;
1347     $('newer-button').hidden = this.pageIndex_ == 0;
1348     $('older-button').hidden =
1349         this.model_.rangeInDays_ != HistoryModel.Range.ALL_TIME ||
1350         !this.model_.hasMoreResults();
1351   }
1352 };
1353
1354 /**
1355  * Updates the visibility of the 'Clear browsing data' button.
1356  * Only used on mobile platforms.
1357  * @private
1358  */
1359 HistoryView.prototype.updateClearBrowsingDataButton_ = function() {
1360   // Ideally, we should hide the 'Clear browsing data' button whenever the
1361   // soft keyboard is visible. This is not possible, so instead, hide the
1362   // button whenever the search field has focus.
1363   $('clear-browsing-data').hidden =
1364       (document.activeElement === $('search-field'));
1365 };
1366
1367 /**
1368  * Dynamically sets the min-width of the time column for history entries.
1369  * This ensures that all entry times will have the same width, without
1370  * imposing a fixed width that may not be appropriate for some locales.
1371  * @private
1372  */
1373 HistoryView.prototype.setTimeColumnWidth_ = function() {
1374   // Find the maximum width of all the time elements on the page.
1375   var times = this.resultDiv_.querySelectorAll('.entry .time');
1376   var widths = Array.prototype.map.call(times, function(el) {
1377     el.style.minWidth = '-webkit-min-content';
1378     var width = el.clientWidth;
1379     el.style.minWidth = '';
1380
1381     // Add an extra pixel to prevent rounding errors from causing the text to
1382     // be ellipsized at certain zoom levels (see crbug.com/329779).
1383     return width + 1;
1384   });
1385   var maxWidth = widths.length ? Math.max.apply(null, widths) : 0;
1386
1387   // Add a dynamic stylesheet to the page (or replace the existing one), to
1388   // ensure that all entry times have the same width.
1389   var styleEl = $('timeColumnStyle');
1390   if (!styleEl) {
1391     styleEl = document.head.appendChild(document.createElement('style'));
1392     styleEl.id = 'timeColumnStyle';
1393   }
1394   styleEl.textContent = '.entry .time { min-width: ' + maxWidth + 'px; }';
1395 };
1396
1397 ///////////////////////////////////////////////////////////////////////////////
1398 // State object:
1399 /**
1400  * An 'AJAX-history' implementation.
1401  * @param {HistoryModel} model The model we're representing.
1402  * @param {HistoryView} view The view we're representing.
1403  * @constructor
1404  */
1405 function PageState(model, view) {
1406   // Enforce a singleton.
1407   if (PageState.instance) {
1408     return PageState.instance;
1409   }
1410
1411   this.model = model;
1412   this.view = view;
1413
1414   if (typeof this.checker_ != 'undefined' && this.checker_) {
1415     clearInterval(this.checker_);
1416   }
1417
1418   // TODO(glen): Replace this with a bound method so we don't need
1419   //     public model and view.
1420   this.checker_ = window.setInterval(function(stateObj) {
1421     var hashData = stateObj.getHashData();
1422     var page = parseInt(hashData.page, 10);
1423     var range = parseInt(hashData.range, 10);
1424     var offset = parseInt(hashData.offset, 10);
1425     if (hashData.q != stateObj.model.getSearchText() ||
1426         page != stateObj.view.getPage() ||
1427         range != stateObj.model.rangeInDays ||
1428         offset != stateObj.model.offset) {
1429       stateObj.view.setPageState(hashData.q, page, range, offset);
1430     }
1431   }, 50, this);
1432 }
1433
1434 /**
1435  * Holds the singleton instance.
1436  */
1437 PageState.instance = null;
1438
1439 /**
1440  * @return {Object} An object containing parameters from our window hash.
1441  */
1442 PageState.prototype.getHashData = function() {
1443   var result = {
1444     q: '',
1445     page: 0,
1446     grouped: false,
1447     range: 0,
1448     offset: 0
1449   };
1450
1451   if (!window.location.hash)
1452     return result;
1453
1454   var hashSplit = window.location.hash.substr(1).split('&');
1455   for (var i = 0; i < hashSplit.length; i++) {
1456     var pair = hashSplit[i].split('=');
1457     if (pair.length > 1) {
1458       result[pair[0]] = decodeURIComponent(pair[1].replace(/\+/g, ' '));
1459     }
1460   }
1461
1462   return result;
1463 };
1464
1465 /**
1466  * Set the hash to a specified state, this will create an entry in the
1467  * session history so the back button cycles through hash states, which
1468  * are then picked up by our listener.
1469  * @param {string} term The current search string.
1470  * @param {number} page The page currently being viewed.
1471  * @param {HistoryModel.Range} range The range to view or search over.
1472  * @param {number} offset Set the begining of the query to the specific offset.
1473  */
1474 PageState.prototype.setUIState = function(term, page, range, offset) {
1475   // Make sure the form looks pretty.
1476   $('search-field').value = term;
1477   var hash = this.getHashData();
1478   if (hash.q != term || hash.page != page || hash.range != range ||
1479       hash.offset != offset) {
1480     window.location.hash = PageState.getHashString(term, page, range, offset);
1481   }
1482 };
1483
1484 /**
1485  * Static method to get the hash string for a specified state
1486  * @param {string} term The current search string.
1487  * @param {number} page The page currently being viewed.
1488  * @param {HistoryModel.Range} range The range to view or search over.
1489  * @param {number} offset Set the begining of the query to the specific offset.
1490  * @return {string} The string to be used in a hash.
1491  */
1492 PageState.getHashString = function(term, page, range, offset) {
1493   // Omit elements that are empty.
1494   var newHash = [];
1495
1496   if (term)
1497     newHash.push('q=' + encodeURIComponent(term));
1498
1499   if (page)
1500     newHash.push('page=' + page);
1501
1502   if (range)
1503     newHash.push('range=' + range);
1504
1505   if (offset)
1506     newHash.push('offset=' + offset);
1507
1508   return newHash.join('&');
1509 };
1510
1511 ///////////////////////////////////////////////////////////////////////////////
1512 // Document Functions:
1513 /**
1514  * Window onload handler, sets up the page.
1515  */
1516 function load() {
1517   uber.onContentFrameLoaded();
1518
1519   var searchField = $('search-field');
1520
1521   historyModel = new HistoryModel();
1522   historyView = new HistoryView(historyModel);
1523   pageState = new PageState(historyModel, historyView);
1524
1525   // Create default view.
1526   var hashData = pageState.getHashData();
1527   var grouped = (hashData.grouped == 'true') || historyModel.getGroupByDomain();
1528   var page = parseInt(hashData.page, 10) || historyView.getPage();
1529   var range = parseInt(hashData.range, 10) || historyView.getRangeInDays();
1530   var offset = parseInt(hashData.offset, 10) || historyView.getOffset();
1531   historyView.setPageState(hashData.q, page, range, offset);
1532
1533   if ($('overlay')) {
1534     cr.ui.overlay.setupOverlay($('overlay'));
1535     cr.ui.overlay.globalInitialization();
1536   }
1537   HistoryFocusManager.getInstance().initialize();
1538
1539   var doSearch = function(e) {
1540     recordUmaAction('HistoryPage_Search');
1541     historyView.setSearch(searchField.value);
1542
1543     if (isMobileVersion())
1544       searchField.blur();  // Dismiss the keyboard.
1545   };
1546
1547   var mayRemoveVisits = loadTimeData.getBoolean('allowDeletingHistory');
1548   $('remove-visit').disabled = !mayRemoveVisits;
1549
1550   if (mayRemoveVisits) {
1551     $('remove-visit').addEventListener('activate', function(e) {
1552       activeVisit.removeFromHistory();
1553       activeVisit = null;
1554     });
1555   }
1556
1557   if (!loadTimeData.getBoolean('showDeleteVisitUI'))
1558     $('remove-visit').hidden = true;
1559
1560   searchField.addEventListener('search', doSearch);
1561   $('search-button').addEventListener('click', doSearch);
1562
1563   $('more-from-site').addEventListener('activate', function(e) {
1564     activeVisit.showMoreFromSite_();
1565     activeVisit = null;
1566   });
1567
1568   // Only show the controls if the command line switch is activated.
1569   if (loadTimeData.getBoolean('groupByDomain') ||
1570       loadTimeData.getBoolean('isManagedProfile')) {
1571     // Hide the top container which has the "Clear browsing data" and "Remove
1572     // selected entries" buttons since they're unavailable in managed mode
1573     $('top-container').hidden = true;
1574     $('history-page').classList.add('big-topbar-page');
1575     $('filter-controls').hidden = false;
1576   }
1577
1578   uber.setTitle(loadTimeData.getString('title'));
1579
1580   // Adjust the position of the notification bar when the window size changes.
1581   window.addEventListener('resize',
1582       historyView.positionNotificationBar.bind(historyView));
1583
1584   cr.ui.FocusManager.disableMouseFocusOnButtons();
1585
1586   if (isMobileVersion()) {
1587     // Move the search box out of the header.
1588     var resultsDisplay = $('results-display');
1589     resultsDisplay.parentNode.insertBefore($('search-field'), resultsDisplay);
1590
1591     window.addEventListener(
1592         'resize', historyView.updateClearBrowsingDataButton_);
1593
1594     // When the search field loses focus, add a delay before updating the
1595     // visibility, otherwise the button will flash on the screen before the
1596     // keyboard animates away.
1597     searchField.addEventListener('blur', function() {
1598       setTimeout(historyView.updateClearBrowsingDataButton_, 250);
1599     });
1600
1601     // Move the button to the bottom of the page.
1602     $('history-page').appendChild($('clear-browsing-data'));
1603   } else {
1604     window.addEventListener('message', function(e) {
1605       if (e.data.method == 'frameSelected')
1606         searchField.focus();
1607     });
1608     searchField.focus();
1609   }
1610
1611 <if expr="is_ios">
1612   function checkKeyboardVisibility() {
1613     // Figure out the real height based on the orientation, becauase
1614     // screen.width and screen.height don't update after rotation.
1615     var screenHeight = window.orientation % 180 ? screen.width : screen.height;
1616
1617     // Assume that the keyboard is visible if more than 30% of the screen is
1618     // taken up by window chrome.
1619     var isKeyboardVisible = (window.innerHeight / screenHeight) < 0.7;
1620
1621     document.body.classList.toggle('ios-keyboard-visible', isKeyboardVisible);
1622   }
1623   window.addEventListener('orientationchange', checkKeyboardVisibility);
1624   window.addEventListener('resize', checkKeyboardVisibility);
1625 </if> /* is_ios */
1626 }
1627
1628 /**
1629  * Updates the managed filter status labels of a host/URL entry to the current
1630  * value.
1631  * @param {Element} statusElement The div which contains the status labels.
1632  * @param {ManagedModeFilteringBehavior} newStatus The filter status of the
1633  *     current domain/URL.
1634  */
1635 function updateHostStatus(statusElement, newStatus) {
1636   var filteringBehaviorDiv =
1637       statusElement.querySelector('.filtering-behavior');
1638   // Reset to the base class first, then add modifier classes if needed.
1639   filteringBehaviorDiv.className = 'filtering-behavior';
1640   if (newStatus == ManagedModeFilteringBehavior.BLOCK) {
1641     filteringBehaviorDiv.textContent =
1642         loadTimeData.getString('filterBlocked');
1643     filteringBehaviorDiv.classList.add('filter-blocked');
1644   } else {
1645     filteringBehaviorDiv.textContent = '';
1646   }
1647 }
1648
1649 /**
1650  * Click handler for the 'Clear browsing data' dialog.
1651  * @param {Event} e The click event.
1652  */
1653 function openClearBrowsingData(e) {
1654   recordUmaAction('HistoryPage_InitClearBrowsingData');
1655   chrome.send('clearBrowsingData');
1656 }
1657
1658 /**
1659  * Shows the dialog for the user to confirm removal of selected history entries.
1660  */
1661 function showConfirmationOverlay() {
1662   $('alertOverlay').classList.add('showing');
1663   $('overlay').hidden = false;
1664   uber.invokeMethodOnParent('beginInterceptingEvents');
1665 }
1666
1667 /**
1668  * Hides the confirmation overlay used to confirm selected history entries.
1669  */
1670 function hideConfirmationOverlay() {
1671   $('alertOverlay').classList.remove('showing');
1672   $('overlay').hidden = true;
1673   uber.invokeMethodOnParent('stopInterceptingEvents');
1674 }
1675
1676 /**
1677  * Shows the confirmation alert for history deletions and permits browser tests
1678  * to override the dialog.
1679  * @param {function=} okCallback A function to be called when the user presses
1680  *     the ok button.
1681  * @param {function=} cancelCallback A function to be called when the user
1682  *     presses the cancel button.
1683  */
1684 function confirmDeletion(okCallback, cancelCallback) {
1685   alertOverlay.setValues(
1686       loadTimeData.getString('removeSelected'),
1687       loadTimeData.getString('deleteWarning'),
1688       loadTimeData.getString('cancel'),
1689       loadTimeData.getString('deleteConfirm'),
1690       cancelCallback,
1691       okCallback);
1692   showConfirmationOverlay();
1693 }
1694
1695 /**
1696  * Click handler for the 'Remove selected items' button.
1697  * Confirms the deletion with the user, and then deletes the selected visits.
1698  */
1699 function removeItems() {
1700   recordUmaAction('HistoryPage_RemoveSelected');
1701   if (!loadTimeData.getBoolean('allowDeletingHistory'))
1702     return;
1703
1704   var checked = $('results-display').querySelectorAll(
1705       '.entry-box input[type=checkbox]:checked:not([disabled])');
1706   var disabledItems = [];
1707   var toBeRemoved = [];
1708
1709   for (var i = 0; i < checked.length; i++) {
1710     var checkbox = checked[i];
1711     var entry = findAncestorByClass(checkbox, 'entry');
1712     toBeRemoved.push(entry.visit);
1713
1714     // Disable the checkbox and put a strikethrough style on the link, so the
1715     // user can see what will be deleted.
1716     var link = entry.querySelector('a');
1717     checkbox.disabled = true;
1718     link.classList.add('to-be-removed');
1719     disabledItems.push(checkbox);
1720     var integerId = parseInt(entry.visit.id_, 10);
1721     // Record the ID of the entry to signify how many entries are above this
1722     // link on the page.
1723     recordUmaHistogram('HistoryPage.RemoveEntryPosition',
1724                        UMA_MAX_BUCKET_VALUE,
1725                        integerId);
1726     if (integerId <= UMA_MAX_SUBSET_BUCKET_VALUE) {
1727       recordUmaHistogram('HistoryPage.RemoveEntryPositionSubset',
1728                          UMA_MAX_SUBSET_BUCKET_VALUE,
1729                          integerId);
1730     }
1731     if (entry.parentNode.className == 'search-results')
1732       recordUmaAction('HistoryPage_SearchResultRemove');
1733   }
1734
1735   function onConfirmRemove() {
1736     recordUmaAction('HistoryPage_ConfirmRemoveSelected');
1737     historyModel.removeVisitsFromHistory(toBeRemoved,
1738         historyView.reload.bind(historyView));
1739     $('overlay').removeEventListener('cancelOverlay', onCancelRemove);
1740     hideConfirmationOverlay();
1741   }
1742
1743   function onCancelRemove() {
1744     recordUmaAction('HistoryPage_CancelRemoveSelected');
1745     // Return everything to its previous state.
1746     for (var i = 0; i < disabledItems.length; i++) {
1747       var checkbox = disabledItems[i];
1748       checkbox.disabled = false;
1749
1750       var entryBox = findAncestorByClass(checkbox, 'entry-box');
1751       entryBox.querySelector('a').classList.remove('to-be-removed');
1752     }
1753     $('overlay').removeEventListener('cancelOverlay', onCancelRemove);
1754     hideConfirmationOverlay();
1755   }
1756
1757   if (checked.length) {
1758     confirmDeletion(onConfirmRemove, onCancelRemove);
1759     $('overlay').addEventListener('cancelOverlay', onCancelRemove);
1760   }
1761 }
1762
1763 /**
1764  * Handler for the 'click' event on a checkbox.
1765  * @param {Event} e The click event.
1766  */
1767 function checkboxClicked(e) {
1768   handleCheckboxStateChange(e.currentTarget, e.shiftKey);
1769 }
1770
1771 /**
1772  * Post-process of checkbox state change. This handles range selection and
1773  * updates internal state.
1774  * @param {!HTMLInputElement} checkbox Clicked checkbox.
1775  * @param {boolean} shiftKey true if shift key is pressed.
1776  */
1777 function handleCheckboxStateChange(checkbox, shiftKey) {
1778   updateParentCheckbox(checkbox);
1779   var id = Number(checkbox.id.slice('checkbox-'.length));
1780   // Handle multi-select if shift was pressed.
1781   if (shiftKey && (selectionAnchor != -1)) {
1782     var checked = checkbox.checked;
1783     // Set all checkboxes from the anchor up to the clicked checkbox to the
1784     // state of the clicked one.
1785     var begin = Math.min(id, selectionAnchor);
1786     var end = Math.max(id, selectionAnchor);
1787     for (var i = begin; i <= end; i++) {
1788       var checkbox = document.querySelector('#checkbox-' + i);
1789       if (checkbox) {
1790         checkbox.checked = checked;
1791         updateParentCheckbox(checkbox);
1792       }
1793     }
1794   }
1795   selectionAnchor = id;
1796
1797   historyView.updateSelectionEditButtons();
1798 }
1799
1800 /**
1801  * Handler for the 'click' event on a domain checkbox. Checkes or unchecks the
1802  * checkboxes of the visits to this domain in the respective group.
1803  * @param {Event} e The click event.
1804  */
1805 function domainCheckboxClicked(e) {
1806   var siteEntry = findAncestorByClass(e.currentTarget, 'site-entry');
1807   var checkboxes =
1808       siteEntry.querySelectorAll('.site-results input[type=checkbox]');
1809   for (var i = 0; i < checkboxes.length; i++)
1810     checkboxes[i].checked = e.currentTarget.checked;
1811   historyView.updateSelectionEditButtons();
1812   // Stop propagation as clicking the checkbox would otherwise trigger the
1813   // group to collapse/expand.
1814   e.stopPropagation();
1815 }
1816
1817 /**
1818  * Updates the domain checkbox for this visit checkbox if it has been
1819  * unchecked.
1820  * @param {Element} checkbox The checkbox that has been clicked.
1821  */
1822 function updateParentCheckbox(checkbox) {
1823   if (checkbox.checked)
1824     return;
1825
1826   var entry = findAncestorByClass(checkbox, 'site-entry');
1827   if (!entry)
1828     return;
1829
1830   var groupCheckbox = entry.querySelector('.site-domain-wrapper input');
1831   if (groupCheckbox)
1832       groupCheckbox.checked = false;
1833 }
1834
1835 function entryBoxMousedown(event) {
1836   // Prevent text selection when shift-clicking to select multiple entries.
1837   if (event.shiftKey)
1838     event.preventDefault();
1839 }
1840
1841 /**
1842  * Handle click event for entryBox labels.
1843  * @param {!MouseEvent} event A click event.
1844  */
1845 function entryBoxClick(event) {
1846   // Do nothing if a bookmark star is clicked.
1847   if (event.defaultPrevented)
1848     return;
1849   var element = event.target;
1850   // Do nothing if the event happened in an interactive element.
1851   for (; element != event.currentTarget; element = element.parentNode) {
1852     switch (element.tagName) {
1853       case 'A':
1854       case 'BUTTON':
1855       case 'INPUT':
1856         return;
1857     }
1858   }
1859   var checkbox = event.currentTarget.control;
1860   checkbox.checked = !checkbox.checked;
1861   handleCheckboxStateChange(checkbox, event.shiftKey);
1862   // We don't want to focus on the checkbox.
1863   event.preventDefault();
1864 }
1865
1866 /**
1867  * Called when an individual history entry has been removed from the page.
1868  * This will only be called when all the elements affected by the deletion
1869  * have been removed from the DOM and the animations have completed.
1870  */
1871 function onEntryRemoved() {
1872   historyView.updateSelectionEditButtons();
1873 }
1874
1875 /**
1876  * Triggers a fade-out animation, and then removes |node| from the DOM.
1877  * @param {Node} node The node to be removed.
1878  * @param {Function?} onRemove A function to be called after the node
1879  *     has been removed from the DOM.
1880  */
1881 function removeNode(node, onRemove) {
1882   node.classList.add('fade-out'); // Trigger CSS fade out animation.
1883
1884   // Delete the node when the animation is complete.
1885   node.addEventListener('webkitTransitionEnd', function(e) {
1886     node.parentNode.removeChild(node);
1887
1888     // In case there is nested deletion happening, prevent this event from
1889     // being handled by listeners on ancestor nodes.
1890     e.stopPropagation();
1891
1892     if (onRemove)
1893       onRemove();
1894   });
1895 }
1896
1897 /**
1898  * Removes a single entry from the view. Also removes gaps before and after
1899  * entry if necessary.
1900  * @param {Node} entry The DOM node representing the entry to be removed.
1901  */
1902 function removeEntryFromView(entry) {
1903   var nextEntry = entry.nextSibling;
1904   var previousEntry = entry.previousSibling;
1905   var dayResults = findAncestorByClass(entry, 'day-results');
1906
1907   var toRemove = [entry];
1908
1909   // if there is no previous entry, and the next entry is a gap, remove it
1910   if (!previousEntry && nextEntry && nextEntry.className == 'gap')
1911     toRemove.push(nextEntry);
1912
1913   // if there is no next entry, and the previous entry is a gap, remove it
1914   if (!nextEntry && previousEntry && previousEntry.className == 'gap')
1915     toRemove.push(previousEntry);
1916
1917   // if both the next and previous entries are gaps, remove one
1918   if (nextEntry && nextEntry.className == 'gap' &&
1919       previousEntry && previousEntry.className == 'gap') {
1920     toRemove.push(nextEntry);
1921   }
1922
1923   // If removing the last entry on a day, remove the entire day.
1924   if (dayResults && dayResults.querySelectorAll('.entry').length == 1) {
1925     toRemove.push(dayResults.previousSibling);  // Remove the 'h3'.
1926     toRemove.push(dayResults);
1927   }
1928
1929   // Callback to be called when each node has finished animating. It detects
1930   // when all the animations have completed, and then calls |onEntryRemoved|.
1931   function onRemove() {
1932     for (var i = 0; i < toRemove.length; ++i) {
1933       if (toRemove[i].parentNode)
1934         return;
1935     }
1936     onEntryRemoved();
1937   }
1938
1939   // Kick off the removal process.
1940   for (var i = 0; i < toRemove.length; ++i) {
1941     removeNode(toRemove[i], onRemove);
1942   }
1943 }
1944
1945 /**
1946  * Toggles an element in the grouped history.
1947  * @param {Element} e The element which was clicked on.
1948  */
1949 function toggleHandler(e) {
1950   var innerResultList = e.currentTarget.parentElement.querySelector(
1951       '.site-results');
1952   var innerArrow = e.currentTarget.parentElement.querySelector(
1953       '.site-domain-arrow');
1954   if (innerArrow.classList.contains('collapse')) {
1955     innerResultList.style.height = 'auto';
1956     // -webkit-transition does not work on height:auto elements so first set
1957     // the height to auto so that it is computed and then set it to the
1958     // computed value in pixels so the transition works properly.
1959     var height = innerResultList.clientHeight;
1960     innerResultList.style.height = 0;
1961     setTimeout(function() {
1962       innerResultList.style.height = height + 'px';
1963     }, 0);
1964     innerArrow.classList.remove('collapse');
1965     innerArrow.classList.add('expand');
1966   } else {
1967     innerResultList.style.height = 0;
1968     innerArrow.classList.remove('expand');
1969     innerArrow.classList.add('collapse');
1970   }
1971 }
1972
1973 /**
1974  * Builds the DOM elements to show the managed status of a domain/URL.
1975  * @param {ManagedModeFilteringBehavior} filteringBehavior The filter behavior
1976  *     for this item.
1977  * @return {Element} Returns the DOM elements which show the status.
1978  */
1979 function getManagedStatusDOM(filteringBehavior) {
1980   var filterStatusDiv = createElementWithClassName('div', 'filter-status');
1981   var filteringBehaviorDiv =
1982       createElementWithClassName('div', 'filtering-behavior');
1983   filterStatusDiv.appendChild(filteringBehaviorDiv);
1984
1985   updateHostStatus(filterStatusDiv, filteringBehavior);
1986   return filterStatusDiv;
1987 }
1988
1989
1990 ///////////////////////////////////////////////////////////////////////////////
1991 // Chrome callbacks:
1992
1993 /**
1994  * Our history system calls this function with results from searches.
1995  * @param {Object} info An object containing information about the query.
1996  * @param {Array} results A list of results.
1997  */
1998 function historyResult(info, results) {
1999   historyModel.addResults(info, results);
2000 }
2001
2002 /**
2003  * Called by the history backend when history removal is successful.
2004  */
2005 function deleteComplete() {
2006   historyModel.deleteComplete();
2007 }
2008
2009 /**
2010  * Called by the history backend when history removal is unsuccessful.
2011  */
2012 function deleteFailed() {
2013   window.console.log('Delete failed');
2014 }
2015
2016 /**
2017  * Called when the history is deleted by someone else.
2018  */
2019 function historyDeleted() {
2020   var anyChecked = document.querySelector('.entry input:checked') != null;
2021   // Reload the page, unless the user has any items checked.
2022   // TODO(dubroy): We should just reload the page & restore the checked items.
2023   if (!anyChecked)
2024     historyView.reload();
2025 }
2026
2027 // Add handlers to HTML elements.
2028 document.addEventListener('DOMContentLoaded', load);
2029
2030 // This event lets us enable and disable menu items before the menu is shown.
2031 document.addEventListener('canExecute', function(e) {
2032   e.canExecute = true;
2033 });