shawkinsl/mtga-tracker

View on GitHub
electron/settingsRenderer.js

Summary

Maintainability
F
2 wks
Test Coverage
const { remote, ipcRenderer, shell } = require('electron')
const {dialog, Menu, MenuItem,} = remote
const fs = require('fs')

const API_URL = remote.getGlobal("API_URL")
const keytar = require('keytar')
const mtga = require('mtga')
const path = require('path')
const request = require('request')
const os = require('os')
const jwt = require('jsonwebtoken')
const Datastore = require('nedb')

var { rendererPreload } = require('electron-routes');
rendererPreload();

var { databaseFiles } = require("./conf")

const tt = require('electron-tooltip')
tt({
  position: 'top',
  style: {
    backgroundColor: 'dark gray',
    color: 'white',
    borderRadius: '4px',
  }
})

var buildWorker = function(func) {
  // https://stackoverflow.com/questions/5408406/web-workers-without-a-separate-javascript-file
  var blobURL = URL.createObjectURL(new Blob([ '(', func.toString(), ')()' ], { type: 'application/javascript' })),

  worker = new Worker(blobURL);

  // Won't be needing this anymore
  URL.revokeObjectURL(blobURL);
  return worker
}

var settingsData = {
  version: remote.getGlobal("version"),
  commit: "",
  build: "",
  logPath: remote.getGlobal("logPath"),
  version: remote.getGlobal("version"),
  mtgaOverlayOnly: remote.getGlobal("mtgaOverlayOnly"),
  settingsPaneIndex: remote.getGlobal("settingsPaneIndex"),
  debug: remote.getGlobal('debug'),
  sendAnonymousUsageInfo: remote.getGlobal('sendAnonymousUsageInfo'),
  showErrors: remote.getGlobal('showErrors'),
  showInspector: remote.getGlobal('showInspector'),
  useFrame: remote.getGlobal('useFrame'),
  staticMode: remote.getGlobal('staticMode'),
  useTheme: remote.getGlobal('useTheme'),
  themeFile: remote.getGlobal('themeFile'),
  mouseEvents: remote.getGlobal('mouseEvents'),
  leftMouseEvents: remote.getGlobal('leftMouseEvents'),
  showTotalWinLossCounter: remote.getGlobal('showTotalWinLossCounter'),
  showDeckWinLossCounter: remote.getGlobal('showDeckWinLossCounter'),
  showDailyTotalWinLossCounter: remote.getGlobal('showDailyTotalWinLossCounter'),
  showDailyDeckWinLossCounter: remote.getGlobal('showDailyDeckWinLossCounter'),
  winLossObj: remote.getGlobal('winLossCounter'),
  counterDeckList: [],
  dailyCounterDeckList: [],
  totalWinLossCounter: null,
  dailyTotalWinLossCounter: null,
  showVaultProgress: remote.getGlobal('showVaultProgress'),
  minVaultProgress: remote.getGlobal('minVaultProgress'),
  showGameTimer: remote.getGlobal('showGameTimer'),
  showChessTimers: remote.getGlobal('showChessTimers'),
  hideDelay: remote.getGlobal('hideDelay'),
  invertHideMode: remote.getGlobal('invertHideMode'),
  rollupMode: remote.getGlobal('rollupMode'),
  minToTray: remote.getGlobal('minToTray'),
  runFromSource: remote.getGlobal('runFromSource'),
  sortMethodSelected: remote.getGlobal('sortMethod'),
  useFlat: remote.getGlobal('useFlat'),
  useMinimal: remote.getGlobal('useMinimal'),
  updateDownloading: remote.getGlobal('updateDownloading'),
  updateReady: remote.getGlobal('updateReady'),
  firstRun: remote.getGlobal('firstRun'),
  trackerID: remote.getGlobal('trackerID'),
  externalDatabaseConnectionStrings: [],
  customStyleFiles: [],
  sortingMethods: [
    {id: "draw", text: "Draw (Default)", help: "By likelihood of next draw with most likely on top, then by name."},
    {id: "emerald", text: 'Emerald',
    help: "By card type, then by cost, then by name."},
    {id: "color", text: "Color",
    help: "By cost, then by color, then by name. This is similar to MTGA sorting."},
    {id: "draw-emerald", text: "Draw, Emerald",
    help: "By likelihood of next draw, then by card type, then by cost, then by name."},
    {id: "draw-color", text: "Draw, Color",
    help: "By likelihood of next draw, then by cost, then by color, then by name."},
  ],
  showUIButtons: remote.getGlobal('showUIButtons'),
  showHideButton: remote.getGlobal('showHideButton'),
  showMenu: remote.getGlobal('showMenu')
}

settingsData.counterDeckList = counterDecks(settingsData.winLossObj.alltime);
settingsData.dailyCounterDeckList = counterDecks(settingsData.winLossObj.daily);
settingsData.totalWinLossCounter = settingsData.winLossObj.alltime.total;
settingsData.dailyTotalWinLossCounter = settingsData.winLossObj.daily.total;

let commitFile = "version_commit.txt"
let buildFile = "version_build.txt"

if (!settingsData.runFromSource) {
  commitFile = path.join(remote.app.getAppPath(), commitFile)
  buildFile = path.join(remote.app.getAppPath(), buildFile)
}

fs.readFile(commitFile, "utf8", (err, data) => {
  settingsData.commit = data;
})

fs.readFile(buildFile, "utf8", (err, data) => {
  settingsData.build = data;
})

const menu = new Menu()
const menuItem = new MenuItem({
  label: 'Inspect Element',
  click: () => {
    remote.getCurrentWindow().inspectElement(rightClickPosition.x, rightClickPosition.y)
  }
})
menu.append(menuItem)

if (settingsData.debug) {
  window.addEventListener('contextmenu', (e) => {
    e.preventDefault()
    rightClickPosition = {x: e.x, y: e.y}
    menu.popup(remote.getCurrentWindow())
  }, false)
}

ipcRenderer.on('counterChanged', (e,new_wlc) => {
  settingsData.winLossObj = new_wlc;
  settingsData.counterDeckList = counterDecks(settingsData.winLossObj.alltime);
  settingsData.dailyCounterDeckList = counterDecks(settingsData.winLossObj.daily);
  settingsData.totalWinLossCounter = settingsData.winLossObj.alltime.total;
  settingsData.dailyTotalWinLossCounter = settingsData.winLossObj.daily.total;
});

/*
 * Format decks in winLossCounter to array for display in rivets.
 */
function counterDecks(winLossObj){
  let decks = [];
  for (let key in winLossObj){
    if (key == 'total') {
      continue;
    }
    decks.push({'id':key,'name': winLossObj[key]['name'], 'win':winLossObj[key]['win'],'loss':winLossObj[key]['loss']});
  }
  return decks.sort((a,b) => {
    if ( a.name < b.name ){
      return -1;
    } else if ( a.name == b.name ) {
      return 0;
    } else {
      return 1;
    }
  } );
}

rivets.formatters.and = function(comparee, comparator) {
    return comparee && comparator;
};

rivets.formatters.andnot = function(comparee, comparator) {
    return comparee && !comparator;
};

rivets.formatters.short = function(val) {
  if (val) {
    return val.substring(0, 6)
  }
}

rivets.formatters.filterBySlideValueRecentCards = function(arr, recentCardsQuantityToShow) {
  if(recentCardsQuantityToShow >= 100) {
    return arr;
  }
  return arr.slice(0,recentCardsQuantityToShow);
}

rivets.binders.ghlink = (el, val) => {
   el.href = `https://github.com/mtgatracker/mtgatracker/commit/${val}`
}

rivets.binders.appveyorlink = (el, val) => {
   el.href = `https://ci.appveyor.com/project/shawkinsl/mtgatracker/builds/${val}`
}

rivets.binders.settingspaneactive = (el, val) => {
  console.log("active")
  el.classList.remove("active")
  if (el.attributes.value.value == val) {
    el.classList.add("active")
  }
}

rivets.binders.showsettingspane = (el, val) => {
  el.style.display = "none"
  if (el.attributes.value.value == val) {
    el.style.display = "block"
    $(el).find(".toggle").each(function(idx, e) {e.style.width="65px"; e.style.height="40px";})  // bad dumb hack for buttons
  }
}

rivets.binders.authcolor = (el, val) => {
  el.style.color = val ? "green" : "red";
}

rivets.binders.datatooltip = (el, val) => {
  if (val) {
    el.setAttribute('data-tooltip', 'Account is authorized!');
  } else {
    el.setAttribute('data-tooltip', 'Account not authorized :(');
  }
}

rivets.binders.authref = (el, val) => {
  el.href = "https://inspector.mtgatracker.com/trackerAuth?code=" + val
}

rivets.binders.deckid = function(el, value) {
  el.setAttribute('data-deckid', value);
}

var firstCounterAdjButtonClicked = true;

function updateConnections() {
  connections = []
  $(".connection-input").filter((e, v) => $(v).val()).each((e, v) => connections.push($(v).val()))
  settingsData.externalDatabaseConnectionStrings = connections;
}

document.addEventListener("DOMContentLoaded", function(event) {
  rivets.bind(document.getElementById('container'), settingsData)

  // TODO: here
  keytar.getPassword("mtgatracker", "external-database-connections").then(connections => {
    // rivets doesn't like raw assignment, each elem must be pushed :|
    if (connections) {
        var asStrings = JSON.parse(connections)
        asStrings.map(conn => settingsData.externalDatabaseConnectionStrings.push(conn))
    }

    // this needs to be AFTER we've loaded / added connection strings, else new connections strings won't get
    // the click event!
    $(".remove-connection").click(e => {
      updateConnections()
      settingsData.externalDatabaseConnectionStrings = settingsData.externalDatabaseConnectionStrings.filter(a => a != e.target.value)
    })
  })

  $("#add-connection").click(e => {
    // before modifying the array, make sure we have the latest copies of all values (else modifying array will erase them)
    updateConnections()
    // now add a new connection
    settingsData.externalDatabaseConnectionStrings.push("")
  })

  $("#sync-databases").click(e => {
    $("#sync-databases").html("Syncing, this may take a while...")
    $("#sync-databases").attr("disabled", "true")
    connections = []
    $(".connection-input").filter((e, v) => $(v).val()).each((e, v) => connections.push($(v).val()))
    keytar.setPassword("mtgatracker", "external-database-connections", JSON.stringify(connections)).then(set => {
      fetch(`insp://sync`)
        .then(resp => resp.json())
        .then(data => {
          console.log(data)
          alert(`Uploaded ${data.uploaded} and downloaded ${data.downloaded} records.`)
          $("#sync-databases").html("Save connections and sync databases")
          $("#sync-databases").removeAttr("disabled")
        })
    })
  })

  $("#save-connections").click(e => {
    connections = []
    $(".connection-input").filter((e, v) => $(v).val()).each((e, v) => connections.push($(v).val()))
    keytar.setPassword("mtgatracker", "external-database-connections", JSON.stringify(connections))

    alert(`Saved ${connections.length} connections`)
  })

  let themePath = settingsData.runFromSource ? "themes" : path.join("..", "themes");
  fs.readdir(themePath, (err, files) => {
    if(files) {
      files.forEach((val) => {
        if (val.endsWith(".css")) {
          settingsData.customStyleFiles.push(val)
        }
      })
    }
    $("#custom-theme-select").val(settingsData.themeFile)
  })

  $("#showTrackerID").on('click', function(event) {
    this.innerHTML = settingsData.trackerID;
  })
  $("#log-select").click(function() {
    dialog.showOpenDialog({
      properties: ["openFile"],
      defaultPath: remote.getGlobal("logPath")
    }, filePath => {
      if (filePath) {
        console.log(`next launch will read from ${filePath}`)
        settingsData.logPath = filePath
        ipcRenderer.send('settingsChanged', {key: "logPath", value: filePath[0]})
        alert("You must restart MTGATracker after changing this setting!")
      }
    })
  })
  $("#sorting-method-select").val(settingsData.sortMethodSelected)
  $(document).on('click', 'a[href^="http"]', function(event) {
      event.preventDefault();
      shell.openExternal(this.href);
  });
  $(".nav-group-item").click((e) => {
    if (e.target.attributes.value != undefined){
      settingsData.settingsPaneIndex = e.target.attributes.value.value
    } else {
      settingsData.settingsPaneIndex = e.target.parentNode.attributes.value.value
    }
  })
  $('.tf-toggle').change(function() {
    console.log("settingsChanged")
    ipcRenderer.send('settingsChanged', {key: $(this).attr("key"), value: $(this).prop('checked')})
  })
  $('#apply-custom-theme-toggle').change(function() {
    console.log("apply theme was just toggled")
    settingsData.useTheme = $(this).prop('checked')
    let themeSelected = $("#custom-theme-select").val()
    ipcRenderer.send('settingsChanged', {key: "useTheme", value: settingsData.useTheme})
    ipcRenderer.send('settingsChanged', {key: "themeFile", value: themeSelected})
  })
  $('#enable-flat-theme-toggle').change(function() {
    console.log("apply flat theme was just toggled")
    settingsData.useFlat = $(this).prop('checked')
    ipcRenderer.send('settingsChanged', {key: "useFlat", value: settingsData.useFlat})
    if (!settingsData.useFlat) {
      settingsData.useMinimal = false;
      ipcRenderer.send('settingsChanged', {key: "useMinimal", value: settingsData.useMinimal})
    }
  })
  $('#enable-minimal-theme-toggle').change(function() {
    console.log("apply theme was just toggled")
    settingsData.useMinimal = $(this).prop('checked')
    ipcRenderer.send('settingsChanged', {key: "useMinimal", value: settingsData.useMinimal})
  })
  $("#custom-theme-select").change(function() {
    console.log("apply theme was just toggled")
    let themeSelected = $("#custom-theme-select").val()
    ipcRenderer.send('settingsChanged', {key: "useTheme", value: settingsData.useTheme})
    ipcRenderer.send('settingsChanged', {key: "themeFile", value: themeSelected})
  })
  $("#sorting-method-select").change(function() {
    console.log("sorting method was just chosen")
    let sortMethodSelected = $("#sorting-method-select").val()
    ipcRenderer.send('settingsChanged', {key: "sortMethod", value: sortMethodSelected})
  })
  $(".reset-button").click((e) => {
    const deck_id = e.target.getAttribute('data-deckid');
    const is_daily = e.target.getAttribute('data-daily') === 'true';
    const type = is_daily ? 'daily' : 'alltime';

    let message = "Are you sure you want to reset (delete) ";
    if (deck_id == 'all'){
      message += 'all ' + ( is_daily ? 'daily ' : '' ) + 'win/loss counters?';
    } else if (deck_id == 'all-decks' ){
      message += 'all ' + ( is_daily ? 'daily ' : '' ) + 'deck win/loss counters?'
    } else if (deck_id == 'total'){
      message += 'the ' + ( is_daily ? 'daily ' : '' ) + 'total win/loss counter?'
    } else {
      message += 'the ' + ( is_daily ? 'daily ' : '' ) + settingsData.winLossObj[type][deck_id].name + ' win/loss counter?';
    }
    if (!dialog.showMessageBox(remote.getCurrentWindow(), {'buttons': ['Cancel','Ok'],'message':message,})){
      return;
    }

    console.log("resetting win/loss");
    let new_wlc = settingsData.winLossObj;

    if (deck_id == 'all') {
      new_wlc[type] = {'total':{'win':0,'loss':0}};
    } else if (deck_id == 'all-decks') {
      new_wlc[type] = {'total':settingsData.winLossObj[type].total};
    } else if (deck_id == 'total'){
      new_wlc[type].total = {'win':0,'loss':0};
    } else {
      /*
        delete prop isn't working. Returns false and doesn't remove prop.
        Old technique of setting to undefined then toString back to JSON isn't working anymore either
        Until I can figure out why, here's a hack.
      */

      let decks_wlc = {};
      for ( key in settingsData.winLossObj[type] ){
        if ( key === deck_id ){
          continue;
        }
        decks_wlc[key] = settingsData.winLossObj[type][key];
      }
      new_wlc[type] = decks_wlc;
    }
    ipcRenderer.send('updateWinLossCounters', {key: 'all', value: new_wlc})
  });

  $(".counter-adj-button").click((e) => {
    /**
      * Ugly Hack Incoming!!!
      *
      * For some strange reason the first firing of this event updates the winLossObj just fine
      * as well as updating mainWindow display, however it would not update settingsWindow.
      * Any subsequent button presses (event firings, really) make the display update fine.
      * So, as a workaround, on the first click, send a change event, but post no actual changes.
      * This pushes us through the bug and lets the display update perfectly.
      */
    if (firstCounterAdjButtonClicked){
      firstCounterAdjButtonClicked = false;
      ipcRenderer.send('updateWinLossCounters', {key: 'all', value: settingsData.winLossObj})
    }

    const deck_id = e.target.getAttribute('data-deckid');
    const winloss = e.target.getAttribute('data-winloss');
    const up = e.target.getAttribute('data-direction') === 'up';
    const is_daily = e.target.getAttribute('data-daily') === 'true';
    const type = is_daily ? 'daily' : 'alltime';
    let new_wlc = {daily:settingsData.winLossObj.daily[deck_id],alltime:settingsData.winLossObj.alltime[deck_id]};
    new_wlc[type][winloss] += up ? 1 : -1;

    ipcRenderer.send('updateWinLossCounters', {key: deck_id, value: new_wlc})
  });


  $("#resetGameHistory").click((e) => {
    console.log("resetting gameHstory")
    ipcRenderer.send('clearGameHistory')
  })
  $("#backup-inspector-data").click((e) => {

    let allDatabaseFiles = Object.values(databaseFiles)
    let dbDirname = path.dirname(allDatabaseFiles[0])
    let backupDirname = path.join(dbDirname, "inspector_backups")

    if (!fs.existsSync(backupDirname)){
        fs.mkdirSync(backupDirname);
    }

    let today = new Date()
    let backupPrefix = `${today.getFullYear()}_${today.getMonth()}_${today.getDate()}_${today.getHours()}_${today.getMinutes()}_${today.getSeconds()}`

    let allWrittenPromises = []

    for (let file of allDatabaseFiles) {
      let filePath = path.parse(file)
      let backupName = `${filePath.name}-${backupPrefix}${filePath.ext}`
      let backupFile = path.join(backupDirname, backupName)
      let stream = fs.createReadStream(path.format(filePath)).pipe(fs.createWriteStream(backupFile))
      allWrittenPromises.push(new Promise((resolve, reject) => {
        stream.on("finish", resolve)
      }))
    }
    Promise.all(allWrittenPromises).then(e => alert(`Files backed up to ${backupDirname}${path.sep}inspector-<type>-${backupPrefix}.db`))
  })
  $("#game-db-import-select").click(function() {
    dialog.showOpenDialog({
      properties: ["openFile"],
      defaultPath: databaseFiles.game
    }, filePath => {
      if (filePath) {
        $("#db-import-progress").append(`<p>Importing from: ${filePath}</p>`)
        let importDB = new Datastore({filename: filePath[0]})
        importDB.loadDatabase(err => {
          if (err) {
            $("#db-import-progress").append(`<p><b>ERROR</b>: ${err}</p>`)
            $("#db-import-progress").append("<hr>")
            return
          }
          importDB.count({}, (err, count) => {
            $("#db-import-progress").append(`<p>Attempting to import ${count} games</p>`)
            importDB.find({}, (err, docs) => {
              console.log(docs[0])
              if (!docs[0].gameID) {
                $("#db-import-progress").append("<p><b>ERROR</b>: wrong database type!</p>")
                $("#db-import-progress").append("<hr>")
                return
              }
              let inserted = 0;
              let alreadyThere = 0;
              let fetchURL = `insp://insert-game`
              $("#db-import-progress").append(`Processing... `)
              let insertFuncs = docs.map(doc => () => fetch(fetchURL, {method: "POST", body: JSON.stringify(doc)})
                .then(resp => resp.json())
                .then(data => {
                  if (data.error) {
                    throw new Error(data.error)
                  }
                  console.log(`got ${data} from insert-game`)
                  inserted += 1;
                  if (inserted % 10 == 0) {
                    $("#db-import-progress").append(` ${inserted}...`)
                  }
                })
                .catch(err => {
                 console.log(err)
                  if (err.message == "game_already_exists") {
                    alreadyThere++;
                  } else {
                    console.log(err)
                    $("#db-import-progress").append(`<p><b>ERROR:</b> ${err}</p>`)
                  }
                  inserted += 1;
                  if (inserted % 10 == 0) {
                    $("#db-import-progress").append(` ${inserted}...`)
                  }
                }))
              promiseSerial(insertFuncs).then(res => {
                let finished = `<p>Finished importing <b>${inserted}</b> games!`
                if (alreadyThere) {
                  finished += ` (<i>${alreadyThere} games already existed</i>)`
                }
                finished += "</p>"
                $("#db-import-progress").append(finished)
                $("#db-import-progress").append("<hr>")
              })
            })
          })
        })
      }
    })
  })

  $("#draft-db-import-select").click(function() {
    dialog.showOpenDialog({
      properties: ["openFile"],
      defaultPath: databaseFiles.draft
    }, filePath => {
      if (filePath) {
        $("#db-import-progress").append(`<p>Importing from: ${filePath}</p>`)
        let importDB = new Datastore({filename: filePath[0]})
        importDB.loadDatabase(err => {
          if (err) {
            $("#db-import-progress").append(`<p><b>ERROR</b>: ${err}</p>`)
            $("#db-import-progress").append("<hr>")
            return
          }
          importDB.count({}, (err, count) => {
            $("#db-import-progress").append(`<p>Attempting to import ${count} drafts</p>`)
            importDB.find({}, (err, docs) => {
              console.log(docs[0])
              if (!docs[0].draftID) {
                $("#db-import-progress").append("<p><b>ERROR</b>: wrong database type!</p>")
                $("#db-import-progress").append("<hr>")
                return
              }
              let inserted = 0;
              let alreadyThere = 0;
              let insertDraftURL = `insp://insert-draft`
              $("#db-import-progress").append(`Processing... `)
              promiseSerial(docs.map(draft => () => fetch(insertDraftURL, {method: "POST", body: JSON.stringify(draft)})
                  .then(resp => resp.json())
                  .then(data => {
                    if (data.error) {
                      throw new Error(data.error)
                    }
                    console.log(`got ${data} from insert-draft`)
                    console.log(data)

                    inserted += 1;
                    if (inserted % 10 == 0) {
                      $("#db-import-progress").append(` ${inserted}...`)
                    }
                  })
                  .catch(err => {
                    console.log(err)
                    if (err.message == "draft_already_exists") {
                      alreadyThere++;
                    } else {
                      console.log(err.message)
                      $("#db-import-progress").append(`<p><b>ERROR:</b> ${err}</p>`)
                    }
                    inserted += 1;
                    if (inserted % 10 == 0) {
                      $("#db-import-progress").append(` ${inserted}...`)
                    }
                  }))).then(res => {
                    let finished = `<p>Finished importing <b>${inserted}</b> drafts!`
                    if (alreadyThere) {
                      finished += ` (<i>${alreadyThere} drafts already existed</i>)`
                    }
                    finished += "</p>"
                    $("#db-import-progress").append(finished)
                    $("#db-import-progress").append("<hr>")
                  })
            })
          })
        })
      }
    })
  })

  $("#collect-all-inspector-data").click((e) => {
    $("#collect-inspector-progress").css("display", "block")
    $("#collect-inspector-progress").append("<p>Authenticating with Inspector API...</p>")
    $("#collect-all-inspector-data").prop("disabled", true)
    let gameUrl = API_URL + "/tracker-api/games"

    keytar.getPassword("mtgatracker", "tracker-id").then(trackerID => {
      let override = $("#key-override").val()
      getTrackerToken(override || trackerID).then(tokenObj => {
        // TODO: un-callback-hell-ify this mess
        $("#collect-inspector-progress").append("<p>Success!</p>")
        $("#collect-inspector-progress").append("<hr>")
        $("#collect-inspector-progress").append("<p>Requesting game records...</p>")
        let {token} = tokenObj;
        request.get({
          url: gameUrl,
          json: true,
          headers: {'User-Agent': 'MTGATracker-App', token: token}
        }, (err, res, data) => {
          $("#collect-inspector-progress").append(`<p>Found <b>${data.docs.length}</b> records to save!</i></p>`)
          let coldStorageDocs = data.docs.filter(doc => doc.inColdStorage)
          let regularDocs = data.docs.filter(doc => doc.inColdStorage == undefined)
          $("#collect-inspector-progress").append(`<p><b>${regularDocs.length}</b> games can be added immediately, <b>${coldStorageDocs.length}</b> must be fetched from cold-storage...</p>`)
          $("#collect-inspector-progress").append(`<p>Adding <b>${regularDocs.length}</b> records to local inspector...</p>`)
          $("#collect-inspector-progress").append(`<p><i><span id="collection-game-count">Processed 0...</span></i></p>`)

          let inserted = 0;
          let alreadyThere = 0;
          let fetchURL = `insp://insert-game`

          for (let game of regularDocs) {
            game.date = new Date(Date.parse(game.date))
          }

          let insertFuncs = regularDocs.map(doc => () => fetch(fetchURL, {method: "POST", body: JSON.stringify(doc)})
              .then(resp => resp.json())
              .then(data => {
                if (data.error) {
                  throw new Error(data.error)
                }
                console.log(`got ${data} from insert-game`)
                console.log(data)

                inserted += 1;
                if (inserted % 10 == 0) {
                  $("#collection-game-count").append(` ${inserted}...`)
                }
              })
              .catch(err => {
                console.log(err)
                if (err.message == "game_already_exists") {
                  alreadyThere++;
                } else {
                  console.log(err.message)
                  $("#collect-inspector-progress").append(`<p><b>ERROR:</b> ${err}</p>`)
                }
                inserted += 1;
                if (inserted % 10 == 0) {
                  $("#collection-game-count").append(` ${inserted}...`)
                }
              }))
          promiseSerial(insertFuncs).then(res => {
            let finished = `<p>Finished importing <b>${inserted}</b> games!`
            if (alreadyThere) {
              finished += ` (<i>${alreadyThere} games already existed</i>)`
            }
            finished += "</p>"
            $("#collect-inspector-progress").append(finished)
            $("#collect-inspector-progress").append("<hr>")
            let coldStorageBuckets = {}
            for (let game of coldStorageDocs) {
              coldStorageBuckets[game.inColdStorage] = game._id
            }
            $("#collect-inspector-progress").append(`<p>Collecting <b>${coldStorageDocs.length}</b> games from cold storage (from ${Object.keys(coldStorageBuckets).length} cold-storage blocks)...`)
            let coldStorageFetchPromises = []
            for (let bucket in coldStorageBuckets) {
              let gameID = coldStorageBuckets[bucket]
              let getFromColdStorageURL = `${API_URL}/tracker-api/game/_id/${gameID}/from_cold_storage`
              coldStorageFetchPromises.push(new Promise((resolve, reject) => {
                request.get({
                  url: getFromColdStorageURL,
                  json: true,
                  headers: {'User-Agent': 'MTGATracker-App', token: token}
                }, (err, res, data) => {
                  resolve(data)
                })
              }))
            }
            Promise.all(coldStorageFetchPromises).then(results => {
              $("#collect-inspector-progress").append(`<p><i><span id="collection-game-count-cs">Processed 0...</span></i></p>`)
              let csInsertFuncs = []
              inserted = 0;
              alreadyThere = 0;
              for (let csResult of results) {
                for (let game of csResult.records) {
                  game.date = new Date(Date.parse(game.date))
                  csInsertFuncs.push(() => fetch(fetchURL, {method: "POST", body: JSON.stringify(game)})
                    .then(resp => resp.json())
                    .then(data => {
                      if (data.error) {
                        throw new Error(data.error)
                      }
                      console.log(`got ${data} from insert-game`)
                      console.log(data)

                      inserted += 1;
                      if (inserted % 10 == 0) {
                        $("#collection-game-count-cs").append(` ${inserted}...`)
                      }
                    })
                    .catch(err => {
                      console.log(err)
                      if (err.message == "game_already_exists") {
                        alreadyThere++;
                      } else {
                        console.log(err.message)
                        $("#collect-inspector-progress").append(`<p><b>ERROR:</b> ${err}</p>`)
                      }
                      inserted += 1;
                      if (inserted % 10 == 0) {
                        $("#collection-game-count-cs").append(` ${inserted}...`)
                      }
                    }))
                }
              }

              promiseSerial(csInsertFuncs).then(res => {
                console.log(res)
                let finished = `<p>Finished importing <b>${inserted}</b> games from cold storage!`
                if (alreadyThere) {
                  finished += ` (<i>${alreadyThere} games already existed</i>)`
                }
                finished += "</p>"
                $("#collect-inspector-progress").append(finished)
                $("#collect-inspector-progress").append("<hr>")
                $("#collect-inspector-progress").append("<p>Collecting all Draft records...</p>")

                let getDraftURL = `${API_URL}/tracker-api/drafts?per_page=1000`  // Does anyone have more than a thousand drafts? no way. nope. not possible.

                request.get({
                  url: getDraftURL,
                  json: true,
                  headers: {'User-Agent': 'MTGATracker-App', token: token}
                }, (err, res, data) => {

                  $("#collect-inspector-progress").append(`<p>Adding ${data.docs.length} draft records to local Inspector...</span></p>`)
                  $("#collect-inspector-progress").append(`<p><i><span id="collection-game-count-draft">Processed 0...</span></i></p>`)
                  let insertDraftURL = `insp://insert-draft`
                  inserted = 0
                  alreadyThere = 0
                  for (let draft of data.docs) draft.date = new Date(Date.parse(draft.date))
                  promiseSerial(data.docs.map(draft => () => fetch(insertDraftURL, {method: "POST", body: JSON.stringify(draft)})
                    .then(resp => resp.json())
                    .then(data => {
                      if (data.error) {
                        throw new Error(data.error)
                      }
                      console.log(`got ${data} from insert-draft`)
                      console.log(data)

                      inserted += 1;
                      if (inserted % 10 == 0) {
                        $("#collection-game-count-draft").append(` ${inserted}...`)
                      }
                    })
                    .catch(err => {
                      console.log(err)
                      if (err.message == "draft_already_exists") {
                        alreadyThere++;
                      } else {
                        console.log(err.message)
                        $("#collect-inspector-progress").append(`<p><b>ERROR:</b> ${err}</p>`)
                      }
                      inserted += 1;
                      if (inserted % 10 == 0) {
                        $("#collection-game-count-draft").append(` ${inserted}...`)
                      }
                    }))).then(res => {
                      let finished = `<p>Finished importing <b>${inserted}</b> drafts!`
                      if (alreadyThere) {
                        finished += ` (<i>${alreadyThere} drafts already existed</i>)`
                      }
                      finished += "</p>"
                      $("#collect-inspector-progress").append(finished)
                      $("#collect-inspector-progress").append("<hr>")
                      $("#collect-inspector-progress").append("<p><b>All Clear!</b> Successfully completed all imports! You may now close this window.</p>")
                    })
                })
              })
            })
          })
        })
      }).catch(err => {
        console.log(err)
        $("#collect-inspector-progress").append(`<p><b>ERROR</b>: authentication failed!</p>`)
      })
    })
  })

  $("#send-feedback").click((e) => {
    console.log("sending feedback")
    let data = {
      feedbackType: $("#feedback-type-select").val(),
      feedbackText: $("#feedback-text").val(),
      contactInfo: $("#contact-info").val(),
    }
    if (!data.feedbackText) {
      alert("Whoops, you forgot to fill out... er, the most important part of the form!")
    } else {
      $("#send-feedback").prop("disabled", true)
      $("#send-feedback").html("Working on it...")
      passThrough("public-api/feedback", data).then(r => {
        console.log("success")
        $("#send-feedback").html("Sent! Thanks!")
      }).catch(e => {
        console.log("error sending feedback :(")
        console.log(e)
        alert("Something went wrong! Try again?")
        $("#send-feedback").prop("disabled", false)
        $("#send-feedback").html("Try again?")
      })
    }
  })

  $("#send-feedback").click((e) => {
    console.log("sending feedback")
    let data = {
      feedbackType: $("#feedback-type-select").val(),
      feedbackText: $("#feedback-text").val(),
      contactInfo: $("#contact-info").val(),
    }
    if (!data.feedbackText) {
      alert("Whoops, you forgot to fill out... er, the most important part of the form!")
    } else {
      $("#send-feedback").prop("disabled", true)
      $("#send-feedback").html("Working on it...")
      passThrough("public-api/feedback", data).then(r => {
        console.log("success")
        $("#send-feedback").html("Sent! Thanks!")
      }).catch(e => {
        console.log("error sending feedback :(")
        console.log(e)
        alert("Something went wrong! Try again?")
        $("#send-feedback").prop("disabled", false)
        $("#send-feedback").html("Try again?")
      })
    }
  })

  document.getElementById("hide-delay").value = "" + settingsData.hideDelay;
  let initialValue = settingsData.hideDelay
  if (initialValue == 100) initialValue = "∞"
  $(".slidevalue").html(initialValue)
  document.getElementById("hide-delay").onchange = function() {
    let value = parseInt(this.value)
    ipcRenderer.send('settingsChanged', {key: "hideDelay", value: value})
  }
  document.getElementById("hide-delay").oninput = function() {
    let value = this.value
    if(value == 100) {
      value = "∞"
    }
    $(".slidevalue").html(value)
  }

  document.getElementById("min-vault-progress").value = "" + settingsData.minVaultProgress;
  let initialValueVault = settingsData.minVaultProgress;

  $(".slidevalue-vault").html(initialValueVault)
  document.getElementById("min-vault-progress").onchange = function() {
    let value = parseInt(this.value)
    ipcRenderer.send('settingsChanged', {key: "minVaultProgress", value: value})
  }
  document.getElementById("min-vault-progress").oninput = function() {
    let value = this.value
    $(".slidevalue-vault").html(value)
  }

})

// TODO: DRY @ mainRenderer.js
var uploadDelay = 0;
function passThrough(endpoint, passData, errors) {
  if (!errors) {
    errors = []
  }
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(`posting ${endpoint} request...`)
      request.post({
        url: `${API_URL}/${endpoint}`,
        json: true,
        body: passData,
        headers: {'User-Agent': 'MTGATracker-App'}
      }, (err, res, data) => {
        console.log(`finished posting ${endpoint} request...`)
        if (err || res.statusCode != 200) {
          errors.push({on: `post_${endpoint}`, error: err || res})
          reject({errors: errors})
        } else {
          console.log(`${endpoint} uploaded! huzzah!`)
          resolve({
            success: true
          })
        }
      })
    }, 100 * uploadDelay)
  })
}

// https://hackernoon.com/functional-javascript-resolving-promises-sequentially-7aac18c4431e
const promiseSerial = funcs =>
  funcs.reduce((promise, func) =>
    promise.then(result => func().then(Array.prototype.concat.bind(result))),
    Promise.resolve([]))

var token;

let getTrackerToken = (trackerID) => {
  return new Promise((resolve, reject) => {
    let tokenOK = true;
    if (token) {
      if (jwt.decode(token).exp < Date.now() / 1000) tokenOK = false
    } else {
      tokenOK = false;
    }
    if (tokenOK) {
      console.log("old token was fine")
      resolve({token: token})
    } else {
      console.log("sending token request...")
      request.post({
          url: `${API_URL}/public-api/tracker-token`,
          json: true,
          body: {trackerID: trackerID},
          headers: {'User-Agent': 'MTGATracker-App'}
      }, (err, res, data) => {
        if (err || res.statusCode != 200) {
          reject({error: err})
        } else {
          token = data.token;
          resolve({token: data.token})
        }
      })
    }
  })
}

// ipcRenderer.send('settingsChanged', {cool: true})
var uploadDelay = 0;
function passThrough(endpoint, passData, errors) {
  if (!errors) {
    errors = []
  }
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(`posting ${endpoint} request...`)
      request.post({
        url: `${API_URL}/${endpoint}`,
        json: true,
        body: passData,
        headers: {'User-Agent': 'MTGATracker-App'}
      }, (err, res, data) => {
        console.log(`finished posting ${endpoint} request...`)
        if (err || res.statusCode != 200) {
          errors.push({on: `post_${endpoint}`, error: err || res})
          reject({errors: errors})
        } else {
          console.log(`${endpoint} uploaded! huzzah!`)
          resolve({
            success: true
          })
        }
      })
    }, 100 * uploadDelay)
  })
}