history.html revision 72a454cd3513ac24fbdd0e0cb9ad70b86a99b801
1<!DOCTYPE HTML>
2<html i18n-values="dir:textdirection;">
3<head>
4<meta charset="utf-8">
5<title i18n-content="title"></title>
6<link rel="icon" href="/app/theme/history_favicon.png">
7<script src="shared/js/local_strings.js"></script>
8<script src="shared/js/util.js"></script>
9<script>
10///////////////////////////////////////////////////////////////////////////////
11// Globals:
12var RESULTS_PER_PAGE = 150;
13var MAX_SEARCH_DEPTH_MONTHS = 18;
14
15// Amount of time between pageviews that we consider a 'break' in browsing,
16// measured in milliseconds.
17var BROWSING_GAP_TIME = 15 * 60 * 1000;
18
19function $(o) {return document.getElementById(o);}
20
21function createElementWithClassName(type, className) {
22  var elm = document.createElement(type);
23  elm.className = className;
24  return elm;
25}
26
27// Escapes a URI as appropriate for CSS.
28function encodeURIForCSS(uri) {
29  // CSS uris need to have '(' and ')' escaped.
30  return uri.replace(/\(/g, "\\(").replace(/\)/g, "\\)");
31}
32
33// TODO(glen): Get rid of these global references, replace with a controller
34//     or just make the classes own more of the page.
35var historyModel;
36var historyView;
37var localStrings;
38var pageState;
39var deleteQueue = [];
40var deleteInFlight = false;
41var selectionAnchor = -1;
42var id2checkbox = [];
43
44
45///////////////////////////////////////////////////////////////////////////////
46// Page:
47/**
48 * Class to hold all the information about an entry in our model.
49 * @param {Object} result An object containing the page's data.
50 * @param {boolean} continued Whether this page is on the same day as the
51 *     page before it
52 */
53function Page(result, continued, model, id) {
54  this.model_ = model;
55  this.title_ = result.title;
56  this.url_ = result.url;
57  this.starred_ = result.starred;
58  this.snippet_ = result.snippet || "";
59  this.id_ = id;
60
61  this.changed = false;
62
63  this.isRendered = false;
64
65  // All the date information is public so that owners can compare properties of
66  // two items easily.
67
68  // We get the time in seconds, but we want it in milliseconds.
69  this.time = new Date(result.time * 1000);
70
71  // See comment in BrowsingHistoryHandler::QueryComplete - we won't always
72  // get all of these.
73  this.dateRelativeDay = result.dateRelativeDay || "";
74  this.dateTimeOfDay = result.dateTimeOfDay || "";
75  this.dateShort = result.dateShort || "";
76
77  // Whether this is the continuation of a previous day.
78  this.continued = continued;
79}
80
81// Page, Public: --------------------------------------------------------------
82/**
83 * @return {DOMObject} Gets the DOM representation of the page
84 *     for use in browse results.
85 */
86Page.prototype.getBrowseResultDOM = function() {
87  var node = createElementWithClassName('div', 'entry');
88  var time = createElementWithClassName('div', 'time');
89  if (this.model_.getEditMode()) {
90    var checkbox = document.createElement('input');
91    checkbox.type = "checkbox";
92    checkbox.name = this.id_;
93    checkbox.time = this.time.toString();
94    checkbox.addEventListener("click", checkboxClicked, false);
95    id2checkbox[this.id_] = checkbox;
96    time.appendChild(checkbox);
97  }
98  time.appendChild(document.createTextNode(this.dateTimeOfDay));
99  node.appendChild(time);
100  node.appendChild(this.getTitleDOM_());
101  return node;
102};
103
104/**
105 * @return {DOMObject} Gets the DOM representation of the page for
106 *     use in search results.
107 */
108Page.prototype.getSearchResultDOM = function() {
109  var row = createElementWithClassName('tr', 'entry');
110  var datecell = createElementWithClassName('td', 'time');
111  datecell.appendChild(document.createTextNode(this.dateShort));
112  row.appendChild(datecell);
113
114  var titleCell = document.createElement('td');
115  titleCell.valign = 'top';
116  titleCell.appendChild(this.getTitleDOM_());
117  var snippet = createElementWithClassName('div', 'snippet');
118  this.addHighlightedText_(snippet,
119                           this.snippet_,
120                           this.model_.getSearchText());
121  titleCell.appendChild(snippet);
122  row.appendChild(titleCell);
123
124  return row;
125};
126
127// Page, private: -------------------------------------------------------------
128/**
129 * Add child text nodes to a node such that occurrences of the spcified text is
130 * highligted.
131 * @param {Node} node The node under which new text nodes will be made as
132 *     children.
133 * @param {string} content Text to be added beneath |node| as one or more
134 *     text nodes.
135 * @param {string} highlightText Occurences of this text inside |content| will
136 *     be highlighted.
137 */
138Page.prototype.addHighlightedText_ = function(node, content, highlightText) {
139  var i = 0;
140  if (highlightText) {
141    var re = new RegExp(Page.pregQuote_(highlightText), 'gim');
142    var match;
143    while (match = re.exec(content)) {
144      if (match.index > i)
145        node.appendChild(document.createTextNode(content.slice(i,
146                                                               match.index)));
147      i = re.lastIndex;
148      // Mark the highlighted text in bold.
149      var b = document.createElement('b');
150      b.textContent = content.substring(match.index, i);
151      node.appendChild(b);
152    }
153  }
154  if (i < content.length)
155    node.appendChild(document.createTextNode(content.slice(i)));
156};
157
158/**
159 * @return {DOMObject} DOM representation for the title block.
160 */
161Page.prototype.getTitleDOM_ = function() {
162  var node = document.createElement('div');
163  node.className = 'title';
164  var link = document.createElement('a');
165  link.href = this.url_;
166
167  link.style.backgroundImage =
168      'url(chrome://favicon/' + encodeURIForCSS(this.url_) + ')';
169  link.id = "id-" + this.id_;
170  this.addHighlightedText_(link, this.title_, this.model_.getSearchText());
171
172  node.appendChild(link);
173
174  if (this.starred_) {
175    node.className += ' starred';
176    node.appendChild(createElementWithClassName('div', 'starred'));
177  }
178
179  return node;
180};
181
182// Page, private, static: -----------------------------------------------------
183
184/**
185 * Quote a string so it can be used in a regular expression.
186 * @param {string} str The source string
187 * @return {string} The escaped string
188 */
189Page.pregQuote_ = function(str) {
190  return str.replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, "\\$1");
191};
192
193///////////////////////////////////////////////////////////////////////////////
194// HistoryModel:
195/**
196 * Global container for history data. Future optimizations might include
197 * allowing the creation of a HistoryModel for each search string, allowing
198 * quick flips back and forth between results.
199 *
200 * The history model is based around pages, and only fetching the data to
201 * fill the currently requested page. This is somewhat dependent on the view,
202 * and so future work may wish to change history model to operate on
203 * timeframe (day or week) based containers.
204 */
205function HistoryModel() {
206  this.clearModel_();
207  this.setEditMode(false);
208  this.view_;
209}
210
211// HistoryModel, Public: ------------------------------------------------------
212/**
213 * Sets our current view that is called when the history model changes.
214 * @param {HistoryView} view The view to set our current view to.
215 */
216HistoryModel.prototype.setView = function(view) {
217  this.view_ = view;
218};
219
220/**
221 * Start a new search - this will clear out our model.
222 * @param {String} searchText The text to search for
223 * @param {Number} opt_page The page to view - this is mostly used when setting
224 *     up an initial view, use #requestPage otherwise.
225 */
226HistoryModel.prototype.setSearchText = function(searchText, opt_page) {
227  this.clearModel_();
228  this.searchText_ = searchText;
229  this.requestedPage_ = opt_page ? opt_page : 0;
230  this.getSearchResults_();
231};
232
233/**
234 * Reload our model with the current parameters.
235 */
236HistoryModel.prototype.reload = function() {
237  var search = this.searchText_;
238  var page = this.requestedPage_;
239  this.clearModel_();
240  this.searchText_ = search;
241  this.requestedPage_ = page;
242  this.getSearchResults_();
243};
244
245/**
246 * @return {String} The current search text.
247 */
248HistoryModel.prototype.getSearchText = function() {
249  return this.searchText_;
250};
251
252/**
253 * Tell the model that the view will want to see the current page. When
254 * the data becomes available, the model will call the view back.
255 * @page {Number} page The page we want to view.
256 */
257HistoryModel.prototype.requestPage = function(page) {
258  this.requestedPage_ = page;
259  this.changed = true;
260  this.updateSearch_(false);
261};
262
263/**
264 * Receiver for history query.
265 * @param {String} term The search term that the results are for.
266 * @param {Array} results A list of results
267 */
268HistoryModel.prototype.addResults = function(info, results) {
269  this.inFlight_ = false;
270  if (info.term != this.searchText_) {
271    // If our results aren't for our current search term, they're rubbish.
272    return;
273  }
274
275  // Currently we assume we're getting things in date order. This needs to
276  // be updated if that ever changes.
277  if (results) {
278    var lastURL, lastDay;
279    var oldLength = this.pages_.length;
280    if (oldLength) {
281      var oldPage = this.pages_[oldLength - 1];
282      lastURL = oldPage.url;
283      lastDay = oldPage.dateRelativeDay;
284    }
285
286    for (var i = 0, thisResult; thisResult = results[i]; i++) {
287      var thisURL = thisResult.url;
288      var thisDay = thisResult.dateRelativeDay;
289
290      // Remove adjacent duplicates.
291      if (!lastURL || lastURL != thisURL) {
292        // Figure out if this page is in the same day as the previous page,
293        // this is used to determine how day headers should be drawn.
294        this.pages_.push(new Page(thisResult, thisDay == lastDay, this,
295            this.last_id_++));
296        lastDay = thisDay;
297        lastURL = thisURL;
298      }
299    }
300    if (results.length)
301      this.changed = true;
302  }
303
304  this.updateSearch_(info.finished);
305};
306
307/**
308 * @return {Number} The number of pages in the model.
309 */
310HistoryModel.prototype.getSize = function() {
311  return this.pages_.length;
312};
313
314/**
315 * @return {boolean} Whether our history query has covered all of
316 *     the user's history
317 */
318HistoryModel.prototype.isComplete = function() {
319  return this.complete_;
320};
321
322/**
323 * Get a list of pages between specified index positions.
324 * @param {Number} start The start index
325 * @param {Number} end The end index
326 * @return {Array} A list of pages
327 */
328HistoryModel.prototype.getNumberedRange = function(start, end) {
329  if (start >= this.getSize())
330    return [];
331
332  var end = end > this.getSize() ? this.getSize() : end;
333  return this.pages_.slice(start, end);
334};
335
336/**
337 * @return {boolean} Whether we are in edit mode where history items can be
338 *    deleted
339 */
340HistoryModel.prototype.getEditMode = function() {
341  return this.editMode_;
342};
343
344/**
345 * @param {boolean} edit_mode Control whether we are in edit mode.
346 */
347HistoryModel.prototype.setEditMode = function(edit_mode) {
348  this.editMode_ = edit_mode;
349};
350
351// HistoryModel, Private: -----------------------------------------------------
352HistoryModel.prototype.clearModel_ = function() {
353  this.inFlight_ = false; // Whether a query is inflight.
354  this.searchText_ = '';
355  this.searchDepth_ = 0;
356  this.pages_ = []; // Date-sorted list of pages.
357  this.last_id_ = 0;
358  selectionAnchor = -1;
359  id2checkbox = [];
360
361  // The page that the view wants to see - we only fetch slightly past this
362  // point. If the view requests a page that we don't have data for, we try
363  // to fetch it and call back when we're done.
364  this.requestedPage_ = 0;
365
366  this.complete_ = false;
367
368  if (this.view_) {
369    this.view_.clear_();
370  }
371};
372
373/**
374 * Figure out if we need to do more searches to fill the currently requested
375 * page. If we think we can fill the page, call the view and let it know
376 * we're ready to show something.
377 */
378HistoryModel.prototype.updateSearch_ = function(finished) {
379  if ((this.searchText_ && this.searchDepth_ >= MAX_SEARCH_DEPTH_MONTHS) ||
380      finished) {
381    // We have maxed out. There will be no more data.
382    this.complete_ = true;
383    this.view_.onModelReady();
384    this.changed = false;
385  } else {
386    // If we can't fill the requested page, ask for more data unless a request
387    // is still in-flight.
388    if (!this.canFillPage_(this.requestedPage_) && !this.inFlight_) {
389      this.getSearchResults_(this.searchDepth_ + 1);
390    }
391
392    // If we have any data for the requested page, show it.
393    if (this.changed && this.haveDataForPage_(this.requestedPage_)) {
394      this.view_.onModelReady();
395      this.changed = false;
396    }
397  }
398};
399
400/**
401 * Get search results for a selected depth. Our history system is optimized
402 * for queries that don't cross month boundaries, but an entire month's
403 * worth of data is huge. When we're in browse mode (searchText is empty)
404 * we request the data a day at a time. When we're searching, a month is
405 * used.
406 *
407 * TODO: Fix this for when the user's clock goes across month boundaries.
408 * @param {number} opt_day How many days back to do the search.
409 */
410HistoryModel.prototype.getSearchResults_ = function(depth) {
411  this.searchDepth_ = depth || 0;
412
413  if (this.searchText_ == "") {
414    chrome.send('getHistory',
415        [String(this.searchDepth_)]);
416  } else {
417    chrome.send('searchHistory',
418        [this.searchText_, String(this.searchDepth_)]);
419  }
420
421  this.inFlight_ = true;
422};
423
424/**
425 * Check to see if we have data for a given page.
426 * @param {number} page The page number
427 * @return {boolean} Whether we have any data for the given page.
428 */
429HistoryModel.prototype.haveDataForPage_ = function(page) {
430  return (page * RESULTS_PER_PAGE < this.getSize());
431};
432
433/**
434 * Check to see if we have data to fill a page.
435 * @param {number} page The page number.
436 * @return {boolean} Whether we have data to fill the page.
437 */
438HistoryModel.prototype.canFillPage_ = function(page) {
439  return ((page + 1) * RESULTS_PER_PAGE <= this.getSize());
440};
441
442///////////////////////////////////////////////////////////////////////////////
443// HistoryView:
444/**
445 * Functions and state for populating the page with HTML. This should one-day
446 * contain the view and use event handlers, rather than pushing HTML out and
447 * getting called externally.
448 * @param {HistoryModel} model The model backing this view.
449 */
450function HistoryView(model) {
451  this.summaryTd_ = $('results-summary');
452  this.summaryTd_.textContent = localStrings.getString('loading');
453  this.editButtonTd_ = $('edit-button');
454  this.editingControlsDiv_ = $('editing-controls');
455  this.resultDiv_ = $('results-display');
456  this.pageDiv_ = $('results-pagination');
457  this.model_ = model
458  this.pageIndex_ = 0;
459  this.lastDisplayed_ = [];
460
461  this.model_.setView(this);
462
463  this.currentPages_ = [];
464
465  var self = this;
466  window.onresize = function() {
467    self.updateEntryAnchorWidth_();
468  };
469  self.updateEditControls_();
470
471  this.boundUpdateRemoveButton_ = function(e) {
472    return self.updateRemoveButton_(e);
473  };
474}
475
476// HistoryView, public: -------------------------------------------------------
477/**
478 * Do a search and optionally view a certain page.
479 * @param {string} term The string to search for.
480 * @param {number} opt_page The page we wish to view, only use this for
481 *     setting up initial views, as this triggers a search.
482 */
483HistoryView.prototype.setSearch = function(term, opt_page) {
484  this.pageIndex_ = parseInt(opt_page || 0, 10);
485  window.scrollTo(0, 0);
486  this.model_.setSearchText(term, this.pageIndex_);
487  if (term) {
488    this.setEditMode(false);
489  }
490  this.updateEditControls_();
491  pageState.setUIState(this.model_.getEditMode(), term, this.pageIndex_);
492};
493
494/**
495 * Controls edit mode where history can be deleted.
496 * @param {boolean} edit_mode Whether to enable edit mode.
497 */
498HistoryView.prototype.setEditMode = function(edit_mode) {
499  this.model_.setEditMode(edit_mode);
500  pageState.setUIState(this.model_.getEditMode(), this.model_.getSearchText(),
501                       this.pageIndex_);
502};
503
504/**
505 * Toggles the edit mode and triggers UI update.
506 */
507HistoryView.prototype.toggleEditMode = function() {
508  var editMode = !this.model_.getEditMode();
509  this.setEditMode(editMode);
510  this.updateEditControls_();
511};
512
513/**
514 * Reload the current view.
515 */
516HistoryView.prototype.reload = function() {
517  this.model_.reload();
518};
519
520/**
521 * Switch to a specified page.
522 * @param {number} page The page we wish to view.
523 */
524HistoryView.prototype.setPage = function(page) {
525  this.clear_();
526  this.pageIndex_ = parseInt(page, 10);
527  window.scrollTo(0, 0);
528  this.model_.requestPage(page);
529  pageState.setUIState(this.model_.getEditMode(), this.model_.getSearchText(),
530      this.pageIndex_);
531};
532
533/**
534 * @return {number} The page number being viewed.
535 */
536HistoryView.prototype.getPage = function() {
537  return this.pageIndex_;
538};
539
540/**
541 * Callback for the history model to let it know that it has data ready for us
542 * to view.
543 */
544HistoryView.prototype.onModelReady = function() {
545  this.displayResults_();
546};
547
548// HistoryView, private: ------------------------------------------------------
549/**
550 * Clear the results in the view.  Since we add results piecemeal, we need
551 * to clear them out when we switch to a new page or reload.
552 */
553HistoryView.prototype.clear_ = function() {
554  this.resultDiv_.textContent = '';
555
556  var pages = this.currentPages_;
557  for (var i = 0; i < pages.length; i++) {
558    pages[i].isRendered = false;
559  }
560  this.currentPages_ = [];
561};
562
563HistoryView.prototype.setPageRendered_ = function(page) {
564  page.isRendered = true;
565  this.currentPages_.push(page);
566};
567
568/**
569 * Update the page with results.
570 */
571HistoryView.prototype.displayResults_ = function() {
572  var results = this.model_.getNumberedRange(
573      this.pageIndex_ * RESULTS_PER_PAGE,
574      this.pageIndex_ * RESULTS_PER_PAGE + RESULTS_PER_PAGE);
575
576  if (this.model_.getSearchText()) {
577    var resultTable = createElementWithClassName('table', 'results');
578    resultTable.cellSpacing = 0;
579    resultTable.cellPadding = 0;
580    resultTable.border = 0;
581
582    for (var i = 0, page; page = results[i]; i++) {
583      if (!page.isRendered) {
584        resultTable.appendChild(page.getSearchResultDOM());
585        this.setPageRendered_(page);
586      }
587    }
588    this.resultDiv_.appendChild(resultTable);
589  } else {
590    var lastTime = Math.infinity;
591    for (var i = 0, page; page = results[i]; i++) {
592      if (page.isRendered) {
593        continue;
594      }
595      // Break across day boundaries and insert gaps for browsing pauses.
596      var thisTime = page.time.getTime();
597
598      if ((i == 0 && page.continued) || !page.continued) {
599        var day = createElementWithClassName('div', 'day');
600        day.appendChild(document.createTextNode(page.dateRelativeDay));
601
602        if (i == 0 && page.continued) {
603          day.appendChild(document.createTextNode(' ' +
604              localStrings.getString('cont')));
605        }
606
607        this.resultDiv_.appendChild(day);
608      } else if (lastTime - thisTime > BROWSING_GAP_TIME) {
609        this.resultDiv_.appendChild(createElementWithClassName('div', 'gap'));
610      }
611      lastTime = thisTime;
612
613      // Add entry.
614      this.resultDiv_.appendChild(page.getBrowseResultDOM());
615      this.setPageRendered_(page);
616    }
617  }
618
619  this.displaySummaryBar_();
620  this.displayNavBar_();
621  this.updateEntryAnchorWidth_();
622};
623
624/**
625 * Update the summary bar with descriptive text.
626 */
627HistoryView.prototype.displaySummaryBar_ = function() {
628  var searchText = this.model_.getSearchText();
629  if (searchText != '') {
630    this.summaryTd_.textContent = localStrings.getStringF('searchresultsfor',
631        searchText);
632  } else {
633    this.summaryTd_.textContent = localStrings.getString('history');
634  }
635};
636
637/**
638 * Update the widgets related to edit mode.
639 */
640HistoryView.prototype.updateEditControls_ = function() {
641  // Display a button (looking like a link) to enable/disable edit mode.
642  var oldButton = this.editButtonTd_.firstChild;
643  if (this.model_.getSearchText()) {
644    this.editButtonTd_.replaceChild(document.createElement('p'), oldButton);
645    this.editingControlsDiv_.textContent = '';
646    return;
647  }
648
649  var editMode = this.model_.getEditMode();
650  var button = createElementWithClassName('button', 'edit-button');
651  button.onclick = toggleEditMode;
652  button.textContent = localStrings.getString(editMode ?
653                                              'doneediting' : 'edithistory');
654  this.editButtonTd_.replaceChild(button, oldButton);
655
656  this.editingControlsDiv_.textContent = '';
657
658  if (editMode) {
659    // Button to delete the selected items.
660    button = document.createElement('button');
661    button.onclick = removeItems;
662    button.textContent = localStrings.getString('removeselected');
663    button.disabled = true;
664    this.editingControlsDiv_.appendChild(button);
665    this.removeButton_ = button;
666
667    // Button that opens up the clear browsing data dialog.
668    button = document.createElement('button');
669    button.onclick = openClearBrowsingData;
670    button.textContent = localStrings.getString('clearallhistory');
671    this.editingControlsDiv_.appendChild(button);
672
673    // Listen for clicks in the page to sync the disabled state.
674    document.addEventListener('click', this.boundUpdateRemoveButton_);
675  } else {
676    this.removeButton_ = null;
677    document.removeEventListener('click', this.boundUpdateRemoveButton_);
678  }
679};
680
681/**
682 * Updates the disabled state of the remove button when in editing mode.
683 * @param {!Event} e The click event object.
684 * @private
685 */
686HistoryView.prototype.updateRemoveButton_ = function(e) {
687  if (e.target.tagName != 'INPUT')
688    return;
689
690  var anyChecked = document.querySelector('.entry input:checked') != null;
691  if (this.removeButton_)
692    this.removeButton_.disabled = !anyChecked;
693};
694
695/**
696 * Update the pagination tools.
697 */
698HistoryView.prototype.displayNavBar_ = function() {
699  this.pageDiv_.textContent = '';
700
701  if (this.pageIndex_ > 0) {
702    this.pageDiv_.appendChild(
703        this.createPageNav_(0, localStrings.getString('newest')));
704    this.pageDiv_.appendChild(
705        this.createPageNav_(this.pageIndex_ - 1,
706                            localStrings.getString('newer')));
707  }
708
709  // TODO(feldstein): this causes the navbar to not show up when your first
710  // page has the exact amount of results as RESULTS_PER_PAGE.
711  if (this.model_.getSize() > (this.pageIndex_ + 1) * RESULTS_PER_PAGE) {
712    this.pageDiv_.appendChild(
713        this.createPageNav_(this.pageIndex_ + 1,
714                            localStrings.getString('older')));
715  }
716};
717
718/**
719 * Make a DOM object representation of a page navigation link.
720 * @param {number} page The page index the navigation element should link to
721 * @param {string} name The text content of the link
722 * @return {HTMLAnchorElement} the pagination link
723 */
724HistoryView.prototype.createPageNav_ = function(page, name) {
725  anchor = document.createElement('a');
726  anchor.className = 'page-navigation';
727  anchor.textContent = name;
728  var hashString = PageState.getHashString(this.model_.getEditMode(),
729                                           this.model_.getSearchText(), page);
730  var link = 'chrome://history/' + (hashString ? '#' + hashString : '');
731  anchor.href = link;
732  anchor.onclick = function() {
733    setPage(page);
734    return false;
735  };
736  return anchor;
737};
738
739/**
740 * Updates the CSS rule for the entry anchor.
741 * @private
742 */
743HistoryView.prototype.updateEntryAnchorWidth_ = function() {
744  // We need to have at least on .title div to be able to calculate the
745  // desired width of the anchor.
746  var titleElement = document.querySelector('.entry .title');
747  if (!titleElement)
748    return;
749
750  // Create new CSS rules and add them last to the last stylesheet.
751  if (!this.entryAnchorRule_) {
752     var styleSheets = document.styleSheets;
753     var styleSheet = styleSheets[styleSheets.length - 1];
754     var rules = styleSheet.cssRules;
755     var createRule = function(selector) {
756       styleSheet.insertRule(selector + '{}', rules.length);
757       return rules[rules.length - 1];
758     };
759     this.entryAnchorRule_ = createRule('.entry .title > a');
760     // The following rule needs to be more specific to have higher priority.
761     this.entryAnchorStarredRule_ = createRule('.entry .title.starred > a');
762   }
763
764   var anchorMaxWith = titleElement.offsetWidth;
765   this.entryAnchorRule_.style.maxWidth = anchorMaxWith + 'px';
766   // Adjust by the width of star plus its margin.
767   this.entryAnchorStarredRule_.style.maxWidth = anchorMaxWith - 23 + 'px';
768};
769
770///////////////////////////////////////////////////////////////////////////////
771// State object:
772/**
773 * An 'AJAX-history' implementation.
774 * @param {HistoryModel} model The model we're representing
775 * @param {HistoryView} view The view we're representing
776 */
777function PageState(model, view) {
778  // Enforce a singleton.
779  if (PageState.instance) {
780    return PageState.instance;
781  }
782
783  this.model = model;
784  this.view = view;
785
786  if (typeof this.checker_ != 'undefined' && this.checker_) {
787    clearInterval(this.checker_);
788  }
789
790  // TODO(glen): Replace this with a bound method so we don't need
791  //     public model and view.
792  this.checker_ = setInterval((function(state_obj) {
793    var hashData = state_obj.getHashData();
794
795    if (hashData.q != state_obj.model.getSearchText(term)) {
796      state_obj.view.setSearch(hashData.q, parseInt(hashData.p, 10));
797    } else if (parseInt(hashData.p, 10) != state_obj.view.getPage()) {
798      state_obj.view.setPage(hashData.p);
799    }
800  }), 50, this);
801}
802
803PageState.instance = null;
804
805/**
806 * @return {Object} An object containing parameters from our window hash.
807 */
808PageState.prototype.getHashData = function() {
809  var result = {
810    e : 0,
811    q : '',
812    p : 0
813  };
814
815  if (!window.location.hash) {
816    return result;
817  }
818
819  var hashSplit = window.location.hash.substr(1).split('&');
820  for (var i = 0; i < hashSplit.length; i++) {
821    var pair = hashSplit[i].split('=');
822    if (pair.length > 1) {
823      result[pair[0]] = decodeURIComponent(pair[1].replace(/\+/g, ' '));
824    }
825  }
826
827  return result;
828};
829
830/**
831 * Set the hash to a specified state, this will create an entry in the
832 * session history so the back button cycles through hash states, which
833 * are then picked up by our listener.
834 * @param {string} term The current search string.
835 * @param {string} page The page currently being viewed.
836 */
837PageState.prototype.setUIState = function(editMode, term, page) {
838  // Make sure the form looks pretty.
839  document.forms[0].term.value = term;
840  var currentHash = this.getHashData();
841  if (Boolean(currentHash.e) != editMode || currentHash.q != term ||
842      currentHash.p != page) {
843    window.location.hash = PageState.getHashString(editMode, term, page);
844  }
845};
846
847/**
848 * Static method to get the hash string for a specified state
849 * @param {string} term The current search string.
850 * @param {string} page The page currently being viewed.
851 * @return {string} The string to be used in a hash.
852 */
853PageState.getHashString = function(editMode, term, page) {
854  var newHash = [];
855  if (editMode) {
856    newHash.push('e=1');
857  }
858  if (term) {
859    newHash.push('q=' + encodeURIComponent(term));
860  }
861  if (page != undefined) {
862    newHash.push('p=' + page);
863  }
864
865  return newHash.join('&');
866};
867
868///////////////////////////////////////////////////////////////////////////////
869// Document Functions:
870/**
871 * Window onload handler, sets up the page.
872 */
873function load() {
874  $('term').focus();
875
876  localStrings = new LocalStrings();
877  historyModel = new HistoryModel();
878  historyView = new HistoryView(historyModel);
879  pageState = new PageState(historyModel, historyView);
880
881  // Create default view.
882  var hashData = pageState.getHashData();
883  if (Boolean(hashData.e)) {
884    historyView.toggleEditMode();
885  }
886  historyView.setSearch(hashData.q, hashData.p);
887}
888
889/**
890 * TODO(glen): Get rid of this function.
891 * Set the history view to a specified page.
892 * @param {String} term The string to search for
893 */
894function setSearch(term) {
895  if (historyView) {
896    historyView.setSearch(term);
897  }
898}
899
900/**
901 * TODO(glen): Get rid of this function.
902 * Set the history view to a specified page.
903 * @param {number} page The page to set the view to.
904 */
905function setPage(page) {
906  if (historyView) {
907    historyView.setPage(page);
908  }
909}
910
911/**
912 * TODO(glen): Get rid of this function.
913 * Toggles edit mode.
914 */
915function toggleEditMode() {
916  if (historyView) {
917    historyView.toggleEditMode();
918    historyView.reload();
919  }
920}
921
922/**
923 * Delete the next item in our deletion queue.
924 */
925function deleteNextInQueue() {
926  if (!deleteInFlight && deleteQueue.length) {
927    deleteInFlight = true;
928    chrome.send('removeURLsOnOneDay',
929                [String(deleteQueue[0])].concat(deleteQueue[1]));
930  }
931}
932
933/**
934 * Open the clear browsing data dialog.
935 */
936function openClearBrowsingData() {
937  chrome.send('clearBrowsingData', []);
938  return false;
939}
940
941/**
942 * Collect IDs from checked checkboxes and send to Chrome for deletion.
943 */
944function removeItems() {
945  var checkboxes = document.getElementsByTagName('input');
946  var ids = [];
947  var disabledItems = [];
948  var queue = [];
949  var date = new Date();
950  for (var i = 0; i < checkboxes.length; i++) {
951    if (checkboxes[i].type == 'checkbox' && checkboxes[i].checked &&
952        !checkboxes[i].disabled) {
953      var cbDate = new Date(checkboxes[i].time);
954      if (date.getFullYear() != cbDate.getFullYear() ||
955          date.getMonth() != cbDate.getMonth() ||
956          date.getDate() != cbDate.getDate()) {
957        if (ids.length > 0) {
958          queue.push(date.valueOf() / 1000);
959          queue.push(ids);
960        }
961        ids = [];
962        date = cbDate;
963      }
964      var link = $('id-' + checkboxes[i].name);
965      checkboxes[i].disabled = true;
966      link.style.textDecoration = 'line-through';
967      disabledItems.push(checkboxes[i]);
968      ids.push(link.href);
969    }
970  }
971  if (ids.length > 0) {
972    queue.push(date.valueOf() / 1000);
973    queue.push(ids);
974  }
975  if (queue.length > 0) {
976    if (confirm(localStrings.getString('deletewarning'))) {
977      deleteQueue = deleteQueue.concat(queue);
978      deleteNextInQueue();
979    } else {
980      // If the remove is cancelled, return the checkboxes to their
981      // enabled, non-line-through state.
982      for (var i = 0; i < disabledItems.length; i++) {
983        var link = $('id-' + disabledItems[i].name);
984        disabledItems[i].disabled = false;
985        link.style.textDecoration = '';
986      }
987    }
988  }
989  return false;
990}
991
992/**
993 * Toggle state of checkbox and handle Shift modifier.
994 */
995function checkboxClicked(event) {
996  if (event.shiftKey && (selectionAnchor != -1)) {
997    var checked = this.checked;
998    // Set all checkboxes from the anchor up to the clicked checkbox to the
999    // state of the clicked one.
1000    var begin = Math.min(this.name, selectionAnchor);
1001    var end = Math.max(this.name, selectionAnchor);
1002    for (var i = begin; i <= end; i++) {
1003      id2checkbox[i].checked = checked;
1004    }
1005  }
1006  selectionAnchor = this.name;
1007  this.focus();
1008}
1009
1010///////////////////////////////////////////////////////////////////////////////
1011// Chrome callbacks:
1012/**
1013 * Our history system calls this function with results from searches.
1014 */
1015function historyResult(info, results) {
1016  historyModel.addResults(info, results);
1017}
1018
1019/**
1020 * Our history system calls this function when a deletion has finished.
1021 */
1022function deleteComplete() {
1023  window.console.log('Delete complete');
1024  deleteInFlight = false;
1025  if (deleteQueue.length > 1) {
1026    deleteQueue = deleteQueue.slice(2);
1027    deleteNextInQueue();
1028  } else {
1029    deleteQueue = [];
1030  }
1031}
1032
1033/**
1034 * Our history system calls this function if a delete is not ready (e.g.
1035 * another delete is in-progress).
1036 */
1037function deleteFailed() {
1038  window.console.log('Delete failed');
1039  // The deletion failed - try again later.
1040  deleteInFlight = false;
1041  setTimeout(deleteNextInQueue, 500);
1042}
1043
1044/**
1045 * We're called when something is deleted (either by us or by someone
1046 * else).
1047 */
1048function historyDeleted() {
1049  window.console.log('History deleted');
1050  historyView.reload();
1051}
1052</script>
1053<link rel="stylesheet" href="dom_ui.css">
1054<style>
1055#results-separator {
1056  margin-top:12px;
1057  border-top:1px solid #9cc2ef;
1058  background-color:#ebeff9;
1059  font-weight:bold;
1060  padding:3px;
1061  margin-bottom:-8px;
1062}
1063#results-separator table {
1064  width: 100%;
1065}
1066#results-summary {
1067  overflow: hidden;
1068  white-space: nowrap;
1069  text-overflow: ellipsis;
1070  width: 50%;
1071}
1072#edit-button {
1073  text-align: end;
1074  overflow: hidden;
1075  white-space: nowrap;
1076  text-overflow: ellipsis;
1077  width: 50%;
1078}
1079#editing-controls button {
1080  margin-top: 18px;
1081  margin-bottom: -8px;
1082}
1083#results-display {
1084  max-width:740px;
1085}
1086.day {
1087  margin-top:18px;
1088  padding:0px 3px;
1089  display:inline-block;
1090}
1091.edit-button {
1092  display: inline;
1093  -webkit-appearance: none;
1094  background: none;
1095  border: 0;
1096  color: blue; /* -webkit-link makes it purple :'( */
1097  cursor: pointer;
1098  text-decoration: underline;
1099  padding:0px 9px;
1100  display:inline-block;
1101  font:inherit;
1102}
1103.gap {
1104  margin-left:18px;
1105  width:15px;
1106  border-right:1px solid #ddd;
1107  height:14px;
1108}
1109.entry {
1110  margin-left:18px;
1111  margin-top:6px;
1112  overflow:auto;
1113}
1114table.results {
1115  margin-left:4px;
1116}
1117.entry .time {
1118  color:#888;
1119  float:left;
1120  min-width:56px;
1121  -webkit-margin-end:5px;
1122  padding-top:1px;
1123  white-space:nowrap;
1124}
1125html[dir='rtl'] .time {
1126  float:right;
1127}
1128.entry .title {
1129  max-width:600px;
1130  overflow: hidden;
1131  white-space: nowrap;
1132  text-overflow: ellipsis;
1133}
1134.results .time, .results .title {
1135  margin-top:18px;
1136}
1137.title > .starred {
1138  background:url('shared/images/star_small.png');
1139  background-repeat:no-repeat;
1140  display:inline-block;
1141  -webkit-margin-start:12px;
1142  -webkit-margin-end:0;
1143  width:11px;
1144  height:11px;
1145}
1146.entry .title > a {
1147  box-sizing: border-box;
1148  background-repeat:no-repeat;
1149  background-size:16px;
1150  background-position:0px 1px;
1151  padding:1px 0px 4px 22px;
1152  display:inline-block;
1153  overflow:hidden;
1154  text-overflow:ellipsis;
1155}
1156html[dir='rtl'] .entry .title > a {
1157  background-position-x:right;
1158  padding-left:0px;
1159  padding-right:22px;
1160}
1161#results-pagination {
1162  padding-top:24px;
1163  -webkit-margin-start:18px;
1164}
1165
1166</style>
1167</head>
1168<body onload="load();" i18n-values=".style.fontFamily:fontfamily;.style.fontSize:fontsize">
1169<div class="header">
1170  <a href="" onclick="setSearch(''); return false;">
1171    <img src="shared/images/history_section.png"
1172         width="67" height="67" class="logo" border="0"></a>
1173  <form method="post" action=""
1174      onsubmit="setSearch(this.term.value); return false;"
1175      class="form">
1176    <input type="text" name="term" id="term">
1177    <input type="submit" name="submit" i18n-values="value:searchbutton">
1178  </form>
1179</div>
1180<div class="main">
1181  <div id="results-separator">
1182    <table border="0" cellPadding="0" cellSpacing="0">
1183      <tr><td id="results-summary"></td><td id="edit-button"><p></p></td></tr>
1184    </table>
1185  </div>
1186  <div id="editing-controls"></div>
1187  <div id="results-display"></div>
1188  <div id="results-pagination"></div>
1189</div>
1190<div class="footer">
1191</div>
1192</body>
1193</html>
1194