OurBigBook
web_api.js
// https://docs.ourbigbook.com#ourbigbook-web-directory-structure
//
// Plus some other random stuff that has to be able to run on frontend, thus no backend stuff here.

const crypto = require('crypto')

const axios = require('axios')

const ourbigbook = require('./index');

function articleHash(opts={}) {
  const jsonStr = JSON.stringify(Object.fromEntries(Object.entries(opts).sort()))
  return crypto.createHash('sha256').update(jsonStr).digest('hex')
}

// https://stackoverflow.com/questions/8135132/how-to-encode-url-parameters/50288717#50288717
function encodeGetParams(p) {
  let ret = Object.entries(p).filter(kv => kv[1] !== undefined).map(kv => kv.map(encodeURIComponent).join("=")).join("&");
  if (ret) {
    ret = '?' + ret
  }
  return ret
}

const encodeGetParamsWithOffset = (opts) => {
  opts = Object.assign({}, opts)
  if (opts.page !== undefined && opts.limit !== undefined) {
    opts.offset = opts.page * opts.limit
  }
  delete opts.page
  return encodeGetParams(opts)
}

function read_include({exists, read, path_sep, ext}) {
  function join(...parts) {
    return parts.join(path_sep)
  }
  if (ext === undefined) {
    ext = `.${ourbigbook.OURBIGBOOK_EXT}`
  }
  return async (id, input_dir) => {
    let found = undefined;
    let test
    let basename = id + ext;
    if (basename[0] === path_sep) {
      test = id.substr(1)
      if (await exists(test)) {
        found = test;
      }
    } else {
      const input_dir_with_sep = input_dir + path_sep
      for (let i = input_dir_with_sep.length - 1; i > 0; i--) {
        if (input_dir_with_sep[i] === path_sep) {
          test = input_dir_with_sep.slice(0, i + 1) + basename
          if (await exists(test)) {
            found = test;
            break
          }
        }
      }
      if (found === undefined && await exists(basename)) {
        found = basename;
      }
    }
    if (found === undefined) {
      test = join(id, ourbigbook.INDEX_BASENAME_NOEXT + ext);
      if (input_dir !=='') {
        test = join(input_dir, test)
      }
      if (await exists(test)) {
        found = test;
      }
      if (found === undefined) {
        const [dir, basename] = ourbigbook.pathSplit(id, path_sep)
        const [basename_noext, ext] = ourbigbook.pathSplitext(basename)
        if (basename_noext === ourbigbook.INDEX_BASENAME_NOEXT) {
          for (let index_basename_noext of ourbigbook.INDEX_FILE_BASENAMES_NOEXT) {
            test = join(dir, index_basename_noext + ext);
            if (await exists(test)) {
              found = test;
              break;
            }
          }
        }
      }
    }
    if (found !== undefined) {
      return [found, await read(found)];
    }
    return undefined;
  }
}

class WebApi {
  constructor(opts) {
    this.opts = opts
  }

  async req(method, path, opts={}) {
    const newopts = Object.assign(
      {},
      this.opts,
      opts
    )
    return sendJsonHttp(method, `/${ourbigbook.WEB_API_PATH}/${path}`, newopts)
  }

  async article(slug, opts={}) {
    const { data, status } = await this.articles(Object.assign({ id: slug }, opts))
    return { data: data.articles[0], status }
  }

  async articles(opts={}) {
    return this.req('get', `articles${encodeGetParamsWithOffset(opts)}`)
  }

  async articlesHash(opts={}) {
    return this.req('get', `articles/hash${encodeGetParamsWithOffset(opts)}`)
  }

  async articleCreate(article, opts={}) {
    const { path, parentId, previousSiblingId, render } = opts
    return this.req('post',
      `articles`,
      { body: Object.assign({ article }, opts)},
    );
  }

  async articleCreateOrUpdate(article, opts={}) {
    return this.req('put',
      `articles`,
      { body: Object.assign({ article }, opts)},
    );
  }

  async articleDelete(slug) {
    return this.req('delete', `articles?id=${slug}`)
  }

  async articleLike(slug) {
    return this.req('post', `articles/like?id=${slug}`)
  }

  async articleFeed(opts={}) {
    return this.req('get', `articles/feed${encodeGetParamsWithOffset(opts)}`)
  }

  async articleFollow(slug) {
    return this.req('post', `articles/follow?id=${slug}`)
  }

  async articleRedirects(opts={}) {
    return this.req('get', `articles/redirects${encodeGetParamsWithOffset(opts)}`)
  }

  async articleUnfollow(slug) {
    return this.req('delete', `articles/follow?id=${slug}`)
  }

  async articleUnlike(slug) {
    return this.req('delete', `articles/like?id=${encodeURIComponent(slug)}`)
  }

  async articleUpdatedNestedSet(user) {
    return this.req('put', `articles/update-nested-set/${encodeURIComponent(user)}`)
  }

  async editorFetchFiles(paths) {
    return this.req('post',
      `editor/fetch-files`,
      {
        body: {
          paths,
        }
      },
    )
  }

  async editorGetNoscopesBaseFetch(ids, ignore_paths_set) {
    return this.req('post',
      `editor/get-noscopes-base-fetch`,
      {
        body: {
          ids,
          ignore_paths_set
        }
      },
    )
  }

  async editorIdExists(idid) {
    const ret = await this.req('post',
      `editor/id-exists`,
      {
        body: {
          idid,
        }
      },
    )
    return ret.data.exists
  }

  async issue(slug, number) {
    const { data, status } = await this.issues({ id: slug, number })
    return { data: data.issues[0], status }
  }

  async issues(opts) {
    return this.req('get',
      `issues${encodeGetParamsWithOffset(opts)}`,
    )
  }

  async issueCreate(slug, issue) {
    return this.req('post',
      `issues?id=${encodeURIComponent(slug)}`,
      {
        body: { issue },
      },
    )
  }

  async issueDelete(slug, issueNumber) {
    return this.req('delete', `issues/${issueNumber}?id=${encodeURIComponent(slug)}`)
  }

  async issueEdit(slug, issueNumber, issue) {
    return this.req('put',
      `issues/${issueNumber}?id=${encodeURIComponent(slug)}`,
      {
        body: { issue },
      },
    )
  }

  async issueFollow(slug, issueNumber) {
    return this.req('post', `issues/${issueNumber}/follow?id=${slug}`)
  }

  async issueUnfollow(slug, issueNumber) {
    return this.req('delete', `issues/${issueNumber}/follow?id=${encodeURIComponent(slug)}`)
  }

  async issueLike(slug, issueNumber) {
    return this.req('post', `issues/${issueNumber}/like?id=${slug}`)
  }

  async issueUnlike(slug, issueNumber) {
    return this.req('delete', `issues/${issueNumber}/like?id=${encodeURIComponent(slug)}`)
  }

  async comments(slug, issueNumber) {
    return this.req('get',
      `issues/${issueNumber}/comments?id=${encodeURIComponent(slug)}`,
    )
  }

  async comment(slug, issueNumber, commentNumber) {
    return this.req('get',
      `issues/${issueNumber}/comment/${commentNumber}?id=${encodeURIComponent(slug)}`,
    )
  }

  async commentCreate(slug, issueNumber, source) {
    return this.req('post',
      `issues/${issueNumber}/comments?id=${encodeURIComponent(slug)}`,
      {
        body: { comment: { source } },
      },
    )
  }

  async commentUpdate(slug, issueNumber, comentNumber, source) {
    return this.req('put',
      `issues/${issueNumber}/comments${commentNumber}?id=${encodeURIComponent(slug)}`,
      {
        body: { comment: { source } },
      },
    )
  }

  async commentDelete(slug, issueNumber, commentNumber) {
    return this.req('delete', `issues/${issueNumber}/comments/${commentNumber}?id=${encodeURIComponent(slug)}`)
  }

  async min(opts={}) {
    return this.req('get', `min${encodeGetParams(opts)}`)
  }

  async siteSettingsUpdate(opts={}) {
    return this.req('put',
      `site`,
      {
        body: opts,
      },
    )
  }

  async topics(opts={}) {
    return this.req('get', `topics${encodeGetParamsWithOffset(opts)}`)
  }

  async users(opts) {
    return this.req('get', `users${encodeGetParamsWithOffset(opts)}`)
  }

  async userCreate(attrs, recaptchaToken) {
    return this.req('post',
      `users`,
      { body: { user: attrs, recaptchaToken } },
    );
  }

  async userFollow(username){
    return this.req('post',
      `users/${username}/follow`,
    );
  }

  async user(username) {
    const { data, status } = await this.users({ username })
    return { data: data.users[0], status }
  }

  async userLogin(attrs) {
    return this.req('post',
      `login`,
      { body: { user: attrs } },
    );
  }

  async userUpdate(username, user) {
    return this.req('put',
      `users/${username}`,
      { body: { user } },
    )
  }

  async userUnfollow(username) {
    return this.req('delete',
      `users/${username}/follow`,
    );
  }
}

// https://stackoverflow.com/questions/6048504/synchronous-request-in-node-js/53338670#53338670
async function sendJsonHttp(method, path, opts={}) {
  let { body, contentType, getToken, headers, https, hostname, port, validateStatus } = opts
  let http
  if (https) {
    http = 'https'
  } else {
    http = 'http'
  }
  if (headers) {
    headers = Object.assign({}, headers)
  } else {
    headers = {}
  }
  headers['Content-Type'] = contentType || "application/json"
  if (getToken) {
    const token = getToken()
    if (token) {
      headers['Authorization'] = `Token ${token}`
    }
  }
  let url
  if (hostname) {
    const portStr = port ? `:${port}` : ''
    url = `${http}://${hostname}${portStr}${path}`
  } else {
    url = path
  }
  const response = await axios({
    data: body,
    headers,
    maxRedirects: 0,
    method,
    url,
    validateStatus,
  })
  return { data: response.data, status: response.status }
}

// Non-API stuff.

class DbProviderBase extends ourbigbook.DbProvider {
  constructor(opts={}) {
    super()
    this.id_cache = {}
    this.ref_cache = {
      from_id: {},
      to_id: {},
    }
    this.path_to_file_cache = {}
  }

  add_file_row_to_cache(row, context) {
    this.path_to_file_cache[row.path] = row
    const toplevelId = row.toplevelId
    if (
      // Happens on some unminimized condition when converting
      // cirosantilli.github.io @ 04f0f5bc03b9071f82b706b3481c09d616d44d7b + 1
      // twice with ourbigbook -S ., no patience to minimize and test now.
      toplevelId !== null
    ) {
      if (
        // We have to do this if here because otherwise it would overwrite the reconciled header
        // we have stiched into the tree with Include.
        !this.id_cache[toplevelId.idid]
      ) {
        this.add_row_to_id_cache(toplevelId, context)
      }
    }
  }

  add_ref_row_to_cache(row, to_id_key, include_key, context) {
    let to_id_key_dict = this.ref_cache[to_id_key][row[to_id_key]]
    if (to_id_key_dict === undefined) {
      to_id_key_dict = {}
      this.ref_cache[to_id_key][row[to_id_key]] = to_id_key_dict
    }
    let to_id_key_dict_type = to_id_key_dict[row.type]
    if (to_id_key_dict_type === undefined) {
      to_id_key_dict_type = []
      to_id_key_dict[row.type] = to_id_key_dict_type
    }
    to_id_key_dict_type.push(row)
    this.add_row_to_id_cache(row[include_key], context)
  }

  add_row_to_id_cache(row, context) {
    if (row !== null) {
      const ast = this.row_to_ast(row, context)
      const oldCache = this.id_cache[ast.id]
      if (
        // This is not just an optimization, we actually had a case that broke because
        // this was overwriting the value from the parsed tree, which contained more
        // information about the header tree not present in the new ast and which was required.
        oldCache
      ) {
        return oldCache
      } else {
        if (
          // Possible on reference to ID that does not exist and some other
          // non error cases I didn't bother to investigate.
          row.to !== undefined
        ) {
          ast.header_parent_ids = row.to.map(to => to.from_id)
        }
        this.id_cache[ast.id] = ast
        return ast
      }
    }
  }

  get_noscopes_base(ids, ignore_paths_set) {
    const cached_asts = []
    for (const id of ids) {
      if (id in this.id_cache) {
        const ast = this.id_cache[id]
        if (
          ignore_paths_set === undefined ||
          !ignore_paths_set.has(ast.input_path)
        ) {
          cached_asts.push(ast)
        }
      }
    }
    return cached_asts
  }

  get_file(path) {
    return this.path_to_file_cache[path]
  }

  /** Convert a Id DB row to a JavaScript AstNode object.
   *
   * @param row: a row from the Ids database
   * @return {AstNode}
   **/
  row_to_ast(row, context) {
    const ast = ourbigbook.AstNode.fromJSON(row.ast_json, context)
    ast.input_path = row.path
    ast.id = row.idid
    ast.toplevel_id = row.toplevel_id
    return ast
  }

  rows_to_asts(rows, context) {
    const asts = []
    for (const row of rows) {
      asts.push(this.add_row_to_id_cache(row, context))
      for (const row_title_title of row.from) {
        if (
          // We need this check because the version of the header it fetches does not have .to
          // so it could override one that did have the .to, and then other things could blow up.
          !(row_title_title.to && row_title_title.to.idid in this.id_cache)
        ) {
          const id2 = row_title_title.to
          if (id2) {
            const ret = this.add_row_to_id_cache(id2, context)
            if (ret !== undefined) {
              asts.push(ret)
            }
            // Get synonym of title title.
            for (const synonymRef of id2.from) {
              const ret = this.add_row_to_id_cache(synonymRef.to, context)
              if (ret !== undefined) {
                asts.push(ret)
              }
            }
          }
        }
      }
    }
    return asts
  }
}

module.exports = {
  articleHash,
  WebApi,
  DbProviderBase,
  encodeGetParams,
  read_include,
  sendJsonHttp,
}