MTurk HIT Database Mk.II

Keep track of the HITs you've done (and more!)

目前為 2015-09-11 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         MTurk HIT Database Mk.II
// @author       feihtality
// @namespace    https://greasyfork.org/en/users/12709
// @version      0.8.205
// @description  Keep track of the HITs you've done (and more!)
// @include      /^https://www\.mturk\.com/mturk/(dash|view|sort|find|prev|search|accept|cont).*/
// @exclude      https://www.mturk.com/mturk/findhits?*hit_scraper
// @grant        none
// ==/UserScript==

/**\
 ** 
 ** This is a complete rewrite of the MTurk HIT Database script from the ground up, which
 ** eliminates obsolete methods, fixes many bugs, and brings this script up-to-date 
 ** with the current modern browser environment.
 **
\**/ 


/*
 * TODO
 *   misc refactoring
 *   expand hits in daily overview
 *   rewrite error handling
 *   tagging (?)
 *   refine searching via R/T buttons
 *   import from old csv format (?)
 *
 */



const DB_VERSION = 2;
const DB_NAME = 'HITDB_TESTING';
const MTURK_BASE = 'https://www.mturk.com/mturk/';

/***************************      Native code modifications      *******************************/
// polyfill for chrome until v45(?) 
if (!NodeList.prototype[Symbol.iterator]) NodeList.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator];
Number.prototype.toPadded = function(length) { // format leading zeros
  'use strict';
  length = length || 2;
  return ("0000000"+this).substr(-length);
};
Math.decRound = function(v, shift) { // decimal rounding
  'use strict';
  v = Math.round(+(v+"e"+shift));
  return +(v+"e"+-shift);
};
Date.prototype.toLocalISOString = function() { // ISOString by local timezone
  'use strict';
  var pad = function(num) { return Number(num).toPadded(); },
      offset = pad(Math.floor(this.getTimezoneOffset()/60)) + pad(this.getTimezoneOffset()%60),
      timezone = this.getTimezoneOffset() > 0 ? "-" + offset : "+" + offset;
  return this.getFullYear() + "-" + pad(this.getMonth()+1) + "-" + pad(this.getDate()) +
    "T" + pad(this.getHours()) + ":" + pad(this.getMinutes()) + ":" + pad(this.getSeconds()) + timezone;
};
/***********************************************************************************************/

(function() { // simplify strict scoping
  'use strict';

  // TODO defer qc init to Promise for case in large obj JSON parsing
  var qc = { 
    extraDays: !!localStorage.getItem("hitdb_extraDays") || false, 
    fetchData: document.location.pathname === "/mturk/dashboard" ? JSON.parse(localStorage.getItem("hitdb_fetchData") || "{}") : null,
    seen: {},
    aat: ~document.location.pathname.search(/(dash|accept|cont)/) ? JSON.parse(localStorage.getItem("hitdb_autoAppTemp") || "{}") : null,
    save: function(key, name, isObj) {
      if (isObj) 
        localStorage.setItem(name, JSON.stringify(this[key]));
      else 
        localStorage.setItem(name, this[key]);
    }
  },
      metrics = {};

  var 
  HITStorage = { //{{{
    data: {},

    versionChange: function hsversionChange() { //{{{
      var db = this.result;
      db.onerror = HITStorage.error;
      db.onversionchange = function(e) { console.log("detected version change??",console.dir(e)); db.close(); };
      this.onsuccess = function() { db.close(); };
      var dbo;

      console.groupCollapsed("HITStorage.versionChange::onupgradeneeded");

      if (!db.objectStoreNames.contains("HIT")) { 
        console.log("creating HIT OS");
        dbo = db.createObjectStore("HIT", { keyPath: "hitId" });
        dbo.createIndex("date", "date", { unique: false });
        dbo.createIndex("requesterName", "requesterName", { unique: false});
        dbo.createIndex("title", "title", { unique: false });
        dbo.createIndex("reward", "reward", { unique: false });
        dbo.createIndex("status", "status", { unique: false });
        dbo.createIndex("requesterId", "requesterId", { unique: false });

        localStorage.setItem("hitdb_extraDays", true);
        qc.extraDays = true;
      }
      
      if (!db.objectStoreNames.contains("STATS")) {
        console.log("creating STATS OS");
        dbo = db.createObjectStore("STATS", { keyPath: "date" });
      }
      if (this.transaction.objectStore("STATS").indexNames.length < 5) { // new in v5: schema additions
        this.transaction.objectStore("STATS").createIndex("approved", "approved", { unique: false });
        this.transaction.objectStore("STATS").createIndex("earnings", "earnings", { unique: false });
        this.transaction.objectStore("STATS").createIndex("pending", "pending", { unique: false });
        this.transaction.objectStore("STATS").createIndex("rejected", "rejected", { unique: false });
        this.transaction.objectStore("STATS").createIndex("submitted", "submitted", { unique: false });
      }

      (function _updateNotes(dbt) { // new in v5: schema change
        if (!db.objectStoreNames.contains("NOTES")) {
          console.log("creating NOTES OS");
          dbo = db.createObjectStore("NOTES", { keyPath: "id", autoIncrement: true });
          dbo.createIndex("hitId", "hitId", { unique: false });
          dbo.createIndex("requesterId", "requesterId", { unique: false });
          dbo.createIndex("tags", "tags", { unique: false, multiEntry: true });
          dbo.createIndex("date", "date", { unique: false });
        }
        if (db.objectStoreNames.contains("NOTES") && dbt.objectStore("NOTES").indexNames.length < 3) {
            _mv(db, dbt, "NOTES", "NOTES", _updateNotes);
        }
      })(this.transaction);

      if (db.objectStoreNames.contains("BLOCKS")) {
        console.log("migrating BLOCKS to NOTES");
        var temp = [];
        this.transaction.objectStore("BLOCKS").openCursor().onsuccess = function() {
          var cursor = this.result;
          if (cursor) {
            temp.push( {
              requesterId: cursor.value.requesterId,
              tags: "Blocked",
              note: "This requester was blocked under the old HitDB. Blocking has been deprecated and removed "+
                "from HIT Databse. All blocks have been converted to a Note."
            } );
            cursor.continue();
          } else {
            console.log("deleting blocks");
            db.deleteObjectStore("BLOCKS");
            for (var entry of temp)
              this.transaction.objectStore("NOTES").add(entry);
          }
        };
      }

      function _mv(db, transaction, source, dest, fn) { //{{{
        var _data = [];
        transaction.objectStore(source).openCursor().onsuccess = function() {
          var cursor = this.result;
            if (cursor) {
              _data.push(cursor.value);
              cursor.continue();
            } else {
              db.deleteObjectStore(source);
              fn(transaction);
              if (_data.length)
                for (var i=0;i<_data.length;i++)
                  transaction.objectStore(dest).add(_data[i]);
                //console.dir(_data);
            }
        };
      } //}}}

      console.groupEnd();
    }, // }}} versionChange

    error: function(e) { //{{{
      if (e === "DatabaseAccessError") {
        Status.color = "red";
        Status.node.innerHTML = "Something went wrong during database access!<br>Please refresh the page and try again";
        console.error("Writing failed with",e);
        return;
      }
      if (typeof e === "string")
        console.log(e);
      else
        console.log("Encountered",e.target.error.name,"--",e.target.error.message,e);
    }, //}}} onerror

    parseDOM: function(doc) {//{{{
      Status.color = "black";

      var errorCheck = doc.querySelector('td[class="error_title"]');

      if (doc.title.search(/Status$/) > 0)            // status overview
        parseStatus();
      else if (doc.querySelector('td[colspan="4"]'))  // valid status detail, but no data
        parseMisc("next");
      else if (doc.title.search(/Status Detail/) > 0) // status detail with data
        parseDetail();
      else if (errorCheck) {                          // encountered an error page
        // hit max request rate
        if (~errorCheck.textContent.indexOf("page request rate")) {
          var _d = doc.documentURI.match(/\d{8}/)[0],
              _p = doc.documentURI.match(/ber=(\d+)/)[1];
          metrics.dbupdate.mark("[PRE]"+_d+"p"+_p, "start");
          console.log("exceeded max requests; refetching", doc.documentURI);
          Status.node.innerHTML = "Exceeded maximum server requests; retrying "+Utils.ISODate(_d)+" page "+_p+"."+
            "<br>Please wait...";
          setTimeout(HITStorage.fetch, 550, doc.documentURI);
          return;
        }
        // no more staus details left in range
        else if (qc.extraDays)
          parseMisc("end");
      }
      else 
        throw "ParseError::unhandled document received @"+doc.documentURI;


      function parseStatus() {//{{{
        HITStorage.data = { HIT: [], STATS: [] };
        qc.seen = {};
        ProjectedEarnings.clear();

        // reload auto-approval data to cover not refreshing the dashboard before running an update
        qc.aat = JSON.parse(localStorage.getItem("hitdb_autoAppTemp") || "{}");
        qc.aac = JSON.parse(localStorage.getItem("hitdb_autoAppCollection") || "{}");

        var _pastDataExists = Boolean(Object.keys(qc.fetchData).length);
        var raw = { 
          day: doc.querySelectorAll(".statusDateColumnValue"), 
          sub: doc.querySelectorAll(".statusSubmittedColumnValue"),
          app: doc.querySelectorAll(".statusApprovedColumnValue"),
          rej: doc.querySelectorAll(".statusRejectedColumnValue"),
          pen: doc.querySelectorAll(".statusPendingColumnValue"),
          pay: doc.querySelectorAll(".statusEarningsColumnValue") 
        };
        
        var timeout = 0;
        for (var i=0;i<raw.day.length;i++) {
          var d = {};
          var _date = raw.day[i].childNodes[1].href.substr(53);
          d.date      = Utils.ISODate(_date);
          d.submitted = +raw.sub[i].textContent;
          d.approved  = +raw.app[i].textContent;
          d.rejected  = +raw.rej[i].textContent;
          d.pending   = +raw.pen[i].textContent;
          d.earnings  = +raw.pay[i].textContent.substr(1);
          HITStorage.data.STATS.push(d);

          // check whether or not we need to get status detail pages for date, then
          // fetch status detail pages per date in range and slightly slow
          // down GET requests to avoid making too many in too short an interval
          var payload = { encodedDate: _date, pageNumber: 1, sortType: "All" };
          if (_pastDataExists) {
            // date not in range but is new date (or old date but we need updates)
            // lastDate stored in ISO format, fetchData date keys stored in mturk's URI ecnodedDate format
            if ( (d.date > qc.fetchData.lastDate) || ~(Object.keys(qc.fetchData).indexOf(_date)) ) {
              setTimeout(HITStorage.fetch, timeout, MTURK_BASE+"statusdetail", payload);
              timeout += 250;

              qc.fetchData[_date] = { submitted: d.submitted, pending: d.pending };
            } 
          } else { // get everything
            setTimeout(HITStorage.fetch, timeout, MTURK_BASE+"statusdetail", payload);
            timeout += 250;

            qc.fetchData[_date] = { submitted: d.submitted, pending: d.pending };
          }
        } // for
        qc.fetchData.expectedTotal = _calcTotals(qc.fetchData);

        // try for extra days
        if (qc.extraDays === true) {
          localStorage.removeItem("hitdb_extraDays");
          d = _decDate(HITStorage.data.STATS[HITStorage.data.STATS.length-1].date);
          qc.extraDays = d; // repurpose extraDays for QC
          payload = { encodedDate: d, pageNumber: 1, sortType: "All" };
          console.log("fetchrequest for", d, "sent by parseStatus");
          setTimeout(HITStorage.fetch, 1000, MTURK_BASE+"statusdetail", payload);
        }
        qc.fetchData.lastDate = HITStorage.data.STATS[0].date; // most recent date seen

      }//}}} parseStatus

      function parseDetail() {//{{{
        var _date = doc.documentURI.replace(/.+(\d{8}).+/, "$1");
        var _page = doc.documentURI.replace(/.+ber=(\d+).+/, "$1");

        metrics.dbupdate.mark("[PRE]"+_date+"p"+_page, "end");
        console.log("page:", _page, "date:", _date);
        Status.message = "Processing "+Utils.ISODate(_date)+" page "+_page;
        var raw = {
          req:      doc.querySelectorAll(".statusdetailRequesterColumnValue"),
          title:    doc.querySelectorAll(".statusdetailTitleColumnValue"),
          pay:      doc.querySelectorAll(".statusdetailAmountColumnValue"),
          status:   doc.querySelectorAll(".statusdetailStatusColumnValue"),
          feedback: doc.querySelectorAll(".statusdetailRequesterFeedbackColumnValue")
        };

        for (var i=0;i<raw.req.length;i++) {
          var d = {};
          d.date          = Utils.ISODate(_date);
          d.feedback      = raw.feedback[i].textContent.trim();
          d.hitId         = raw.req[i].childNodes[1].href.replace(/.+HIT\+(.+)/, "$1");
          d.requesterId   = raw.req[i].childNodes[1].href.replace(/.+rId=(.+?)&.+/, "$1");
          d.requesterName = raw.req[i].textContent.trim().replace(/\|/g,"");
          d.reward        = +raw.pay[i].textContent.substr(1);
          d.status        = raw.status[i].textContent.replace(/\s/g, " "); // replace char160 spaces with char32 spaces
          d.title         = raw.title[i].textContent.replace(/\|/g, "");

          // mturk apparently never marks $0.00 HITs as 'Paid' so we fix that
          if (!d.reward && ~d.status.search(/approved/i)) d.status = "Paid";
          // insert autoApproval times
          d.autoAppTime = HITStorage.autoApprovals.getTime(_date,d.hitId);

          HITStorage.data.HIT.push(d);

          if (!qc.seen[_date]) qc.seen[_date] = {};
          qc.seen[_date] = { 
            submitted:   qc.seen[_date].submitted + 1 || 1,
            pending: ~d.status.search(/pending/i)  ? 
              (qc.seen[_date].pending + 1 || 1) : (qc.seen[_date].pending || 0)
          };

          ProjectedEarnings.updateValues(d);
        }

        // additional pages remain; get them
        if (doc.querySelector('img[src="/media/right_dbl_arrow.gif"]')) {
          var payload = { encodedDate: _date, pageNumber: +_page+1, sortType: "All" };
          setTimeout(HITStorage.fetch, 250, MTURK_BASE+"statusdetail", payload);
          return;
        }

        if (!qc.extraDays) { // not fetching extra days
          //no longer any more useful data here, don't need to keep rechecking this date
          if (Utils.ISODate(_date) !== qc.fetchData.lastDate &&
              qc.seen[_date].submitted === qc.fetchData[_date].submitted && 
              qc.seen[_date].pending === 0) {
            console.log("no more pending hits, removing",_date,"from fetchData");
            delete qc.fetchData[_date];
            localStorage.setItem("hitdb_fetchData", JSON.stringify(qc.fetchData));
            HITStorage.autoApprovals.purge(_date);
          }
          // finished scraping; start writing
          console.log("totals", _calcTotals(qc.seen), qc.fetchData.expectedTotal);
          Status.message += " [ "+_calcTotals(qc.seen)+"/"+ qc.fetchData.expectedTotal+" ]";
          if (_calcTotals(qc.seen) === qc.fetchData.expectedTotal) {
            Status.message = "Writing to database...";
            HITStorage.autoApprovals.purge();
            HITStorage.write(HITStorage.data, "update");
          }
        } else if (_date <= qc.extraDays) { // day is older than default range and still fetching extra days
          parseMisc("next");
          console.log("fetchrequest for", _decDate(Utils.ISODate(_date)));
        }
      }//}}} parseDetail

      function parseMisc(type) {//{{{
        var _d = doc.documentURI.match(/\d{8}/)[0],
            _p = doc.documentURI.match(/ber=(\d+)/)[1];
        metrics.dbupdate.mark("[PRE]"+_d+"p"+_p, "end");
        var payload = { encodedDate: _decDate(Utils.ISODate(_d)), pageNumber: 1, sortType: "All" };

        if (type === "next" && +qc.extraDays > 1) {
          setTimeout(HITStorage.fetch, 250, MTURK_BASE+"statusdetail", payload);
          console.log("going to next page", payload.encodedDate);
        } else if (type === "end" && +qc.extraDays > 1) {
          Status.message = "Writing to database...";
          HITStorage.write(HITStorage.data, "update");
        } else 
          throw 'Unhandled case -- "'+type+'" in '+doc.documentURI;
      }//}}}

      function _decDate(date) {//{{{
        var y = date.substr(0,4);
        var m = date.substr(5,2);
        var d = date.substr(8,2);
        date = new Date(y,m-1,d-1);
        return Number(date.getMonth()+1).toPadded() + Number(date.getDate()).toPadded() + date.getFullYear();
      }//}}}

      function _calcTotals(obj) {//{{{
        var sum = 0;
        for (var k in obj){
          if (obj.hasOwnProperty(k) && !isNaN(+k)) 
            sum += obj[k].submitted;
        }
        return sum;
      }//}}}
    },//}}} parseDOM
    
    autoApprovals: {//{{{
      getTime : function(date, hitId) {
        if (qc.extraDays || (!Object.keys(qc.aac).length && !Object.keys(qc.aat).length)) return "";
        var found = false,
            filter = function(id) { return id === hitId; },
            autoApp = "";

        if (qc.aac[date]) {
          autoApp = qc.aac[date][Object.keys(qc.aac[date]).filter(filter)[0]] || "";
          if (autoApp) found = true;
        }
        if (!found && Object.keys(qc.aat).length) {
          for (var key in qc.aat) { if (qc.aat.hasOwnProperty(key)) { // for all dates in aat
            var id = Object.keys(qc.aat[key]).filter(filter)[0];
            autoApp = qc.aat[key][id] || "";
            if (autoApp) {
              found = true;
              qc.aac[date] = qc.aac[date] || {};
              qc.aac[date][id] = qc.aat[key][id]; // move time from temp var to collection var
              delete qc.aat[key][id];
              qc.save("aat", "hitdb_autoAppTemp", true);
              qc.save("aac", "hitdb_autoAppCollection", true);
              break;
            }
          }} // for key (dates)
        } // if !found && aat not empty
        return autoApp;
      },// getTime
      purge : function(date) {
        if (date) { 
          delete qc.aac[date];
          qc.save("aac", "hitdb_autoAppCollection", true);
          return;
        }

        if (!Object.keys(qc.aat).length) return; // nothing here

        var pad = function(num) { return Number(num).toPadded(); },
            _date = Date.parse(new Date().getFullYear() + "-" + pad(new Date().getMonth()+1) + "-" + pad(new Date().getDate()));

        for (var key of Object.keys(qc.aat)) {
          if (_date - key > 169200000) delete qc.aat[key]; // at least 2 days old, no need to keep it around
        }
        qc.save("aat", "hitdb_autoAppTemp", true);
      } // purge
    },//}}} autoApprovals

    fetch: function(url, payload) { //{{{
      //format GET request with query payload
      if (payload) {
        var args = 0;
        url += "?";
        for (var k in payload) {
          if (payload.hasOwnProperty(k)) {
            if (args++) url += "&";
            url += k + "=" + payload[k];
          }
        }
      }
      // defer XHR to a promise
      var fetch = new Promise( function(fulfill, deny) {
        var urlreq = new XMLHttpRequest();
        urlreq.open("GET", url, true);
        urlreq.responseType = "document";
        urlreq.send();
        urlreq.onload = function() { 
          if (this.status === 200) {
            fulfill(this.response);
          } else {
            deny("Error ".concat(String(this.status)).concat(": "+this.statusText));
          }
        };
        urlreq.onerror   = function() { deny("Error ".concat(String(this.status)).concat(": "+this.statusText)); };
        urlreq.ontimeout = function() { deny("Error ".concat(String(this.status)).concat(": "+this.statusText)); };
      } );
      fetch.then( HITStorage.parseDOM, HITStorage.error );

    }, //}}} fetch
    
    write: function(input, statusUpdate) { //{{{
      if (statusUpdate === "update")
        qc.timeoutTimer = setTimeout(HITStorage.error, 5555, "DatabaseAccessError");

      var dbh = window.indexedDB.open(DB_NAME);
      dbh.onerror = HITStorage.error;
      dbh.onsuccess = function() { _write(this.result); };

      var counts = { requests: 0, total: 0 };

      function _write(db) {
        db.onerror = HITStorage.error;
        var os = Object.keys(input);

        var dbt = db.transaction(os, "readwrite");
        var dbo = [];
        for (var i=0;i<os.length;i++) { // cycle object stores
          dbo[i] = dbt.objectStore(os[i]);
          for (var k of input[os[i]]) { // cycle entries to put into object stores
            if (statusUpdate && ++counts.requests)
              dbo[i].put(k).onsuccess = _statusCallback;
            else
              dbo[i].put(k);
          }
        }
        db.close();
      }

      function _statusCallback() {
        if (++counts.total === counts.requests) {
          Status.push(statusUpdate === "update" ? "Update Complete!" : 
            statusUpdate === "restore" ? "Restoring " + counts.total + " entries... Done!" : 
            "Done!", "green");
          document.getElementById("hdbProgressBar").style.display = "none";

          if (statusUpdate === "update") {
            clearTimeout(qc.timeoutTimer);
            ProjectedEarnings.data.dbUpdated = new Date().toLocalISOString();
            ProjectedEarnings.saveState();
            ProjectedEarnings.draw(false);

            metrics.dbupdate.stop();
            metrics.dbupdate.report();
          }
        }
      }

    }, //}}} write

    recall: function(store, options) {//{{{
      if (options) {
        var index = options.index  || null,
            range = options.range  || null,
            dir   = options.dir    || "next";
        if (options.filter) {
          var fs    = options.filter.status !== "*" ? new RegExp(options.filter.status, "i") : false,
              fq    = options.filter.query  !== "*" ? new RegExp(options.filter.query,"i")  : false,
              fd    = options.filter.date || null;
        }
        if (options.progress) {
          var progressBar = document.getElementById("hdbProgressBar");
          progressBar.style.display = "block";
        }
      } // if options

      var sr = new DatabaseResult(), matches = 0, total = 0;
      return new Promise( function(resolve) {
        window.indexedDB.open(DB_NAME).onsuccess = function() {
          var dbo = this.result.transaction(store, "readonly").objectStore(store), dbq = null;
          if (index) 
            dbq = dbo.index(index).openCursor(range, dir);
          else
            dbq = dbo.openCursor(range, dir);
          dbq.onsuccess = function() {
            var c = this.result;
            if (c) { 
              Status.message = "Retrieving data... [ " + matches + " / " + (++total) + " ]";
              if ( fd && (c.value.date < (fd[0] || "0000") || c.value.date > (fd[1] || "9999")) ) {
                c.continue();
                return;
              }
              if ( (!fs && !fq) ||                              // no query filter and no status filter OR
                   (fs && !fq && ~c.value.status.search(fs)) || // status match and no query filter OR
                   (!fs && fq &&                                // query match and no status filter OR
                     (~c.value.title.search(fq) || ~c.value.requesterName.search(fq) || ~c.value.hitId.search(fq)))  ||
                   (fs && fq && ~c.value.status.search(fs) &&   // status match and query match
                     (~c.value.title.search(fq) || ~c.value.requesterName.search(fq) || ~c.value.hitId.search(fq))) ) {
                sr.include(c.value);
                Status.message = "Retrieving data... [ " + (++matches) + " / " + total + " ]";
              }
              c.continue();
            } else {
              Status.message = "Done.";
              resolve(sr);
            }
          }; // IDBCursor
          this.result.close();
        }; // IDBOpenRequest
      } ); // promise
    },//}}} HITStorage::recall

    backup: function() {//{{{
      var bData = {},
          os    = ["STATS", "NOTES", "HIT"],
          count = 0,
          prog  = document.querySelector("#hdbProgressBar");

      prog.style.display = "block";
      Status.push("Preparing backup...", "black");

      window.indexedDB.open(DB_NAME).onsuccess = function() {
        for (var store of os) {
          this.result.transaction(os, "readonly").objectStore(store).openCursor().onsuccess = populateBackup;
        }
        this.result.close();
      };
      function populateBackup(e) {
        var cursor = e.target.result;
        if (cursor) {
          if (!bData[cursor.source.name]) bData[cursor.source.name] = [];
          bData[cursor.source.name].push(cursor.value);
          cursor.continue();
        } else 
          if (++count === 3)
            finalizeBackup();
      }
      function finalizeBackup() {
        var backupblob = new Blob([JSON.stringify(bData)], {type:"application/json"});
        var date = new Date();
        var dl = document.createElement("A");
        date = date.getFullYear() + Number(date.getMonth()+1).toPadded() + Number(date.getDate()).toPadded();
        dl.href = URL.createObjectURL(backupblob);
        console.log(dl.href);
        dl.download = "hitdb_"+date+".bak";
        document.body.appendChild(dl); // FF doesn't support forced events unless element is part of the document
        dl.click();                    // so we make it so and click,
        dl.remove();                   // then immediately remove it
        prog.style.display = "none";
        Status.push("Done!", "green");
      }

    }//}}} backup

  }, //}}} HITStorage

  Utils = { //{{{
    ftime : function(t, noBlanks) {//{{{
      if (t === 0) return "0s";
      if (!t && noBlanks) return "n/a";
      var d = Math.floor(t/86400),
          h = Math.floor(t%86400/3600),
          m = Math.floor(t%86400%3600/60),
          s = t%86400%3600%60;
      return ((d>0) ? d+" day"+(d>1 ? "s " : " ") : "") + ((h>0) ? h+"h " : "") + ((m>0) ? m+"m " : "") + ((s>0) ? s+"s" : "");
    },//}}}ftime

    ISODate: function(date) { //{{{ MMDDYYYY <-> YYYY-MM-DD
      if (date.length === 10)
        return date.substr(5,2)+date.substr(-2)+date.substr(0,4);
      else
        return date.substr(4)+"-"+date.substr(0,2)+"-"+date.substr(2,2);
    },//}}} ISODate

    getPosition: function(element, includeHeight) {//{{{
      var offsets = { x: 0, y: includeHeight ? element.offsetHeight : 0 };
      do {
        offsets.x += element.offsetLeft;
        offsets.y += element.offsetTop;
        element = element.offsetParent;
      } while (element);
      return offsets;
    }//}}} getPosition

  }, //}}} Utils

  ProjectedEarnings = {//{{{
    data: JSON.parse(localStorage.getItem("hitdb_projectedEarnings") || "{}"),
    updateDate: function() {//{{{
      var el        = document.querySelectorAll(".metrics-table")[5].querySelector(".metrics-table-first-value").children[0],
          date      = el.href.match(/\d{8}/)[0],
          day       = el.textContent,
          isToday   = day === "Today",
          _date     = new Date(),
          pad       = function(num) { return Number(num).toPadded(); },
          weekEnd   = null,
          weekStart = null;

      _date.setDate(_date.getDate() - _date.getDay()); // sunday
      weekStart = Date.parse(_date.getFullYear() + "-" + pad(_date.getMonth()+1) + "-" + pad(_date.getDate()));
      _date.setDate(_date.getDate() + 7); // next sunday
      weekEnd   = Date.parse(_date.getFullYear() + "-" + pad(_date.getMonth()+1) + "-" + pad(_date.getDate()-_date.getDay()+7));

      if (!Object.keys(this.data).length) {
        this.data = {
          today: date, weekStart: weekStart, weekEnd: weekEnd, day: new Date().getDay(), dbUpdated: "n/a",
          pending: 0, earnings: {}, target: { day: 0, week: 0 }
        };
      }

      if ( (Date.parse(Utils.ISODate(date)) >= this.data.weekEnd) ||
          (!isToday && new Date().getDay() < this.data.day) ) { // new week
        this.data.earnings = {};
        this.data.weekEnd = weekEnd;
        this.data.weekStart = weekStart;
      }
      if ( (this.data.today === null && isToday) || (this.data.today !== null && (date !== this.data.today || !isToday)) ) { // new day
        this.data.today = date === this.data.today ? null : date;
        this.data.day = new Date().getDay();
      }

      this.saveState();
    },//}}} updateDate
    
    draw: function(init) {//{{{
      var parentTable = document.querySelector("#total_earnings_amount").offsetParent,
          rowPending  = init ? parentTable.insertRow(-1) : parentTable.rows[4],
          rowProjectedDay = init ? parentTable.insertRow(-1) : parentTable.rows[5],
          rowProjectedWeek = init ? parentTable.insertRow(-1) : parentTable.rows[6],
          title = "Click to set/change the target value",
          weekTotal = this.getWeekTotal(),
          dayTotal = this.data.earnings[this.data.today] || 0;

      if (init) {
        rowPending.insertCell(-1);rowPending.insertCell(-1);rowPending.className = "even";
        rowProjectedDay.insertCell(-1);rowProjectedDay.insertCell(-1);rowProjectedDay.className = "odd";
        rowProjectedWeek.insertCell(-1);rowProjectedWeek.insertCell(-1);rowProjectedWeek.className = "even";
        for (var i=0;i<rowPending.cells.length;i++) rowPending.cells[i].style.borderTop = "dotted 1px black";
        rowPending.cells[0].className = "metrics-table-first-value";
        rowProjectedDay.cells[0].className = "metrics-table-first-value";
        rowProjectedWeek.cells[0].className = "metrics-table-first-value";
        rowPending.cells[1].title = "This value includes all earnings that are not yet fully cleared as 'Paid'";
      }

      rowPending.cells[0].innerHTML = 'Pending earnings '+
        '<span style="font-family:arial;font-size:10px;" title="Timestamp of last database update">[ ' + this.data.dbUpdated + ' ]</span>';
      rowPending.cells[1].textContent = "$"+Number(this.data.pending).toFixed(2);
      rowProjectedDay.cells[0].innerHTML = 'Projected earnings for the day<br>'+
        '<meter id="projectedDayProgress" style="width:220px;" title="'+title+
          '" value="'+dayTotal+'" max="'+this.data.target.day+'"></meter>'+
        '<span style="color:blue;font-family:arial;font-size:10px;"> ' + Number(dayTotal-this.data.target.day).toFixed(2) + '</span>';
      rowProjectedDay.cells[1].textContent = "$"+Number(dayTotal).toFixed(2);
      rowProjectedWeek.cells[0].innerHTML = 'Projected earnings for the week<br>' +
        '<meter id="projectedWeekProgress" style="width:220px;" title="'+title+
          '" value="'+weekTotal+'" max="'+this.data.target.week+'"></meter>' +
        '<span style="color:blue;font-family:arial;font-size:10px;"> ' + Number(weekTotal-this.data.target.week).toFixed(2) + '</span>';
      rowProjectedWeek.cells[1].textContent = "$"+Number(weekTotal).toFixed(2);

      document.querySelector("#projectedDayProgress").onclick = updateTargets.bind(this, "day");
      document.querySelector("#projectedWeekProgress").onclick = updateTargets.bind(this, "week");

      function updateTargets(span, e) {
        /*jshint validthis:true*/
        var goal = prompt("Set your " + (span === "day" ? "daily" : "weekly") + " target:",
            this.data.target[span === "day" ? "day" : "week"]);
        if (goal && !isNaN(goal)) {
          this.data.target[span === "day" ? "day" : "week"] = goal;
          e.target.max = goal;
          e.target.nextSibling.textContent = " "+Number((span === "day" ? dayTotal : weekTotal) - goal).toFixed(2);
          this.saveState();
        }
      }
    },//}}} draw

    getWeekTotal: function() {
      var totals = 0;
      for (var k of Object.keys(this.data.earnings))
        totals += this.data.earnings[k];

      return Math.decRound(totals, 2);
    },
    
    saveState: function() {
      localStorage.setItem("hitdb_projectedEarnings", JSON.stringify(this.data));
    },

    clear: function() {
      this.data.pending = 0;

      for (var day of Object.keys(this.data.earnings)) {
        //
        // schema change -- remove if block after update propagation
        //
        if (isNaN(day)) { delete this.data.earnings[day]; continue; }

        this.data.earnings[day] = 0;
      }
    },

    updateValues: function(obj) {
      var vDate = Date.parse(obj.date), iDate = Utils.ISODate(obj.date);

      if (~obj.status.search(/pending/i)) // sum pending earnings (include approved until fully cleared as paid)
        this.data.pending = Math.decRound(obj.reward+this.data.pending, 2);
      if (vDate < this.data.weekEnd && vDate >= this.data.weekStart && !~obj.status.search(/rejected/i)){ // sum weekly earnings by day
        this.data.earnings[iDate] = Math.decRound(obj.reward+(this.data.earnings[iDate] || 0), 2 );
      }
    }
  },//}}} ProjectedEarnings

  DatabaseResult = function(resArr, colObj) {//{{{
    this.results = resArr || [];
    this.collation = colObj || null;
    this.formatHTML = function(type, simple) {//{{{
      simple = simple || false;
      var count = 0, htmlTxt = [], entry = null, _trClass = null;

      if (this.results.length < 1) return "<h2>No entries found matching your query.</h2>";

      if (type === "daily") {
        htmlTxt.push('<thead><tr class="hdbHeaderRow"><th style="background:white;"></th>'+
            '<th>Date</th><th>Submitted</th><th>Approved</th><th>Rejected</th><th>Pending</th><th>Earnings</th></tr></thead><tbody>');
        var r = this.collate(this.results,"date");
        for (entry of this.results) {
          _trClass = (count++ % 2 === 0) ? 'class="even"' : 'class="odd"';
          
          htmlTxt.push('<tr '+_trClass+' style="text-align:center"><td style="background:white;"></td>'+
              '<td>' + entry.date + '</td><td>' + entry.submitted + '</td>' +
              '<td>' + entry.approved + '</td><td>' + entry.rejected + '</td><td>' + entry.pending + '</td>' +
              '<td>' + Number(entry.earnings).toFixed(2) + '</td></tr>');
        }
        htmlTxt.push('</tbody><tfoot><tr class="hdbTotalsRow"><td style="text-align:right;">Totals:</td>' +
            '<td style="text-align:right;">' + r.totalEntries + ' days</td><td style="text-align:center;">' + r.totalSub + '</td>' +
            '<td style="text-align:center;">' + r.totalApp + '</td><td style="text-align:center;">' + r.totalRej + '</td>' +
            '<td style="text-align:center;">' + r.totalPen + '</td><td style="text-align:center;">$' + 
            Number(Math.decRound(r.totalPay,2)).toFixed(2) + '</td></tr></tfoot>');
      } else if (type === "pending" || type === "requester") {
        htmlTxt.push('<thead><tr data-sort="99999" class="hdbHeaderRow"><th>Requester ID</th>' +
            '<th width="500">Requester</th><th>' + (type === "pending" ? 'Pending' : 'HITs') + '</th><th>Rewards</th></tr></thead><tbody>');
        r = this.collate(this.results,"requesterId");
        for (var k in r) {
          if (!~k.search(/total/) && r.hasOwnProperty(k)) {
            var tr = ['<tr data-hits="'+r[k].length+'"><td>' +
                '<span style="cursor:pointer;color:blue;" class="hdbExpandRow" title="Display all pending HITs from this requester">' +
                '[+]</span> ' + r[k][0].requesterId + '</td><td>' + r[k][0].requesterName + '</td>' +
                '<td style="text-align:center;">' + r[k].length + '</td><td>' + Number(Math.decRound(r[k].pay,2)).toFixed(2) + '</td></tr>'];

            for (var hit of r[k]) {  // hits in range per requester id
              tr.push('<tr data-rid="'+r[k][0].requesterId+'" style="color:#c60000;display:none;"><td style="text-align:right">' + 
                  hit.date + '</td><td width="500" colspan="2">[ <span class="helpSpan" title="Auto-approval time">AA: '+
                  Utils.ftime(hit.autoAppTime, true).trim()+'</span> ] '+ 
                  hit.title + '</td><td style="text-align:right">' + _parseRewards(hit.reward,"pay") + '</td></tr>');
            }
            htmlTxt.push(tr.join(''));
          }
        }
        htmlTxt.sort(function(a,b) { return +b.substr(15,5).match(/\d+/) - +a.substr(15,5).match(/\d+/); });
        htmlTxt.push('</tbody><tfoot><tr class="hdbTotalsRow"><td style="text-align:right;">Totals:</td>' +
            '<td style="text-align:center;">' + (Object.keys(r).length-7) + ' Requesters</td>' +
            '<td style="text-align:right;">' + r.totalEntries + '</td>'+
            '<td style="text-align:right;">$' + Number(Math.decRound(r.totalPay,2)).toFixed(2) + '</td></tr></tfoot>');
      } else { // default
        if (!simple)
          htmlTxt.push('<thead><tr class="hdbHeaderRow"><th colspan="3"></th>' +
            '<th colspan="2" title="Bonuses must be added in manually.\n\nClick inside' +
            'the cell to edit, click out of the cell to save">Reward</th><th colspan="3"></th></tr>'+
            '<tr class="hdbHeaderRow">' +
            '<th>Date</th><th>Requester</th><th>HIT title</th><th style="font-size:10px;">Pay</th>'+
            '<th style="font-size:10px;"><span class="helpSpan" title="Click the cell to edit.\nIts value is automatically saved">'+
            'Bonus</span></th><th>Status</th><th>'+
            '<span class="helpSpan" title="Auto-approval times">AA</span></th><th>Feedback</th></tr></thead><tbody>');

        for (entry of this.results) {
          _trClass = (count++ % 2 === 0) ? 'class="even"' : 'class="odd"';
          var _stColor = ~entry.status.search(/(paid|approved)/i) ? "green"  :
                         entry.status === "Pending Approval"      ? "orange" : "red";
          var href = MTURK_BASE+'contact?requesterId='+entry.requesterId+'&requesterName='+entry.requesterName+
            '&subject=Regarding+Amazon+Mechanical+Turk+HIT+'+entry.hitId;

          if (!simple)
            htmlTxt.push('<tr '+_trClass+' data-id="'+entry.hitId+'">'+
              '<td width="74px">' + entry.date + '</td><td style="max-width:145px;">' +
              '<a target="_blank" title="Contact this requester" href="'+href+'">' + entry.requesterName + '</a></td>' + 
              '<td width="375px" title="HIT ID:   '+entry.hitId+'">' + 
              '<span title="Add a note" id="note-'+entry.hitId+'" style="cursor:pointer;">&nbsp;&#128221;&nbsp;</span>' + 
              entry.title + '</td><td style="text-align:right">' + _parseRewards(entry.reward,"pay") + '</td>' +
              '<td style="text-align:right" class="bonusCell" title="Click to add/edit" contenteditable="true" data-hitid="'+entry.hitId+'">' + 
              (+_parseRewards(entry.reward,"bonus") ? _parseRewards(entry.reward,"bonus") : "") + 
              '</td><td style="color:'+_stColor+';text-align:center">' + entry.status + '</td>' +
              '<td>' + Utils.ftime(entry.autoAppTime) + '</td><td>' + entry.feedback + '</td></tr>');
          else
            htmlTxt.push('<tr data-rid="'+entry.requesterId+'" style="display:none"><td>'+entry.date+'</td><td>'+entry.title+'</td><td>'+
              _parseRewards(entry.reward,"pay") + '</td><td>'+ entry.status+'</td></tr>');
        }

        if (!simple) {
          r = this.collation || this.collate(this.results,"requesterId");
          htmlTxt.push('</tbody><tfoot><tr class="hdbTotalsRow"><td></td>' +
              '<td style="text-align:right">Totals:</td><td style="text-align:center;">' + r.totalEntries + ' HITs</td>' +
              '<td style="text-align:right">$' + Number(Math.decRound(r.totalPay,2)).toFixed(2) + '</td>' +
              '<td style="text-align:right">$' + Number(Math.decRound(r.totalBonus,2)).toFixed(2) + '</td>' +
              '<td colspan="3"></td></tr></tfoot>');
        }
      }
      return htmlTxt.join('');
    };//}}} formatHTML
    this.formatCSV = function(type) {//{{{
      var csvTxt = [], entry = null, delimiter="\t";
      if (type === "daily") {
        csvTxt.push( ["Date", "Submitted", "Approved", "Rejected", "Pending", "Earnings\n"].join(delimiter) );
        for (entry of this.results) {
          csvTxt.push( [entry.date, entry.submitted, entry.approved, entry.rejected, 
              entry.pending, Number(entry.earnings).toFixed(2)+"\n"].join(delimiter) );
        }
        csvToFile(csvTxt, "hitdb_dailyOverview.csv");
      } else if (type === "pending" || type === "requester") {
        csvTxt.push( ["RequesterId","Requester", (type === "pending" ? "Pending" : "HITs"), "Rewards\n"].join(delimiter) );
        var r = this.collation || this.collate(this.results,"requesterId");
        for (var k in r) {
          if (!~k.search(/total/) && r.hasOwnProperty(k))
            csvTxt.push( [k, r[k][0].requesterName, r[k].length, Number(Math.decRound(r[k].pay,2)).toFixed(2)+"\n"].join(delimiter) );
        }
        csvToFile(csvTxt, "hitdb_"+type+"Overview.csv");
      } else {
        csvTxt.push(["Date","Requester","Title","Pay","Bonus","Status","Feedback\n"].join(delimiter));
        for (entry of this.results) {
          csvTxt.push([entry.date, entry.requesterName, entry.title, Number(_parseRewards(entry.reward,"pay")).toFixed(2),
              (+_parseRewards(entry.reward,"bonus") ? Number(_parseRewards(entry.reward,"bonus")).toFixed(2) : ""),
              entry.status, entry.feedback+"\n"].join(delimiter));
        }
        csvToFile(csvTxt, "hitdb_queryResults.csv");
      }

      return "<pre>"+csvTxt.join('')+"</pre>";

      function csvToFile(csv, filename) {
        var blob = new Blob(csv, {type: "text/csv", endings: "native"}),
            dl   = document.createElement("A");
        dl.href = URL.createObjectURL(blob);
        dl.download = filename;
        document.body.appendChild(dl); // FF doesn't support forced events unless element is part of the document
        dl.click();                    // so we make it so and click,
        dl.remove();                   // then immediately remove it
        return dl;
      }
    };//}}} formatCSV
    this.include = function(value) {
      this.results.push(value);
    };
    this.collate = function(data, index) {//{{{
      var r = { 
        totalPay: 0, totalBonus: 0, totalEntries: data.length,
        totalSub: 0, totalApp: 0, totalRej: 0, totalPen: 0
      };
      for (var e of data) {
        if (!r[e[index]]) { r[e[index]] = []; r[e[index]].pay = 0; }
        r[e[index]].push(e);

        if (index === "date") {
          r.totalSub += e.submitted;
          r.totalApp += e.approved;
          r.totalRej += e.rejected;
          r.totalPen += e.pending;
          r.totalPay += e.earnings;
        } else {
          r[e[index]].pay += (+_parseRewards(e.reward,"pay"));
          r.totalPay += (+_parseRewards(e.reward,"pay"));
          r.totalBonus += (+_parseRewards(e.reward,"bonus"));
        }
      }
      return r;
    };//}}} _collate

    function _parseRewards(rewards,value) {//{{{
      if (!isNaN(rewards)) {
        if (value === "pay")
          return Number(rewards).toFixed(2);
        else
          return "0.00";
      } else {
        if (value === "pay")
          return Number(rewards.pay).toFixed(2);
        else
          return Number(rewards.bonus).toFixed(2);
      }
    } //}}} _parse
  },//}}} databaseresult

  DashboardUI = {//{{{
    //
    // TODO refactor
    //
    draw: function() {//{{{
      var controlPanel = document.createElement("TABLE");
      var insertionNode = document.querySelector(".footer_separator").previousSibling;
      document.body.insertBefore(controlPanel, insertionNode);
      controlPanel.width = "760";
      controlPanel.align = "center";
      controlPanel.id = "hdbControlPanel";
      controlPanel.cellSpacing = "0";
      controlPanel.cellPadding = "0";
      controlPanel.innerHTML = '<tr height="25px"><td width="10" bgcolor="#7FB448" style="padding-left: 10px;"></td>' +
        '<td class="white_text_14_bold" style="padding-left:10px; background-color:#7FB448;">' +
          'HIT Database Mk. II&nbsp;<a href="https://greasyfork.org/en/scripts/11733-mturk-hit-database-mk-ii" class="whatis" target="_blank">' +
          '(What\'s this?)</a></td></tr>' +
        '<tr><td class="container-content" colspan="2">' +
        '<div style="text-align:center;" id="hdbDashboardInterface">' +
        '<button id="hdbBackup" title="Export your entire database!\nPerfect for moving between computers or as a periodic backup">Create Backup</button>' +
        '<button id="hdbRestore" title="Restore database from external backup file" style="margin:5px">Restore</button>' +
        '<button id="hdbUpdate" title="Update... the database" style="color:green;">Update Database</button>' +
        '<div id="hdbFileSelector" style="display:none"><input id="hdbFileInput" type="file" /></div>' +
        '<br>' +
        '<button id="hdbPending" title="Summary of all pending HITs\n Can be exported as CSV" style="margin: 0px 5px 5px;">Pending Overview</button>' +
        '<button id="hdbRequester" title="Summary of all requesters\n Can be exported as CSV" style="margin: 0px 5px 5px;">Requester Overview</button>' +
        '<button id="hdbDaily" title="Summary of each day you\'ve worked\nCan be exported as CSV" style="margin:0px 5px 5px;">Daily Overview</button>' +
        '<br>' +
        '<label>Find </label>' +
        '<select id="hdbStatusSelect"><option value="*">ALL</option>' +
        '<option value="Pending Approval" style="color: orange;">Pending Approval</option>' +
        '<option value="Rejected" style="color: red;">Rejected</option>' +
        '<option value="Approved - Pending Payment" style="color:green;">Approved - Pending Payment</option>' +
        '<option value="(Paid|Approved)" style="color:green;">Paid OR Approved</option></select>' +
        '<label> HITs matching: </label><input id="hdbSearchInput" title="Query can be HIT title, HIT ID, or requester name" />' +
        '<button id="hdbSearch">Search</button>' +
        '<br>' +
        '<label>from date </label><input id="hdbMinDate" maxlength="10" size="10" title="Specify a date, or leave blank">' +
        '<label> to </label><input id="hdbMaxDate" malength="10" size="10" title="Specify a date, or leave blank">' +
        '<label for="hdbCSVInput" title="Export results as CSV file" style="margin-left:50px; vertical-align:middle;">export CSV</label>' +
        '<input id="hdbCSVInput" title="Export results as CSV file" type="checkbox" style="vertical-align:middle;">' +
        '<br>' +
        '<label id="hdbStatusText"></label>' +
        '<div id="hdbProgressBar" class="hdbProgressContainer"><div class="hdbProgressOuter"><div class="hdbProgressInner"></div></div></div>' +
        '</div></td></tr>';

      var searchResults = document.createElement("DIV");
      searchResults.align = "center";
      searchResults.id = "hdbSearchResults";
      searchResults.style.display = "block";
      searchResults.innerHTML = 
        '<span class="hdbResControl" id="hdbResClear">[ clear results ]</span>' +
        '<span class="hdbTablePagination" id="hdbPageTop">blah blah blah</span><br>' +
        '<table cellSpacing="0" cellpadding="2" id="hdbResultsTable"></table>' +
        '<span class="hdbResControl" id="hdbVpTop">Back to top</span>' +
        '<span class="hdbTablePagination" id="hdbPageBot">mooooo ids i</span><br>';
      document.body.insertBefore(searchResults, insertionNode);
    },//}}} dashboardUI::draw

    initClickables: function() {//{{{
      var updateBtn      = document.getElementById("hdbUpdate"),
          backupBtn      = document.getElementById("hdbBackup"),
          restoreBtn     = document.getElementById("hdbRestore"),
          fileInput      = document.getElementById("hdbFileInput"),
          exportCSVInput = document.getElementById("hdbCSVInput"),
          searchBtn      = document.getElementById("hdbSearch"),
          searchInput    = document.getElementById("hdbSearchInput"),
          pendingBtn     = document.getElementById("hdbPending"),
          reqBtn         = document.getElementById("hdbRequester"),
          dailyBtn       = document.getElementById("hdbDaily"),
          fromdate       = document.getElementById("hdbMinDate"),
          todate         = document.getElementById("hdbMaxDate"),
          statusSelect   = document.getElementById("hdbStatusSelect"),
          progressBar    = document.getElementById("hdbProgressBar"),
          searchResults  = document.getElementById("hdbSearchResults"),
          resultsTable   = document.getElementById("hdbResultsTable");

      searchResults.firstChild.onclick = function() { //{{{ clear results
        resultsTable.innerHTML = null;
        for (var d of ["hdbResClear","hdbPageTop","hdbVpTop", "hdbPageBot"]) {
          if (~d.search(/page/i)) d.innerHTML = "";
          document.getElementById(d).style.display = "none";
        }
      };//}}}
      document.getElementById("hdbVpTop").onclick = function() { autoScroll("#hdbControlPanel"); };

      updateBtn.onclick = function() { //{{{
        progressBar.style.display = "block";
        metrics.dbupdate = new Metrics("database_update");
        HITStorage.fetch(MTURK_BASE+"status");
        Status.message = "fetching status page....";
      };//}}}
      exportCSVInput.addEventListener("click", function() {//{{{
        if (exportCSVInput.checked) {
          searchBtn.textContent = "Export CSV";
          pendingBtn.textContent += " (csv)";
          reqBtn.textContent += " (csv)";
          dailyBtn.textContent += " (csv)";
        }
        else {
          searchBtn.textContent = "Search";
          pendingBtn.textContent = pendingBtn.textContent.replace(" (csv)","");
          reqBtn.textContent = reqBtn.textContent.replace(" (csv)","");
          dailyBtn.textContent = dailyBtn.textContent.replace(" (csv)", "");
        }
      });//}}}
      fromdate.addEventListener("focus", function() {//{{{ dates
        var offsets = Utils.getPosition(this, true);
        new Calendar(offsets.x, offsets.y, this).drawCalendar();
      });
      todate.addEventListener("focus", function() {
        var offsets = Utils.getPosition(this, true);
        new Calendar(offsets.x, offsets.y, this).drawCalendar();
      });//}}} dates

      backupBtn.onclick = HITStorage.backup;
      restoreBtn.onclick = function() { fileInput.click(); };
      fileInput.onchange = processFile;

      searchBtn.onclick = function() {//{{{
        qc.sr = []; // clear prev pagination
        var r = this.getRange();
        var _filter = { status: statusSelect.value, query: searchInput.value.trim().length > 0 ? searchInput.value : "*" };
        var _opt = { index: r.index, range: r.range, dir: r.dir, filter: _filter, progress: true };

        Status.push("Preparing database...", "black");
        metrics.dbrecall = new Metrics("database_recall::search");

        HITStorage.recall("HIT", _opt).then(function(r) {
          Status.message = "Building HTML...";
          for (var d of ["hdbResClear","hdbPageTop","hdbVpTop", "hdbPageBot"])
            document.getElementById(d).style.display = "initial";

          var limiter = 500;
          if (exportCSVInput.checked) 
            resultsTable.innerHTML = r.formatCSV();
          else if (r.results.length > limiter) {
            var collation = r.collate(r.results, "requesterId");
            do { qc.sr.push(new DatabaseResult(r.results.splice(0,limiter), collation)) } while (r.results.length);
            resultConstrain(qc.sr, 0);
          } else
            resultConstrain(r);

          autoScroll("#hdbSearchResults");
          Status.push("Done!", "green");
          metrics.dbrecall.stop(); metrics.dbrecall.report();
          progressBar.style.display = "none";
        }); // recall
      }.bind(this); //}}} search button click event
      //{{{ overview buttons
      pendingBtn.onclick = function() {
        var _filter = { date: [fromdate.value, todate.value], query: searchInput.value.trim().length > 0 ? searchInput.value : "*" },
            _opt    = { index: "status", dir: "prev", range: window.IDBKeyRange.only("Pending Approval"), filter: _filter, progress: true };

        Status.push("Preparing database...", "black");
        metrics.dbrecall = new Metrics("database_recall::pending");
        HITStorage.recall("HIT", _opt).then(function(r) {
          Status.message = "Building HTML...";
          for (var d of ["hdbResClear","hdbPageTop","hdbVpTop", "hdbPageBot"])
            document.getElementById(d).style.display = "initial";
          resultsTable.innerHTML = exportCSVInput.checked ? r.formatCSV("pending") : r.formatHTML("pending");
          autoScroll("#hdbSearchResults");
          var expands = document.querySelectorAll(".hdbExpandRow");
          for (var el of expands) {
            el.onclick = showHiddenRows;
          }
          Status.push("Done!", "green");
          metrics.dbrecall.stop(); metrics.dbrecall.report();
          progressBar.style.display = "none";
        });
      }.bind(this); //pending overview click event
      reqBtn.onclick = function() {
        var r = this.getRange();
        var _opt = { index: r.index, range: r.range, progress: true };

        Status.push("Preparing database...", "black");
        metrics.dbrecall = new Metrics("database_recall::requester");
        HITStorage.recall("HIT", _opt).then(function(r) {
          Status.message = "Building HTML...";
          for (var d of ["hdbResClear","hdbPageTop","hdbVpTop", "hdbPageBot"])
            document.getElementById(d).style.display = "initial";
          resultsTable.innerHTML = exportCSVInput.checked ? r.formatCSV("requester") : r.formatHTML("requester");
          autoScroll("#hdbSearchResults");
          var expands = document.querySelectorAll(".hdbExpandRow");
          for (var el of expands) {
            el.onclick = showHiddenRows;
          }
          Status.push("Done!", "green");
          metrics.dbrecall.stop(); metrics.dbrecall.report();
          progressBar.style.display = "none";
        });
      }.bind(this); //requester overview click event
      dailyBtn.onclick = function() {
        Status.push("Preparing database...", "black");
        metrics.dbrecall = new Metrics("database_recall::daily");
        HITStorage.recall("STATS", { dir: "prev" }).then(function(r) {
          Status.message = "Building HTML...";
          for (var d of ["hdbResClear","hdbPageTop","hdbVpTop", "hdbPageBot"])
            document.getElementById(d).style.display = "initial";
          resultsTable.innerHTML = exportCSVInput.checked ? r.formatCSV("daily") : r.formatHTML("daily");
          autoScroll("#hdbSearchResults");
          Status.push("Done!", "green");
          metrics.dbrecall.stop(); metrics.dbrecall.report();
        });
      }; //daily overview click event
      //}}}
    },//}}} dashboardUI::initClickables

    getRange: function(status) {//{{{
      var fromdate     = document.getElementById("hdbMinDate"),
          todate       = document.getElementById("hdbMaxDate"),
          statusSelect = document.getElementById("hdbStatusSelect");
      var _min = fromdate.value.length === 10 ? fromdate.value : undefined,
          _max = todate.value.length   === 10 ? todate.value   : undefined;
          status = status || statusSelect.value;
      var _range = 
        (_min === undefined && _max === undefined) ? 
          (status.length > 1 && !~status.search(/\(/) ? window.IDBKeyRange.only(status) : null) :
        (_min === undefined)                       ? window.IDBKeyRange.upperBound(_max) :
        (_max === undefined)                       ? window.IDBKeyRange.lowerBound(_min) :
        (_max < _min)                              ? window.IDBKeyRange.bound(_max,_min) : window.IDBKeyRange.bound(_min,_max),
          _index = _min === undefined && _max === undefined && status.length > 1 && !~status.search(/\(/) ? "status" : "date";
      return { min: _min, max: _max, range: _range, dir: _max < _min ? "prev" : "next", index: _index };
    }//}}} dashboardUI::getRange
  };//}}} dashboard

  /* 
   *
   *
   *
   *
   *///{{{
  // the Set() constructor is never actually used other than to test for Chrome v38+
  if (!("indexedDB" in window && "Set" in window)) alert("HITDB::Your browser is too outdated or otherwise incompatible with this script!");
  else {
  /* 
    var tdbh = window.indexedDB.open(DB_NAME);
    tdbh.onerror = function(e) { console.log("[TESTDB]",e.target.error.name+":", e.target.error.message, e); };
    tdbh.onsuccess = INFLATEDUMMYVALUES;
    //tdbh.onupgradeneeded = BLANKSLATE;
    var dbh = null;
  */
    
    if (document.location.pathname.search(/dashboard/) > 0) {
      var dbh = window.indexedDB.open(DB_NAME, DB_VERSION);
      dbh.onerror = function(e) { console.log("[HITDB]",e.target.error.name+":", e.target.error.message, e); };
      dbh.onupgradeneeded = HITStorage.versionChange;
      dbh.onsuccess = function() { this.result.close(); };

      DashboardUI.draw();
      DashboardUI.initClickables();

      ProjectedEarnings.updateDate();
      ProjectedEarnings.draw(true);

      var Status = {
        node: document.getElementById("hdbStatusText"),
        get message() { return this.node.textContent; },
        set message(str) { this.node.textContent = str; },
        get color() { return this.node.style.color; },
        set color(c) { this.node.style.color = c; },
        push: function(m,c) { c = c || "black"; this.message = m; this.color = c; }
      };

      qc.vp = new Viewport();
      console.log(qc.vp);
    } else { // page is not dashboard
      beenThereDoneThat();
    }
  }
  /*}}}
   *
   *
   *
   *
   */

  // {{{ css injection
  var css = "<style type='text/css'>" +
  ".hitdbRTButtons {border:1px solid; font-size: 10px; height: 18px; padding-left: 5px; padding-right: 5px; background: pink;}" +
  ".hitdbRTButtons-green {background: lightgreen;}" +
  ".hitdbRTButtons-large {width:80px;}" +
  ".hdbProgressContainer {margin:auto; width:500px; height:6px; position:relative; display:none; border-radius:10px; overflow:hidden; background:#d3d8db;}" +
  ".hdbProgressInner {width:100%; position:absolute; left:0;top:0;bottom:0; animation: kfpin 1.4s infinite; background:" +
    "linear-gradient(262deg, rgba(208,69,247,0), rgba(208,69,247,1), rgba(69,197,247,1), rgba(69,197,247,0)); background-size: 300% 500%;}" +
  ".hdbProgressOuter {width:30%; position:absolute; left:0;top:0;bottom:0; animation: kfpout 2s cubic-bezier(0,0.55,0.2,1) infinite;}" +
  "@keyframes kfpout { 0% {left:-100%;} 70%{left:100%;} 100%{left:100%;} }" +
  "@keyframes kfpin { 0%{background-position: 0% 50%} 50%{background-position: 100% 15%} 100%{background-position:0% 30%} }" +
  ".hdbCalControls {cursor:pointer;} .hdbCalControls:hover {color:c27fcf;}" +
  ".hdbCalCells {background:#f0f6f9; height:19px}" +
  ".hdbCalDays {cursor:pointer; text-align:center;} .hdbCalDays:hover {background:#7fb4cf; color:white;}" +
  ".hdbDayHeader {width:26px; text-align:center; font-weight:bold; font-size:12px; background:#f0f6f9;}" +
  ".hdbCalHeader {background:#7fb4cf; color:white; font-weight:bold; text-align:center; font-size:11px; padding:3px 0px;}" +
  "#hdbCalendarPanel {position:absolute; z-index:10; box-shadow:-2px 3px 5px 0px rgba(0,0,0,0.68);}" +
  ".hdbTotalsRow {background:#CCC; color:#369; font-weight:bold;}" +
  ".hdbHeaderRow {background:#7FB448; font-size:12px; color:white}" +
  ".helpSpan {border-bottom:1px dotted; cursor:help;}" +
  ".hdbResControl {border-bottom:1px solid; color:#c60; cursor:pointer; display:none;}" +
  ".hdbTablePagination {margin-left:15em; color:#c60; display:none;}" +
  "</style>";
  document.head.innerHTML += css;
  // }}}

  function Viewport(vp) {//{{{
    vp = vp || {};
    this.height    = window.innerHeight;
    this.width     = window.innerWidth;
    this.top       = window.scrollY;
    this.bottom    = this.top+this.height;
    this.left      = window.scrollX;
    this.right     = this.left+this.width;
    this.scrollDir = this.top - vp.top > 0 ? "down" : this.top - vp.top < 0 ? "up" : 
      this.left - vp.left > 0 ? "right" : this.left - vp.left < 0 ? "left" : null;
  }//}}}

  function resultConstrain(data, index) {//{{{
    data = data || qc.sr;

    var table  = document.getElementById("hdbResultsTable"),
        rslice = data.length ? data[index].results : data.results,
        pager  = [document.getElementById("hdbPageTop"), document.getElementById("hdbPageBot")],
        sopt   = [];
    pager[0].innerHTML = '';

    if (data instanceof DatabaseResult)
      table.innerHTML = data.formatHTML();
    else {
      table.innerHTML = data[index].formatHTML();
      pager[0].innerHTML = '<span style="cursor:pointer;">' + (index > 0 ? '&#9664; Prev' : '') + '</span> ' +
        '<span style="cursor:pointer;">' + (+index+1 === data.length ? '' : 'Next &#9654;') + '</span> &nbsp; || &nbsp; '+
        '<label>Select page: </label><select></select>';
      for (var i=0;i<data.length;i++) {
        if (i === +index)
          sopt.push('<option value="' + i + '" selected="selected">' + (i+1) + '</option>');
        else
          sopt.push('<option value="' + i + '">' + (i+1) + '</option>');
      }
      pager[0].lastChild.innerHTML = sopt.join('');
      pager[2] = pager[0].cloneNode(true);
      pager[2].id = "hdbPageBot";
      for (i of [0,2]) {
        pager[i].children[0].onclick = resultConstrain.bind(null,null,+index-1);
        pager[i].children[1].onclick = resultConstrain.bind(null,null,+index+1);
        pager[i].children[3].onchange = _f;
      }
      pager[0].parentNode.replaceChild(pager[2], pager[1]);
    }

    for (var _r of rslice) { // retrieve and append notes
      HITStorage.recall("NOTES", { index: "hitId", range: window.IDBKeyRange.only(_r.hitId) }).then(noteHandler.bind(null,"attach"));
    }

    var _nodes   = [document.querySelectorAll(".bonusCell"), document.querySelectorAll('span[id^="note-"]')];
    for (i=0;i<_nodes[0].length;i++) {
      var bonus = _nodes[0][i],
          note  = _nodes[1][i];
      bonus.dataset.initial = bonus.textContent;
      bonus.onkeydown = updateBonus;
      bonus.onblur    = updateBonus;
      note.onclick    = noteHandler.bind(null,"new");
    }

    // to avoid defining a function within a loop
    function _f(e) { resultConstrain(null,e.target.value); }
  }//}}} resultConstrain

  function beenThereDoneThat() {//{{{
    // 
    // TODO refine searching
    //
    if (~document.location.pathname.search(/(accept|continue)/)) {
      if (!document.querySelector('input[name="hitAutoAppDelayInSeconds"]')) return;

      // capture autoapproval times
      var _aa = document.querySelector('input[name="hitAutoAppDelayInSeconds"]').value,
          _hid = document.querySelectorAll('input[name="hitId"]')[1].value,
          pad = function(num) { return Number(num).toPadded(); },
          _d  = Date.parse(new Date().getFullYear() + "-" + pad(new Date().getMonth()+1) + "-" + pad(new Date().getDate()));

      if (!qc.aat[_d]) qc.aat[_d] = {};
      qc.aat[_d][_hid] = _aa;
      qc.save("aat", "hitdb_autoAppTemp", true);
      return;
    }
    var qualNode = document.querySelector('td[colspan="11"]');
    if (qualNode) { // we're on the preview page!
      var requester     = document.querySelector('input[name="requesterId"]').value,
          //hitId         = document.querySelector('input[name="hitId"]').value,
          autoApproval  = document.querySelector('input[name="hitAutoAppDelayInSeconds"]').value,
          hitTitle      = document.querySelector('div[style*="ellipsis"]').textContent.trim().replace(/\|/g,""),
          insertionNode = qualNode.parentNode.parentNode;
      var row = document.createElement("TR"), cellL = document.createElement("TD"), cellR = document.createElement("TD");
      var _resultsTable = document.createElement("TABLE");
      _resultsTable.id = "resultsTableFor"+requester;
      insertionNode.parentNode.parentNode.appendChild(_resultsTable);

      cellR.innerHTML = '<span class="capsule_field_title">Auto-Approval:</span>&nbsp;&nbsp;'+Utils.ftime(autoApproval);
      var rbutton = document.createElement("BUTTON");
      rbutton.classList.add("hitdbRTButtons","hitdbRTButtons-large");
      rbutton.textContent = "Requester";
      rbutton.onclick = function(e) { 
        e.preventDefault();
        showResults(requester);
      };
      var tbutton = rbutton.cloneNode(false);
      rbutton.dataset.id = requester;
      rbutton.title = "Show HITs completed from this requester";
      tbutton.textContent = "HIT Title";
      tbutton.onclick = function(e) { e.preventDefault(); };
      HITStorage.recall("HIT", {index: "requesterId", range: window.IDBKeyRange.only(requester)})
        .then(processResults.bind(rbutton));
      HITStorage.recall("HIT", {index: "title", range: window.IDBKeyRange.only(hitTitle)})
        .then(processResults.bind(tbutton));
      row.appendChild(cellL);
      row.appendChild(cellR);
      cellL.appendChild(rbutton);
      cellL.appendChild(tbutton);
      cellL.colSpan = "3";
      cellR.colSpan = "8";
      insertionNode.appendChild(row);
    } else { // browsing HITs n sutff 
      var titleNodes = document.querySelectorAll('a[class="capsulelink"]');
      if (titleNodes.length < 1) return; // nothing left to do here!
      var requesterNodes = document.querySelectorAll('a[href*="hitgroups&requester"]');
      var insertionNodes = [];

      for (var i=0;i<titleNodes.length;i++) {
        var _title = titleNodes[i].textContent.trim().replace(/\|/g,"");
        var _tbutton = document.createElement("BUTTON");
        var _id = requesterNodes[i].href.replace(/.+Id=(.+)/, "$1");
        var _rbutton = document.createElement("BUTTON");
        var _div = document.createElement("DIV"), _tr = document.createElement("TR");
        _resultsTable = document.createElement("TABLE");
        insertionNodes.push(requesterNodes[i].parentNode.parentNode.parentNode);
        insertionNodes[i].offsetParent.offsetParent.offsetParent.offsetParent.appendChild(_resultsTable);
        _resultsTable.id = "resultsTableFor"+_id;

        HITStorage.recall("HIT", {index: "title", range: window.IDBKeyRange.only(_title)} )
          .then(processResults.bind(_tbutton));
        HITStorage.recall("HIT", {index: "requesterId", range: window.IDBKeyRange.only(_id)} )
          .then(processResults.bind(_rbutton));

        _tr.appendChild(_div);
        _div.id = "hitdbRTInjection-"+i;
        _div.appendChild(_rbutton);
        _rbutton.textContent = 'R';
        _rbutton.classList.add("hitdbRTButtons");
        _rbutton.dataset.id = _id;
        _rbutton.onclick = showResults.bind(null, _id, null);
        _rbutton.title = "Show HITs completed from this requester";
        _div.appendChild(_tbutton);
        _tbutton.textContent = 'T';
        _tbutton.classList.add("hitdbRTButtons");
        insertionNodes[i].appendChild(_tr);
      }
    } // else

    function showResults(rid, title) {
      console.log(rid, title);
      var el = null;
      if (rid) {
        for (el of document.querySelectorAll('tr[data-rid="'+rid+'"]')) {
          if (el.style.display === "none")
            el.style.display = "table-row";
          else
            el.style.display = "none";
        }
      }
    }

    function processResults(r) {
      /*jshint validthis: true*/
      if (r.results.length) {
        this.classList.add("hitdbRTButtons-green");
        if (this.dataset.id) {
          var rtable = document.querySelector("#resultsTableFor"+this.dataset.id);
          rtable.innerHTML += r.formatHTML(null,true);
        }
      }
    }

    
  }//}}} btdt

  function showHiddenRows(e) {//{{{
    var rid = e.target.parentNode.textContent.substr(4);
    var nodes = document.querySelectorAll('tr[data-rid="'+rid+'"]'), el = null;
    if (e.target.textContent === "[+]") {
      for (el of nodes)
        el.style.display="table-row";
      e.target.textContent = "[-]";
    } else {
      for (el of nodes)
        el.style.display="none";
      e.target.textContent = "[+]";
    }
  }//}}}

  function updateBonus(e) {//{{{
    if (e instanceof window.KeyboardEvent && e.keyCode === 13) {
      e.target.blur();
      return false;
    } else if (e instanceof window.FocusEvent) {
      var _bonus = +e.target.textContent.replace(/\$/,"");
      if (_bonus !== +e.target.dataset.initial) {
        console.log("updating bonus to",_bonus,"from",e.target.dataset.initial,"("+e.target.dataset.hitid+")");
        e.target.dataset.initial = _bonus;
        var _pay   = +e.target.previousSibling.textContent,
            _range = window.IDBKeyRange.only(e.target.dataset.hitid);

        window.indexedDB.open(DB_NAME).onsuccess = function() {
          this.result.transaction("HIT", "readwrite").objectStore("HIT").openCursor(_range).onsuccess = function() {
            var c = this.result;
            if (c) {
              var v = c.value;
              v.reward = { pay: _pay, bonus: _bonus };
              c.update(v);
            } 
          }; // idbcursor
        }; // idbopen
      } // bonus is new value
    } // keycode
  } //}}} updateBonus

  function noteHandler(type, e) {//{{{
    // 
    // TODO restructure event handling/logic tree
    //      combine save and delete; it's ugly :(
    //      actually this whole thing is messy and in need of refactoring
    //
    if (e instanceof window.KeyboardEvent) {
     if (e.keyCode === 13) {
        e.target.blur();
        return false;
      }
     return;
    }

    if (e instanceof window.FocusEvent) {
      if (e.target.textContent.trim() !== e.target.dataset.initial) {
        if (!e.target.textContent.trim()) { e.target.previousSibling.previousSibling.firstChild.click(); return; }
        var note   = e.target.textContent.trim(),
            _range = window.IDBKeyRange.only(e.target.dataset.id),
            inote  = e.target.dataset.initial,
            hitId  = e.target.dataset.id,
            date   = e.target.previousSibling.textContent;

        e.target.dataset.initial = note;
        window.indexedDB.open(DB_NAME).onsuccess = function() {
          this.result.transaction("NOTES", "readwrite").objectStore("NOTES").index("hitId").openCursor(_range).onsuccess = function() {
            if (this.result) {
              var r = this.result.value;
              if (r.note === inote) {  // note already exists in database, so we update its value
                r.note = note;
                this.result.update(r);
                return;
              }
              this.result.continue();
            } else {
              if (this.source instanceof window.IDBObjectStore)
                this.source.put({ note:note, date:date, hitId:hitId });
              else
                this.source.objectStore.put({ note:note, date:date, hitId:hitId });
            }
          };
          this.result.close();
        };
      } 
      return; // end of save event; no need to proceed
    }

    if (type === "delete") {
      var tr       = e.target.parentNode.parentNode,
          noteCell = tr.lastChild;
          _range   = window.IDBKeyRange.only(noteCell.dataset.id);
      if (!noteCell.dataset.initial) tr.remove();
      else {
        window.indexedDB.open(DB_NAME).onsuccess = function() {
          this.result.transaction("NOTES", "readwrite").objectStore("NOTES").index("hitId").openCursor(_range).onsuccess = function() {
            if (this.result) {
              if (this.result.value.note === noteCell.dataset.initial) {
                this.result.delete();
                tr.remove();
                return;
              }
              this.result.continue();
            } 
          };
          this.result.close();
        };
      }
      return; // end of deletion event; no need to proceed
    } else {
      if (type === "attach" && !e.results.length) return;

      var trow  = e instanceof window.MouseEvent ? e.target.parentNode.parentNode : null,
          tbody = trow ? trow.parentNode : null,
          row   = document.createElement("TR"),
          c1    = row.insertCell(0),
          c2    = row.insertCell(1),
          c3    = row.insertCell(2);
          date  = new Date();
          hitId = e instanceof window.MouseEvent ? e.target.id.substr(5) : null;

      c1.innerHTML = '<span class="removeNote" title="Delete this note" style="cursor:pointer;color:crimson;">[x]</span>';
      c1.firstChild.onclick = noteHandler.bind(null,"delete");
      c1.style.textAlign = "right";
      c2.title = "Date on which the note was added";
      c3.style.color = "crimson";
      c3.colSpan = "6";
      c3.contentEditable = "true";
      c3.onblur = noteHandler.bind(null,"blur");
      c3.onkeydown = noteHandler.bind(null, "kb");
      
      if (type === "new") {
        row.classList.add(trow.classList);
        tbody.insertBefore(row, trow.nextSibling);
        c2.textContent = date.getFullYear()+"-"+Number(date.getMonth()+1).toPadded()+"-"+Number(date.getDate()).toPadded();
        c3.dataset.initial = "";
        c3.dataset.id = hitId;
        c3.focus();
        return;
      }

      for (var entry of e.results) {
        trow  = document.querySelector('tr[data-id="'+entry.hitId+'"]');
        tbody = trow.parentNode;
        row   = row.cloneNode(true);
        c1    = row.firstChild;
        c2    = c1.nextSibling;
        c3    = row.lastChild;
        
        row.classList.add(trow.classList);
        tbody.insertBefore(row, trow.nextSibling);

        c1.firstChild.onclick = noteHandler.bind(null,"delete");
        c2.textContent = entry.date;
        c3.textContent = entry.note;
        c3.dataset.initial = entry.note;
        c3.dataset.id = entry.hitId;
        c3.onblur = noteHandler.bind(null,"blur");
        c3.onkeydown = noteHandler.bind(null, "kb");
      }
    } // new/attach
  }//}}} noteHandler

  function processFile(e) {//{{{
    var f = e.target.files;
    if (f.length && f[0].name.search(/\.(bak|csv)$/) && ~f[0].type.search(/(text|json)/)) {
      var reader = new FileReader(), testing = true, isCsv = false;
      reader.readAsText(f[0].slice(0,10));
      reader.onload = function(e) { 
        if (testing && e.target.result.search(/(STATS|NOTES|HIT)/) < 0) {
          return error();
        } else if (testing) {
          testing = false;
          document.getElementById("hdbProgressBar").style.display = "block";
          reader.readAsText(f[0]);
        } else {
          var data = JSON.parse(e.target.result);
          console.log(data);
          HITStorage.write(data, "restore");
        }
      }; // reader.onload
    } else {
      error();
    }

    function error() {
      var e = "Restore::FileReadError : encountered unsupported file";
      Status.push(e,"red");
      throw e;
    }
  }//}}} processFile

  function autoScroll(location, dt) {//{{{
    var target = document.querySelector(location).offsetTop,
        pos    = window.scrollY,
        dpos   = Math.ceil((target - pos)/3);
    qc.isScrolling = true;
    dt = dt ? dt-1 : 25; // time step/max recursions

    if (target === pos || dpos === 0 || dt === 0) { qc.isScrolling = false; return; }

    window.scrollBy(0, dpos);
    setTimeout(function() { autoScroll(location, dt); }, dt);
  }//}}}

  function Calendar(offsetX, offsetY, caller) {//{{{
    this.date = new Date();
    this.offsetX = offsetX;
    this.offsetY = offsetY;
    this.caller = caller;
    this.drawCalendar = function(year,month,day) {//{{{
      year = year || this.date.getFullYear();
      month = month || this.date.getMonth()+1;
      day = day || this.date.getDate();
      var longMonths = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
      var date = new Date(year,month-1,day);
      var anchors = _getAnchors(date);

      //make new container if one doesn't already exist
      var container = null;
      if (document.querySelector("#hdbCalendarPanel")) { 
        container = document.querySelector("#hdbCalendarPanel");
        container.removeChild( container.getElementsByTagName("TABLE")[0] );
      }
      else {
        container = document.createElement("DIV");
        container.id = "hdbCalendarPanel";
        document.body.appendChild(container);
      }
      container.style.left = this.offsetX;
      container.style.top = this.offsetY;
      var cal = document.createElement("TABLE");
      cal.cellSpacing = "0";
      cal.cellPadding = "0";
      cal.border = "0";
      container.appendChild(cal);
      cal.innerHTML = '<tr>' +
        '<th class="hdbCalHeader hdbCalControls" title="Previous month" style="text-align:right;"><span>&lt;</span></th>' +
        '<th class="hdbCalHeader hdbCalControls" title="Previous year" style="text-align:center;"><span>&#8810;</span></th>' +
        '<th colspan="3" id="hdbCalTableTitle" class="hdbCalHeader">'+date.getFullYear()+'<br>'+longMonths[date.getMonth()]+'</th>' +
        '<th class="hdbCalHeader hdbCalControls" title="Next year" style="text-align:center;"><span>&#8811;</span></th>' +
        '<th class="hdbCalHeader hdbCalControls" title="Next month" style="text-align:left;"><span>&gt;</span></th>' +
        '</tr><tr><th class="hdbDayHeader" style="color:red;">S</th><th class="hdbDayHeader">M</th>' +
        '<th class="hdbDayHeader">T</th><th class="hdbDayHeader">W</th><th class="hdbDayHeader">T</th>' +
        '<th class="hdbDayHeader">F</th><th class="hdbDayHeader">S</th></tr>';
      
      document.querySelector('th[title="Previous month"]').addEventListener( "click", function() { 
        this.drawCalendar(date.getFullYear(), date.getMonth(), 1);
      }.bind(this) );
      document.querySelector('th[title="Previous year"]').addEventListener( "click", function() {
        this.drawCalendar(date.getFullYear()-1, date.getMonth()+1, 1);
      }.bind(this) );
      document.querySelector('th[title="Next month"]').addEventListener( "click", function() {
        this.drawCalendar(date.getFullYear(), date.getMonth()+2, 1);
      }.bind(this) );
      document.querySelector('th[title="Next year"]').addEventListener( "click", function() {
        this.drawCalendar(date.getFullYear()+1, date.getMonth()+1, 1);
      }.bind(this) );

      var hasDay = false, thisDay = 1;
      for (var i=0;i<6;i++) { // cycle weeks
        var row = document.createElement("TR");
        for (var j=0;j<7;j++) { // cycle days
          if (!hasDay && j === anchors.first && thisDay < anchors.total)
            hasDay = true;
          else if (hasDay && thisDay > anchors.total)
            hasDay = false;

          var cell = document.createElement("TD");
          cell.classList.add("hdbCalCells");
          row.appendChild(cell);
          if (hasDay) {
            cell.classList.add("hdbCalDays");
            cell.textContent = thisDay;
            cell.addEventListener("click", _clickHandler.bind(this));
            cell.dataset.year = date.getFullYear();
            cell.dataset.month = date.getMonth()+1;
            cell.dataset.day = thisDay++;
          }
        } // for j
        cal.appendChild(row);
      } // for i
      var controls = cal.insertRow(-1);
      controls.insertCell(0);
      controls.cells[0].colSpan = "7";
      controls.cells[0].classList.add("hdbCalCells");
      controls.cells[0].innerHTML = ' &nbsp; &nbsp; <a href="javascript:void(0)" style="font-weight:bold;text-decoration:none;">Clear</a>' + 
        ' &nbsp; <a href="javascript:void(0)" style="font-weight:bold;text-decoration:none;">Close</a>';
      controls.cells[0].children[0].onclick = function() { this.caller.value = ""; }.bind(this);
      controls.cells[0].children[1].onclick = this.die;

      function _clickHandler(e) {
        /*jshint validthis:true*/

        var y = e.target.dataset.year;
        var m = Number(e.target.dataset.month).toPadded();
        var d = Number(e.target.dataset.day).toPadded();
        this.caller.value = y+"-"+m+"-"+d;
        this.die();
      }

      function _getAnchors(date) {
        var _anchors = {};
        date.setMonth(date.getMonth()+1);
        date.setDate(0);
        _anchors.total = date.getDate();
        date.setDate(1);
        _anchors.first = date.getDay();
        return _anchors;
      }
    };//}}} drawCalendar

    this.die = function() { document.getElementById('hdbCalendarPanel').remove(); };

  }//}}} Calendar

  // instance metrics apart from window scoped PerformanceTiming API
  function Metrics(name) {//{{{
    this.name = name || "undefined";
    this.marks = {};
    this.start = window.performance.now();
    this.end = null;
    this.stop = function(){
      if (!this.end) 
        this.end = window.performance.now();
      else
        console.error("Metrics::AccessViolation: end point cannot be overwritten");
    };
    this.mark = function(name,position) {
      if (position === "end" && (!this.marks[name] || this.marks[name].end)) return;

      if (!this.marks[name])
        this.marks[name] = {};

      this.marks[name][position] = window.performance.now();
    };
    this.report = function() {
      console.group("Metrics for",this.name.toUpperCase());
      console.log("Process completed in",+Number((this.end-this.start)/1000).toFixed(3),"seconds");
      for (var k in this.marks) {
        if (this.marks.hasOwnProperty(k)) {
          console.log(k,"occurred after",+Number((this.marks[k].start-this.start)/1000).toFixed(3),"seconds,",
              "resolving in", +Number((this.marks[k].end-this.marks[k].start)/1000).toFixed(3), "seconds");
        }
      }
      console.groupEnd();
    };
  }//}}}

})(); //scoping

/*
 *
 *
 * * * * * * * * * * * * * TESTING FUNCTIONS -- DELETE BEFORE FINAL RELEASE * * * * * * * * * * * 
 *
 *
 */


function INFLATEDUMMYVALUES() { //{{{
  'use strict';

  var tdb = this.result;
  tdb.onerror = function(e) { console.log("requesterror",e.target.error.name,e.target.error.message,e); };
  tdb.onversionchange = function(e) { console.log("tdb received versionchange request", e); tdb.close(); };
  //console.log(tdb.transaction("HIT").objectStore("HIT").indexNames.contains("date"));
  console.groupCollapsed("Populating test database");
  var tdbt = {};
  //tdbt.trans = tdb.transaction(["HIT", "NOTES", "BLOCKS"], "readwrite");
  tdbt.trans = tdb.transaction("HIT", "readwrite");
  tdbt.hit   = tdbt.trans.objectStore("HIT");
  //tdbt.notes = tdbt.trans.objectStore("NOTES");
  //tdbt.blocks= tdbt.trans.objectStore("BLOCKS");

  var filler = { notes:[], hit:[], blocks:[] };
  for (var n=0;n<100000;n++) {
    filler.hit.push({ date: "2015-08-00", requesterName: "testRequester", title: "Greatest Title Ever #"+(n+1), 
      reward: Number((n+1)%(200/n)+(((n+1)%200)/100)).toFixed(2), status: "moo",
      requesterId: ("RRRRRRR"+n).substr(-7), hitId: ("HHHHHHH"+n).substr(-7) });
    /*if (n%1000 === 0) {
      filler.notes.push({ requesterId: ("RRRRRRR"+n).substr(-7), note: n+1 +
        " Proin vel erat commodo mi interdum rhoncus. Sed lobortis porttitor arcu, et tristique ipsum semper a." +
        " Donec eget aliquet lectus, vel scelerisque ligula." });
      filler.blocks.push({requesterId: ("RRRRRRR"+n).substr(-7)});
    }*/
  }

  _write(tdbt.hit, filler.hit);
  _write(tdbt.notes, filler.notes);
  //_write(tdbt.blocks, filler.blocks);

  function _write(store, obj) {
    if (obj.length) {
      var t = obj.pop();
      store.put(t).onsuccess = function() { _write(store, obj) };
    } else {
      console.log("population complete");
    }
  }

  console.groupEnd();
/*
  var dbh = window.indexedDB.open(DB_NAME, DB_VERSION);
  dbh.onerror = function(e) { console.log("[HITDB]",e.target.error.name+":", e.target.error.message, e); };
  console.log(dbh.readyState, dbh);
  dbh.onupgradeneeded = HITStorage.versionChange;
  dbh.onblocked = function(e) { console.log("blocked event triggered:", e); };
*/
  tdb.close();

}//}}}
/*
function BLANKSLATE() { //{{{ create empty db equivalent to original schema to test upgrade
    'use strict';
    var tdb = this.result;
    if (!tdb.objectStoreNames.contains("HIT")) { 
        console.log("creating HIT OS");
        var dbo = tdb.createObjectStore("HIT", { keyPath: "hitId" });
        dbo.createIndex("date", "date", { unique: false });
        dbo.createIndex("requesterName", "requesterName", { unique: false});
        dbo.createIndex("title", "title", { unique: false });
        dbo.createIndex("reward", "reward", { unique: false });
        dbo.createIndex("status", "status", { unique: false });
        dbo.createIndex("requesterId", "requesterId", { unique: false });

    }
    if (!tdb.objectStoreNames.contains("STATS")) {
        console.log("creating STATS OS");
        dbo = tdb.createObjectStore("STATS", { keyPath: "date" });
    }
    if (!tdb.objectStoreNames.contains("NOTES")) {
        console.log("creating NOTES OS");
        dbo = tdb.createObjectStore("NOTES", { keyPath: "requesterId" });
    }
    if (!tdb.objectStoreNames.contains("BLOCKS")) {
        console.log("creating BLOCKS OS");
        dbo = tdb.createObjectStore("BLOCKS", { keyPath: "id", autoIncrement: true });
        dbo.createIndex("requesterId", "requesterId", { unique: false });
    }
} //}}}
*/


// vim: ts=2:sw=2:et:fdm=marker:noai