loader.js revision addcddeda699216395e3cbd2c53835fdbd4cf5c6
1/*
2 * Loader:
3 * Reads GM result reports written out by results.py, and imports
4 * them into $scope.extraColumnHeaders and $scope.imagePairs .
5 */
6var Loader = angular.module(
7    'Loader',
8    ['ConstantsModule']
9);
10
11Loader.directive(
12  'resultsUpdatedCallbackDirective',
13  ['$timeout',
14   function($timeout) {
15     return function(scope, element, attrs) {
16       if (scope.$last) {
17         $timeout(function() {
18           scope.resultsUpdatedCallback();
19         });
20       }
21     };
22   }
23  ]
24);
25
26// TODO(epoger): Combine ALL of our filtering operations (including
27// truncation) into this one filter, so that runs most efficiently?
28// (We would have to make sure truncation still took place after
29// sorting, though.)
30Loader.filter(
31  'removeHiddenImagePairs',
32  function(constants) {
33    return function(unfilteredImagePairs, hiddenResultTypes, hiddenConfigs,
34                    builderSubstring, testSubstring, viewingTab) {
35      var filteredImagePairs = [];
36      for (var i = 0; i < unfilteredImagePairs.length; i++) {
37        var imagePair = unfilteredImagePairs[i];
38        var extraColumnValues = imagePair[constants.KEY__EXTRA_COLUMN_VALUES];
39        // For performance, we examine the "set" objects directly rather
40        // than calling $scope.isValueInSet().
41        // Besides, I don't think we have access to $scope in here...
42        if (!(true == hiddenResultTypes[extraColumnValues[
43                  constants.KEY__EXTRACOLUMN__RESULT_TYPE]]) &&
44            !(true == hiddenConfigs[extraColumnValues[
45                  constants.KEY__EXTRACOLUMN__CONFIG]]) &&
46            !(-1 == extraColumnValues[constants.KEY__EXTRACOLUMN__BUILDER]
47                    .indexOf(builderSubstring)) &&
48            !(-1 == extraColumnValues[constants.KEY__EXTRACOLUMN__TEST]
49                    .indexOf(testSubstring)) &&
50            (viewingTab == imagePair.tab)) {
51          filteredImagePairs.push(imagePair);
52        }
53      }
54      return filteredImagePairs;
55    };
56  }
57);
58
59
60Loader.controller(
61  'Loader.Controller',
62    function($scope, $http, $filter, $location, $log, $timeout, constants) {
63    $scope.constants = constants;
64    $scope.windowTitle = "Loading GM Results...";
65    $scope.resultsToLoad = $location.search().resultsToLoad;
66    $scope.loadingMessage = "Loading results from '" + $scope.resultsToLoad +
67        "', please wait...";
68
69    /**
70     * On initial page load, load a full dictionary of results.
71     * Once the dictionary is loaded, unhide the page elements so they can
72     * render the data.
73     */
74    $http.get($scope.resultsToLoad).success(
75      function(data, status, header, config) {
76        var dataHeader = data[constants.KEY__HEADER];
77        if (dataHeader[constants.KEY__HEADER__IS_STILL_LOADING]) {
78          // Apply the server's requested reload delay to local time,
79          // so we will wait the right number of seconds regardless of clock
80          // skew between client and server.
81          var reloadDelayInSeconds =
82              dataHeader[constants.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE] -
83              dataHeader[constants.KEY__HEADER__TIME_UPDATED];
84          var timeNow = new Date().getTime();
85          var timeToReload = timeNow + reloadDelayInSeconds * 1000;
86          $scope.loadingMessage =
87              "Server is still loading results; will retry at " +
88              $scope.localTimeString(timeToReload / 1000);
89          $timeout(
90              function(){location.reload();},
91              timeToReload - timeNow);
92        } else if (dataHeader[constants.KEY__HEADER__SCHEMA_VERSION] !=
93                   constants.REBASELINE_SERVER_SCHEMA_VERSION_NUMBER) {
94          $scope.loadingMessage = "ERROR: Got JSON file with schema version "
95              + dataHeader[constants.KEY__HEADER__SCHEMA_VERSION]
96              + " but expected schema version "
97              + constants.REBASELINE_SERVER_SCHEMA_VERSION_NUMBER;
98        } else {
99          $scope.loadingMessage = "Processing data, please wait...";
100
101          $scope.header = dataHeader;
102          $scope.extraColumnHeaders = data[constants.KEY__EXTRACOLUMNHEADERS];
103          $scope.imagePairs = data[constants.KEY__IMAGEPAIRS];
104          $scope.imageSets = data[constants.KEY__IMAGESETS];
105          $scope.sortColumnSubdict = constants.KEY__DIFFERENCE_DATA;
106          $scope.sortColumnKey = constants.KEY__DIFFERENCE_DATA__WEIGHTED_DIFF;
107
108          $scope.showSubmitAdvancedSettings = false;
109          $scope.submitAdvancedSettings = {};
110          $scope.submitAdvancedSettings[
111              constants.KEY__EXPECTATIONS__REVIEWED] = true;
112          $scope.submitAdvancedSettings[
113              constants.KEY__EXPECTATIONS__IGNOREFAILURE] = false;
114          $scope.submitAdvancedSettings['bug'] = '';
115
116          // Create the list of tabs (lists into which the user can file each
117          // test).  This may vary, depending on isEditable.
118          $scope.tabs = [
119            'Unfiled', 'Hidden'
120          ];
121          if (dataHeader[constants.KEY__HEADER__IS_EDITABLE]) {
122            $scope.tabs = $scope.tabs.concat(
123                ['Pending Approval']);
124          }
125          $scope.defaultTab = $scope.tabs[0];
126          $scope.viewingTab = $scope.defaultTab;
127
128          // Track the number of results on each tab.
129          $scope.numResultsPerTab = {};
130          for (var i = 0; i < $scope.tabs.length; i++) {
131            $scope.numResultsPerTab[$scope.tabs[i]] = 0;
132          }
133          $scope.numResultsPerTab[$scope.defaultTab] = $scope.imagePairs.length;
134
135          // Add index and tab fields to all records.
136          for (var i = 0; i < $scope.imagePairs.length; i++) {
137            $scope.imagePairs[i].index = i;
138            $scope.imagePairs[i].tab = $scope.defaultTab;
139          }
140
141          // Arrays within which the user can toggle individual elements.
142          $scope.selectedImagePairs = [];
143
144          // Sets within which the user can toggle individual elements.
145          $scope.hiddenResultTypes = {};
146          $scope.hiddenResultTypes[
147              constants.KEY__RESULT_TYPE__FAILUREIGNORED] = true;
148          $scope.hiddenResultTypes[
149              constants.KEY__RESULT_TYPE__NOCOMPARISON] = true;
150          $scope.hiddenResultTypes[
151              constants.KEY__RESULT_TYPE__SUCCEEDED] = true;
152          $scope.allResultTypes = Object.keys(
153              $scope.extraColumnHeaders[constants.KEY__EXTRACOLUMN__RESULT_TYPE]
154                                       [constants.KEY__VALUES_AND_COUNTS]);
155          $scope.hiddenConfigs = {};
156          $scope.allConfigs = Object.keys(
157              $scope.extraColumnHeaders[constants.KEY__EXTRACOLUMN__CONFIG]
158                                       [constants.KEY__VALUES_AND_COUNTS]);
159
160          // Associative array of partial string matches per category.
161          $scope.categoryValueMatch = {};
162          $scope.categoryValueMatch.builder = "";
163          $scope.categoryValueMatch.test = "";
164
165          // If any defaults were overridden in the URL, get them now.
166          $scope.queryParameters.load();
167
168          $scope.updateResults();
169          $scope.loadingMessage = "";
170          $scope.windowTitle = "Current GM Results";
171        }
172      }
173    ).error(
174      function(data, status, header, config) {
175        $scope.loadingMessage = "Failed to load results from '"
176            + $scope.resultsToLoad + "'";
177        $scope.windowTitle = "Failed to Load GM Results";
178      }
179    );
180
181
182    //
183    // Select/Clear/Toggle all tests.
184    //
185
186    /**
187     * Select all currently showing tests.
188     */
189    $scope.selectAllImagePairs = function() {
190      var numImagePairsShowing = $scope.limitedImagePairs.length;
191      for (var i = 0; i < numImagePairsShowing; i++) {
192        var index = $scope.limitedImagePairs[i].index;
193        if (!$scope.isValueInArray(index, $scope.selectedImagePairs)) {
194          $scope.toggleValueInArray(index, $scope.selectedImagePairs);
195        }
196      }
197    }
198
199    /**
200     * Deselect all currently showing tests.
201     */
202    $scope.clearAllImagePairs = function() {
203      var numImagePairsShowing = $scope.limitedImagePairs.length;
204      for (var i = 0; i < numImagePairsShowing; i++) {
205        var index = $scope.limitedImagePairs[i].index;
206        if ($scope.isValueInArray(index, $scope.selectedImagePairs)) {
207          $scope.toggleValueInArray(index, $scope.selectedImagePairs);
208        }
209      }
210    }
211
212    /**
213     * Toggle selection of all currently showing tests.
214     */
215    $scope.toggleAllImagePairs = function() {
216      var numImagePairsShowing = $scope.limitedImagePairs.length;
217      for (var i = 0; i < numImagePairsShowing; i++) {
218        var index = $scope.limitedImagePairs[i].index;
219        $scope.toggleValueInArray(index, $scope.selectedImagePairs);
220      }
221    }
222
223
224    //
225    // Tab operations.
226    //
227
228    /**
229     * Change the selected tab.
230     *
231     * @param tab (string): name of the tab to select
232     */
233    $scope.setViewingTab = function(tab) {
234      $scope.viewingTab = tab;
235      $scope.updateResults();
236    }
237
238    /**
239     * Move the imagePairs in $scope.selectedImagePairs to a different tab,
240     * and then clear $scope.selectedImagePairs.
241     *
242     * @param newTab (string): name of the tab to move the tests to
243     */
244    $scope.moveSelectedImagePairsToTab = function(newTab) {
245      $scope.moveImagePairsToTab($scope.selectedImagePairs, newTab);
246      $scope.selectedImagePairs = [];
247      $scope.updateResults();
248    }
249
250    /**
251     * Move a subset of $scope.imagePairs to a different tab.
252     *
253     * @param imagePairIndices (array of ints): indices into $scope.imagePairs
254     *        indicating which test results to move
255     * @param newTab (string): name of the tab to move the tests to
256     */
257    $scope.moveImagePairsToTab = function(imagePairIndices, newTab) {
258      var imagePairIndex;
259      var numImagePairs = imagePairIndices.length;
260      for (var i = 0; i < numImagePairs; i++) {
261        imagePairIndex = imagePairIndices[i];
262        $scope.numResultsPerTab[$scope.imagePairs[imagePairIndex].tab]--;
263        $scope.imagePairs[imagePairIndex].tab = newTab;
264      }
265      $scope.numResultsPerTab[newTab] += numImagePairs;
266    }
267
268
269    //
270    // $scope.queryParameters:
271    // Transfer parameter values between $scope and the URL query string.
272    //
273    $scope.queryParameters = {};
274
275    // load and save functions for parameters of each type
276    // (load a parameter value into $scope from nameValuePairs,
277    //  save a parameter value from $scope into nameValuePairs)
278    $scope.queryParameters.copiers = {
279      'simple': {
280        'load': function(nameValuePairs, name) {
281          var value = nameValuePairs[name];
282          if (value) {
283            $scope[name] = value;
284          }
285        },
286        'save': function(nameValuePairs, name) {
287          nameValuePairs[name] = $scope[name];
288        }
289      },
290
291      'categoryValueMatch': {
292        'load': function(nameValuePairs, name) {
293          var value = nameValuePairs[name];
294          if (value) {
295            $scope.categoryValueMatch[name] = value;
296          }
297        },
298        'save': function(nameValuePairs, name) {
299          nameValuePairs[name] = $scope.categoryValueMatch[name];
300        }
301      },
302
303      'set': {
304        'load': function(nameValuePairs, name) {
305          var value = nameValuePairs[name];
306          if (value) {
307            var valueArray = value.split(',');
308            $scope[name] = {};
309            $scope.toggleValuesInSet(valueArray, $scope[name]);
310          }
311        },
312        'save': function(nameValuePairs, name) {
313          nameValuePairs[name] = Object.keys($scope[name]).join(',');
314        }
315      },
316
317    };
318
319    // parameter name -> copier objects to load/save parameter value
320    $scope.queryParameters.map = {
321      'resultsToLoad':         $scope.queryParameters.copiers.simple,
322      'displayLimitPending':   $scope.queryParameters.copiers.simple,
323      'showThumbnailsPending': $scope.queryParameters.copiers.simple,
324      'imageSizePending':      $scope.queryParameters.copiers.simple,
325      'sortColumnSubdict':     $scope.queryParameters.copiers.simple,
326      'sortColumnKey':         $scope.queryParameters.copiers.simple,
327
328      'hiddenResultTypes': $scope.queryParameters.copiers.set,
329      'hiddenConfigs':     $scope.queryParameters.copiers.set,
330    };
331    $scope.queryParameters.map[constants.KEY__EXTRACOLUMN__BUILDER] =
332        $scope.queryParameters.copiers.categoryValueMatch;
333    $scope.queryParameters.map[constants.KEY__EXTRACOLUMN__TEST] =
334        $scope.queryParameters.copiers.categoryValueMatch;
335
336    // Loads all parameters into $scope from the URL query string;
337    // any which are not found within the URL will keep their current value.
338    $scope.queryParameters.load = function() {
339      var nameValuePairs = $location.search();
340      angular.forEach($scope.queryParameters.map,
341                      function(copier, paramName) {
342                        copier.load(nameValuePairs, paramName);
343                      }
344                     );
345    };
346
347    // Saves all parameters from $scope into the URL query string.
348    $scope.queryParameters.save = function() {
349      var nameValuePairs = {};
350      angular.forEach($scope.queryParameters.map,
351                      function(copier, paramName) {
352                        copier.save(nameValuePairs, paramName);
353                      }
354                     );
355      $location.search(nameValuePairs);
356    };
357
358
359    //
360    // updateResults() and friends.
361    //
362
363    /**
364     * Set $scope.areUpdatesPending (to enable/disable the Update Results
365     * button).
366     *
367     * TODO(epoger): We could reduce the amount of code by just setting the
368     * variable directly (from, e.g., a button's ng-click handler).  But when
369     * I tried that, the HTML elements depending on the variable did not get
370     * updated.
371     * It turns out that this is due to variable scoping within an ng-repeat
372     * element; see http://stackoverflow.com/questions/15388344/behavior-of-assignment-expression-invoked-by-ng-click-within-ng-repeat
373     *
374     * @param val boolean value to set $scope.areUpdatesPending to
375     */
376    $scope.setUpdatesPending = function(val) {
377      $scope.areUpdatesPending = val;
378    }
379
380    /**
381     * Update the displayed results, based on filters/settings,
382     * and call $scope.queryParameters.save() so that the new filter results
383     * can be bookmarked.
384     */
385    $scope.updateResults = function() {
386      $scope.renderStartTime = window.performance.now();
387      $log.debug("renderStartTime: " + $scope.renderStartTime);
388      $scope.displayLimit = $scope.displayLimitPending;
389      // TODO(epoger): Every time we apply a filter, AngularJS creates
390      // another copy of the array.  Is there a way we can filter out
391      // the imagePairs as they are displayed, rather than storing multiple
392      // array copies?  (For better performance.)
393
394      if ($scope.viewingTab == $scope.defaultTab) {
395
396        // TODO(epoger): Until we allow the user to reverse sort order,
397        // there are certain columns we want to sort in a different order.
398        var doReverse = (
399            ($scope.sortColumnKey ==
400             constants.KEY__DIFFERENCE_DATA__PERCENT_DIFF_PIXELS) ||
401            ($scope.sortColumnKey ==
402             constants.KEY__DIFFERENCE_DATA__WEIGHTED_DIFF));
403
404        $scope.filteredImagePairs =
405            $filter("orderBy")(
406                $filter("removeHiddenImagePairs")(
407                    $scope.imagePairs,
408                    $scope.hiddenResultTypes,
409                    $scope.hiddenConfigs,
410                    $scope.categoryValueMatch.builder,
411                    $scope.categoryValueMatch.test,
412                    $scope.viewingTab
413                ),
414                $scope.getSortColumnValue, doReverse);
415        $scope.limitedImagePairs = $filter("limitTo")(
416            $scope.filteredImagePairs, $scope.displayLimit);
417      } else {
418        $scope.filteredImagePairs =
419            $filter("orderBy")(
420                $filter("filter")(
421                    $scope.imagePairs,
422                    {tab: $scope.viewingTab},
423                    true
424                ),
425                $scope.getSortColumnValue);
426        $scope.limitedImagePairs = $scope.filteredImagePairs;
427      }
428      $scope.showThumbnails = $scope.showThumbnailsPending;
429      $scope.imageSize = $scope.imageSizePending;
430      $scope.setUpdatesPending(false);
431      $scope.queryParameters.save();
432    }
433
434    /**
435     * This function is called when the results have been completely rendered
436     * after updateResults().
437     */
438    $scope.resultsUpdatedCallback = function() {
439      $scope.renderEndTime = window.performance.now();
440      $log.debug("renderEndTime: " + $scope.renderEndTime);
441    }
442
443    /**
444     * Re-sort the displayed results.
445     *
446     * @param subdict (string): which subdictionary
447     *     (constants.KEY__DIFFERENCE_DATA, constants.KEY__EXPECTATIONS_DATA,
448     *      constants.KEY__EXTRA_COLUMN_VALUES) the sort column key is within
449     * @param key (string): sort by value associated with this key in subdict
450     */
451    $scope.sortResultsBy = function(subdict, key) {
452      $scope.sortColumnSubdict = subdict;
453      $scope.sortColumnKey = key;
454      $scope.updateResults();
455    }
456
457    /**
458     * For a particular ImagePair, return the value of the column we are
459     * sorting on (according to $scope.sortColumnSubdict and
460     * $scope.sortColumnKey).
461     *
462     * @param imagePair: imagePair to get a column value out of.
463     */
464    $scope.getSortColumnValue = function(imagePair) {
465      if ($scope.sortColumnSubdict in imagePair) {
466        return imagePair[$scope.sortColumnSubdict][$scope.sortColumnKey];
467      } else {
468        return undefined;
469      }
470    }
471
472    /**
473     * Set $scope.categoryValueMatch[name] = value, and update results.
474     *
475     * @param name
476     * @param value
477     */
478    $scope.setCategoryValueMatch = function(name, value) {
479      $scope.categoryValueMatch[name] = value;
480      $scope.updateResults();
481    }
482
483    /**
484     * Update $scope.hiddenResultTypes so that ONLY this resultType is showing,
485     * and update the visible results.
486     *
487     * @param resultType
488     */
489    $scope.showOnlyResultType = function(resultType) {
490      $scope.hiddenResultTypes = {};
491      // TODO(epoger): Maybe change $scope.allResultTypes to be a Set like
492      // $scope.hiddenResultTypes (rather than an array), so this operation is
493      // simpler (just assign or add allResultTypes to hiddenResultTypes).
494      $scope.toggleValuesInSet($scope.allResultTypes, $scope.hiddenResultTypes);
495      $scope.toggleValueInSet(resultType, $scope.hiddenResultTypes);
496      $scope.updateResults();
497    }
498
499    /**
500     * Update $scope.hiddenResultTypes so that ALL resultTypes are showing,
501     * and update the visible results.
502     */
503    $scope.showAllResultTypes = function() {
504      $scope.hiddenResultTypes = {};
505      $scope.updateResults();
506    }
507
508    /**
509     * Update $scope.hiddenConfigs so that ONLY this config is showing,
510     * and update the visible results.
511     *
512     * @param config
513     */
514    $scope.showOnlyConfig = function(config) {
515      $scope.hiddenConfigs = {};
516      $scope.toggleValuesInSet($scope.allConfigs, $scope.hiddenConfigs);
517      $scope.toggleValueInSet(config, $scope.hiddenConfigs);
518      $scope.updateResults();
519    }
520
521    /**
522     * Update $scope.hiddenConfigs so that ALL configs are showing,
523     * and update the visible results.
524     */
525    $scope.showAllConfigs = function() {
526      $scope.hiddenConfigs = {};
527      $scope.updateResults();
528    }
529
530
531    //
532    // Operations for sending info back to the server.
533    //
534
535    /**
536     * Tell the server that the actual results of these particular tests
537     * are acceptable.
538     *
539     * TODO(epoger): This assumes that the original expectations are in
540     * imageSetA, and the actuals are in imageSetB.
541     *
542     * @param imagePairsSubset an array of test results, most likely a subset of
543     *        $scope.imagePairs (perhaps with some modifications)
544     */
545    $scope.submitApprovals = function(imagePairsSubset) {
546      $scope.submitPending = true;
547
548      // Convert bug text field to null or 1-item array.
549      var bugs = null;
550      var bugNumber = parseInt($scope.submitAdvancedSettings['bug']);
551      if (!isNaN(bugNumber)) {
552        bugs = [bugNumber];
553      }
554
555      // TODO(epoger): This is a suboptimal way to prevent users from
556      // rebaselining failures in alternative renderModes, but it does work.
557      // For a better solution, see
558      // https://code.google.com/p/skia/issues/detail?id=1748 ('gm: add new
559      // result type, RenderModeMismatch')
560      var encounteredComparisonConfig = false;
561
562      var updatedExpectations = [];
563      for (var i = 0; i < imagePairsSubset.length; i++) {
564        var imagePair = imagePairsSubset[i];
565        var updatedExpectation = {};
566        updatedExpectation[constants.KEY__EXPECTATIONS_DATA] =
567            imagePair[constants.KEY__EXPECTATIONS_DATA];
568        updatedExpectation[constants.KEY__EXTRA_COLUMN_VALUES] =
569            imagePair[constants.KEY__EXTRA_COLUMN_VALUES];
570        updatedExpectation[constants.KEY__NEW_IMAGE_URL] =
571            imagePair[constants.KEY__IMAGE_B_URL];
572        if (0 == updatedExpectation[constants.KEY__EXTRA_COLUMN_VALUES]
573                                   [constants.KEY__EXTRACOLUMN__CONFIG]
574                                   .indexOf('comparison-')) {
575          encounteredComparisonConfig = true;
576        }
577
578        // Advanced settings...
579        if (null == updatedExpectation[constants.KEY__EXPECTATIONS_DATA]) {
580          updatedExpectation[constants.KEY__EXPECTATIONS_DATA] = {};
581        }
582        updatedExpectation[constants.KEY__EXPECTATIONS_DATA]
583                          [constants.KEY__EXPECTATIONS__REVIEWED] =
584            $scope.submitAdvancedSettings[
585                constants.KEY__EXPECTATIONS__REVIEWED];
586        if (true == $scope.submitAdvancedSettings[
587            constants.KEY__EXPECTATIONS__IGNOREFAILURE]) {
588          // if it's false, don't send it at all (just keep the default)
589          updatedExpectation[constants.KEY__EXPECTATIONS_DATA]
590                            [constants.KEY__EXPECTATIONS__IGNOREFAILURE] = true;
591        }
592        updatedExpectation[constants.KEY__EXPECTATIONS_DATA]
593                          [constants.KEY__EXPECTATIONS__BUGS] = bugs;
594
595        updatedExpectations.push(updatedExpectation);
596      }
597      if (encounteredComparisonConfig) {
598        alert("Approval failed -- you cannot approve results with config " +
599            "type comparison-*");
600        $scope.submitPending = false;
601        return;
602      }
603      var modificationData = {};
604      modificationData[constants.KEY__EDITS__MODIFICATIONS] =
605          updatedExpectations;
606      modificationData[constants.KEY__EDITS__OLD_RESULTS_HASH] =
607          $scope.header[constants.KEY__HEADER__DATAHASH];
608      modificationData[constants.KEY__EDITS__OLD_RESULTS_TYPE] =
609          $scope.header[constants.KEY__HEADER__TYPE];
610      $http({
611        method: "POST",
612        url: "/edits",
613        data: modificationData
614      }).success(function(data, status, headers, config) {
615        var imagePairIndicesToMove = [];
616        for (var i = 0; i < imagePairsSubset.length; i++) {
617          imagePairIndicesToMove.push(imagePairsSubset[i].index);
618        }
619        $scope.moveImagePairsToTab(imagePairIndicesToMove,
620                                   "HackToMakeSureThisImagePairDisappears");
621        $scope.updateResults();
622        alert("New baselines submitted successfully!\n\n" +
623            "You still need to commit the updated expectations files on " +
624            "the server side to the Skia repo.\n\n" +
625            "When you click OK, your web UI will reload; after that " +
626            "completes, you will see the updated data (once the server has " +
627            "finished loading the update results into memory!) and you can " +
628            "submit more baselines if you want.");
629        // I don't know why, but if I just call reload() here it doesn't work.
630        // Making a timer call it fixes the problem.
631        $timeout(function(){location.reload();}, 1);
632      }).error(function(data, status, headers, config) {
633        alert("There was an error submitting your baselines.\n\n" +
634            "Please see server-side log for details.");
635        $scope.submitPending = false;
636      });
637    }
638
639
640    //
641    // Operations we use to mimic Set semantics, in such a way that
642    // checking for presence within the Set is as fast as possible.
643    // But getting a list of all values within the Set is not necessarily
644    // possible.
645    // TODO(epoger): move into a separate .js file?
646    //
647
648    /**
649     * Returns the number of values present within set "set".
650     *
651     * @param set an Object which we use to mimic set semantics
652     */
653    $scope.setSize = function(set) {
654      return Object.keys(set).length;
655    }
656
657    /**
658     * Returns true if value "value" is present within set "set".
659     *
660     * @param value a value of any type
661     * @param set an Object which we use to mimic set semantics
662     *        (this should make isValueInSet faster than if we used an Array)
663     */
664    $scope.isValueInSet = function(value, set) {
665      return (true == set[value]);
666    }
667
668    /**
669     * If value "value" is already in set "set", remove it; otherwise, add it.
670     *
671     * @param value a value of any type
672     * @param set an Object which we use to mimic set semantics
673     */
674    $scope.toggleValueInSet = function(value, set) {
675      if (true == set[value]) {
676        delete set[value];
677      } else {
678        set[value] = true;
679      }
680    }
681
682    /**
683     * For each value in valueArray, call toggleValueInSet(value, set).
684     *
685     * @param valueArray
686     * @param set
687     */
688    $scope.toggleValuesInSet = function(valueArray, set) {
689      var arrayLength = valueArray.length;
690      for (var i = 0; i < arrayLength; i++) {
691        $scope.toggleValueInSet(valueArray[i], set);
692      }
693    }
694
695
696    //
697    // Array operations; similar to our Set operations, but operate on a
698    // Javascript Array so we *can* easily get a list of all values in the Set.
699    // TODO(epoger): move into a separate .js file?
700    //
701
702    /**
703     * Returns true if value "value" is present within array "array".
704     *
705     * @param value a value of any type
706     * @param array a Javascript Array
707     */
708    $scope.isValueInArray = function(value, array) {
709      return (-1 != array.indexOf(value));
710    }
711
712    /**
713     * If value "value" is already in array "array", remove it; otherwise,
714     * add it.
715     *
716     * @param value a value of any type
717     * @param array a Javascript Array
718     */
719    $scope.toggleValueInArray = function(value, array) {
720      var i = array.indexOf(value);
721      if (-1 == i) {
722        array.push(value);
723      } else {
724        array.splice(i, 1);
725      }
726    }
727
728
729    //
730    // Miscellaneous utility functions.
731    // TODO(epoger): move into a separate .js file?
732    //
733
734    /**
735     * Returns a human-readable (in local time zone) time string for a
736     * particular moment in time.
737     *
738     * @param secondsPastEpoch (numeric): seconds past epoch in UTC
739     */
740    $scope.localTimeString = function(secondsPastEpoch) {
741      var d = new Date(secondsPastEpoch * 1000);
742      return d.toString();
743    }
744
745    /**
746     * Returns a hex color string (such as "#aabbcc") for the given RGB values.
747     *
748     * @param r (numeric): red channel value, 0-255
749     * @param g (numeric): green channel value, 0-255
750     * @param b (numeric): blue channel value, 0-255
751     */
752    $scope.hexColorString = function(r, g, b) {
753      var rString = r.toString(16);
754      if (r < 16) {
755        rString = "0" + rString;
756      }
757      var gString = g.toString(16);
758      if (g < 16) {
759        gString = "0" + gString;
760      }
761      var bString = b.toString(16);
762      if (b < 16) {
763        bString = "0" + bString;
764      }
765      return '#' + rString + gString + bString;
766    }
767
768    /**
769     * Returns a hex color string (such as "#aabbcc") for the given brightness.
770     *
771     * @param brightnessString (string): 0-255, 0 is completely black
772     *
773     * TODO(epoger): It might be nice to tint the color when it's not completely
774     * black or completely white.
775     */
776    $scope.brightnessStringToHexColor = function(brightnessString) {
777      var v = parseInt(brightnessString);
778      return $scope.hexColorString(v, v, v);
779    }
780
781    /**
782     * Returns the last path component of image diff URL for a given ImagePair.
783     *
784     * Depending on which diff this is (whitediffs, pixeldiffs, etc.) this
785     * will be relative to different base URLs.
786     *
787     * We must keep this function in sync with _get_difference_locator() in
788     * ../imagediffdb.py
789     *
790     * @param imagePair: ImagePair to generate image diff URL for
791     */
792    $scope.getImageDiffRelativeUrl = function(imagePair) {
793      var before =
794          imagePair[constants.KEY__IMAGE_A_URL] + "-vs-" +
795          imagePair[constants.KEY__IMAGE_B_URL];
796      return before.replace(/[^\w\-]/g, "_") + ".png";
797    }
798
799  }
800);
801