OurBigBook logoOurBigBook Docs OurBigBook logoOurBigBook.comSite Source code
web/test_lib.js
// https://ourbigbook/ourbigbook/demo-data
//
// Need a separate file from test.js because Mocha automatically defines stuff like it,
// which would break non-Mocha requirers.

const fs = require('fs')
const path = require('path')
const perf_hooks = require('perf_hooks')

const lodash = require('lodash')

const ourbigbook = require('ourbigbook')
const back_js = require('./back/js')
const convert = require('./convert')
const models = require('./models')
const config = require('./front/config')

const { performance } = require('perf_hooks')
const now = performance.now.bind(performance)
const ISSUE_DATE = new Date(2000, 0, 1, 2, 3, 4, 5)
const NTAGS = 21

let printTimeNow;
function printTime() {
  const newNow = now()
  console.error((newNow - printTimeNow)/1000.0)
  printTimeNow = newNow
}

// https://stackoverflow.com/questions/563406/add-days-to-javascript-date
function addDays(oldDate, days) {
  const newDate = new Date(oldDate.valueOf());
  newDate.setDate(oldDate.getDate() + days);
  return newDate;
}
const date0 = new Date(2000, 0, 0, 0, 0, 0, 0)

const userData = [
  ['Barack Obama', 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Official_portrait_of_Barack_Obama.jpg/160px-Official_portrait_of_Barack_Obama.jpg'],
  ['Donald Trump', 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/56/Donald_Trump_official_portrait.jpg/160px-Donald_Trump_official_portrait.jpg'],
  ['Xi Jinping', 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/32/Xi_Jinping_2019.jpg/90px-Xi_Jinping_2019.jpg'],
  ['Mao Zedong', 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e8/Mao_Zedong_portrait.jpg/90px-Mao_Zedong_portrait.jpg'],
  ['Isaac Newton', 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/3b/Portrait_of_Sir_Isaac_Newton%2C_1689.jpg/220px-Portrait_of_Sir_Isaac_Newton%2C_1689.jpg'],
  ['Joe Biden', 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/68/Joe_Biden_presidential_portrait.jpg/160px-Joe_Biden_presidential_portrait.jpg'],
  ['Li Hongzhi', 'https://upload.wikimedia.org/wikipedia/commons/thumb/0/06/Li_Hongzhi_1.jpg/200px-Li_Hongzhi_1.jpg'],
  ['Jiang Zemin', 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/ce/Jiang_Zemin_St._Petersburg.jpg/90px-Jiang_Zemin_St._Petersburg.jpg'],
  ['John F. Kennedy', 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/c3/John_F._Kennedy%2C_White_House_color_photo_portrait.jpg/160px-John_F._Kennedy%2C_White_House_color_photo_portrait.jpg'],
  ['Erwin Schrödinger', 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/99/Erwin_Schrodinger2.jpg/170px-Erwin_Schrodinger2.jpg'],
  ['Jesus', 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/Spas_vsederzhitel_sinay.jpg/220px-Spas_vsederzhitel_sinay.jpg'],
  ['Deng Xiaoping', 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/16/Deng_Xiaoping_and_Jimmy_Carter_at_the_arrival_ceremony_for_the_Vice_Premier_of_China._-_NARA_-_183157-restored%28cropped%29.jpg/220px-Deng_Xiaoping_and_Jimmy_Carter_at_the_arrival_ceremony_for_the_Vice_Premier_of_China._-_NARA_-_183157-restored%28cropped%29.jpg'],
  ['George W. Bush', 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d4/George-W-Bush.jpeg/160px-George-W-Bush.jpeg'],
  ['Einstein', 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Einstein_patentoffice.jpg/170px-Einstein_patentoffice.jpg'],
  ['Bill Clinton', 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d3/Bill_Clinton.jpg/160px-Bill_Clinton.jpg'],
  ['Gautama Buddha', 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/56/Mahapajapati.jpg/220px-Mahapajapati.jpg'],
  ['President Reagan', 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/16/Official_Portrait_of_President_Reagan_1981.jpg/165px-Official_Portrait_of_President_Reagan_1981.jpg'],
  ['Euclid of Alexandria', 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/ce/Scuola_di_atene_23.jpg/220px-Scuola_di_atene_23.jpg'],
  ['Richard Nixon', 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2c/Richard_Nixon_presidential_portrait_%281%29.jpg/160px-Richard_Nixon_presidential_portrait_%281%29.jpg'],
  ['Moses', 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/14/Guido_Reni_-_Moses_with_the_Tables_of_the_Law_-_WGA19289.jpg/220px-Guido_Reni_-_Moses_with_the_Tables_of_the_Law_-_WGA19289.jpg'],
]

const articleData = [
  ['Mathematics', [
    ['Algebra', [
      ['Linear algebra', [
        ['Vector space', []],
      ]],
      ['Abstract algebra', []],
    ]],
    ['Calculus', [
      ['Derivative', []],
      ['Integral', [
        ['Fundamental theorem of calculus', [
          ['Proof of the fundamental theorem of calculus', []],
        ]],
        ['$L^p$ space', []],
      ]],
    ]],
  ]],
  ['Natural science', [
    ['Physics', [
      ['Quantum mechanics', [
        ['Schrödinger equation', [
          ['Schrödinger equation solution for the hydrogen atom', [
            ['Atomic orbital', [
              ['Principal quantum number', []],
              ['Azimuthal quantum number', []],
              ['Magnetic quantum number', []],
            ]],
          ], { headerArgs: '{c}' }],
        ], { headerArgs: '{c}' }],
        ['Quantum mechanics experiment', [
          ['Emission spectrum', [
            ['Rydberg formula', [], { headerArgs: '{c}' }],
            ['Fine structure', [
              ['Hyperfine structure', []],
            ]],
          ]],
          ['Double-slit experiment', []],
        ]],
      ]],
      ['Special relativity', [
        ['Lorentz transformation', [
          ['Time dilation', []],
        ], { headerArgs: '{c}' }],
      ]],
    ], { toplevel: true }],
    ['Chemistry', [
      ['Chemical element', [
        ['Hydrogen', [
          ['Water', []],
        ]],
        ['Helium', []],
        ['Carbon', [
          ['Carbon-14', []],
        ]],
      ]],
      ['Organic chemistry', [
      ]],
    ], { toplevel: true }],
    ['Biology', [
      ['Molecular biology', [
        ['DNA', [], { headerArgs: '{c}' }],
        ['Protein', []],
      ]],
      ['Cell biology', [
        ['Organelle', [
          ['Mitochondrion', []],
          ['Ribosome', [
            ['Ribosome large subunit', []],
            ['Ribosome small subunit', []],
          ]],
        ]],
      ]],
    ], { toplevel: true }],
  ]],
  ['Test data',
    [
      ['Test scope', [
        ['Test scope 1', [
            ['Test scope 1 1', []],
            ['Test scope 1 2', []],
          ],
          {
            headerArgs: '{tag=Test subscope 1/Test subscope 1 1}',
            body: '<Test subscope 1/Test subscope 1 1>',
          }
        ],
        ['Test scope 2', []],
        ['Test subscope 1', [
            ['Test subscope 1 2', [
              ['Test subscope 1 1', [], {
                headerArgs: '{tag=Test outside scope}\n{tag=Test scope 1}',
                body: '<Test outside scope>\n\n<Test scope 1>\n',
              }],
            ]],
          ],
          {
            headerArgs: '{scope}'
          }
        ],
        ['Test subscope 2', [
          ['Test subscope 2 1', []],
          ['Test subscope 2 2', []],
        ], { headerArgs: '{scope}' }],
        ['Test synonymNoScope inside', [], { body: '= Test synonymNoScope outside\n{synonymNoScope}\n' }],
      ], { headerArgs: '{scope}' }],
      ['Test outside scope', [], {
        headerArgs: '{tag=Test scope/Test subscope 1/Test subscope 1 1}'
      }],
      ['Barack Obama',
        [
          ['Mathematics', []],
        ],
        {
          headerArgs: '{scope}',
          body: `This is a a scope named after the first test user.

The goal of this is to test this possible edge case of short URL fragments e.g. when you visit <mathematics>.
`,
        }
      ],
      ['Test tag', [
         ['Test tagged 1', [], { headerArgs: [...Array(NTAGS).keys()].map(i => `{tag=Test tagger ${i + 1}}\n`).join('') + '{wiki}' }],
         ...[...Array(NTAGS - 1).keys()].map(i => [`Test tagged ${i + 2}`, [], { headerArgs: '{tag=Test tagger 1}\n{wiki}' }]),
         ...[...Array(NTAGS).keys()].map(i => [`Test tagger ${i + 1}`, []])
      ]],
      ['Test wiki', [], { headerArgs: '{wiki}' }],
      ['Test child', [
        ['Test child 1', [
          ['Test child 1 1', []],
        ], { body: `Link to outsider: <test child 2>

Link to parent: <test child>

Link to external subelement: <equation my favorite equation>

Link to external subelement long: <equation Test data long before ID>

Link to child: <test child 1 1>

Link to external: http://example.com

Link to topic: <#mathematics>
` }],
        ['Test child 2', [], { body: `Link to synonym: <Test child with synonym 2>
` }],
        ['Test child with synonym', [], {
          body: `= Test child with synonym 2
{synonym}

= Test child with synonym 3
{synonym}
`
        }],
      ]],
      // This is used to check if we are jumping to an ID at the bottom of the page correctly.
      ['Test data long before ID', [], { body: [...Array(50).keys()].map(i => `spacer ${i}\n\n`).join('') +
         `$$\\frac{1}{\\sqrt{2}}$\${title=Test data long before ID}` }],
      ['Test data disambiguate',
        [['Test data disambiguate child 1', []]],
        { headerArgs: '{disambiguate=specific type 1}' }
      ],
      ['Test data disambiguate',
        [['Test data disambiguate child 2', []]],
        { headerArgs: '{disambiguate=specific type 2}' }
      ],
      ['Test data disambiguate next sibling', []],
      ['Test data unlisted', [
        ['Test data unlisted listed child', []],
        ['Test data unlisted unlisted child', [], { list: false }],
      ], { body: `This article is unlisted.\n\nIt shouldn't appear on ToCs, topics and most other listings by default.`, list: false }],
      ['Test data with title2', [], { body: `{title2=My title 2}` }],
      ['Test data with math on title $\\frac{1}{\\sqrt{2}}$', []],
      ['Test data with code on title `int main() { return 0; }`', []],
      ['test/subdir/myfile.txt', [], { body: `{file}

This is about my amazing file \\a[myfile.txt]
` }],
    ],
    {
      body: `Block math: <equation My favorite equation>

$$
\\frac{1}{\\sqrt{2}}
$$
{title=My favorite equation}

And another: <equation My second favorite equation>

$$
\\frac{1}{\\sqrt{2}}
$$
{title=My second favorite equation}

Ourbigbook defined LaTeX macro: $\\abs{x}$

Empty link to home page: <>

Link followed by nbsp literal: http://example.com\u{00A0}asdf qwer

Link to self: <test data>{id=x-link-to-self-header}

Link to child: <test child>{id=x-link-to-child}
`
    }
  ],
]
const issueData = [
  ['Test issue', `Link to article: <test data>

Link to ID in this issue: <test issue 2>

== Test issue 2

=== Test issue 3

== Test child

Conflict resolution between issue IDs and article IDs:

* to issue: <test child>
* to article: </test child>
`],
  ['There\'s a typo in this article at "mathmatcs"', ``],
  ['Add mention of the fundamental theorem of calculus', `The fundamental theorem of calculus is very important to understanding this subject.

I would add something like:
\\Q[
The reason why the superconductor laser is blue, is due to the integral of its resonance modes.

From the fundamental theorem of calculus, we understand that this is because the derivative of the temperature is too small.
]

== Also mentions Newton's rule

As an added bonus, a mention of Newton's rule would also be very useful.
`],
  ['$\\sqrt{1 + 1} = 3$, not 2 as mentioned', 'I can\'t believe you got such a basic fact wrong!'],
  ['The code `f(x) + 1` should be `f(x) + 2`', 'Zero indexing always gets me too.'],
]
const commentData = [
  `= My test comment

Link to ID in comment: <My test comment 2>

Link to article: <test data>

== My test comment 2

=== My test comment 3

== Test child

Conflict resolution between issue IDs and article IDs:

* to issue: <test child>
* to article: </test child>
`,
//  `My test comment without a header
//
//Link to article: <test data>
//`,
  'Thanks, you\'re totally right, I\'ll look into it!',
  'Just fixed the issue on a new edit, thanks.',
  `= Why I think you are stupid

Here's my essay with irrefutable proof, notably: <I don't think this is correct>.

== I don't think this is correct.

Consider what happens when $\\sqrt{a + b} > 0$`,
  `Ah, maybe. But are you sure that the sum of:
\`\`
f() + 3*g()
\`\`
is enough to make the loop terminate?
`,
]
let todo_visit = articleData.map(a => [null, a])
let articleDataCount = 0
while (todo_visit.length > 0) {
  let [parentEntry, entry] = todo_visit.pop()
  const { children, opts } = expandArticleDataEntry(entry)
  entry[2] = opts
  if (parentEntry) {
    opts.parentEntry = parentEntry
  }
  for (let i = children.length - 1; i >= 0; i--) {
    const child = children[i]
    todo_visit.push([entry, child]);
    if (i > 0) {
      const { opts } = expandArticleDataEntry(child)
      child[2] = opts
      opts.previousSiblingEntry = children[i - 1]
    }
  }
  articleDataCount++
}

class ArticleDataProvider {
  constructor(articleData, userIdx) {
    // These store the current tree transversal state across .get calls.
    this.gen = 0
    this.todo_visit = articleData.slice()
    // Set of all entries we visited that don't have a parent.
    // We will want to include those from the toplevel index.
    this.toplevelTitleToEntry = {}
  }

  // Pre order depth first transversal to ensure that parents are created before children.
  pop() {
    if (this.todo_visit.length === 0) {
      this.todo_visit = articleData.slice()
      this.gen++
    }
    while (this.todo_visit.length !== 0) {
      let entry = this.todo_visit.pop();
      entry = Object.assign({}, entry)
      let title = entry[0]
      if (this.gen > 0) {
        title = `${title} v${this.gen}`
        entry[0] = title
      }
      let children = entry[1]
      for (let i = 0; i < children.length; i++) {
        this.todo_visit.push(children[i]);
      }
      this.toplevelTitleToEntry[title] = entry
      this.globalI++
      return entry
    }
  }
}

async function generateDemoData(params) {
  // Input Param defaults.
  const nUsers = params.nUsers === undefined ? 2 : params.nUsers
  const nArticlesPerUser = params.nArticlesPerUser === undefined ? articleDataCount : params.nArticlesPerUser
  const nMaxIssuesPerArticle = params.nMaxIssuesPerArticle === undefined ? 3 : params.nMaxIssuesPerArticle
  const nMaxCommentsPerIssue = params.nMaxCommentsPerIssue === undefined ? 3 : params.nMaxCommentsPerIssue
  const nFollowsPerUser = params.nFollowsPerUser === undefined ? 2 : params.nFollowsPerUser
  const nLikesPerUser = params.nLikesPerUser === undefined ? 20 : params.nLikesPerUser
  const directory = params.directory
  const basename = params.basename
  const verbose = params.verbose === undefined ? false : params.verbose
  const empty = params.empty === undefined ? false : params.empty
  const clear = params.clear === undefined ? false : params.clear

  const nArticles = nUsers * nArticlesPerUser
  const sequelize = models.getSequelize(directory, basename);
  const { Article, Id, User } = sequelize.models
  const katex_macros = back_js.preloadKatex()
  await models.sync(sequelize, { force: empty || clear })
  if (!empty) {
    const sourceRoot = path.join(__dirname, 'tmp', 'demo')
    fs.rmSync(sourceRoot, { recursive: true, force: true });

    if (verbose) printTimeNow = now()
    if (verbose) console.error('User');
    const userArgs = [];
    for (let i = 0; i < nUsers; i++) {
      let [displayName, image] = userData[i % userData.length]
      let username = ourbigbook.titleToId(displayName)
      if (i >= userData.length) {
        username = `user${i}`
        displayName = `User${i}`
        image = undefined
      }
      const userArg = {
        username,
        displayName,
        email: `user${i}@mail.com`,
        verified: true,
      }
      if (image) {
        userArg.image = image
      }
      User.setPassword(userArg, process.env.OURBIGBOOK_DEMO_USER_PASSWORD || 'asdf')
      userArgs.push(userArg)
    }
    const users = []
    const userIdToUser = {}
    for (const userArg of userArgs) {
      let user = await User.findOne({ where: { username: userArg.username } })
      if (user) {
        Object.assign(user, userArg)
        await user.save()
      } else {
        user = await User.create(userArg)
      }
      userIdToUser[user.id] = user
      users.push(user)
    }
    // TODO started livelocking after we started creating index articles on hooks.
    //const users = await User.bulkCreate(
    //  userArgs,
    //  {
    //    validate: true,
    //    individualHooks: true,
    //  }
    //)
    if (verbose) printTime()

    if (verbose) console.error('UserFollowUser');
    for (let i = 0; i < nUsers; i++) {
      let nFollowsPerUserEffective = nUsers < nFollowsPerUser ? nUsers : nFollowsPerUser
      for (var j = 0; j < nFollowsPerUserEffective; j++) {
        const follower = users[i]
        const followed = users[(i + 1 + j) % nUsers]
        if (!(await follower.hasFollow(followed))) {
          await follower.addFollowSideEffects(followed)
        }
      }
    }

    if (verbose) printTime()

    if (verbose) console.error('Article');
    const articleDataProviders = {}
    const articleIdToArticle = {}
    for (let userIdx = 0; userIdx < nUsers; userIdx++) {
      let authorId = users[userIdx].id
      articleDataProvider = new ArticleDataProvider(articleData, userIdx)
      articleDataProviders[authorId] = articleDataProvider
    }
    const articleArgs = [];
    const toplevelTopicIds = new Set()
    let dateI = 0
    async function makeArticleArg(articleDataEntry, i, authorId) {
      const date = addDays(date0, dateI)
      dateI++
      articleDataEntry.articleIdx = i
      let { titleSource, headerArgs, children, opts } = expandArticleDataEntry(articleDataEntry)
      headerArgs = opts.headerArgs
      if (headerArgs === undefined) {
        headerArgs = ''
      } else {
        headerArgs += '\n\n'
      }
      let body = opts.body
      if (body === undefined) {
        body = makeBody(titleSource)
      }
      const id_noscope = await titleToId(titleSource)
      toplevelTopicIds.add(id_noscope)
      return {
        titleSource,
        authorId,
        createdAt: date,
        // TODO not taking effect. Appears to be because of the hook.
        updatedAt: date,
        bodySource: `${headerArgs}${body}`,
        opts,
      }
    }
    for (let userIdx = 0; userIdx < nUsers; userIdx++) {
      const authorId = users[userIdx].id
      const articleDataProvider = articleDataProviders[authorId]

      // All other articles.
      for (let i = 0; i < nArticlesPerUser; i++) {
        const articleArg = await makeArticleArg(articleDataProvider.pop(), i, authorId)
        if (articleArg) {
          articleArgs.push(articleArg)
        }
      }

      // Custom home message.
      articleArgs.push({
        titleSource: '',
        authorId,
        createdAt: date0,
        // TODO not taking effect. Appears to be because of the hook.
        updatedAt: date0,
        bodySource: `${User.defaultIndexBody}
Link to home: <>

Link to test data: <test data>
`,
        opts: { parentEntry: undefined },
      })
    }

    //// Sort first by topic id, and then by user id to mix up votes a little:
    //// otherwise user0 gets all votes, then user1, and so on.
    //articleArgs.sort((a, b) => {
    //  if (a.title < b.title) {
    //    return -1
    //  } else if(a.title > b.title) {
    //    return 1
    //  } else if(a.authorId < b.authorIdtitle) {
    //    return -1
    //  } else if(a.authorId > b.authorIdtitle) {
    //    return 1
    //  } else {
    //    return 0;
    //  }
    //})
    const articles = []
    for (const render of [false, true]) {
      let articleId = 0
      let i = 0
      let pref
      if (verbose) {
        if (render) {
          pref = 'render'
        } else {
          pref = 'extract_ids'
        }
      }
      for (const articleArg of articleArgs) {
        const msg = `${pref}: ${i + 1}/${articleArgs.length}: ${userIdToUser[articleArg.authorId].username}/${articleArg.titleSource}`
        if (verbose) console.error(msg);
        const author = userIdToUser[articleArg.authorId]
        let opts = articleArg.opts
        if (opts === undefined) {
          opts = {}
        }

        let parentId
        {
          const parentEntry = opts.parentEntry
          if (parentEntry) {
            ;({ opts: parentOpts } = expandArticleDataEntry(parentEntry))
            parentId = `${ourbigbook.AT_MENTION_CHAR}${author.username}/${parentOpts.topicId}`
          } else {
            if (!opts.hasOwnProperty('parentEntry')) {
              parentId = `${ourbigbook.AT_MENTION_CHAR}${author.username}`
            }
          }
        }
        const before = now();
        const { articles: newArticles, extra_returns } = await convert.convertArticle({
          author,
          bodySource: articleArg.bodySource,
          convertOptionsExtra: { katex_macros },
          enforceMaxArticles: false,
          list: opts.list,
          parentId,
          path: opts.path,
          render,
          sequelize,
          titleSource: articleArg.titleSource,
        })
        const after = now();
        opts.topicId = extra_returns.context.header_tree.children[0].ast.id.substring(
          ourbigbook.AT_MENTION_CHAR.length + author.username.length + 1)
        if (verbose) console.error(`${msg} finished in ${after - before}ms`);
        for (const article of newArticles) {
          articleIdToArticle[article.id] = article
          articles.push(article)
          articleId++
        }
        i++
      }
    }

    // Create an article in a topic that exists only for user0. All other articles exist for all users.
    // This is useful to test that case which hsa UI implications such as "show create new vs view mine".
    let articleManyDiscussions
    if (users.length) {
      const parentId = `${ourbigbook.AT_MENTION_CHAR}${users[0].username}/${await titleToId('Test data')}`
      const parentIdObj = await Id.findOne({ where: { idid: parentId } })
      let articleUser0Only
      if (parentIdObj) {
        const { articles } = await convert.convertArticle({
          author: users[0],
          bodySource: 'This topic only exists for the first user.\n',
          convertOptionsExtra: { katex_macros },
          parentId,
          render: true,
          sequelize,
          titleSource: 'Test data user0 only',
        })
        articleUser0Only = articles[0]
      }
      if (articleUser0Only) {
        const { articles } = await convert.convertArticle({
          author: users[0],
          bodySource: 'This article has many discussions. To test article discussion pagination.',
          convertOptionsExtra: { katex_macros },
          parentId: `${ourbigbook.AT_MENTION_CHAR}${articleUser0Only.slug}`,
          render: true,
          sequelize,
          titleSource: 'Test data article with many discussions',
        })
        articleManyDiscussions = articles[0]
      }
    }

    // Write files to filesystem.
    // https://docs.ourbigbook.com/demo-data-local-file-output
    for (const user of users) {
      const articles = (await Article.getArticles({
        author: user.username,
        count: false,
        sequelize,
      }))
      for (const article of articles) {
        const slugParse = path.parse(article.slug)
        let outdir, outbase_noext
        if (slugParse.dir) {
          outdir = path.join(sourceRoot, slugParse.dir)
          outbase_noext = slugParse.base
        } else {
          // Toplevel index.
          outdir = path.join(sourceRoot, slugParse.base)
          outbase_noext = ourbigbook.INDEX_BASENAME_NOEXT
        }
        fs.mkdirSync(outdir, { recursive: true })
        const outpath = path.join(outdir, outbase_noext + '.' + ourbigbook.OURBIGBOOK_EXT)
        fs.writeFileSync(outpath, await article.getSourceExport());
      }
      fs.writeFileSync(path.join(sourceRoot, user.username, ourbigbook.OURBIGBOOK_JSON_BASENAME), '{}\n');
    }

    // TODO This was livelocking (taking a very long time, live queries)
    // due to update_database_after_convert on the hook it would be good to investigate it.
    // Just converted to the regular for loop above instead.
    //const articles = await Article.bulkCreate(
    //  articleArgs,
    //  {
    //    validate: true,
    //    individualHooks: true,
    //  }
    //)

    if (verbose) printTime()

    if (verbose) console.error('Like');
    let articleIdx = 0
    for (let i = 0; i < nUsers; i++) {
      const user = users[i]
      for (let j = 0; j < nLikesPerUser; j++) {
        const article = articles[(i * j) % nArticles];
        if (
          article
          && article.file.authorId !== user.id
        ) {
          if (!(await user.hasLikedArticle(article))) {
            await user.addArticleLikeSideEffects(article)
          }
          if (!(await user.hasFollowedArticle(article))) {
            await user.addArticleFollowSideEffects(article)
          }
        }
      }
    }

    // 0.5s faster than the addArticleLikeSideEffects version, total run 7s.
    //let articleIdx = 0
    //const likeArgs = []
    //for (let i = 0; i < nUsers; i++) {
    //  const userId = users[i].id
    //  for (var j = 0; j < nLikesPerUser; j++) {
    //    likeArgs.push({
    //      userId: userId,
    //      articleId: articles[articleIdx % nArticles].id,
    //    })
    //    articleIdx += 1
    //  }
    //}
    //await sequelize.models.UserLikeArticle.bulkCreate(likeArgs)
    if (verbose) printTime()

    if (verbose) console.error('Issue');
    const issues = [];
    let issueIdx = 0;
    await sequelize.models.Issue.destroy({ where: { authorId: users.map(user => user.id) } })
    for (let i = 0; i < nArticles; i++) {
      let articleIssueIdx = 0;
      const article = articles[i]
      for (var j = 0; j < (i % (nMaxIssuesPerArticle + 1)); j++) {
        if (verbose) console.error(`${article.slug}#${articleIssueIdx}`)
        const [titleSource, bodySource] = issueData[issueIdx % issueData.length]
        const issue = await convert.convertDiscussion({
          article,
          bodySource,
          date: ISSUE_DATE,
          number: articleIssueIdx + 1,
          sequelize,
          titleSource,
          user: users[issueIdx % nUsers],
        })
        issue.article = article
        issues.push(issue)
        issueIdx++
        articleIssueIdx++
      }
    }
    const nIssues = issueIdx
    if (verbose) printTime()

    if (verbose) console.error('Comment');
    const comments = [];
    let commentIdx = 0;
    await sequelize.models.Comment.destroy({ where: { authorId: users.map(user => user.id) } })
    for (let i = 0; i < nIssues; i++) {
      let issueCommentIdx = 0;
      const issue = issues[i]
      for (var j = 0; j < (i % (nMaxCommentsPerIssue + 1)); j++) {
        if (verbose) console.error(`${articleIdToArticle[issue.articleId].slug}#${issue.number}#${issueCommentIdx}`)
        const source = commentData[commentIdx % commentData.length]
        const comment = await convert.convertComment({
          date: ISSUE_DATE,
          issue,
          source,
          number: issueCommentIdx + 1,
          sequelize,
          user: users[commentIdx % nUsers],
        })
        comments.push(comment)
        commentIdx++
        issueCommentIdx++
      }
    }
    if (verbose) printTime()

    // Create an article in a topic that exists only for user0. All other articles exist for all users.
    // This is useful to test that case which hsa UI implications such as "show create new vs view mine".
    if (articleManyDiscussions) {
      let issueManyComments
      for (let i = 0; i < config.articleLimit + 2; i++) {
        const article =  articleManyDiscussions
        if (verbose) console.error(`${article.slug}#${i}`)
        const issue = await convert.convertDiscussion({
          article,
          bodySource: `Many discussions body ${i}.`,
          date: ISSUE_DATE,
          number: i + 1,
          sequelize,
          titleSource: `Many discussions title ${i}`,
          user: users[0],
        })
        if (i === 0) {
          issueManyComments = issue
        }
      }
      for (let i = 0; i < config.articleLimit + 2; i++) {
        if (verbose) console.error(`${articleManyDiscussions.slug}#${issueManyComments.number}#${i}`)
        await convert.convertComment({
          date: ISSUE_DATE,
          issue: issueManyComments,
          source: `Many comments body ${i}.`,
          number: i + 1,
          sequelize,
          user: users[0],
        })
      }
    }
  }

  return sequelize
}
exports.generateDemoData = generateDemoData

function expandArticleDataEntry(articleDataEntry) {
  let titleSource, children, opts
  if (articleDataEntry === undefined) {
    titleSource = `My title ${articleDataEntry.articleIdx * (userIdx + 1)}`
    children = []
    opts = {}
  } else {
    titleSource = articleDataEntry[0]
    children = articleDataEntry[1]
    opts = articleDataEntry[2] || {}
  }
  return { titleSource, children, opts }
}

function makeBody(titleSource) {
  return `This is a section about ${titleSource}!

${titleSource} is a very important subject about which there is a lot to say.

For example, this sentence. And then another one.
`
/*
`This is a section about ${titleSource}!

${refsString}\\i[Italic]

\\b[Bold]

http://example.com[External link]

Inline code: \`int main() { return 1; }\`

Code block:
\`\`
function myFunc() {
  return 1;
}
\`\`

Inline math: $\\sqrt{1 + 1}$

Block math and a reference to it: \\x[equation-in-${id_noscope}]:
$$\\frac{1}{\\sqrt{2}}$$\{id=equation-in-${id_noscope}}

Block quote:
\\Q[
To be or not to be.

That is the question.
]

List:
* item 1
* item 2
* item 3

Table:
|| String col
|| Integer col
|| Float col

| ab
| 2
| 10.1

| a
| 10
| 10.2

| c
| 2
| 3.4

| c
| 3
| 3.3

Reference to the following image: \\x[image-my-xi-chrysanthemum-${id_noscope}].

\\Image[https://raw.githubusercontent.com/cirosantilli/media/master/Chrysanthemum_Xi_Jinping_with_black_red_liusi_added_by_Ciro_Santilli.jpg]
{title=Xi Chrysanthemum is a very nice image}
{id=image-my-xi-chrysanthemum-${id_noscope}}
{source=https://commons.wikimedia.org/wiki/File:Lotus_flower_(978659).jpg}

An YouTube video: \\x[video-sample-youtube-video-in-${id_noscope}].

\\Video[https://youtube.com/watch?v=YeFzeNAHEhU&t=38]
{title=Sample YouTube video in ${titleSource}}${includesString}
`,
*/
}

async function titleToId(titleSource) {
  return ourbigbook.titleToId(
    await ourbigbook.convert(
      titleSource,
      { output_format: ourbigbook.OUTPUT_FORMAT_ID }
    )
  )
}