OurBigBook
web/front/js.js
// Random stuff shared between front and backend.
// Has to be .js until we port backend to TypeScript..
// Maybe this should just be merged with ./config.js

const ourbigbook = require('ourbigbook')

const config = require('./config')

// https://stackoverflow.com/questions/14382725/how-to-get-the-correct-ip-address-of-a-client-into-a-node-socket-io-app-hosted-o/14382990#14382990
// Works on Heroku 2021.
function getClientIp(req) {
  return req.header('x-forwarded-for')
}

function getOrderAndPage(req, page, opts={}) {
  const [order, orderErr] = getOrder(req, opts)
  const [pageNum, pageErr] = getPage(page)
  let errs = []
  if (orderErr) {
    errs.push(orderErr)
  }
  if (pageErr) {
    errs.push(pageErr)
  }
  return [order, pageNum, errs.length ? errs : undefined]
}

function getOrder(req, opts={}) {
  let sort = req.query.sort;
  const default_ = opts.defaultOrder || 'createdAt'
  const urlToDbSort = opts.urlToDbSort || {}
  if (sort) {
    if (
      sort === 'created'
    ) {
      return ['createdAt']
    } else if (sort === 'score' || sort === 'followerCount') {
      return [sort]
    } else {
      if (sort in urlToDbSort) {
        return [urlToDbSort[sort]]
      } else {
        return [default_, `Invalid sort value: '${sort}'`]
      }
    }
  } else {
    return [default_]
  }
}

/**
 * @param {string|string[]} page - 1-based.
 * @returns {[number, string|undefined]} 0-based return, error string if any.
 */
function getPage(page='1') {
  const pageString = typeof page === 'string' ? page : page[0]
  const [pageNum, ok] = typecastInteger(page)
  if (ok) {
    if (pageNum <= 0) {
      return [0, 'The page must be postive']
    } else {
      return [pageNum - 1,]
    }
  } else {
    return [0, 'Invalid page number']
  }
}

function hasReachedMaxItemCount(loggedInUser, itemCount, itemType) {
  if (!loggedInUser.admin && itemCount >= loggedInUser.maxArticles) {
    return `You have reached your maximum number of ${itemType}: ${loggedInUser.maxArticles}. Please ask an admin to raise it for you: ${config.contactUrl}`
  }
}

function modifyEditorInput(title, body) {
  let ret = ''
  if (title !== undefined) {
    ret += `${ourbigbook.INSANE_HEADER_CHAR} ${title}\n`
  }
  let offsetOffset = 0
  // Append title to body. Add a newline if the body doesn's start
  // with a header argument like `{c}` in:
  //
  // = h1
  // {c}
  if (body) {
    if (body[0] !== ourbigbook.START_NAMED_ARGUMENT_CHAR) {
      ret += '\n'
      offsetOffset = 1
    }
    ret += body
  }
  return { offset: 1 + offsetOffset, new: ret };
}

function typecastBoolean(s) {
  let b
  let ok = true
  if (s === 'true') {
    b = true
  } else if (s === 'false') {
    b = true
  } else {
    ok = false
  }
  return [b, ok]
}

/**
 * Typecast string to integer. Typically used to typecast
 * URL GET parameters to types with error checking. This is unlike
 * JSON bodies which are clearly typed already.
 * 
 * @param {string} s
 * @returns {[number, boolean]}
 */
function typecastInteger(s) {
  const i = Number(s)
  let ok = s !== '' && Number.isInteger(i)
  return [i, ok]
}

function isNonNegativeInteger(i) {
  return i >= 0
}

function isPositiveInteger(i) {
  return i > 0
}

// Elements either match cb, or is an array where each type matches cb.
function isTypeOrArrayOf(cb) {
  return (a) => {
    if (cb(a)) return true
    return isArrayOf(cb)(a)
  }
}

function isArrayOf(cb) {
  return (a) => {
    if (!(a instanceof Array)) {
      return false
    }
    for (const elem of a) {
      if (!cb(elem)) {
        return false
      }
    }
    return true
  }
}

function isBoolean(tf) {
  return typeof tf === 'boolean'
}

function isSmallerOrEqualTo(max) {
  return (n) => n <= max
}

function isLengthSmallerOrEqualTo(max) {
  return (s) => s.length <= max
}

function isString(s) {
  return typeof s === 'string'
}

function isTruthy(s) {
  return !!s
}

function idToSlug(id) {
  return id.slice(ourbigbook.AT_MENTION_CHAR.length)
}

function slugToId(slug) {
  return ourbigbook.AT_MENTION_CHAR + slug
}

module.exports = {
  AUTH_COOKIE_NAME: 'auth',
  getClientIp,
  getOrder,
  getOrderAndPage,
  getPage,
  hasReachedMaxItemCount,
  idToSlug,
  slugToId,
  isArrayOf,
  isBoolean,
  isLengthSmallerOrEqualTo,
  isNonNegativeInteger,
  isPositiveInteger,
  isSmallerOrEqualTo,
  isString,
  isTypeOrArrayOf,
  isTruthy,
  modifyEditorInput,
  typecastBoolean,
  typecastInteger,
}