Quizlet Spell Auto-Giveup Countdown.

A forced, automatic, and short study session for spelling words you obviously do NOT know. Includes a short configurable break and an alarm to get them to come back when the break ends. Intended for use with kids.

// ==UserScript==
// @name     Quizlet Spell Auto-Giveup Countdown.
// @version  0.0.7
// @grant    none
// @match    https://quizlet.com/*/spell
// @match    https://quizlet.com/*/spell/stared
// @namespace https://github.com/Brandon-Beck
// @description A forced, automatic, and short study session for spelling words you obviously do NOT know. Includes a short configurable break and an alarm to get them to come back when the break ends. Intended for use with kids.
// @require  https://gitcdn.xyz/repo/Brandon-Beck/Userscripts/604449d00e316a699e1cd63d54b8b14f824dbb91/common.js
// @require  https://gitcdn.xyz/repo/Brandon-Beck/Userscripts/e9750f7ddfc0a4717207be2112114a57bb76b00c/uncommon.js
// @license  MIT
// ==/UserScript==

'use strict'

// TODO Settings UI
/*
function newNumberCorrector(min ,max) {
  return (textVal) => {
    const val = parseInt(textVal)
    if (
      textVal == null
        || (typeof textVal === 'string' && (textVal.length === 0 || textVal.match(/[^0-9]/)))
        || typeof val !== 'number') {
      return undefined
    }
    if (val < min) return min
    if (val > max) return max
    // dont triger callback via type change.
    return textVal
  }
}
const settingsUI = new SettingUI()
settingsUI.addTextbox({
  key: 'max_spell_time'
  ,title: 'Spell Time'
  ,settingsTreeConfig: {
    defaultValue: 40
    ,corrector: newNumberCorrector(5 ,5 * 60)
  }
  ,min: 5
  ,max: 5 * 60
  ,titleText: 'Seconds we should wait for the player to type an answer before we snatch the paper from them, check it, and(if incorrect) correct them, and make them do it again.'
  ,type: 'number'
})
settingsUI.addTextbox({
  key: 'break_length'
  ,title: 'Break length'
  ,settingsTreeConfig: {
    defaultValue: 5 * 60
    ,corrector: newNumberCorrector(0 ,60 * 60)
  }
  ,min: 0
  ,max: 60 * 60
  ,titleText: 'How long of a break(in seconds) the player should have. Reccomend a minimum of 5 minutes. Should be long enough to allow them to use the bathroom.'
  ,type: 'number'
})
settingsUI.addTextbox({
  key: 'break_delay'
  ,title: 'Break delay'
  ,settingsTreeConfig: {
    defaultValue: 15 * 60
    ,corrector: newNumberCorrector(5 * 60 ,60 * 60)
  }
  ,min: 5 * 60
  ,max: 60 * 60
  ,titleText: 'How long the player should be required to play(in seconds) before their break. Reccomend a maximum of 30 minutes. Should be an appropriate studdy time given their age and willingness to studdy.'
  ,type: 'number'
})
*/

function CountdownElm(attr = {}) {
  const self = this
  if (!(self instanceof CountdownElm)) {
    return new CountdownElm(attr)
  }
  const elm = attr.elm
  let endtime = attr.endtime
  let paused_remainingtime = 0
  let is_paused = false
  let should_reset_time = false
  self.updateUI = () => {
    let r = Math.floor(self.remainingtime / 1000 + 0.5)
    r = Math.max(r ,0)
    elm.innerHTML = r
  }
  self.pause = () => {
    if (!is_paused) {
      paused_remainingtime = self.remainingtime
      is_paused = true
      should_reset_time = true
    }
  }
  self.unpause = () => {
    if (is_paused) {
      if (should_reset_time) {
        self.endtime_from_millisec(paused_remainingtime)
      }
      paused_remainingtime = 0
      is_paused = false
      should_reset_time = false
    }
  }
  self.endtime_from_millisec = (milli) => {
    endtime = Date.now() + milli
    should_reset_time = false
    paused_remainingtime = milli
  }
  Object.defineProperties(self ,{
    endtime: {
      get() {
        return endtime
      }
      ,set(val) {
        endtime = val
        should_reset_time = false
        self.updateUI()
      }
    }
    ,remainingtime: {
      get() {
        if (!is_paused) {
          return endtime - Date.now()
        }
        return paused_remainingtime
      }
    }
  })
  return self
}


function QuizletSpellTimer(initargs = {}) {
  const self = this
  if (!(self instanceof QuizletSpellTimer)) {
    return new QuizletSpellTimer(initArgs)
  }
  const qst = self
  function Print(msg) {
    console.log(msg)
  }

  function toSeconds(hms) { // HH:MM:SS to seconds
    const a = hms.split(':') // split it at the colons
    // minutes are worth 60 seconds. Hours are worth 60 minutes.
    return (+a[0]) * 60 * 60 + (+a[1]) * 60 + (+a[2])
  }
  function htmlToElement(html) {
    const template = document.createElement('template')
    html = html.trim() // Never return a text node of whitespace as the result
    template.innerHTML = html
    return template.content.firstChild
  }
  function isHidden(el) {
    return (el.offsetParent === null)
  }


  const enter_keyboard_event = new KeyboardEvent('keydown' ,{
    keyCode: KEYCODES.enter ,which: KEYCODES.enter
  })
  const GameState = {
    Spelling: 0
    ,ReviewingMisspelled: 1
    ,Break: 2
    ,BreakOver: 3
    ,Paused: 4
  }

  const progress_cont_xp = new XPath('//div[').contains('@class' ,'ModeControls-progress').append(']')
  const break_countdown_cont = htmlToElement(`<div class='ModeControls-progressSection'>
                                   <h4 id="break_tite">Next Break</h4>
                                   <br>
                                   <h5 id="break_time">0</h5>
                                   </div>`)
  const spell_countdown_cont = htmlToElement(`<div class='ModeControls-progressSection'>
                                     <h4 id="spell_tite">Give Up In</h4>
                                     <br>
                                     <h5 id="spell_time">0</h5>
                                     </div>`)
  const pause_button_cont = htmlToElement(`<div class='ModeControls-progressSection'>
                                     <button class="h1" id="pause_play">Play</button>
                                     </div>`)
  const paused_overlay = htmlToElement(`<div style="z-index: 100; background-color: #000; height: 100%; width: 100%; position: absolute; top:0; left:0; display:none; ">
                                     </div>`)

  qst.settings = {
    'per_letter_time': 5
    ,'max_spell_time': 40
    ,'loop_timeout': 300
    ,'break': {
      length: toSeconds('00:05:00')
      ,delay: toSeconds('00:15:00')
    }
    ,'sounds': { break_over: 'https://freesound.org/data/previews/250/250629_4486188-hq.mp3' }
  }
  qst.audio = new Audio(qst.settings.sounds.break_over)
  qst.audio.loop = true
  qst.elm = {}
  qst.state = {}
  qst.is_initialized = false
  qst.ctime = new Date().getTime()
  qst.pause = {
    max_pause_cnt: 2
    ,pause_history_durration: toSeconds('00:05:00')
    ,pause_hist: []
    ,can_pause() {
      let pause_cnt = 0
      for (const pause of qst.pause.pause_hist) {
        if (pause >= (qst.ctime - (qst.pause.pause_history_durration * 1000))) {
          pause_cnt += 1
        }
      }
      return pause_cnt < qst.pause.max_pause_cnt
    }
    ,try_pause() {
      if (qst.pause.can_pause()) {
        qst.pause.pause_hist.push(qst.ctime)
        qst.pause.pause_hist.splice(0 ,qst.pause.pause_hist.length - qst.pause.max_pause_cnt)
        return true
      }
      return false
    }
  }

  qst.gamestate = GameState.Spelling

  function isReviewing() {
    // Print(Game.$diff.textContent);
    if (Game.$diff.textContent) {
      return true
    }
    return false
  }

  function initNewWord() {
    qst.state.spell_countdown.unpause()
    qst.state.break_countdown.unpause()
    qst.state.spell_countdown.endtime_from_millisec(qst.settings.max_spell_time * 1000)
    qst.gamestate = GameState.Spelling
    qst.rev_workaround_cnt = 0
  }
  function onSpellTimeout() {
    // FIXME for some reason this does not trigger pause
    qst.elm.spelling_box.dispatchEvent(enter_keyboard_event)
    //Game.missedTerm()
  }
  function onPause() {
    qst.gamestate = GameState.Paused
    paused_overlay.style.display = 'inline'
    qst.state.spell_countdown.pause()
    qst.state.break_countdown.pause()
  }
  function onResume() {
    qst.gamestate = GameState.Spelling
    paused_overlay.style.display = 'none'
    qst.state.spell_countdown.unpause()
    qst.state.break_countdown.unpause()
    qst.audio.pause()
    qst.audio.fastSeek(0)
    Game.speakCurrent()
  }
  function onPausePlay() {
    if (qst.gamestate == GameState.Paused || qst.gamestate == GameState.BreakOver) {
      onResume()
    }
    else if (qst.gamestate == GameState.Spelling || qst.gamestate == GameState.ReviewingMisspelled) {
      if (qst.pause.try_pause()) {
        onPause()
      }
    }
  }
  function updatePausePlay() {
    if (qst.gamestate == GameState.Paused || qst.gamestate == GameState.BreakOver) {
      qst.elm.pauseplay_button.disabled = false
      qst.elm.pauseplay_button.textContent = 'Play'
    }
    else if (qst.gamestate == GameState.Spelling || qst.gamestate == GameState.ReviewingMisspelled) {
      qst.elm.pauseplay_button.textContent = 'Pause'
      qst.elm.pauseplay_button.disabled = !qst.pause.can_pause()
    }
  }

  function onBreak() {
    onPause()
    qst.state.break_countdown.endtime_from_millisec(qst.settings.break.length * 1000)
    qst.state.break_countdown.unpause()
    qst.gamestate = GameState.Break
  }
  function onCorrect() {
    Print('Correct')
    clearTimeout(qst.mainloop_timer)
    initNewWord()
    qst.rev_workaround_cnt = 0
    qst.mainloop_timer = setTimeout(MainLoop ,1000)
  }
  function onMissed() {
    Print('Missed')
    clearTimeout(qst.mainloop_timer)
    qst.gamestate = GameState.ReviewingMisspelled
    qst.state.spell_countdown.pause()
    qst.state.break_countdown.pause()
    qst.rev_workaround_cnt = 0
    qst.mainloop_timer = setTimeout(MainLoop ,1000)
  }
  qst.mainloop_timer=null
  qst.rev_workaround_cnt = 0
  function MainLoop() {
    clearTimeout(qst.mainloop_timer)
    qst.ctime = new Date().getTime()
    const ctimeout = qst.settings.loop_timeout
    if (qst.gamestate == GameState.Spelling) {
      paused_overlay.style.display = 'none'
      if (qst.state.break_countdown.remainingtime <= 0) {
        onBreak()
      }
      else if (qst.state.spell_countdown.remainingtime <= 0) {
        onSpellTimeout()
      }
    }
    else if (qst.gamestate == GameState.ReviewingMisspelled) {
      if (!isReviewing()) {
        initNewWord()
        qst.gamestate = GameState.Spelling
      }
      /* if (! isReviewing() && qst.rev_workaround_cnt < 1) {
        qst.rev_workaround_cnt+=1
      }
      else if (! isReviewing() && qst.rev_workaround_cnt >= 1) {
        qst.rev_workaround_cnt=0;
        initNewWord();
        qst.gamestate=GameState.Spelling;
      } */
    }
    else if (qst.gamestate == GameState.Break) {
      if (qst.state.break_countdown.remainingtime <= 0) {
        qst.gamestate = GameState.BreakOver
        qst.audio.play()
      }
    }
    else if (qst.gamestate == GameState.BreakOver) {
      //qst.gamestate = GameState.Spelling
      //initNewWord() // Lets give them a full word's worth of time
      qst.state.break_countdown.pause()
      qst.state.break_countdown.endtime_from_millisec(qst.settings.break.delay * 1000)
      qst.state.spell_countdown.endtime_from_millisec(qst.settings.max_spell_time * 1000)
    }
    qst.state.spell_countdown.updateUI()
    qst.state.break_countdown.updateUI()
    updatePausePlay()
    qst.mainloop_timer = setTimeout(MainLoop ,ctimeout)
  }

  // NOTE: Game.curTerm.getRawWord() -> Current term string.
  function Init() {
    const spell_cont = XPath("//div[@id='SpellModeTarget']").getElement()
    paused_overlay.style.backgroundColor = '#c9ceeb'
    XPath('//div[').contains('@class' ,'ModeLayout-content').append(']').getElement(spell_cont)
      .appendChild(paused_overlay)
    qst.elm.spelling_box = new XPath('//div[').contains('@class' ,'UITextarea-content').append(']//textarea').getElement()
    qst.progress_container = progress_cont_xp.getElement()
    qst.progress_container.appendChild(break_countdown_cont)
    qst.progress_container.appendChild(spell_countdown_cont)
    qst.progress_container.appendChild(pause_button_cont)
    qst.elm.break_countdown = new XPath("//*[@id='break_time']").getElement(break_countdown_cont)
    qst.elm.spell_countdown = new XPath("//*[@id='spell_time']").getElement(spell_countdown_cont)
    qst.elm.pauseplay_button = new XPath("//*[@id='pause_play']").getElement(pause_button_cont)
    qst.state = {
      spell_countdown: CountdownElm({
        elm: qst.elm.spell_countdown
        ,endtime: 0
      })
      ,break_countdown: CountdownElm({
        elm: qst.elm.break_countdown
        ,endtime: 0
      })
    }
    qst.elm.pauseplay_button.onclick = onPausePlay
    qst.state.break_countdown.endtime_from_millisec(qst.settings.break.delay * 1000)
    const oldMissed = Game.missedTerm
    Game.missedTerm = () => {
      onMissed()
      oldMissed.apply(Game)
    }
    const oldCorrect = Game.beatTerm
    Game.beatTerm = () => {
      onCorrect()
      oldCorrect.apply(Game)
    }
    initNewWord()
    MainLoop()
  }
  /*
    for (let key in initargs) {
        this.settings[key] = initargs[key];
    } */
  qst.run = () => {
    // CheckLoop(progress_cont_xp.toString() ,Init)
    waitForElementByXpath({ xpath: progress_cont_xp }).then(Init)
  }
  return qst
}

const qst = new QuizletSpellTimer()
//setTimeout(qst.run ,3000)
qst.run()