OurBigBook logoOurBigBook Docs OurBigBook logoOurBigBook.comSite Source code
web/convert.js
const lodash = require('lodash')

const ourbigbook = require('ourbigbook')
const {
  AT_MENTION_CHAR,
  INDEX_BASENAME_NOEXT,
  Macro,
  OURBIGBOOK_EXT,
  renderArg,
} = ourbigbook
const {
  fetch_ancestors,
  update_database_after_convert,
  remove_duplicates_sorted_array,
  SqlDbProvider,
} = require('ourbigbook/nodejs_webpack_safe')
const ourbigbook_nodejs_webpack_safe = require('ourbigbook/nodejs_webpack_safe')
const { articleHash } = require('ourbigbook/web_api')

const { ValidationError } = require('./api/lib')
const {
  commentIdPrefix,
  convertOptions,
  forbidMultiheaderMessage,
  hideArticleDatesDate,
  maxArticleTitleSize,
  read_include_web
} = require('./front/config')
const { hasReachedMaxItemCount, idToSlug, slugToId } = require('./front/js')

function getConvertOpts({
  authorUsername,
  extraOptions={},
  input_path,
  sequelize,
  parentId,
  render,
  splitHeaders,
  type,
  transaction,
}) {
  const db_provider = new SqlDbProvider(sequelize)
  return {
    db_provider,
    convertOptions: lodash.merge(
      {
        db_provider,
        input_path,
        ourbigbook_json: {
          h: {
            splitDefault: false,
            splitDefaultNotToplevel: true,
          },
        },
        parent_id: parentId,
        read_include: read_include_web(async (idid) => (await sequelize.models.Id.count({ where: { idid }, transaction })) > 0),
        ref_prefix: `${AT_MENTION_CHAR}${authorUsername}`,
        render,
        split_headers: splitHeaders === undefined ? true : splitHeaders,
        h_web_ancestors: type === 'article',
      },
      extraOptions,
      convertOptions
    )
  }
}

// Subset of convertArticle for usage in issues and comments. Used by convertArticle as well.
// This is a much simpler procedure as it does not alter the File/Article database.
async function convert({
  author,
  convertOptionsExtra,
  parentId,
  path,
  perf,
  render,
  sequelize,
  source,
  splitHeaders,
  transaction,
  type,
}) {
  let t0
  if (perf) {
    t0 = performance.now();
    console.error('perf: convert.start');
  }
  const { db_provider, convertOptions } = getConvertOpts({
    authorUsername: author.username,
    input_path: path,
    parentId,
    render,
    sequelize,
    splitHeaders,
    transaction,
    type,
  })
  const extra_returns = {}
  await ourbigbook.convert(
    source,
    lodash.merge(convertOptions, convertOptionsExtra),
    extra_returns,
  )
  if (perf) {
    console.error(`perf: convert.after_convert: ${performance.now() - t0} ms`);
  }
  if (extra_returns.errors.length > 0) {
    const errsNoDupes = remove_duplicates_sorted_array(
      extra_returns.errors.map(e => e.toString()))
    throw new ValidationError(errsNoDupes, 422, { info: { source } })
  }
  if (perf) {
    console.error(`perf: convert.finish: ${performance.now() - t0} ms`);
  }
  return {
    db_provider,
    extra_returns,
  }
}

/*
 * Create or update an article.
 *
 * This does the type of stuff that OurBigBook CLI does for CLI
 * around the conversion itself, i.e. setting up the database, saving output files
 * but on the Web.
 *
 * This is how Articles should always be created and updated.
 *
 * Regarding the article tree:
 * - unless updateTree=false, which is not exposed to end users, the Ref parent tree
 *   is always up-to-date and consistent, including with render=false
 * - the nested set index can become out of date in a few different cases including:
 *   - render=false
 *   - updateNestedSetIndex=false
 *   both of which are exposed to users and exercized by the ourbigbook CLI.
 *
 * @param {string} parentId - Required for h2Render to render correctly. Otherwise it looks like an h1Render.
 * @param {boolean} updateTree - If false, don't change the position of the article in the tree.
 *   This also prevents the creation of new articles, only content updates are allowed in that case.
 *   This option can massively save time by skipping unnecessary nested set tree updates.
 *   This option is just an optimization, originally introduced for rerender.
 *   It is not exposed to end users, for which we always update the tree.
 */
async function convertArticle({
  author,
  bodySource,
  convertOptionsExtra,
  enforceMaxArticles,
  forceNew,
  list,
  path,
  perf,
  parentId,
  previousSiblingId,
  render,
  sequelize,
  titleSource,
  transaction,
  updateNestedSetIndex,
  updateHash,
  updateTree,
  updateUpdatedAt,
}) {
  if (render === undefined) {
    render = true
  }
  if (updateNestedSetIndex === undefined) {
    updateNestedSetIndex = true
  }
  if (updateHash === undefined) {
    updateHash = true
  }
  if (updateTree === undefined) {
    updateTree = true
  }
  if (updateUpdatedAt === undefined) {
    updateUpdatedAt = true
  }
  let t0
  const { Article, File, Id, Issue, Ref, Topic, UserLikeArticle } = sequelize.models
  if (perf) {
    t0 = performance.now();
    console.error(`perf: convertArticle.start titleSource="${titleSource}"`);
  }
  let articles
  let extra_returns
  const convertType = 'article'
  if (enforceMaxArticles === undefined) {
    enforceMaxArticles = true
  }
  if (convertOptionsExtra === undefined) {
    convertOptionsExtra = {}
  }
  let nestedSetNeedsUpdate = true
  const source = ourbigbook.modifyEditorInput(titleSource, bodySource).new
  const idPrefix = `${AT_MENTION_CHAR}${author.username}`
  await sequelize.transaction({ transaction }, async (transaction) => {
    // Determine the correct parentId from parentId and previousSiblingId
    let newParentId = parentId
    let newParentArticle
    let parentIdRow
    const parentAndPreviousSiblingPromises = []
    if (newParentId !== undefined) {
      const findParent = async (isSynonym) => {
        let include
        const idInclude = {
          model: File,
          required: false,
          as: 'toplevelId',
          include: [
            {
              model: Article,
              as: 'articles',
            }
          ],
        }
        if (isSynonym) {
          include = [{
            model: Ref,
            as: 'from',
            required: true,
            where: {
              type: Ref.Types[ourbigbook.REFS_TABLE_SYNONYM],
            },
            include: [{
              model: Id,
              as: 'to',
              include: [idInclude]
            }]
          }]
        } else {
          include = [idInclude]
          if (newParentId !== idPrefix) {
            // This ID is not the index and has a parent point to it.
            // Therefore it cannot be a synonym.
            include.push({
              model: Ref,
              as: 'to',
              required: true,
              where: {
                type: Ref.Types[ourbigbook.REFS_TABLE_PARENT],
              },
            })
          }
        }
        return Id.findOne({
          include,
          subQuery: false,
          where: {
            idid: newParentId,
            macro_name: Macro.HEADER_MACRO_NAME,
          },
        })
      }
      parentAndPreviousSiblingPromises.push(...[
        findParent(false),
        findParent(true),
      ])
    } else {
      parentAndPreviousSiblingPromises.push(null, null)
    }
    if (previousSiblingId !== undefined) {
      let refWhere = {
        to_id: previousSiblingId,
        type: Ref.Types[ourbigbook.REFS_TABLE_PARENT],
      }
      if (newParentId !== undefined) {
        refWhere.from_id = newParentId
      }
      parentAndPreviousSiblingPromises.push(
        Ref.findOne({
          where: refWhere,
          include: [
            {
              model: Id,
              as: 'to',
              where: {
                macro_name: Macro.HEADER_MACRO_NAME,
              },
              include: [
                {
                  model: File,
                  as: 'toplevelId',
                  include: [
                    {
                      model: Article,
                      as: 'articles',
                    }
                  ],
                }
              ],
            },
            {
              model: Id,
              as: 'from',
              include: [
                {
                  model: File,
                  as: 'toplevelId',
                  include: [
                    {
                      model: Article,
                      as: 'articles',
                    }
                  ],
                }
              ],
            },
          ],
          transaction,
        })
      )
    } else {
      parentAndPreviousSiblingPromises.push(null)
    }
    const [
      parentIdNoSynonym,
      parentIdSynonym,
      previousSiblingRef,
    ] = await Promise.all(parentAndPreviousSiblingPromises)
    if (parentIdNoSynonym) {
      // We prefer the non-synonym header if there is one.
      parentIdRow = parentIdNoSynonym
    } else {
      if (parentIdSynonym) {
        // If there is no non-synonym header, we just pick one of the synonym headers at random.
        // There can be more than one at the render: false phase before we are checking for duplicates.
        const from = parentIdSynonym.from
        if (from.length) {
          // This is a synonym. Use the non-synonym target instead.
          parentIdRow = from[0].to
          newParentId = parentIdRow.idid
        }
      }
    }
    if (previousSiblingRef) {
      // Deduce parent from given sibling.
      parentIdRow = previousSiblingRef.from
      newParentId = parentIdRow.idid
      newParentArticle = parentIdRow.toplevelId.articles[0]
    }

    // Determine the input_path and toplevelId
    let input_path
    let toplevelId
    {
      if (
        // This case could likely be handled more elegantly inside the else
        // by correctly adding username as a prefix to input_path there. But I'm
        // lazy to think now on possible ramifications.
        path === INDEX_BASENAME_NOEXT
      ) {
        input_path = `${AT_MENTION_CHAR}${author.username}/${INDEX_BASENAME_NOEXT}.${OURBIGBOOK_EXT}`
        toplevelId = `${AT_MENTION_CHAR}${author.username}`
      } else {
        let scope
        // Do one pre-conversion to determine the file path.
        // All we need from it is the toplevel header.
        // E.g. this finds the correct path from id= and {disambiguate=
        // https://github.com/ourbigbook/ourbigbook/issues/304
        // We do this even when path is known in order to catch
        // the 'index' -> '' path to ID conversion.
        const extra_returns = {}
        const { convertOptions } = getConvertOpts({
          authorUsername: author.username,
          extraOptions: {
            h1Only: true,
          },
          input_path: path === undefined ? undefined : `${path}.${OURBIGBOOK_EXT}`,
          render: false,
          sequelize,
          splitHeaders: false,
          transaction,
          type: convertType,
        })
        await ourbigbook.convert(
          source,
          lodash.merge(convertOptions, convertOptionsExtra),
          extra_returns,
        )
        const toplevelAst = extra_returns.context.header_tree.children[0].ast
        const toplevelIdNoUsername = toplevelAst.id
        if (path === undefined && parentIdRow) {
          const context = ourbigbook.convertInitContext()
          const parentH1Ast = ourbigbook.AstNode.fromJSON(parentIdRow.ast_json, context)
          parentH1Ast.id = parentIdRow.idid
          const parentScope = parentH1Ast.calculate_scope()
          // Inherit scope from parent. In particular, this forces every article by a
          // user to be scoped under @username due to this being recursive from the index page.
          scope = parentScope
        }
        if (scope === undefined) {
          scope = idPrefix
        }
        input_path = `${scope}/${toplevelIdNoUsername ? toplevelIdNoUsername : INDEX_BASENAME_NOEXT}.${OURBIGBOOK_EXT}`
        toplevelId = `${scope}${toplevelIdNoUsername ? '/' + toplevelIdNoUsername : ''}`
        if (toplevelId !== toplevelId.toLowerCase() && !toplevelAst.validation_output.file.given) {
          throw new ValidationError(`Article ID cannot contain uppercase characters: "${toplevelId}"`)
        }
      }
      if (forceNew && (await File.findOne({ where: { path: input_path }, transaction }))) {
        throw new ValidationError(`Article with this ID already exists: ${toplevelId}`)
      }
    }

    // Check if the article already existed. If it did and
    // if we are still missing parentId and previousSiblingId,
    // take them from the old article.
    const oldRef = await Ref.findOne({
      where: {
        to_id: [toplevelId],
        type: Ref.Types[ourbigbook.REFS_TABLE_PARENT],
      },
      include: [
        {
          model: Id,
          as: 'to',
          include: [
            {
              model: File,
              as: 'toplevelId',
              include: [
                {
                  model: Article,
                  as: 'articles',
                }
              ],
            }
          ]
        },
        {
          model: Id,
          as: 'from',
          include: [
            {
              model: File,
              as: 'toplevelId',
              include: [
                {
                  model: Article,
                  as: 'articles',
                }
              ],
            }
          ]
        },
      ],
      transaction,
    })
    if (newParentId === undefined && oldRef) {
      parentIdRow = oldRef.from
    }
    if (parentIdRow) {
      // Happens when updating a page.
      newParentId = parentIdRow.idid
      newParentArticle = parentIdRow.toplevelId.articles[0]
    }

    // Error checking on parentId and previousSiblingId
    if (parentId && !parentIdRow) {
      throw new ValidationError(`parentId does not exist: "${newParentId}"`)
    }
    if (parentIdRow && parentIdRow.macro_name !== Macro.HEADER_MACRO_NAME) {
      throw new ValidationError(`parentId is not a header: "${newParentId}"`)
    }
    // Index conversion check.
    const isIndex = toplevelId === idPrefix
    if (isIndex && parentId !== undefined) {
      // As of writing, this will be caught and thrown on the ancestors part of conversion:
      // as changing the Index to anything else always leads to infinite loop.
      throw new ValidationError(`cannot give parentId for index conversion, received "${toplevelId}"`)
    }
    if (previousSiblingId && !previousSiblingRef) {
      throw new ValidationError(`previousSiblingId "${previousSiblingId}" does not exist, is not a header or is not a child of parentId "${newParentId}"`)
    }
    if (newParentId) {
      if (
        newParentId === toplevelId ||
        (await fetch_ancestors(sequelize, newParentId, {
          onlyIncludeId: toplevelId,
          stopAt: toplevelId,
          transaction,
        })).length
      ) {
        throw new ValidationError(`parentId="${toplevelId}" would lead to infinite parent loop"`)
      }
    } else {
      if (!isIndex) {
        throw new ValidationError(`parent ID was not specified for new article "${toplevelId}", it is mandatory for new articles`)
      }
    }

    let db_provider
    ;({ db_provider, extra_returns } = await convert({
      author,
      convertOptionsExtra: Object.assign({
        forbid_multiheader: forbidMultiheaderMessage,
        // 1 to remove the @ from every single ID, but still keep the `username` prefix.
        // This is necessary so we can use the same h2 render for articles under a scope for both
        // renderings inside and outside of the scope. With dynamic article tree on web, we cannot know if the
        // page will be visible from inside or outside the toplevel scope, so if we use a cut up version:
        // `my-scope/section-id` as just `section-id` from something outside of `my-scope`, then there could
        // be ambiguity with other headers with ID `section-id`. We could keep multiple h2 renderings around
        // for different situations, but let's not muck around with that for now. This option will also remove
        // the @username prefix, which is implemented as a scope. This does have an advantage: we can use the same
        // rendering on topic pages, and in the future on collections, which require elements by different users
        // to show fine under a single page.
        fixedScopeRemoval: AT_MENTION_CHAR.length,
        h_web_metadata: true,
        prefixNonIndexedIdsWithParentId: true,
      }, convertOptionsExtra),
      forceNew,
      parentId,
      path: input_path,
      perf,
      render,
      sequelize,
      source,
      transaction,
      type: convertType,
    }))
    const toplevelAst = extra_returns.context.header_tree.children[0].ast

    // Synonym handling part 1
    const synonymHeadersArr = Array.from(extra_returns.context.synonym_headers)
    const synonymIds = synonymHeadersArr.map(h => h.id)
    const synonymArticles = await Article.getArticles({
      count: false,
      includeParentAndPreviousSibling: true,
      sequelize,
      slug: synonymIds.map(id => idToSlug(id)),
      transaction,
    })
    if (synonymIds.length) {
      // Clear IDs of the synonyms.
      await db_provider.clear(
        synonymArticles.map(a => a.file.path),
        transaction,
      )
    }

    const update_database_after_convert_arg = {
      authorId: author.id,
      bodySource,
      extra_returns,
      db_provider,
      sequelize,
      synonymHeaderPaths: Array.from(extra_returns.context.synonym_headers).map(h => `${h.id}.${OURBIGBOOK_EXT}`),
      path: input_path,
      render,
      titleSource,
      transaction,
      updateHash,
    }
    if (updateHash) {
      update_database_after_convert_arg.hash = articleHash({ list, parentId, previousSiblingId, source })
    }
    const { file: newFile } = await update_database_after_convert(update_database_after_convert_arg)

    // Set the article of the parent. The previously existing ref, if there was one,
    // has already been necessarily removed during update_database_after_convert.
    let new_to_id_index
    if (previousSiblingRef) {
      new_to_id_index = previousSiblingRef.to_id_index + 1
    } else {
      new_to_id_index = 0
    }

    // Update nestedSetIndex and other things that can only be updated after the initial non-render pass.
    //
    // nestedSetIndex requires the initial non-render pass because it can only be calculated correctly
    //
    // Note however that nestedSetIndex is also calculated incrementally on the render pass, and as a result,
    // article instances returned by this function do not have the correct final value for it.
    let nestedSetSize
    let newDepth = 0
    let newNestedSetIndex = 0
    let newNestedSetIndexParent
    let newNestedSetNextSibling = 1
    let oldArticle
    let oldDepth
    let oldNestedSetIndex
    let oldNestedSetIndexParent
    let oldParentArticle
    let oldParentId
    let old_to_id_index
    if (previousSiblingRef) {
      const article = previousSiblingRef.to.toplevelId.articles[0]
      if (article) {
        newNestedSetIndex = article.nestedSetNextSibling
      }
    }
    if (parentIdRow) {
      if (!previousSiblingRef) {
        newParentArticle = parentIdRow.toplevelId.articles[0]
      }
    }
    if (newParentArticle) {
      newDepth = newParentArticle.depth + 1
      if (!previousSiblingRef) {
        newNestedSetIndex = newParentArticle.nestedSetIndex + 1
      }
      newNestedSetIndexParent = newParentArticle.nestedSetIndex
    }
    if (oldRef) {
      oldParentArticle = oldRef.from.toplevelId.articles[0]
      oldParentId = oldRef.from_id
      old_to_id_index = oldRef.to_id_index
      oldArticle = oldRef.to.toplevelId.articles[0]
      if (oldArticle) {
        oldNestedSetIndex = oldArticle.nestedSetIndex
        nestedSetSize = oldArticle.nestedSetNextSibling - oldArticle.nestedSetIndex
        oldNestedSetIndexParent = oldParentArticle.nestedSetIndex
        oldDepth = oldArticle.depth
      }
    } else if (isIndex) {
      old_to_id_index = 0
    }
    const doUpdateNestedSetIndex =
      updateNestedSetIndex &&
      // Don't update if not rendering, as articles might not exist and we store
      // nested information set in Article columns.
      // It would have been cleaner if the nested set was not a part of articles directly.
      // then we wouldn't have to think about this kind of issue.
      render &&
      // Can happen if the new position does not have an article yet.
      newNestedSetIndex !== undefined
    if (!oldArticle) {
      nestedSetSize = 1
    }
    newNestedSetNextSibling = newNestedSetIndex + nestedSetSize
    if (isIndex) {
      // It would be better to handle this by oldArticle to the old article.
      // But we don't have it in this case because there is no oldRef. So let's fake it until things blow up somehow.
      oldNestedSetIndex = newNestedSetIndex
      oldDepth = newDepth
    }
    nestedSetNeedsUpdate = !doUpdateNestedSetIndex &&
      (
        newParentId !== oldParentId ||
        new_to_id_index !== old_to_id_index
      )
    if (nestedSetNeedsUpdate && !author.nestedSetNeedsUpdate) {
      await author.update({ nestedSetNeedsUpdate: true }, { transaction })
    }

    if (
      // Fails only for the index page which has no parent.
      newParentId !== undefined &&
      // If the article is new, create space to insert it there.
      // For the moving of existing articles however, we leave the space opening up to the
      // Article.treeMoveRangeTo function instead.
      updateTree
    ) {
      const openSpaceForNestedSet = doUpdateNestedSetIndex && !oldArticle
      await Article.treeOpenSpace({
        parentNestedSetIndex: newNestedSetIndexParent,
        perf,
        nestedSetIndex: newNestedSetIndex,
        parentId: newParentId,
        shiftNestedSetBy: nestedSetSize,
        shiftRefBy: 1,
        to_id_index: new_to_id_index,
        transaction,
        updateRef: !oldRef,
        updateNestedSetIndex: openSpaceForNestedSet,
        username: author.username,
      })
      if (
        openSpaceForNestedSet &&
        newNestedSetIndex <= oldNestedSetIndexParent
      ) {
        oldNestedSetIndexParent += nestedSetSize
      }
    }

    if (!isIndex && !oldRef) {
      // Must come after the previous treeOpenSpace call.
      await Ref.create(
        {
          type: Ref.Types[ourbigbook.REFS_TABLE_PARENT],
          to_id: toplevelId,
          from_id: newParentId,
          inflected: false,
          to_id_index: new_to_id_index,
          defined_at: null,
        },
        { transaction }
      )
    }

    if (render) {
      // Actual rendering.
      const ancestorsWithScopeRenders = []
      const ancestors = await fetch_ancestors(sequelize, toplevelId, {
        transaction,
      })
      const context = extra_returns.context
      for (const ancestor of ancestors.slice(1)) {
        const ast = ourbigbook.AstNode.fromJSON(
          ancestor.ast_json, context
        )
        if (ast.validation_output.scope.given) {
          ancestorsWithScopeRenders.push(
            renderArg(ast.args[Macro.TITLE_ARGUMENT_NAME], context)
          )
        }
      }
      const [check_db_errors, file] = await Promise.all([
        ourbigbook_nodejs_webpack_safe.check_db(
          sequelize,
          [input_path],
          {
            web: true,
            perf,
            transaction,
          },
        ),
        File.findOne({ where: { path: input_path }, transaction }),
      ])
      if (check_db_errors.length > 0) {
        throw new ValidationError(check_db_errors)
      }
      const articleArgs = []
      for (const outpath in extra_returns.rendered_outputs) {
        const rendered_output = extra_returns.rendered_outputs[outpath]
        const renderFull = rendered_output.full
        const topicId = outpath.slice(
          AT_MENTION_CHAR.length + author.username.length + 1,
          -ourbigbook.HTML_EXT.length - 1
        )
        const articleArg = {
          authorId: author.id,
          fileId: file.id,
          h1Render: renderFull.substring(0, rendered_output.h1RenderLength),
          h2Render: rendered_output.h2Render,
          render: renderFull.substring(rendered_output.h1RenderLength),
          slug: outpath.slice(AT_MENTION_CHAR.length, -ourbigbook.HTML_EXT.length - 1),
          titleRender: rendered_output.title,
          titleRenderWithScope: [
              ...ancestorsWithScopeRenders,
              rendered_output.title,
            ].join(` <span class="meta">${ourbigbook.Macro.HEADER_SCOPE_SEPARATOR}</span> `)
          ,
          titleSource: rendered_output.titleSource,
          titleSourceLine:
            rendered_output.titleSourceLocation
              ? rendered_output.titleSourceLocation.line
              // Can happen if user tries to add h1 to a document. TODO investigate further why.
              : undefined,
          topicId,
        }
        if (list !== undefined) {
          articleArg.list = list
        }
        if (doUpdateNestedSetIndex) {
          articleArg.depth = newDepth
        }
        if (author.hideArticleDates) {
          articleArg.createdAt = hideArticleDatesDate
          articleArg.updatedAt = hideArticleDatesDate
        }
        articleArgs.push(articleArg)
        if (titleSource.length > maxArticleTitleSize) {
          throw new ValidationError(`Title source too long: ${titleSource.length} bytes, maximum: ${maxArticleTitleSize} bytes, title: ${titleSource}`)
        }
      }
      const articleArgs0 = articleArgs[0]
      if (doUpdateNestedSetIndex) {
        // Due to this limited setup, nested set ordering currently only works on one article per source setups.
        // https://docs.ourbigbook.com/todo#web-create-multiple-headers
        articleArgs0.nestedSetIndex = newNestedSetIndex
        articleArgs0.nestedSetNextSibling = newNestedSetNextSibling
      }

      const updateOnDuplicate = [
        'h1Render',
        'h2Render',
        'titleRender',
        'titleRenderWithScope',
        'titleSource',
        'titleSourceLine',
        'render',
        'topicId',
        'authorId',
        // We intentionally skip:
        // * depth
        // * nestedSetIndex
        // * nestedSetNextSibling
        // as those will be updated in bulk soon afterwards together with all descendants.
      ]
      if (updateUpdatedAt) {
        updateOnDuplicate.push('updatedAt')
      }
      if (author.hideArticleDates) {
        updateOnDuplicate.push('createdAt')
      }
      if (list !== undefined) {
        updateOnDuplicate.push('list')
      }
      await Article.bulkCreate(
        articleArgs,
        {
          updateOnDuplicate,
          transaction,
          // Trying this to validate mas titleSource length here leads to another error.
          // validate: true,
          // individualHooks: true,
        }
      )

      // Find here because upsert not yet supported in SQLite, so above updateOnDuplicate wouldn't work.
      // https://stackoverflow.com/questions/29063232/how-to-get-the-id-of-an-inserted-or-updated-record-in-sequelize-upsert
      articles = await Article.getArticles({
        count: false,
        order: 'slug',
        orderAscDesc: 'ASC',
        sequelize,
        slug: articleArgs.map(arg => Article.slugTransform(arg.slug)),
        transaction,
      })
    } else {
      articles = []
    }
    if (updateTree) {
      await Promise.all([
        // Check file limit
        render && File.count({ where: { authorId: author.id }, transaction }).then(articleCountByLoggedInUser => {
          if (enforceMaxArticles) {
            const err = hasReachedMaxItemCount(author, articleCountByLoggedInUser - 1, 'articles')
            if (err) { throw new ValidationError(err, 403) }
          }
        }),
        (async () => {
          if (oldRef) {
            // Move an existing article to the new location determined by the user via API parentId field.
            await Article.treeMoveRangeTo({
              logging: false,
              depthDelta: newDepth - oldDepth,
              // Total toplevel sibling articles to be moved, excluding their descendants.
              nArticlesToplevel: 1,
              // Total articles to be moved, including toplevel siblings and their descendants.
              nArticles: nestedSetSize,
              newNestedSetIndex,
              newNestedSetIndexParent,
              newParentId,
              new_to_id_index,
              oldNestedSetIndex,
              oldNestedSetIndexParent,
              oldParentId,
              old_to_id_index,
              perf,
              transaction,
              updateNestedSetIndex: doUpdateNestedSetIndex && oldArticle !== undefined,
              username: author.username,
            })
          }

          // Synonym handling part 2
          // Now that we have the new article we merge any pre-existing synonyms into it.
          // All issues are moved into this new article, and then
          // the synonym articles are destroyed.
          if (render && synonymIds.length) {
            const article = articles[0]
            // TODO this find could be replaced with manual updating of the prefetched articles
            // to account for things moving around.
            const synonymArticles = await Article.getArticles({
              count: false,
              includeParentAndPreviousSibling: true,
              sequelize,
              slug: synonymIds.map(id => idToSlug(id)),
              transaction,
            })
            const synonymIdsToArticles = {}
            for (const article of synonymArticles) {
              synonymIdsToArticles[slugToId(article.slug)] = article
            }

            let synonymNewNestedSetIndex = newNestedSetIndex + nestedSetSize
            let [lastIssue, synonym_new_to_id_index_ref] = await Promise.all([
              Issue.findOne({
                order: [['number', 'DESC']],
                where: { articleId: article.id, },
                transaction,
              }),
              Ref.findOne({
                where: {
                  from_id: toplevelId,
                  type: Ref.Types[ourbigbook.REFS_TABLE_PARENT],
                  to_id_index: {[sequelize.Sequelize.Op.ne]: null},
                },
                order: [['to_id_index', 'DESC']],
                transaction
              }),
              UserLikeArticle.destroy({
                where: { articleId: synonymArticles.map(a => a.id) },
                transaction
              }),
            ])
            let synonym_new_to_id_index = synonym_new_to_id_index_ref === null ? 0 : synonym_new_to_id_index_ref.to_id_index + 1
            let issueNumberDelta = lastIssue ? lastIssue.number : 0
            for (const synonymId of synonymIds) {
              const synonymArticle = synonymIdsToArticles[synonymId]
              if (synonymArticle) {
                const synonymNDescendantArticles = synonymArticle.nestedSetNextSibling - synonymArticle.nestedSetIndex - 1
                const synonymNChildArticles = await Ref.count({
                  where: {
                    from_id: synonymArticle.idid,
                    type: Ref.Types[ourbigbook.REFS_TABLE_PARENT],
                  },
                  transaction
                })
                const [[synonymNIssues], _] = await Promise.all([
                  // Move issues of synonym to new article.
                  Issue.update(
                    {
                      number: sequelize.fn(`${issueNumberDelta} + `, sequelize.col('number')),
                      articleId: article.id,
                    },
                    {
                      where: { articleId: synonymArticle.id, },
                      transaction,
                    }
                  ),
                  // Move all children of deleted synonym to its new parent.
                  Article.treeMoveRangeTo({
                    logging: false,
                    depthDelta: article.depth - synonymArticle.depth,
                    nArticlesToplevel: synonymNChildArticles,
                    nArticles: synonymNDescendantArticles,
                    newNestedSetIndex: synonymNewNestedSetIndex,
                    newNestedSetIndexParent: article.nestedSetIndex,
                    newParentId: toplevelId,
                    new_to_id_index: synonym_new_to_id_index,
                    oldNestedSetIndex: synonymArticle.nestedSetIndex + 1,
                    oldNestedSetIndexParent: synonymArticle.nestedSetIndex,
                    oldParentId: synonymArticle.idid,
                    old_to_id_index: 0,
                    transaction,
                    updateNestedSetIndex: doUpdateNestedSetIndex,
                    username: author.username,
                  })
                ])

                // Account for changes in position due to descendant moving since we fetched this from DB.
                let forceNestedSetIndex
                if (
                  synonymArticle.parentId.idid === newParentId &&
                  new_to_id_index <= synonym_new_to_id_index
                ) {
                  synonymArticle.nestedSetIndex += synonymNDescendantArticles
                }
                synonymArticle.nestedSetNextSibling = synonymArticle.nestedSetIndex + 1

                await synonymArticle.destroySideEffects({
                  logging: false,
                  transaction,
                })
                synonymNewNestedSetIndex += synonymNDescendantArticles
                synonym_new_to_id_index += synonymNChildArticles
                issueNumberDelta += synonymNIssues
              }
            }
          }
        })(),
        render && !oldArticle && Topic.updateTopics(articles, { newArticles: true, transaction }),
        render &&
          !oldArticle && Promise.all(articles.map(
            article => author.addArticleFollowSideEffects(article, { transaction })
          )),
      ])
    }
  })
  if (perf) {
    console.error(`perf: convertArticle.finish: ${performance.now() - t0} ms`);
  }
  return { articles, extra_returns, nestedSetNeedsUpdate }
}

async function convertComment({
  comment,
  convertOptionsExtra,
  date,
  issue,
  number,
  sequelize,
  source,
  transaction,
  user,
}) {
  if (source === undefined) {
    source = comment.source
  } else if(comment !== undefined) {
    comment.source = source
  }
  return sequelize.transaction({ transaction }, async (transaction) => {
    const { extra_returns } = await convert({
      author: user,
      convertOptionsExtra: Object.assign({
        fixedScopeRemoval: 0,
        tocIdPrefix: `${commentIdPrefix}${number}-`,
      }, convertOptionsExtra),
      path: `@${user.username}/${commentIdPrefix}${number}/${INDEX_BASENAME_NOEXT}.${OURBIGBOOK_EXT}`,
      render: true,
      sequelize,
      source,
      splitHeaders: false,
      titleSource: undefined,
      transaction,
      type: 'comment',
    })
    const outpath = Object.keys(extra_returns.rendered_outputs)[0]
    const renders = extra_returns.rendered_outputs[outpath]
    const render = renders.full
    if (comment === undefined) {
      const outpath = Object.keys(extra_returns.rendered_outputs)[0]
      const attrs = {
        number,
        render: extra_returns.rendered_outputs[outpath].full,
        source,
      }
      if (date) {
        attrs.createdAt = date
        // TODO doesn't really work
        attrs.updatedAt = date
      }
      return sequelize.models.Comment.createSideEffects(
        user,
        issue,
        attrs,
        { transaction }
      )
    } else {
      comment.render = render
      if (date) {
        comment.createdAt = date
        comment.updatedAt = date
      }
      return comment.save({ transaction })
    }
  })
}

async function convertDiscussion({
  article,
  bodySource,
  convertOptionsExtra,
  date,
  issue,
  number,
  sequelize,
  titleSource,
  transaction,
  user,
}) {
  if (issue) {
    if (bodySource === undefined) {
      bodySource = issue.bodySource
    } else {
      issue.bodySource = bodySource
    }
    if (titleSource === undefined) {
      titleSource = issue.titleSource
    } else {
      issue.titleSource = titleSource
    }
    if (number === undefined) {
      number = issue.number
    }
    if (article === undefined) {
      article = issue.article
    }
  }
  const source = ourbigbook.modifyEditorInput(titleSource, bodySource).new
  return sequelize.transaction({ transaction }, async (transaction) => {
    // We use routes here to achieve a path that matches the exact length of what the issue will render to,
    // so that the internal links will render with the correct number of ../
    const { extra_returns } = await convert({
      author: user,
      convertOptionsExtra: Object.assign({
        fixedScopeRemoval: 0,
        h_web_metadata: true,
      }, convertOptionsExtra),
      path: `@${user.username}/_issue-${article.slug}/${number}/${INDEX_BASENAME_NOEXT}.${OURBIGBOOK_EXT}`,
      render: true,
      sequelize,
      source,
      splitHeaders: false,
      transaction,
      type: 'issue',
    })
    const outpath = Object.keys(extra_returns.rendered_outputs)[0]
    const renders = extra_returns.rendered_outputs[outpath]
    const titleRender = renders.title
    const render = renders.full
    if (issue === undefined) {
      const attrs = {
        bodySource,
        date,
        number,
        render,
        titleRender,
        titleSource,
      }
      if (date) {
        attrs.createdAt = date
        // TODO doesn't really work
        attrs.updatedAt = date
      }
      return sequelize.models.Issue.createSideEffects(
        user,
        article,
        attrs,
        {
          transaction,
        },
      )
    } else {
      issue.titleRender = titleRender
      issue.render = render
      if (date) {
        issue.createdAt = date
        issue.updatedAt = date
      }
      return issue.save({ transaction })
    }
  })
}

module.exports = {
  convert,
  convertArticle,
  convertComment,
  convertDiscussion,
  getConvertOpts,
}