SB100 / GGn Dwarf Drop Notifier

// ==UserScript==
// @name         GGn Dwarf Drop Notifier
// @namespace    https://openuserjs.org/users/SB100
// @description  Get a notification if a dwarf drops an item. Needs an API key with 'User' permissions that you can generate here: https://gazellegames.net/user.php?action=edit#save
// @updateURL    https://openuserjs.org/meta/SB100/GGn_Dwarf_Drop_Notifier.meta.js
// @version      1.3.4
// @author       SB100
// @copyright    2022, SB100 (https://openuserjs.org/users/SB100)
// @license      MIT
// @match        https://gazellegames.net/*
// @connect      gazellegames.net
// @grant        GM.xmlHttpRequest
// @grant        GM.getValue
// @grant        GM.setValue

// ==/UserScript==

// ==OpenUserJS==
// @author SB100
// ==/OpenUserJS==

/**
 * =============================
 * ADVANCED OPTIONS
 * =============================
 */

// check every 5 mins max
const SETTING_THROTTLE = 1000 * 60 * 5;

// true = never auto hide the popup (click will remove it)
// false = popup self disappears after 5 seconds
const SETTING_PERMANENT_POPUP = true;

// whether to show console output
const DEBUG_ENABLED = false;

/**
 * =============================
 * END ADVANCED OPTIONS
 * DO NOT MODIFY BELOW THIS LINE
 * =============================
 */

/**
 * Output a console.log line if debug is enabled
 */
function debug(...str) {
  if (!DEBUG_ENABLED) {
    return;
  }

  // eslint-disable-next-line no-console
  console.log('[GGn Dwarf Drop Notifier]', ...str);
}

/**
 * Try parsing a string into JSON, otherwise fallback
 */
function JsonParseWithDefault(s, fallback = null) {
  try {
    return JSON.parse(s);
  }
  catch (e) {
    return fallback;
  }
}

/**
 * Gets your GGn API key. Asks the user for one if it hasn't been set yet
 */
async function getGgnApiKey() {
  const key = await GM.getValue('ggn_key', '');
  if (!key) {
    // eslint-disable-next-line no-alert
    const input = window.prompt(`Please input your GGn API key.
If you don't have one, please generate one from your Edit Profile page: https://gazellegames.net/user.php?action=edit.

Please disable this userscript until you have one as this prompt will continue to show until you enter one in.`);
    const trimmed = input.trim();

    if (/[a-f0-9]{64}/.test(trimmed)) {
      await GM.setValue('ggn_key', trimmed);
      return trimmed;
    }
  }

  return key;
}

/**
 * Creates a UTC date object from a date string
 * d should be in the form yyyy-mm-dd hh:ii:ss, and is assumed a UTC date
 */
function createUTCDate(d) {
  const parts = d.split(/[-\s:]/);
  parts[1] = (parseInt(parts[1], 10) - 1).toString();
  return new Date(Date.UTC(...parts));
}

/**
 * Creates a UTC date string from a date object
 * Returns the format yyyy-mm-dd hh:ii:ss
 */
function createUTCString(d = Date.now()) {
  return new Date(d).toISOString().replace(/T/, ' ').replace(/\..+/, '');
}

/**
 * Return the last drop info from localStorage, as date objects
 */
function getLastDropInfo() {
  // default date is 1 day ago. So when you install the script for the first time, it sets up checks properly.
  const utcString = createUTCString(new Date(Date.now() - 24 * 3600 * 1000));
  const defaultDropInfo = {
    check: utcString,
    drop: utcString,
  };
  const lastDropInfoAsStrings = JsonParseWithDefault(
    window.localStorage.getItem('lastDropInfo') || defaultDropInfo,
    defaultDropInfo
  );

  return {
    check: createUTCDate(lastDropInfoAsStrings.check),
    drop: createUTCDate(lastDropInfoAsStrings.drop),
  };
}

/**
 * Sets the lastDropInfo value in localStorage
 */
function setLastDropInfo(check, drop, lastDropInfo) {
  window.localStorage.setItem(
    'lastDropInfo',
    JSON.stringify({
      check: check !== null ? check : createUTCString(lastDropInfo.check),
      drop: drop !== null ? drop : createUTCString(lastDropInfo.drop),
    })
  );
}

/**
 * Returns a boolean by comparing the lastDropInfo.check value to the current time + SETTING_THROTTLE
 */
function isTimeToCheck(lastDropInfo) {
  const currentTime = createUTCDate(createUTCString());
  if (currentTime < new Date(lastDropInfo.check.getTime() + SETTING_THROTTLE)) {
    debug('[GGn Dwarf Drop Notifier] Not checking for companion drop');
    return false;
  }

  debug('Checking for companion drop');
  return true;
}

/**
 * If we have notifications to show, create a popup, and remove it after 5s
 */
function createNotification(drops) {
  if (drops.length === 0) {
    debug('No new drops found');
    return;
  }

  const innerHTML = `<a id="ggn-dwarf-drop-notif" href='#close' style='display: block; border-radius: 5px; border: 1px solid rgb(80, 194, 78); box-shadow: rgba(0, 0, 0, 0.1) 0px 2px 4px; color: darkgreen; width: 290px; cursor: pointer; font-size: 13px; line-height: 16px; text-align: left; padding: 8px 10px 9px; overflow: hidden; background-image: linear-gradient(135deg, #8ae68a 25%, #8fee8f 25%, #8fee8f 50%, #8ae68a 50%, #8ae68a 75%, #8fee8f 75%, #8fee8f 100%); background-size: 28.28px 28.28px;'>
    <ul style='list-style-type: none; margin: 0; padding: 0;'>
      ${drops.map((drop) => `<li>${drop.message}</li>`).join('')}
</ul>
  </li>`;

  const notif = document.createElement('div');
  notif.classList.add('i-am-new');
  notif.innerHTML = innerHTML;

  document.body.appendChild(notif);

  if (SETTING_PERMANENT_POPUP) {
    notif.onclick = () => {
      notif.remove();
    };
  }
  else {
    setTimeout(() => {
      notif.remove();
    }, 5000);
  }
}

/**
 * Parses the API response, and checks if a new drop has happened
 */
function xmlOnLoad(response, lastDropInfo) {
  // something wrong with the response
  if (response.status !== 'success') {
    debug('API returned unsuccessful response');
    return [];
  }

  // no results found
  if (!response.response || response.response.length === 0) {
    debug('No drops found in response');
    return [];
  }

  const results = [];
  for (let i = 0, len = response.response.length; i < len; i += 1) {
    const item = response.response[i];
    if (createUTCDate(item.time) > lastDropInfo.check) {
      debug('Pushed', createUTCDate(item.time));
      results.push(item);
    }
  }

  setLastDropInfo(
    createUTCString(),
    results.length > 0 ? results[0].time : createUTCString(lastDropInfo.drop),
    lastDropInfo
  );

  return results;
}

/**
 * Sends a request to the API, checking for companion drops
 */
function sendApiRequest(key, action, params, lastDropInfo) {
  const paramStr = new URLSearchParams(params).toString();

  let resolver;
  let rejecter;
  const p = new Promise((resolveFn, rejectFn) => {
    resolver = resolveFn;
    rejecter = rejectFn;
  });

  const url = `/api.php?request=${action}${
    paramStr.length > 0 ? `&${paramStr}` : ''
  }`;
  GM.xmlHttpRequest({
    method: 'get',
    url,
    timeout: 30000,
    headers: {
      'X-API-Key': key,
    },
    onload(done) {
      if (done.status !== 200) {
        setLastDropInfo(createUTCString(), null, lastDropInfo);

        rejecter(new Error('Not OK'));
        return;
      }

      resolver(JsonParseWithDefault(done.response));
    },
    onerror() {
      rejecter(new Error('Error'));
    },
    ontimeout() {
      rejecter(new Error('Timeout'));
    },
  });

  return p;
}

/**
 * Creates the style tag to show a popup if needed
 */
function createStyleTag() {
  const css = `.i-am-new {
    bottom: 20px;
    right: 20px;
    position: fixed;
    width: 310px;
    margin: 0px;
    padding: 0px;
    list-style-type: none;
    z-index: 10000000;
}

.i-am-new + .i-am-new {
    bottom: 70px!important;
}`;

  const style = document.createElement('style');
  style.appendChild(document.createTextNode(css));

  document.head.appendChild(style);
}

(async function main() {
  const key = await getGgnApiKey();
  if (!key) {
    debug(
      '[GGn Dwarf Drop Notifier] No valid API key found, exiting userscript'
    );
    return;
  }

  const lastDropInfo = getLastDropInfo();
  debug('Last Drop Info', lastDropInfo);
  if (!isTimeToCheck(lastDropInfo)) {
    return;
  }

  createStyleTag();
  sendApiRequest(key, 'userlog', {
      search: 'dropped',
      limit: 25
    }, lastDropInfo)
    .then((response) => xmlOnLoad(response, lastDropInfo))
    .then((drops) => createNotification(drops))
    // eslint-disable-next-line no-console
    .catch((e) => console.error(`[GGn Dwarf Drop Notifier] ${e.message}`));
})();