您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Keep track of the HITs you've done (and more!)
当前为
// ==UserScript== // @name MTurk HIT Database Mk.II // @author feihtality // @namespace https://greasyfork.org/en/users/12709 // @version 0.8.082 // @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 * optimize searching: index -> date filter * 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/'; //const TO_BASE = 'http://turkopticon.ucsd.edu/api/multi-attrs.php'; // polyfill for chrome until v45(?) if (!NodeList.prototype[Symbol.iterator]) NodeList.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator]; // format leading zeros Number.prototype.toPadded = function(length) { 'use strict'; length = length || 2; return ("0000000"+this).substr(-length); }; // decimal rounding Math.decRound = function(v, shift) { 'use strict'; v = Math.round(+(v+"e"+shift)); return +(v+"e"+-shift); }; Date.prototype.toLocalISOString = function() { '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; }; /***********************************************************************************************/ // 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, aac: document.location.pathname === "/mturk/dashboard" ? JSON.parse(localStorage.getItem("hitdb_autoAppCollection") || "{}") : null, save: function(key, name, isObj) { 'use strict'; if (isObj) localStorage.setItem(name, JSON.stringify(this[key])); else localStorage.setItem(name, this[key]); } }, metrics = {}; var HITStorage = { //{{{ data: {}, versionChange: function hsversionChange() { //{{{ 'use strict'; 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) { //{{{ 'use strict'; if (e === "DatabaseCreationError") { var s = document.getElementById("hdbStatusText"); s.style.color = "red"; s.innerHTML = "Something went wrong during database creation!<br>Please refresh the page and try again"; console.log("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) {//{{{ 'use strict'; var statusLabel = document.querySelector("#hdbStatusText"); statusLabel.style.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); statusLabel.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(); 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); statusLabel.textContent = "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; 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); statusLabel.textContent += " [ "+_calcTotals(qc.seen)+"/"+ qc.fetchData.expectedTotal+" ]"; if (_calcTotals(qc.seen) === qc.fetchData.expectedTotal) { statusLabel.textContent = "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) { statusLabel.textContent = "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) { 'use strict'; 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) { 'use strict'; 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) { //{{{ 'use strict'; //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) { //{{{ 'use strict'; if (statusUpdate === "update") qc.timeoutTimer = setTimeout(HITStorage.error, 5555, "DatabaseCreationError"); 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) { var statusLabel = document.querySelector("#hdbStatusText"); statusLabel.style.color = "green"; statusLabel.textContent = statusUpdate === "update" ? "Update Complete!" : statusUpdate === "restore" ? "Restoring " + counts.total + " entries... Done!" : "Done!"; document.querySelector("#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) {//{{{ 'use strict'; var index = options ? (options.index || null) : null, range = options ? (options.range || null) : null, dir = options ? (options.dir || "next") : "next", fs = options ? (options.filter ? options.filter.status !== "*" ? new RegExp(options.filter.status, "i") : false : false) : false, fq = options ? (options.filter ? options.filter.query !== "*" ? new RegExp(options.filter.query,"i") : false : false) : false, limit = 0; if (options && options.progress) { var progressBar = document.querySelector("#hdbProgressBar"); //statusText = document.querySelector("#hdbStatusText"); progressBar.style.display = "block"; } var sr = new DatabaseResult(); 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) { 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))) ) if (limit++ < 3800) // limit to save memory usage in large databases sr.include(c.value); c.continue(); } else resolve(sr); }; }; } ); // promise },//}}} recall backup: function() {//{{{ 'use strict'; var bData = {}, os = ["STATS", "NOTES", "HIT"], count = 0, prog = document.querySelector("#hdbProgressBar"); prog.style.display = "block"; window.indexedDB.open(DB_NAME).onsuccess = function() { for (var store of os) { this.result.transaction(os, "readonly").objectStore(store).openCursor().onsuccess = populateBackup; } }; 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"; } }//}}} backup }, //}}} HITStorage Utils = { //{{{ ftime : function(t, noBlanks) {//{{{ 'use strict'; 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 'use strict'; 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 }; //}}} Utils // ProjectedEarnings doesn't belong up here, but it needs to be for variable assignment purposes :( var ProjectedEarnings = {//{{{ data: JSON.parse(localStorage.getItem("hitdb_projectedEarnings") || "{}"), updateDate: function() {//{{{ 'use strict'; 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: _date.getDay(), dbUpdated: "n/a", pending: 0, earnings: { day: 0, week: 0 }, target: { day: 0, week: 0 } }; } if ( (Date.parse(Utils.ISODate(date)) >= this.data.weekEnd) || (!isToday && _date.getDay() < this.data.day) ) { // new week this.data.earnings.week = 0; this.data.weekEnd = weekEnd; this.data.weekStart = weekStart; } if (date !== this.data.today || !isToday) { // new day this.data.today = date; this.data.day = _date.getDay(); this.data.earnings.day = 0; } this.saveState(); },//}}} updateDate draw: function(init) {//{{{ 'use strict'; 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"; 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="'+this.data.earnings.day+'" max="'+this.data.target.day+'"></meter>'+ '<span style="color:blue;font-family:arial;font-size:10px;"> ' + Number(this.data.earnings.day-this.data.target.day).toFixed(2) + '</span>'; rowProjectedDay.cells[1].textContent = "$"+Number(this.data.earnings.day).toFixed(2); rowProjectedWeek.cells[0].innerHTML = 'Projected earnings for the week<br>' + '<meter id="projectedWeekProgress" style="width:220px;" title="'+title+ '" value="'+this.data.earnings.week+'" max="'+this.data.target.week+'"></meter>' + '<span style="color:blue;font-family:arial;font-size:10px;"> ' + Number(this.data.earnings.week-this.data.target.week).toFixed(2) + '</span>'; rowProjectedWeek.cells[1].textContent = "$"+Number(this.data.earnings.week).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(this.data.earnings[span==="day" ? "day":"week"] - goal).toFixed(2); this.saveState(); } } },//}}} draw saveState: function() { 'use strict'; localStorage.setItem("hitdb_projectedEarnings", JSON.stringify(this.data)); }, clear: function() { 'use strict'; this.data.earnings = { day:0, week:0 }; this.data.pending = 0; }, updateValues: function(obj) { 'use strict'; var vDate = Date.parse(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 (Utils.ISODate(obj.date) === this.data.today && !~obj.status.search(/rejected/i)) // sum daily earnings this.data.earnings.day = Math.decRound(obj.reward+this.data.earnings.day, 2); if (vDate < this.data.weekEnd && vDate >= this.data.weekStart && !~obj.status.search(/rejected/i)) // sum weekly earnings this.data.earnings.week = Math.decRound(obj.reward+this.data.earnings.week, 2); } };//}}} ProjectedEarnings function DatabaseResult() {//{{{ 'use strict'; this.results = []; 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 = _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 = _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;"> 📝 </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 = _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 = _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); }; 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 function _collate(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 }//}}} databaseresult /* * * Above contains the core functions. Below is the * main body, interface, and tangential functions. * *///{{{ // 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) { 'use strict'; 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) { 'use strict'; console.log("[HITDB]",e.target.error.name+":", e.target.error.message, e); }; dbh.onupgradeneeded = HITStorage.versionChange; dbh.onsuccess = function() { 'use strict'; this.result.close(); }; dashboardUI(); ProjectedEarnings.updateDate(); ProjectedEarnings.draw(true); } else { beenThereDoneThat(); } } /*}}} * * Above is the main body and core functions. Below * defines UI layout/appearance and tangential functions. * */ // {{{ 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;}" + "</style>"; document.head.innerHTML += css; // }}} function beenThereDoneThat() {//{{{ // // TODO refine searching // 'use strict'; 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> '+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 dashboardUI() {//{{{ // // TODO refactor // 'use strict'; var controlPanel = document.createElement("TABLE"); var insertionNode = document.querySelector(".footer_separator").previousSibling; document.body.insertBefore(controlPanel, insertionNode); controlPanel.width = "760"; controlPanel.align = "center"; 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 <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="Approval" style="color: orange;">Pending Approval</option>' + '<option value="Rejected" style="color: red;">Rejected</option><option value="Approved" 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">placeholder status text</label>' + '<div id="hdbProgressBar" class="hdbProgressContainer"><div class="hdbProgressOuter"><div class="hdbProgressInner"></div></div></div>' + '</div></td></tr>'; var updateBtn = document.querySelector("#hdbUpdate"), backupBtn = document.querySelector("#hdbBackup"), restoreBtn = document.querySelector("#hdbRestore"), fileInput = document.querySelector("#hdbFileInput"), exportCSVInput = document.querySelector("#hdbCSVInput"), searchBtn = document.querySelector("#hdbSearch"), searchInput = document.querySelector("#hdbSearchInput"), pendingBtn = document.querySelector("#hdbPending"), reqBtn = document.querySelector("#hdbRequester"), dailyBtn = document.querySelector("#hdbDaily"), fromdate = document.querySelector("#hdbMinDate"), todate = document.querySelector("#hdbMaxDate"), statusSelect = document.querySelector("#hdbStatusSelect"), progressBar = document.querySelector("#hdbProgressBar"); var searchResults = document.createElement("DIV"); searchResults.align = "center"; searchResults.id = "hdbSearchResults"; searchResults.style.display = "block"; searchResults.innerHTML = '<span style="border-bottom:1px solid;color:blue;cursor:pointer;display:none;">[ clear results ]</span><br>' + '<table cellSpacing="0" cellpadding="2" id="hdbResultsTable"></table>'; document.body.insertBefore(searchResults, insertionNode); searchResults.firstChild.onclick = function(e) { e.target.style.display = "none"; searchResults.children[2].innerHTML = null; }; updateBtn.onclick = function() { progressBar.style.display = "block"; metrics.dbupdate = new Metrics("database_update"); HITStorage.fetch(MTURK_BASE+"status"); document.querySelector("#hdbStatusText").textContent = "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() { var offsets = getPosition(this, true); new Calendar(offsets.x, offsets.y, this).drawCalendar(); }); todate.addEventListener("focus", function() { var offsets = getPosition(this, true); new Calendar(offsets.x, offsets.y, this).drawCalendar(); }); backupBtn.onclick = HITStorage.backup; restoreBtn.onclick = function() { fileInput.click(); }; fileInput.onchange = processFile; searchBtn.onclick = function() { var r = getRange(); var _filter = { status: statusSelect.value, query: searchInput.value.trim().length > 0 ? searchInput.value : "*" }; var _opt = { index: "date", range: r.range, dir: r.dir, filter: _filter, progress: true }; metrics.dbrecall = new Metrics("database_recall::search"); HITStorage.recall("HIT", _opt).then(function(r) { searchResults.children[0].style.display = "initial"; searchResults.children[2].innerHTML = exportCSVInput.checked ? r.formatCSV() : r.formatHTML(); autoScroll("#hdbSearchResults"); for (var _r of r.results) { // retrieve and append notes HITStorage.recall("NOTES", { index: "hitId", range: window.IDBKeyRange.only(_r.hitId) }).then(noteHandler.bind(null,"attach")); } var el = null; for (el of document.querySelectorAll(".bonusCell")) { el.dataset.initial = el.textContent; el.onblur = updateBonus; el.onkeydown = updateBonus; } for (el of document.querySelectorAll('span[id^="note-"]')) { el.onclick = noteHandler.bind(null,"new"); } metrics.dbrecall.stop(); metrics.dbrecall.report(); progressBar.style.display = "none"; }); }; // search button click event pendingBtn.onclick = function() { var r = getRange(); var _filter = { status: "Approval", query: searchInput.value.trim().length > 0 ? searchInput.value : "*" }, _opt = { index: "date", dir: "prev", range: r.range, filter: _filter, progress: true }; metrics.dbrecall = new Metrics("database_recall::pending"); HITStorage.recall("HIT", _opt).then(function(r) { searchResults.children[0].style.display = "initial"; searchResults.children[2].innerHTML = exportCSVInput.checked ? r.formatCSV("pending") : r.formatHTML("pending"); autoScroll("#hdbSearchResults"); var expands = document.querySelectorAll(".hdbExpandRow"); for (var el of expands) { el.onclick = showHiddenRows; } metrics.dbrecall.stop(); metrics.dbrecall.report(); progressBar.style.display = "none"; }); }; //pending overview click event reqBtn.onclick = function() { var r = getRange(); var _opt = { index: "date", range: r.range, progress: true }; metrics.dbrecall = new Metrics("database_recall::requester"); HITStorage.recall("HIT", _opt).then(function(r) { searchResults.children[0].style.display = "initial"; searchResults.children[2].innerHTML = exportCSVInput.checked ? r.formatCSV("requester") : r.formatHTML("requester"); autoScroll("#hdbSearchResults"); var expands = document.querySelectorAll(".hdbExpandRow"); for (var el of expands) { el.onclick = showHiddenRows; } metrics.dbrecall.stop(); metrics.dbrecall.report(); progressBar.style.display = "none"; }); }; //requester overview click event dailyBtn.onclick = function() { metrics.dbrecall = new Metrics("database_recall::daily"); HITStorage.recall("STATS", { dir: "prev" }).then(function(r) { searchResults.children[0].style.display = "initial"; searchResults.children[2].innerHTML = exportCSVInput.checked ? r.formatCSV("daily") : r.formatHTML("daily"); autoScroll("#hdbSearchResults"); metrics.dbrecall.stop(); metrics.dbrecall.report(); }); }; //daily overview click event function getRange() { var _min = fromdate.value.length === 10 ? fromdate.value : undefined, _max = todate.value.length === 10 ? todate.value : undefined; var _range = (_min === undefined && _max === undefined) ? 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); return { min: _min, max: _max, range: _range, dir: _max < _min ? "prev" : "next" }; } function getPosition(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; } }//}}} dashboard function showHiddenRows(e) {//{{{ 'use strict'; 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) {//{{{ 'use strict'; 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 // 'use strict'; 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 = "5"; 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) {//{{{ 'use strict'; 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.querySelector("#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 s = document.querySelector("#hdbStatusText"), e = "Restore::FileReadError : encountered unsupported file"; s.style.color = "red"; s.textContent = e; throw e; } }//}}} processFile function autoScroll(location, dt) {//{{{ 'use strict'; var target = document.querySelector(location).offsetTop, pos = window.scrollY, dpos = Math.ceil((target - pos)/3); dt = dt ? dt-1 : 25; // time step/max recursions if (target === pos || dpos === 0 || dt === 0) return; window.scrollBy(0, dpos); setTimeout(function() { autoScroll(location, dt); }, dt); }//}}} function Calendar(offsetX, offsetY, caller) {//{{{ 'use strict'; 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><</span></th>' + '<th class="hdbCalHeader hdbCalControls" title="Previous year" style="text-align:center;"><span>≪</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>≫</span></th>' + '<th class="hdbCalHeader hdbCalControls" title="Next month" style="text-align:left;"><span>></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 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.querySelector("#hdbCalendarPanel").remove(); }; }//}}} Calendar // instance metrics apart from window scoped PerformanceTiming API function Metrics(name) {//{{{ 'use strict'; 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 throw "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(); }; }//}}} /* * * * * * * * * * * * * * * * 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.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: "tReq"+(n+1), 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(); 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