[
  {
    "path": ".gitignore",
    "content": "node_modules\nout\nslack-archive\n.DS_Store\n*.log\n.token\nlib"
  },
  {
    "path": ".node-version",
    "content": "16.4.0\n"
  },
  {
    "path": ".npmignore",
    "content": "node_modules\nout\nslack-archive\n.DS_Store\n*.log\n.token\nsrc"
  },
  {
    "path": "README.md",
    "content": "# Export your Slack workspace as static HTML\n\nAlright, so you want to export all your messages on Slack. You want them in a format that you\ncan still enjoy in 20 years. This tool will help you do that.\n\n * **Completely static**: The generated files are pure HTML and will still work in 50 years.\n * **Everything you care about**: This tool downloads messages, files, and avatars.\n * **Nothing you do not care about**: Choose exactly which channels and DMs to download.\n * **All types of conversations**: We'll fetch public channels, private channels, DMs, and multi-person DMs.\n * **Incremental backups**: If you already have local data, we'll extend it - no need to download existing stuff again.\n * **JSON included**: All data is also stored as JSON, so you can consume it with other tools later.\n * **No cloud, free**: Do all of this for free, without giving anyone your information.\n * **Basic search**: Offers basic search functionality.\n\n<img width=\"1151\" alt=\"Screen Shot 2021-09-09 at 6 43 55 PM\" src=\"https://user-images.githubusercontent.com/1426799/132776566-0f75a1b4-4b9a-4b53-8a39-e44e8a747a68.png\">\n\n## Using it\n\n1. Do you already have a user token for your workspace? If not, read on below on how to get a token.\n2. Make sure you have [`node` and `npm`](https://nodejs.org/en/) installed, ideally something newer than Node v14.\n3. Run `slack-archive`, which will interactively guide you through the options.\n\n```sh\nnpx slack-archive\n```\n\n### Parameters\n\n```\n--automatic:                Don't prompt and automatically fetch all messages from all channels.\n--use-previous-channel-config: Fetch messages from channels selected in previous run instead of prompting.\n--channel-types             Comma-separated list of channel types to fetch messages from.\n                            (public_channel, private_channel, mpim, im)\n--exclude-channels          Comma-separated list of channels to exclude, in automatic mode\n--no-backup:                Don't create backups. Not recommended.\n--no-search:                Don't create a search file, saving disk space.\n--no-file-download:         Don't download files.\n--no-slack-connect:         Don't connect to Slack, just generate HTML from local data.\n--force-html-generation:    Force regeneration of HTML files. Useful after slack-archive upgrades.\n```\n\n## Getting a token\n\nIn order to download messages from private channels and direct messages, we will need a \"user\ntoken\". Slack uses the token to identify what permissions it'll give this app. We used to be able\nto just copy a token out of your Slack app, but now, we'll need to create a custom app and jump\nthrough a few hoops.\n\nThis will be mostly painless, I promise.\n\n### 1) Make a custom app\n\nHead over to https://api.slack.com/apps and `Create New App`. Select `From scratch`.\nGive it a name and choose the workspace you'd like to export.\n\nThen, from the `Features` menu on the left, select `OAuth & Permission`. \n\nAs a redirect URL, enter something random that doesn't actually exist, or a domain you control. For instace:\n\n```\nhttps://notarealurl.com/\n```\n\n(Note that redirects will take a _very_ long time if using a domain that doesn't actually exist)\n\nThen, add the following `User Token Scopes`:\n\n * channels:history\n * channels:read\n * files:read\n * groups:history\n * groups:read\n * im:history\n * im:read\n * mpim:history\n * mpim:read\n * remote_files:read\n * users:read\n\nFinally, head back to `Basic Information` and make a note of your app's `client\nid` and `client secret`. We'll need both later.\n\n### 2) Authorize\n\nMake sure you have your Slack workspace `URL` (aka team name) and your app's `client id`.\nThen, in a browser, open this URL - replacing `{your-team-name}` and `{your-client-id}`\nwith your values.\n\n```\nhttps://{your-team-name}.slack.com/oauth/authorize?client_id={your-client-id}&scope=client\n```\n\nConfirm everything until Slack sends you to the mentioned non-existent URL. Look at your\nbrowser's address bar - it should contain an URL that looks like this:\n\n```\nhttps://notarealurl.com/?code={code}&state=\n```\n\nCopy everything between `?code=` and `&state`. This is your `code`. We'll need it in the\nnext step.\n\nNext, we'll exchange your code for a token. To do so, we'll also need your `client secret` \nfrom the first step when we created your app. In a browser, open this URL - replacing \n`{your-team-name}`, `{your-client-id}`, `{your-code}` and `{your-client-secret}` with \nyour values.\n\n```\nhttps://{your-team-name}.slack.com/api/oauth.access?client_id={your-client-id}&client_secret={your-client-secret}&code={your-code}\n```\n\nYour browser should now be returning some JSON including a token. Make a note of it - that's what we'll use. Paste it in the command line, OR create a file called `.token` in the slack-archive directory (created when the command is first run) and paste it in there.\n"
  },
  {
    "path": "bin/slack-archive.js",
    "content": "#!/usr/bin/env node\n\nimport('../lib/cli.js')\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"slack-archive\",\n  \"version\": \"1.6.1\",\n  \"description\": \"Create static HTML archives for your Slack workspaces\",\n  \"scripts\": {\n    \"prettier\": \"npx prettier --write src/*\",\n    \"cli\": \"ts-node src/cli.ts\",\n    \"html\": \"ts-node src/create-html.tsx\",\n    \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\",\n    \"compile\": \"tsc\",\n    \"watch\": \"tsc -w\",\n    \"prepublishOnly\": \"npm run compile\"\n  },\n  \"bin\": {\n    \"slack-archive\": \"./bin/slack-archive.js\"\n  },\n  \"type\": \"module\",\n  \"keywords\": [\n    \"slack\",\n    \"export\",\n    \"download\"\n  ],\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/felixrieseberg/slack-archive.git\"\n  },\n  \"author\": \"Felix Rieseberg <felix@felixrieseberg.com>\",\n  \"license\": \"MIT\",\n  \"dependencies\": {\n    \"@slack/web-api\": \"^6.7.2\",\n    \"date-fns\": \"^2.28.0\",\n    \"emoji-datasource\": \"^14.0.0\",\n    \"es-main\": \"^1.0.2\",\n    \"fs-extra\": \"^10.1.0\",\n    \"inquirer\": \"^8.2.0\",\n    \"lodash-es\": \"^4.17.21\",\n    \"node-fetch\": \"^2.6.7\",\n    \"ora\": \"^6.1.0\",\n    \"react\": \"^17.0.2\",\n    \"react-dom\": \"^17.0.2\",\n    \"rimraf\": \"^5.0.5\",\n    \"slack-markdown\": \"^0.2.0\",\n    \"trash\": \"^8.1.0\"\n  },\n  \"devDependencies\": {\n    \"@types/date-fns\": \"^2.6.0\",\n    \"@types/fs-extra\": \"^9.0.13\",\n    \"@types/inquirer\": \"^8.1.3\",\n    \"@types/lodash-es\": \"^4.17.5\",\n    \"@types/node\": \"^17.0.5\",\n    \"@types/node-fetch\": \"^2.5.12\",\n    \"@types/react\": \"^17.0.38\",\n    \"@types/react-dom\": \"^17.0.11\",\n    \"ts-node\": \"^10.8.1\",\n    \"tslib\": \"^2.4.0\",\n    \"typescript\": \"^4.7.4\"\n  },\n  \"ts-node\": {\n    \"files\": true\n  }\n}\n"
  },
  {
    "path": "src/ambient.d.ts",
    "content": "declare module \"slack-markdown\";\ndeclare module \"es-main\";\ndeclare module \"emoji-datasource\";\n"
  },
  {
    "path": "src/archive-data.ts",
    "content": "import fs from \"fs-extra\";\n\nimport { SLACK_ARCHIVE_DATA_PATH } from \"./config.js\";\nimport { readJSON } from \"./data-load.js\";\nimport { write } from \"./data-write.js\";\nimport { SlackArchiveData, User } from \"./interfaces.js\";\n\nexport async function getSlackArchiveData(): Promise<SlackArchiveData> {\n  const returnIfEmpty: SlackArchiveData = { channels: {} };\n\n  if (!fs.existsSync(SLACK_ARCHIVE_DATA_PATH)) {\n    return returnIfEmpty;\n  }\n\n  const result = await readJSON<SlackArchiveData>(SLACK_ARCHIVE_DATA_PATH);\n  const merged = { channels: result.channels || {}, auth: result.auth };\n\n  return merged;\n}\n\nexport async function setSlackArchiveData(\n  newData: SlackArchiveData\n): Promise<void> {\n  const oldData = await getSlackArchiveData();\n  const dataToWrite = {\n    channels: { ...oldData.channels, ...newData.channels },\n    auth: newData.auth,\n  };\n\n  return write(\n    SLACK_ARCHIVE_DATA_PATH,\n    JSON.stringify(dataToWrite, undefined, 2)\n  );\n}\n"
  },
  {
    "path": "src/backup.ts",
    "content": "import fs from \"fs-extra\";\nimport inquirer from \"inquirer\";\nimport path from \"path\";\nimport trash from \"trash\";\nimport { rimraf } from \"rimraf\";\n\nimport { AUTOMATIC_MODE, DATA_DIR, NO_BACKUP, OUT_DIR } from \"./config.js\";\n\nconst { prompt } = inquirer;\n\nlet backupDir = `${DATA_DIR}_backup_${Date.now()}`;\n\nexport async function createBackup() {\n  if (NO_BACKUP || !fs.existsSync(DATA_DIR)) {\n    return;\n  }\n\n  const hasFiles = fs.readdirSync(DATA_DIR);\n\n  if (hasFiles.length === 0) {\n    return;\n  }\n\n  console.log(`Existing data directory found. Creating backup: ${backupDir}`);\n\n  await fs.copy(DATA_DIR, backupDir);\n\n  console.log(`Backup created.\\n`);\n}\n\nexport async function deleteBackup() {\n  if (!fs.existsSync(backupDir)) {\n    return;\n  }\n\n  console.log(\n    `Cleaning up backup: If anything went wrong, you'll find it in your system's trash.`\n  );\n\n  try {\n    // NB: trash doesn't work on many Linux distros\n    await trash(backupDir);\n    return;\n  } catch (error) {\n    console.log('Moving backup to trash failed.');\n  }\n\n  if (!process.env['TRASH_HARDER']) {\n    console.log(`Set TRASH_HARDER=1 to delete files permanently.`);\n    return;\n  }\n\n  try {\n    await rimraf(backupDir);\n  } catch (error) {\n    console.log(`Deleting backup permanently failed. Aborting here.`);\n  }\n}\n\nexport async function deleteOlderBackups() {\n  try {\n    const oldBackupNames: Array<string> = [];\n    const oldBackupPaths: Array<string> = [];\n\n    for (const entry of fs.readdirSync(OUT_DIR)) {\n      const isBackup = entry.startsWith(\"data_backup_\");\n      if (!isBackup) continue;\n\n      const dir = path.join(OUT_DIR, entry);\n      const { isDirectory } = fs.statSync(dir);\n      if (!isDirectory) continue;\n\n      oldBackupPaths.push(dir);\n      oldBackupNames.push(entry);\n    }\n\n    if (oldBackupPaths.length === 0) return;\n\n    if (AUTOMATIC_MODE) {\n      console.log(\n        `Found existing older backups, but in automatic mode: Proceeding without deleting them.`\n      );\n      return;\n    }\n\n    const { del } = await prompt([\n      {\n        type: \"confirm\",\n        default: true,\n        name: \"del\",\n        message: `We've found existing backups (${oldBackupNames.join(\n          \", \"\n        )}). Do you want to delete them?`,\n      },\n    ]);\n\n    if (del) {\n      oldBackupPaths.forEach((v) => fs.removeSync(v));\n    }\n  } catch (error) {\n    // noop\n  }\n}\n"
  },
  {
    "path": "src/channels.ts",
    "content": "import {\n  ConversationsListArguments,\n  ConversationsListResponse,\n} from \"@slack/web-api\";\nimport ora from \"ora\";\nimport { NO_SLACK_CONNECT } from \"./config.js\";\n\nimport { Channel, Users } from \"./interfaces.js\";\nimport { downloadUser, getName } from \"./users.js\";\nimport { getWebClient } from \"./web-client.js\";\n\nexport function getChannelName(channel: Channel) {\n  return (\n    channel.name || channel.id || channel.purpose?.value || \"Unknown channel\"\n  );\n}\n\nexport function isPublicChannel(channel: Channel) {\n  return !channel.is_private && !channel.is_mpim && !channel.is_im;\n}\n\nexport function isPrivateChannel(channel: Channel) {\n  return channel.is_private && !channel.is_im && !channel.is_mpim;\n}\n\nexport function isDmChannel(channel: Channel, users: Users) {\n  return channel.is_im && channel.user && !users[channel.user]?.is_bot;\n}\n\nexport function isBotChannel(channel: Channel, users: Users) {\n  return channel.user && users[channel.user]?.is_bot;\n}\n\nfunction isChannels(input: any): input is ConversationsListResponse {\n  return !!input.channels;\n}\n\nexport async function downloadChannels(\n  options: ConversationsListArguments,\n  users: Users\n): Promise<Array<Channel>> {\n  const channels: Array<Channel> = [];\n\n  if (NO_SLACK_CONNECT) {\n    return channels;\n  }\n\n  const spinner = ora(\"Downloading channels\").start();\n\n  for await (const page of getWebClient().paginate(\n    \"conversations.list\",\n    options\n  )) {\n    if (isChannels(page)) {\n      spinner.text = `Found ${page.channels?.length} channels (found so far: ${\n        channels.length + (page.channels?.length || 0)\n      })`;\n\n      const pageChannels = (page.channels || []).filter((c) => !!c.id);\n\n      for (const channel of pageChannels) {\n        if (channel.is_im) {\n          const user = await downloadUser(channel, users);\n          channel.name =\n            channel.name || `${getName(user?.id, users)} (${user?.name})`;\n        }\n\n        if (channel.is_mpim) {\n          channel.name = channel.purpose?.value;\n        }\n      }\n\n      channels.push(...pageChannels);\n    }\n  }\n\n  spinner.succeed(`Found ${channels.length} channels`);\n\n  return channels;\n}\n"
  },
  {
    "path": "src/cli.ts",
    "content": "import { uniqBy } from \"lodash-es\";\nimport inquirer from \"inquirer\";\nimport fs from \"fs-extra\";\nimport { User } from \"@slack/web-api/dist/response/UsersInfoResponse\";\nimport { Channel } from \"@slack/web-api/dist/response/ConversationsListResponse\";\nimport ora from \"ora\";\n\nimport {\n  CHANNELS_DATA_PATH,\n  USERS_DATA_PATH,\n  getChannelDataFilePath,\n  OUT_DIR,\n  config,\n  TOKEN_FILE,\n  AUTOMATIC_MODE,\n  USE_PREVIOUS_CHANNEL_CONFIG,\n  CHANNEL_TYPES,\n  DATE_FILE,\n  EMOJIS_DATA_PATH,\n  NO_SLACK_CONNECT,\n  EXCLUDE_CHANNELS,\n} from \"./config.js\";\nimport { downloadExtras } from \"./messages.js\";\nimport { downloadMessages } from \"./messages.js\";\nimport { downloadFilesForChannel } from \"./download-files.js\";\nimport {\n  createHtmlForChannels,\n  getChannelsToCreateFilesFor,\n} from \"./create-html.js\";\nimport { createBackup, deleteBackup, deleteOlderBackups } from \"./backup.js\";\nimport { isValid, parseISO } from \"date-fns\";\nimport { createSearch } from \"./search.js\";\nimport { write, writeAndMerge } from \"./data-write.js\";\nimport { messagesCache, getUsers, getChannels } from \"./data-load.js\";\nimport { getSlackArchiveData, setSlackArchiveData } from \"./archive-data.js\";\nimport { downloadEmojiList, downloadEmojis } from \"./emoji.js\";\nimport { downloadAvatars } from \"./users.js\";\nimport { downloadChannels } from \"./channels.js\";\nimport { authTest } from \"./web-client.js\";\nimport { SlackArchiveChannelData } from \"./interfaces.js\";\n\nconst { prompt } = inquirer;\n\nasync function selectMergeFiles(): Promise<boolean> {\n  const defaultResponse = true;\n\n  if (!fs.existsSync(CHANNELS_DATA_PATH)) {\n    return false;\n  }\n\n  // We didn't download any data. Merge.\n  if (AUTOMATIC_MODE || NO_SLACK_CONNECT) {\n    return defaultResponse;\n  }\n\n  const { merge } = await prompt([\n    {\n      type: \"confirm\",\n      default: defaultResponse,\n      name: \"merge\",\n      message: `We've found existing archive files. Do you want to append new data (recommended)? \\n If you select \"No\", we'll delete the existing data.`,\n    },\n  ]);\n\n  if (!merge) {\n    fs.emptyDirSync(OUT_DIR);\n  }\n\n  return merge;\n}\n\nasync function selectChannels(\n  channels: Array<Channel>,\n  previouslyDownloadedChannels: Record<string, SlackArchiveChannelData>\n): Promise<Array<Channel>> {\n  if (USE_PREVIOUS_CHANNEL_CONFIG) {\n    const selectedChannels: Array<Channel> = channels.filter(\n      (channel) => channel.id && channel.id in previouslyDownloadedChannels\n    );\n    const selectedChannelNames = selectedChannels.map(\n      (channel) => channel.name || channel.id || \"Unknown\"\n    );\n    console.log(\n      `Downloading channels selected previously: ${selectedChannelNames}.`\n    );\n\n    const previousChannelIds = Object.keys(previouslyDownloadedChannels);\n    if (previousChannelIds.length != selectedChannels.length) {\n      console.warn(\n        \"WARNING: Did not find all previously selected channel IDs.\"\n      );\n      console.log(\n        `Expected to find ${previousChannelIds.length} channels, but only ${selectedChannels.length} matched.`\n      );\n      // Consider Looking up the user-facing names of the missing channels in the saved data.\n      const availableChannelIds = new Set<string>(\n        channels.map((channel) => channel.id || \"\")\n      );\n      const missingChannelIds = previousChannelIds.filter(\n        (cId) => !availableChannelIds.has(cId)\n      );\n      //console.log(availableChannelIds);\n      console.log(`Missing channel ids: ${missingChannelIds}`);\n    } else {\n      console.log(\n        `Matched all ${previousChannelIds.length} previously selected channels out of ${channels.length} total channels available.`\n      );\n    }\n\n    return selectedChannels;\n  }\n\n  const choices = channels.map((channel) => ({\n    name: channel.name || channel.id || \"Unknown\",\n    value: channel,\n  }));\n\n  if (AUTOMATIC_MODE || NO_SLACK_CONNECT) {\n    if (EXCLUDE_CHANNELS) {\n      const excludeChannels = EXCLUDE_CHANNELS.split(',');\n      return channels.filter((channel) => !excludeChannels.includes(channel.name || ''));\n    }\n    return channels;\n  }\n\n  const result = await prompt([\n    {\n      type: \"checkbox\",\n      loop: true,\n      name: \"channels\",\n      message: \"Which channels do you want to download?\",\n      choices,\n    },\n  ]);\n\n  return result.channels;\n}\n\nasync function selectChannelTypes(): Promise<Array<string>> {\n  const choices = [\n    {\n      name: \"Public Channels\",\n      value: \"public_channel\",\n    },\n    {\n      name: \"Private Channels\",\n      value: \"private_channel\",\n    },\n    {\n      name: \"Multi-Person Direct Message\",\n      value: \"mpim\",\n    },\n    {\n      name: \"Direct Messages\",\n      value: \"im\",\n    },\n  ];\n\n  if (CHANNEL_TYPES) {\n    return CHANNEL_TYPES.split(\",\");\n  }\n\n  if (AUTOMATIC_MODE || USE_PREVIOUS_CHANNEL_CONFIG || NO_SLACK_CONNECT) {\n    return [\"public_channel\", \"private_channel\", \"mpim\", \"im\"];\n  }\n\n  const result = await prompt([\n    {\n      type: \"checkbox\",\n      loop: true,\n      name: \"channel-types\",\n      message: `Which channel types do you want to download?`,\n      choices,\n    },\n  ]);\n\n  return result[\"channel-types\"];\n}\n\nasync function getToken() {\n  if (NO_SLACK_CONNECT) {\n    return;\n  }\n\n  if (config.token) {\n    console.log(`Using token ${config.token}`);\n    return;\n  }\n\n  if (fs.existsSync(TOKEN_FILE)) {\n    config.token = fs.readFileSync(TOKEN_FILE, \"utf-8\").trim();\n    return;\n  }\n\n  const result = await prompt([\n    {\n      name: \"token\",\n      type: \"input\",\n      message:\n        \"Please enter your Slack token (xoxp-...). See README for more details.\",\n    },\n  ]);\n\n  config.token = result.token;\n}\n\nasync function writeLastSuccessfulArchive() {\n  const now = new Date();\n  write(DATE_FILE, now.toISOString());\n}\n\nfunction getLastSuccessfulRun() {\n  if (!fs.existsSync(DATE_FILE)) {\n    return \"\";\n  }\n\n  const lastSuccessfulArchive = fs.readFileSync(DATE_FILE, \"utf-8\");\n\n  let date = null;\n\n  try {\n    date = parseISO(lastSuccessfulArchive);\n  } catch (error) {\n    return \"\";\n  }\n\n  if (date && isValid(date)) {\n    return `. Last successful run: ${date.toLocaleString()}`;\n  }\n\n  return \"\";\n}\n\nasync function getAuthTest() {\n  if (NO_SLACK_CONNECT) {\n    return;\n  }\n\n  const spinner = ora(\"Testing authentication with Slack...\").start();\n  const result = await authTest();\n\n  if (!result.ok) {\n    spinner.fail(`Authentication with Slack failed.`);\n\n    console.log(\n      `Authentication with Slack failed. The error was: ${result.error}`\n    );\n    console.log(\n      `The provided token was ${config.token}. Double-check the token and try again.`\n    );\n    console.log(\n      `For more information on the error code, see the error table at https://api.slack.com/methods/auth.test`\n    );\n    console.log(`This tool will now exit.`);\n\n    await deleteBackup();\n    process.exit(-1);\n  } else {\n    spinner.succeed(`Successfully authorized with Slack as ${result.user}\\n`);\n  }\n\n  return result;\n}\n\nexport async function main() {\n  console.log(`Welcome to slack-archive${getLastSuccessfulRun()}`);\n\n  if (AUTOMATIC_MODE) {\n    console.log(`Running in fully automatic mode without prompts`);\n  }\n\n  if (NO_SLACK_CONNECT) {\n    console.log(`Not connecting to Slack and skipping all Slack API calls`);\n  }\n\n  await getToken();\n  await createBackup();\n\n  const slackArchiveData = await getSlackArchiveData();\n  const users: Record<string, User> = await getUsers();\n  const channelTypes = (await selectChannelTypes()).join(\",\");\n\n  slackArchiveData.auth = await getAuthTest();\n\n  const channels = await downloadChannels({ types: channelTypes }, users);\n  const selectedChannels = await selectChannels(\n    channels,\n    slackArchiveData.channels\n  );\n  const newMessages: Record<string, number> = {};\n\n  // Emoji\n  // We don't actually download the images here, we'll\n  // do that as needed\n  const emojis = await downloadEmojiList();\n  await writeAndMerge(EMOJIS_DATA_PATH, emojis);\n\n  // Do we want to merge data?\n  await selectMergeFiles();\n  await writeAndMerge(CHANNELS_DATA_PATH, selectedChannels);\n\n  // Download messages and extras for each channel\n  await downloadEachChannel();\n\n  // Save data\n  await setSlackArchiveData(slackArchiveData);\n\n  // Create HTML, but only for channels with new messages\n  // - or channels that we didn't make HTML for yet\n  const channelsToCreateFilesFor = await getChannelsToCreateFilesFor(\n    selectedChannels,\n    newMessages\n  );\n  await createHtmlForChannels(channelsToCreateFilesFor);\n\n  // Create search file\n  await createSearch();\n\n  // Cleanup and finalize\n  await deleteBackup();\n  await deleteOlderBackups();\n  await writeLastSuccessfulArchive();\n\n  console.log(`All done.`);\n\n  async function downloadEachChannel() {\n    if (NO_SLACK_CONNECT) return;\n\n    for (const [i, channel] of selectedChannels.entries()) {\n      if (!channel.id) {\n        console.warn(`Selected channel does not have an id`, channel);\n        continue;\n      }\n\n      // Do we already have everything?\n      slackArchiveData.channels[channel.id] =\n        slackArchiveData.channels[channel.id] || {};\n      if (slackArchiveData.channels[channel.id].fullyDownloaded) {\n        continue;\n      }\n\n      // Download messages & users\n      let downloadData = await downloadMessages(\n        channel,\n        i,\n        selectedChannels.length\n      );\n      let result = downloadData.messages;\n      newMessages[channel.id] = downloadData.new;\n\n      await downloadExtras(channel, result, users);\n      await downloadEmojis(result, emojis);\n      await downloadAvatars();\n\n      // Sort messages\n      const spinner = ora(\n        `Saving message data for ${channel.name || channel.id} to disk`\n      ).start();\n      spinner.render();\n\n      result = uniqBy(result, \"ts\");\n      result = result.sort((a, b) => {\n        return parseFloat(b.ts || \"0\") - parseFloat(a.ts || \"0\");\n      });\n\n      await writeAndMerge(USERS_DATA_PATH, users);\n      fs.outputFileSync(\n        getChannelDataFilePath(channel.id),\n        JSON.stringify(result, undefined, 2)\n      );\n\n      // Download files. This needs to run after the messages are saved to disk\n      // since it uses the message data to find which files to download.\n      await downloadFilesForChannel(channel.id!, spinner);\n\n      // Update the data load cache\n      messagesCache[channel.id!] = result;\n\n      // Update the data\n      const { is_archived, is_im, is_user_deleted } = channel;\n      if (is_archived || (is_im && is_user_deleted)) {\n        slackArchiveData.channels[channel.id].fullyDownloaded = true;\n      }\n      slackArchiveData.channels[channel.id].messages = result.length;\n\n      spinner.succeed(`Saved message data for ${channel.name || channel.id}`);\n    }\n  }\n}\n\nmain();\n"
  },
  {
    "path": "src/config.ts",
    "content": "import path from \"path\";\nimport { dirname } from \"path\";\nimport { fileURLToPath } from \"url\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\n\nexport const config = {\n  token: process.env.SLACK_TOKEN,\n};\n\nfunction findCliParameter(param: string) {\n  const args = process.argv;\n\n  for (const arg of args) {\n    if (arg === param) {\n      return true;\n    }\n  }\n\n  return false;\n}\n\nfunction getCliParameter(param: string) {\n  const args = process.argv;\n\n  for (const [i, arg] of args.entries()) {\n    if (arg === param) {\n      return args[i + 1];\n    }\n  }\n\n  return null;\n}\n\nexport const AUTOMATIC_MODE = findCliParameter(\"--automatic\");\nexport const USE_PREVIOUS_CHANNEL_CONFIG = findCliParameter(\n  \"--use-previous-channel-config\"\n);\nexport const CHANNEL_TYPES = getCliParameter(\"--channel-types\");\nexport const NO_BACKUP = findCliParameter(\"--no-backup\");\nexport const NO_SEARCH = findCliParameter(\"--no-search\");\nexport const NO_FILE_DOWNLOAD = findCliParameter(\"--no-file-download\");\nexport const NO_SLACK_CONNECT = findCliParameter(\"--no-slack-connect\");\nexport const FORCE_HTML_GENERATION = findCliParameter(\n  \"--force-html-generation\"\n);\nexport const EXCLUDE_CHANNELS = getCliParameter(\"--exclude-channels\");\nexport const BASE_DIR = process.cwd();\nexport const OUT_DIR = path.join(BASE_DIR, \"slack-archive\");\nexport const TOKEN_FILE = path.join(OUT_DIR, \".token\");\nexport const DATE_FILE = path.join(OUT_DIR, \".last-successful-run\");\nexport const DATA_DIR = path.join(OUT_DIR, \"data\");\nexport const HTML_DIR = path.join(OUT_DIR, \"html\");\nexport const FILES_DIR = path.join(HTML_DIR, \"files\");\nexport const AVATARS_DIR = path.join(HTML_DIR, \"avatars\");\nexport const EMOJIS_DIR = path.join(HTML_DIR, \"emojis\");\n\nexport const INDEX_PATH = path.join(OUT_DIR, \"index.html\");\nexport const SEARCH_PATH = path.join(OUT_DIR, \"search.html\");\nexport const MESSAGES_JS_PATH = path.join(__dirname, \"../static/scroll.js\");\nexport const SEARCH_TEMPLATE_PATH = path.join(\n  __dirname,\n  \"../static/search.html\"\n);\nexport const CHANNELS_DATA_PATH = path.join(DATA_DIR, \"channels.json\");\nexport const USERS_DATA_PATH = path.join(DATA_DIR, \"users.json\");\nexport const EMOJIS_DATA_PATH = path.join(DATA_DIR, \"emojis.json\");\nexport const SLACK_ARCHIVE_DATA_PATH = path.join(\n  DATA_DIR,\n  \"slack-archive.json\"\n);\nexport const SEARCH_DATA_PATH = path.join(DATA_DIR, \"search.js\");\n\nexport function getChannelDataFilePath(channelId: string) {\n  return path.join(DATA_DIR, `${channelId}.json`);\n}\n\nexport function getChannelUploadFilePath(channelId: string, fileName: string) {\n  return path.join(FILES_DIR, channelId, fileName);\n}\n\nexport function getHTMLFilePath(channelId: string, index: number) {\n  return path.join(HTML_DIR, `${channelId}-${index}.html`);\n}\n\nexport function getAvatarFilePath(userId: string, extension: string) {\n  return path.join(AVATARS_DIR, `${userId}${extension}`);\n}\n"
  },
  {
    "path": "src/create-html.tsx",
    "content": "import { format } from \"date-fns\";\nimport fs from \"fs-extra\";\nimport path from \"path\";\nimport React from \"react\";\nimport ReactDOMServer from \"react-dom/server.js\";\nimport ora, { Ora } from \"ora\";\nimport { chunk, sortBy } from \"lodash-es\";\nimport { dirname } from \"path\";\nimport { fileURLToPath } from \"url\";\nimport esMain from \"es-main\";\nimport slackMarkdown from \"slack-markdown\";\n\nimport { getChannels, getMessages, getUsers } from \"./data-load.js\";\nimport {\n  ArchiveMessage,\n  Channel,\n  ChunksInfo,\n  Message,\n  Reaction,\n  SlackArchiveData,\n  User,\n  Users,\n} from \"./interfaces.js\";\nimport {\n  getHTMLFilePath,\n  INDEX_PATH,\n  OUT_DIR,\n  MESSAGES_JS_PATH,\n  FORCE_HTML_GENERATION,\n} from \"./config.js\";\nimport { slackTimestampToJavaScriptTimestamp } from \"./timestamp.js\";\nimport { recordPage } from \"./search.js\";\nimport { write } from \"./data-write.js\";\nimport { getSlackArchiveData } from \"./archive-data.js\";\nimport { getEmojiFilePath, getEmojiUnicode, isEmojiUnicode } from \"./emoji.js\";\nimport { getName } from \"./users.js\";\nimport {\n  isBotChannel,\n  isDmChannel,\n  isPrivateChannel,\n  isPublicChannel,\n} from \"./channels.js\";\n\nconst _dirname = dirname(fileURLToPath(import.meta.url));\nconst MESSAGE_CHUNK = 1000;\n\n// This used to be a prop on the components, but passing it around\n// was surprisingly slow. Global variables are cool again!\n// Set by createHtmlForChannels().\nlet users: Users = {};\nlet slackArchiveData: SlackArchiveData = { channels: {} };\nlet me: User | null;\n\n// Little hack to switch between ./index.html and ./html/...\nlet base = \"\";\n\nfunction formatTimestamp(message: Message, dateFormat = \"PPPPpppp\") {\n  const jsTs = slackTimestampToJavaScriptTimestamp(message.ts);\n  const ts = format(jsTs, dateFormat);\n\n  return ts;\n}\n\ninterface FilesProps {\n  message: Message;\n  channelId: string;\n}\nconst Files: React.FunctionComponent<FilesProps> = (props) => {\n  const { message, channelId } = props;\n  const { files } = message;\n\n  if (!files || files.length === 0) return null;\n\n  const fileElements = files.map((file) => {\n    const { thumb_1024, thumb_720, thumb_480, thumb_pdf } = file as any;\n    const thumb = thumb_1024 || thumb_720 || thumb_480 || thumb_pdf;\n    let src = `files/${channelId}/${file.id}.${file.filetype}`;\n    let href = src;\n\n    if (file.mimetype?.startsWith(\"image\")) {\n      return (\n        <a key={file.id} href={href} target=\"_blank\">\n          <img className=\"file\" src={src} />\n        </a>\n      );\n    }\n\n    if (file.mimetype?.startsWith(\"video\")) {\n      return <video key={file.id} controls src={src} />;\n    }\n\n    if (file.mimetype?.startsWith(\"audio\")) {\n      return <audio key={file.id} controls src={src} />;\n    }\n\n    if (!file.mimetype?.startsWith(\"image\") && thumb) {\n      href = file.url_private || href;\n      src = src.replace(`.${file.filetype}`, \".png\");\n\n      return (\n        <a key={file.id} href={href} target=\"_blank\">\n          <img className=\"file\" src={src} />\n        </a>\n      );\n    }\n\n    return (\n      <a key={file.id} href={href} target=\"_blank\">\n        {file.name}\n      </a>\n    );\n  });\n\n  return <div className=\"files\">{...fileElements}</div>;\n};\n\ninterface AvatarProps {\n  userId?: string;\n}\nconst Avatar: React.FunctionComponent<AvatarProps> = ({ userId }) => {\n  if (!userId) return null;\n\n  const user = users[userId];\n  if (!user || !user.profile || !user.profile.image_512) return null;\n\n  const ext = path.extname(user?.profile?.image_512!);\n  const src = `${base}avatars/${userId}${ext}`;\n\n  return <img className=\"avatar\" src={src} />;\n};\n\ninterface ParentMessageProps {\n  message: ArchiveMessage;\n  channelId: string;\n}\nconst ParentMessage: React.FunctionComponent<ParentMessageProps> = (props) => {\n  const { message, channelId } = props;\n  const hasFiles = !!message.files;\n\n  return (\n    <Message message={message} channelId={channelId}>\n      {hasFiles ? <Files message={message} channelId={channelId} /> : null}\n      {message.reactions?.map((reaction) => (\n        <Reaction key={reaction.name} reaction={reaction} />\n      ))}\n      {message.replies?.map((reply) => (\n        <ParentMessage message={reply} channelId={channelId} key={reply.ts} />\n      ))}\n    </Message>\n  );\n};\n\ninterface ReactionProps {\n  reaction: Reaction;\n}\nconst Reaction: React.FunctionComponent<ReactionProps> = ({ reaction }) => {\n  const reactors = [];\n\n  if (reaction.users) {\n    for (const userId of reaction.users) {\n      reactors.push(getName(userId, users));\n    }\n  }\n\n  return (\n    <div className=\"reaction\" title={reactors.join(\", \")}>\n      <Emoji name={reaction.name!} />\n      <span>{reaction.count}</span>\n    </div>\n  );\n};\n\ninterface EmojiProps {\n  name: string;\n}\nconst Emoji: React.FunctionComponent<EmojiProps> = ({ name }) => {\n  if (isEmojiUnicode(name)) {\n    return <>{getEmojiUnicode(name)}</>;\n  }\n\n  return <img src={getEmojiFilePath(name)} />;\n};\n\ninterface MessageProps {\n  message: ArchiveMessage;\n  channelId: string;\n}\nconst Message: React.FunctionComponent<MessageProps> = (props) => {\n  const { message } = props;\n  const username = getName(message.user, users);\n  const slackCallbacks = {\n    user: ({ id }: { id: string }) => `@${getName(id, users)}`,\n  };\n\n  return (\n    <div className=\"message-gutter\" id={message.ts}>\n      <div className=\"\" data-stringify-ignore=\"true\">\n        <Avatar userId={message.user} />\n      </div>\n      <div className=\"\">\n        <span className=\"sender\">{username}</span>\n        <span className=\"timestamp\">\n          <span className=\"c-timestamp__label\">{formatTimestamp(message)}</span>\n        </span>\n        <br />\n        <div\n          className=\"text\"\n          dangerouslySetInnerHTML={{\n            __html: slackMarkdown.toHTML(message.text, {\n              escapeHTML: false,\n              slackCallbacks,\n            }),\n          }}\n        />\n        {props.children}\n      </div>\n    </div>\n  );\n};\n\ninterface MessagesPageProps {\n  messages: Array<ArchiveMessage>;\n  channel: Channel;\n  index: number;\n  chunksInfo: ChunksInfo;\n}\nconst MessagesPage: React.FunctionComponent<MessagesPageProps> = (props) => {\n  const { channel, index, chunksInfo } = props;\n  const messagesJs = fs.readFileSync(MESSAGES_JS_PATH, \"utf8\");\n\n  // Newest message is first\n  const messages = props.messages\n    .map((m) => (\n      <ParentMessage key={m.ts} message={m} channelId={channel.id!} />\n    ))\n    .reverse();\n\n  if (messages.length === 0) {\n    messages.push(<span key=\"empty\">No messages were ever sent!</span>);\n  }\n\n  return (\n    <HtmlPage>\n      <div style={{ paddingLeft: 10 }}>\n        <Header index={index} chunksInfo={chunksInfo} channel={channel} />\n        <div className=\"messages-list\">{messages}</div>\n        <script dangerouslySetInnerHTML={{ __html: messagesJs }} />\n      </div>\n    </HtmlPage>\n  );\n};\n\ninterface ChannelLinkProps {\n  channel: Channel;\n}\nconst ChannelLink: React.FunctionComponent<ChannelLinkProps> = ({\n  channel,\n}) => {\n  let name = channel.name || channel.id;\n  let leadSymbol = <span># </span>;\n\n  const channelData = slackArchiveData.channels[channel.id!];\n  if (channelData && channelData.messages === 0) {\n    return null;\n  }\n\n  // Remove the user's name from the group mpdm channel name\n  if (me && channel.is_mpim) {\n    name = name?.replace(`@${me.name}`, \"\").replace(\"  \", \" \");\n  }\n\n  if (channel.is_im && (channel as any).user) {\n    leadSymbol = <Avatar userId={(channel as any).user} />;\n  }\n\n  if (channel.is_mpim) {\n    leadSymbol = <></>;\n    name = name?.replace(\"Group messaging with: \", \"\");\n  }\n\n  return (\n    <li key={name}>\n      <a title={name} href={`html/${channel.id!}-0.html`} target=\"iframe\">\n        {leadSymbol}\n        <span>{name}</span>\n      </a>\n    </li>\n  );\n};\n\ninterface IndexPageProps {\n  channels: Array<Channel>;\n}\nconst IndexPage: React.FunctionComponent<IndexPageProps> = (props) => {\n  const { channels } = props;\n  const sortedChannels = sortBy(channels, \"name\");\n\n  const publicChannels = sortedChannels\n    .filter((channel) => isPublicChannel(channel) && !channel.is_archived)\n    .map((channel) => <ChannelLink key={channel.id} channel={channel} />);\n\n  const publicArchivedChannels = sortedChannels\n    .filter((channel) => isPublicChannel(channel) && channel.is_archived)\n    .map((channel) => <ChannelLink key={channel.id} channel={channel} />);\n\n  const privateChannels = sortedChannels\n    .filter((channel) => isPrivateChannel(channel) && !channel.is_archived)\n    .map((channel) => <ChannelLink key={channel.id} channel={channel} />);\n\n  const privateArchivedChannels = sortedChannels\n    .filter((channel) => isPrivateChannel(channel) && channel.is_archived)\n    .map((channel) => <ChannelLink key={channel.id} channel={channel} />);\n\n  const dmChannels = sortedChannels\n    .filter(\n      (channel) => isDmChannel(channel, users) && !users[channel.user!].deleted\n    )\n    .sort((a, b) => {\n      // Self first\n      if (me && a.user && a.user === me.id) {\n        return -1;\n      }\n\n      // Then alphabetically\n      return (a.name || \"Unknown\").localeCompare(b.name || \"Unknown\");\n    })\n    .map((channel) => <ChannelLink key={channel.id} channel={channel} />);\n\n  const dmDeletedChannels = sortedChannels\n    .filter(\n      (channel) => isDmChannel(channel, users) && users[channel.user!].deleted\n    )\n    .sort((a, b) => (a.name || \"Unknown\").localeCompare(b.name || \"Unknown\"))\n    .map((channel) => <ChannelLink key={channel.id} channel={channel} />);\n\n  const groupChannels = sortedChannels\n    .filter((channel) => channel.is_mpim)\n    .map((channel) => <ChannelLink key={channel.id} channel={channel} />);\n\n  const botChannels = sortedChannels\n    .filter((channel) => isBotChannel(channel, users))\n    .sort((a, b) => {\n      if (a.name && b.name) {\n        return a.name!.localeCompare(b.name!);\n      }\n\n      return 1;\n    })\n    .map((channel) => <ChannelLink key={channel.id} channel={channel} />);\n\n  return (\n    <HtmlPage>\n      <div id=\"index\">\n        <div id=\"channels\">\n          <p className=\"section\">Public Channels</p>\n          <ul>{publicChannels}</ul>\n          <p className=\"section\">Private Channels</p>\n          <ul>{privateChannels}</ul>\n          <p className=\"section\">DMs</p>\n          <ul>{dmChannels}</ul>\n          <p className=\"section\">Group DMs</p>\n          <ul>{groupChannels}</ul>\n          <p className=\"section\">Bots</p>\n          <ul>{botChannels}</ul>\n          <p className=\"section\">Archived Public Channels</p>\n          <ul>{publicArchivedChannels}</ul>\n          <p className=\"section\">Archived Private Channels</p>\n          <ul>{privateArchivedChannels}</ul>\n          <p className=\"section\">DMs (Deleted Users)</p>\n          <ul>{dmDeletedChannels}</ul>\n        </div>\n        <div id=\"messages\">\n          <iframe name=\"iframe\" src={`html/${channels[0].id!}-0.html`} />\n        </div>\n        <script\n          dangerouslySetInnerHTML={{\n            __html: `\n            const urlSearchParams = new URLSearchParams(window.location.search);\n            const channelValue = urlSearchParams.get(\"c\");\n            const tsValue = urlSearchParams.get(\"ts\");\n            \n            if (channelValue) {\n              const iframe = document.getElementsByName('iframe')[0]\n              iframe.src = \"html/\" + decodeURIComponent(channelValue) + '.html' + '#' + (tsValue || '');\n            }\n            `,\n          }}\n        />\n      </div>\n    </HtmlPage>\n  );\n};\n\nconst HtmlPage: React.FunctionComponent = (props) => {\n  return (\n    <html lang=\"en\">\n      <head>\n        <meta httpEquiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n        <meta charSet=\"UTF-8\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n        <title>Slack</title>\n        <link rel=\"stylesheet\" href={`${base}style.css`} />\n      </head>\n      <body>{props.children}</body>\n    </html>\n  );\n};\n\ninterface HeaderProps {\n  index: number;\n  chunksInfo: ChunksInfo;\n  channel: Channel;\n}\nconst Header: React.FunctionComponent<HeaderProps> = (props) => {\n  const { channel, index, chunksInfo } = props;\n  let created;\n\n  if (!channel.is_im && !channel.is_mpim) {\n    const creator = getName(channel.creator, users);\n    const time = channel.created\n      ? format(channel.created * 1000, \"PPPP\")\n      : \"Unknown\";\n\n    created =\n      creator && time ? (\n        <span className=\"created\">\n          Created by {creator} on {time}\n        </span>\n      ) : null;\n  }\n\n  return (\n    <div className=\"header\">\n      <h1>{channel.name || channel.id}</h1>\n      {created}\n      <p className=\"topic\">{channel.topic?.value}</p>\n      <Pagination\n        channelId={channel.id!}\n        index={index}\n        chunksInfo={chunksInfo}\n      />\n    </div>\n  );\n};\n\ninterface PaginationProps {\n  index: number;\n  chunksInfo: ChunksInfo;\n  channelId: string;\n}\nconst Pagination: React.FunctionComponent<PaginationProps> = (props) => {\n  const { index, channelId, chunksInfo } = props;\n  const length = chunksInfo.length;\n\n  if (length === 1) {\n    return null;\n  }\n\n  const older =\n    index + 1 < length ? (\n      <span>\n        <a href={`${channelId}-${index + 1}.html`}>Older Messages</a>\n      </span>\n    ) : null;\n  const newer =\n    index > 0 ? (\n      <span>\n        <a href={`${channelId}-${index - 1}.html`}>Newer Messages </a>\n      </span>\n    ) : null;\n  const sep1 = older && newer ? \" | \" : null;\n  const sep2 = older || newer ? \" | \" : null;\n\n  const options: Array<JSX.Element> = [];\n  for (const [i, chunk] of chunksInfo.entries()) {\n    const text = `${i} - ${chunk.newest} to ${chunk.oldest}`;\n    const value = `${channelId}-${i}.html`;\n    const selected = i === index;\n    options.push(\n      <option selected={selected} key={value} value={value}>\n        {text}\n      </option>\n    );\n  }\n\n  return (\n    <div className=\"pagination\">\n      {newer}\n      {sep1}\n      {older}\n      {sep2}\n      <div className=\"jumper\">\n        <select id=\"jumper\">{options}</select>\n        <script\n          dangerouslySetInnerHTML={{\n            __html: `\n              document.getElementById(\"jumper\").onchange = function () {\n                window.location.href = this.value;\n              }\n            `,\n          }}\n        />\n      </div>\n    </div>\n  );\n};\n\nasync function renderIndexPage() {\n  base = \"html/\";\n  const channels = await getChannels();\n  const page = <IndexPage channels={channels} />;\n\n  return renderAndWrite(page, INDEX_PATH);\n}\n\ninterface RenderMessagesPageOptions {\n  channel: Channel;\n  messages: Array<ArchiveMessage>;\n  chunkIndex: number;\n  chunksInfo: ChunksInfo;\n}\n\nfunction renderMessagesPage(options: RenderMessagesPageOptions, spinner: Ora) {\n  const { channel, messages, chunkIndex: index, chunksInfo } = options;\n  const page = (\n    <MessagesPage\n      channel={channel}\n      messages={messages}\n      index={index}\n      chunksInfo={chunksInfo}\n    />\n  );\n\n  const filePath = getHTMLFilePath(channel.id!, index);\n  spinner.text = `${channel.name || channel.id}: Writing ${index + 1}/${\n    chunksInfo.length\n  } ${filePath}`;\n  spinner.render();\n\n  // Update the search index. In messages, the youngest message is first.\n  if (messages.length > 0) {\n    recordPage(channel.id, messages[messages.length - 1]?.ts);\n  }\n\n  return renderAndWrite(page, filePath);\n}\n\nasync function renderAndWrite(page: JSX.Element, filePath: string) {\n  const html = ReactDOMServer.renderToStaticMarkup(page);\n  const htmlWDoc = \"<!DOCTYPE html>\" + html;\n\n  await write(filePath, htmlWDoc);\n}\n\nexport async function getChannelsToCreateFilesFor(\n  channels: Array<Channel>,\n  newMessages: Record<string, number>\n) {\n  const result: Array<Channel> = [];\n\n  // If HTML regeneration is forced, ignore everything\n  // and just return all channels\n  if (FORCE_HTML_GENERATION) {\n    return await getChannels();\n  }\n\n  for (const channel of channels) {\n    if (channel.id) {\n      // Do we have new messages?\n      if (newMessages[channel.id] > 0) {\n        result.push(channel);\n      }\n\n      // Did we never create a file?\n      if (!fs.existsSync(getHTMLFilePath(channel.id!, 0))) {\n        result.push(channel);\n      }\n    }\n  }\n\n  return result;\n}\n\nasync function createHtmlForChannel({\n  channel,\n  i,\n  total,\n}: {\n  channel: Channel;\n  i: number;\n  total: number;\n}) {\n  const messages = await getMessages(channel.id!, true);\n  const chunks = chunk(messages, MESSAGE_CHUNK);\n  const spinner = ora(\n    `Rendering HTML for ${i + 1}/${total} ${channel.name || channel.id}`\n  ).start();\n\n  // Calculate info about all chunks\n  const chunksInfo: ChunksInfo = [];\n  for (const iChunk of chunks) {\n    chunksInfo.push({\n      oldest: formatTimestamp(iChunk[iChunk.length - 1], \"Pp\"),\n      newest: formatTimestamp(iChunk[0], \"Pp\"),\n      count: iChunk.length,\n    });\n  }\n\n  if (chunks.length === 0) {\n    await renderMessagesPage(\n      {\n        channel,\n        messages: [],\n        chunkIndex: 0,\n        chunksInfo: chunksInfo,\n      },\n      spinner\n    );\n  }\n\n  for (const [chunkI, chunk] of chunks.entries()) {\n    await renderMessagesPage(\n      {\n        channel,\n        messages: chunk,\n        chunkIndex: chunkI,\n        chunksInfo,\n      },\n      spinner\n    );\n  }\n\n  spinner.succeed(\n    `Rendered HTML for ${i + 1}/${total} ${channel.name || channel.id}`\n  );\n}\n\nexport async function createHtmlForChannels(channels: Array<Channel> = []) {\n  console.log(`\\n Creating HTML files for ${channels.length} channels...`);\n\n  users = await getUsers();\n  slackArchiveData = await getSlackArchiveData();\n  me = slackArchiveData.auth?.user_id\n    ? users[slackArchiveData.auth?.user_id]\n    : null;\n\n  for (const [i, channel] of channels.entries()) {\n    if (!channel.id) {\n      console.warn(`Can't create HTML for channel: No id found`, channel);\n      continue;\n    }\n\n    await createHtmlForChannel({ channel, i, total: channels.length });\n  }\n\n  await renderIndexPage();\n\n  // Copy in fonts & css\n  fs.copySync(path.join(_dirname, \"../static\"), path.join(OUT_DIR, \"html/\"));\n}\n\nif (esMain(import.meta)) {\n  createHtmlForChannels();\n}\n"
  },
  {
    "path": "src/data-load.ts",
    "content": "import fs from \"fs-extra\";\n\nimport {\n  ArchiveMessage,\n  Channel,\n  Emojis,\n  SearchFile,\n  Users,\n} from \"./interfaces.js\";\nimport {\n  CHANNELS_DATA_PATH,\n  EMOJIS_DATA_PATH,\n  getChannelDataFilePath,\n  SEARCH_DATA_PATH,\n  USERS_DATA_PATH,\n} from \"./config.js\";\nimport { retry } from \"./retry.js\";\n\nasync function getFile<T>(filePath: string, returnIfEmpty: T): Promise<T> {\n  if (!fs.existsSync(filePath)) {\n    return returnIfEmpty;\n  }\n\n  const data: T = await readJSON(filePath);\n\n  return data;\n}\n\nexport const messagesCache: Record<string, Array<ArchiveMessage>> = {};\n\nexport async function getMessages(\n  channelId: string,\n  cachedOk: boolean = false\n): Promise<Array<ArchiveMessage>> {\n  if (cachedOk && messagesCache[channelId]) {\n    return messagesCache[channelId];\n  }\n\n  const filePath = getChannelDataFilePath(channelId);\n  messagesCache[channelId] = await getFile<Array<ArchiveMessage>>(filePath, []);\n\n  return messagesCache[channelId];\n}\n\nexport async function getUsers(): Promise<Users> {\n  return getFile<Users>(USERS_DATA_PATH, {});\n}\n\nexport async function getEmoji(): Promise<Emojis> {\n  return getFile<Emojis>(EMOJIS_DATA_PATH, {});\n}\n\nexport async function getChannels(): Promise<Array<Channel>> {\n  return getFile<Array<Channel>>(CHANNELS_DATA_PATH, []);\n}\n\nexport async function getSearchFile(): Promise<SearchFile> {\n  const returnIfEmpty = { users: {}, channels: {}, messages: {}, pages: {} };\n\n  if (!fs.existsSync(SEARCH_DATA_PATH)) {\n    return returnIfEmpty;\n  }\n\n  const contents = await readFile(SEARCH_DATA_PATH, \"utf8\");\n\n  // See search.ts, the file is actually JS (not JSON)\n  return JSON.parse(contents.slice(21, contents.length - 1));\n}\n\nexport async function readFile(filePath: string, encoding = \"utf8\") {\n  return retry<string>({ name: `Reading ${filePath}` }, () => {\n    return fs.readFileSync(SEARCH_DATA_PATH, \"utf8\");\n  });\n}\n\nexport async function readJSON<T>(filePath: string) {\n  return retry<T>({ name: `Loading JSON from ${filePath}` }, () => {\n    return fs.readJSONSync(filePath);\n  });\n}\n"
  },
  {
    "path": "src/data-write.ts",
    "content": "import fs from \"fs-extra\";\nimport { differenceBy } from \"lodash-es\";\n\nimport { retry } from \"./retry.js\";\n\nexport async function write(filePath: string, data: any) {\n  await retry({ name: `Writing ${filePath}` }, () => {\n    fs.outputFileSync(filePath, data);\n  });\n}\n\nexport async function writeAndMerge(filePath: string, newData: any) {\n  await retry({ name: `Writing ${filePath}` }, () => {\n    let dataToWrite = newData;\n\n    if (fs.existsSync(filePath)) {\n      const oldData = fs.readJSONSync(filePath);\n\n      if (Array.isArray(oldData)) {\n        if (newData && newData[0] && newData[0].id) {\n          // Take the old data, exclude aything that is in the new data,\n          // and then add the new data\n          dataToWrite = [\n            ...differenceBy(oldData, newData, (v: any) => v.id),\n            ...newData,\n          ];\n        } else {\n          dataToWrite = [...oldData, ...newData];\n        }\n      } else if (typeof newData === \"object\") {\n        dataToWrite = { ...oldData, ...newData };\n      } else {\n        console.error(`writeAndMerge: Did not understand type of data`, {\n          filePath,\n          newData,\n        });\n      }\n    }\n\n    fs.outputFileSync(filePath, JSON.stringify(dataToWrite, undefined, 2));\n  });\n}\n"
  },
  {
    "path": "src/download-files.ts",
    "content": "import fetch from \"node-fetch\";\nimport fs from \"fs-extra\";\nimport esMain from \"es-main\";\nimport ora, { Ora } from \"ora\";\nimport path from \"path\";\n\nimport { File } from \"./interfaces.js\";\nimport {\n  getChannelUploadFilePath,\n  config,\n  NO_FILE_DOWNLOAD,\n} from \"./config.js\";\nimport { getChannels, getMessages } from \"./data-load.js\";\nimport { downloadAvatars } from \"./users.js\";\n\nexport interface DownloadUrlOptions {\n  authorize?: boolean;\n  force?: boolean;\n}\n\nexport async function downloadURL(\n  url: string,\n  filePath: string,\n  options: DownloadUrlOptions = {}\n) {\n  const authorize = options.authorize === undefined ? true : options.authorize;\n\n  if (!options.force && fs.existsSync(filePath)) {\n    return;\n  }\n\n  const { token } = config;\n  const headers: HeadersInit = authorize\n    ? {\n        Authorization: `Bearer ${token}`,\n      }\n    : {};\n\n  try {\n    const response = await fetch(url, { headers });\n    const buffer = await response.buffer();\n    fs.outputFileSync(filePath, buffer);\n  } catch (error) {\n    console.warn(`Failed to download file ${url}`, error);\n  }\n}\n\nasync function downloadFile(\n  file: File,\n  channelId: string,\n  i: number,\n  total: number,\n  spinner: Ora\n) {\n  const { url_private, id, is_external, mimetype } = file;\n  const { thumb_1024, thumb_720, thumb_480, thumb_pdf } = file as any;\n\n  const fileUrl = is_external\n    ? thumb_1024 || thumb_720 || thumb_480 || thumb_pdf\n    : url_private;\n\n  if (!fileUrl) return;\n\n  spinner.text = `Downloading ${i}/${total}: ${fileUrl}`;\n\n  const extension = path.extname(fileUrl);\n  const filePath = getChannelUploadFilePath(channelId, `${id}${extension}`);\n\n  await downloadURL(fileUrl, filePath);\n\n  if (mimetype === \"application/pdf\" && thumb_pdf) {\n    spinner.text = `Downloading ${i}/${total}: ${thumb_pdf}`;\n    const thumbFile = filePath.replace(extension, \".png\");\n    await downloadURL(thumb_pdf, thumbFile);\n  }\n}\n\nexport async function downloadFilesForChannel(channelId: string, spinner: Ora) {\n  if (NO_FILE_DOWNLOAD) {\n    return;\n  }\n\n  const messages = await getMessages(channelId);\n  const channels = await getChannels();\n  const channel = channels.find(({ id }) => id === channelId);\n  const fileMessages = messages.filter(\n    (m) => (m.files?.length || m.replies?.length || 0) > 0\n  );\n  const getSpinnerText = (i: number, ri?: number) => {\n    let reply = \"\";\n    if (ri !== undefined) {\n      reply = ` (reply ${ri})`;\n    }\n\n    return `Downloading ${i}/${\n      fileMessages.length\n    }${reply} messages with files for channel ${channel?.name || channelId}...`;\n  };\n\n  spinner.text = getSpinnerText(0);\n\n  for (const [i, fileMessage] of fileMessages.entries()) {\n    if (!fileMessage.files && !fileMessage.replies) {\n      continue;\n    }\n\n    if (fileMessage.files) {\n      for (const file of fileMessage.files) {\n        spinner.text = getSpinnerText(i);\n        spinner.render();\n        await downloadFile(file, channelId, i, fileMessages.length, spinner);\n      }\n    }\n\n    if (fileMessage.replies) {\n      for (const [ri, reply] of fileMessage.replies.entries()) {\n        if (reply.files) {\n          for (const file of reply.files) {\n            spinner.text = getSpinnerText(i, ri);\n            spinner.render();\n            await downloadFile(\n              file,\n              channelId,\n              i,\n              fileMessages.length,\n              spinner\n            );\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/emoji.ts",
    "content": "import path from \"path\";\nimport ora from \"ora\";\nimport fs from \"fs\";\nimport { createRequire } from \"node:module\";\n\nimport { EMOJIS_DIR, NO_SLACK_CONNECT } from \"./config.js\";\nimport { downloadURL } from \"./download-files.js\";\nimport { ArchiveMessage, Emojis } from \"./interfaces.js\";\nimport { getWebClient } from \"./web-client.js\";\n\nconst require = createRequire(import.meta.url);\nconst emojiData = require(\"emoji-datasource\");\n\nlet _unicodeEmoji: Record<string, string>;\nfunction getUnicodeEmoji() {\n  if (_unicodeEmoji) {\n    return _unicodeEmoji;\n  }\n\n  _unicodeEmoji = {};\n  for (const emoji of emojiData) {\n    _unicodeEmoji[emoji.short_name as string] = emoji.unified;\n  }\n\n  return _unicodeEmoji;\n}\n\nexport function getEmojiFilePath(name: string, extension?: string) {\n  // If we have an extension, return the correct path\n  if (extension) {\n    return path.join(EMOJIS_DIR, `${name}${extension}`);\n  }\n\n  // If we don't have an extension, return the first path that exists\n  // regardless of extension\n  const extensions = [\".png\", \".jpg\", \".gif\"];\n  for (const ext of extensions) {\n    if (fs.existsSync(path.join(EMOJIS_DIR, `${name}${ext}`))) {\n      return path.join(EMOJIS_DIR, `${name}${ext}`);\n    }\n  }\n}\n\nexport function isEmojiUnicode(name: string) {\n  const unicodeEmoji = getUnicodeEmoji();\n  return !!unicodeEmoji[name];\n}\n\nexport function getEmojiUnicode(name: string) {\n  const unicodeEmoji = getUnicodeEmoji();\n  const unified = unicodeEmoji[name];\n  const split = unified.split(\"-\");\n\n  return split\n    .map((code) => {\n      return String.fromCodePoint(parseInt(code, 16));\n    })\n    .join(\"\");\n}\n\nexport async function downloadEmojiList(): Promise<Emojis> {\n  if (NO_SLACK_CONNECT) {\n    return {};\n  }\n\n  const response = await getWebClient().emoji.list();\n\n  if (response.ok) {\n    return response.emoji!;\n  } else {\n    return {};\n  }\n}\n\nexport async function downloadEmoji(\n  name: string,\n  url: string,\n  emojis: Emojis\n): Promise<void> {\n  // Alias?\n  if (url.startsWith(\"alias:\")) {\n    const alias = getEmojiAlias(url);\n\n    if (!emojis[alias]) {\n      console.warn(\n        `Found emoji alias ${alias}, which does not exist in master emoji list`\n      );\n      return;\n    } else {\n      return downloadEmoji(alias, emojis[alias], emojis);\n    }\n  }\n\n  const extension = path.extname(url);\n  const filePath = getEmojiFilePath(name, extension);\n\n  return downloadURL(url, filePath!);\n}\n\nexport function getEmojiAlias(name: string): string {\n  // Ugh regex methods - this should turn \"alias:hi-bob\" into \"hi-bob\"\n  const alias = [...name.matchAll(/alias:(.*)/g)][0][1]!;\n  return alias!;\n}\n\nexport async function downloadEmojis(\n  messages: Array<ArchiveMessage>,\n  emojis: Emojis\n) {\n  const regex = /:[^:\\s]*(?:::[^:\\s]*)*:/g;\n\n  const spinner = ora(\n    `Scanning 0/${messages.length} messages for emoji shortcodes...`\n  ).start();\n  let downloaded = 0;\n\n  for (const [i, message] of messages.entries()) {\n    spinner.text = `Scanning ${i}/${messages.length} messages for emoji shortcodes...`;\n\n    // Reactions\n    if (message.reactions && message.reactions.length > 0) {\n      for (const reaction of message.reactions) {\n        const reactEmoji = emojis[reaction.name!];\n        if (reactEmoji) {\n          downloaded++;\n          await downloadEmoji(reaction.name!, reactEmoji, emojis);\n        }\n      }\n    }\n  }\n\n  spinner.succeed(\n    `Scanned ${messages.length} messages for emoji (and downloaded ${downloaded})`\n  );\n}\n"
  },
  {
    "path": "src/interfaces.ts",
    "content": "import { Message as SlackMessage } from \"@slack/web-api/dist/response/ConversationsHistoryResponse\";\nimport { Channel as SlackChannel } from \"@slack/web-api/dist/response/ConversationsListResponse\";\nimport { User as SlackUser } from \"@slack/web-api/dist/response/UsersInfoResponse\";\nimport { File as SlackFile } from \"@slack/web-api/dist/response/FilesInfoResponse\";\nimport { Reaction as SlackReaction } from \"@slack/web-api/dist/response/ReactionsGetResponse\";\nimport { AuthTestResponse } from \"@slack/web-api\";\n\nexport type User = SlackUser;\n\nexport type Users = Record<string, User>;\n\nexport type Emojis = Record<string, string>;\n\nexport interface ArchiveMessage extends SlackMessage {\n  replies?: Array<SlackMessage>;\n}\n\nexport type Reaction = SlackReaction;\n\nexport type Message = SlackMessage;\n\nexport type Channel = SlackChannel;\n\nexport type File = SlackFile;\n\nexport type SearchPageIndex = Record<string, Array<string>>;\n\nexport type SearchFile = {\n  users: Record<string, string>; // userId -> userName\n  channels: Record<string, string>; // channelId -> channelName\n  messages: Record<string, Array<SearchMessage>>;\n  pages: SearchPageIndex;\n};\n\nexport type SearchMessage = {\n  m?: string; // Message\n  u?: string; // User\n  t?: string; // Timestamp\n  c?: string; // Channel\n};\n\nexport interface SlackArchiveChannelData {\n  messages: number;\n  fullyDownloaded: boolean;\n}\n\nexport interface SlackArchiveData {\n  channels: Record<string, SlackArchiveChannelData>;\n  auth?: AuthTestResponse;\n}\n\nexport interface ChunkInfo {\n  oldest?: string;\n  newest?: string;\n  count: number;\n}\n\nexport type ChunksInfo = Array<ChunkInfo>;\n"
  },
  {
    "path": "src/messages.ts",
    "content": "import {\n  ConversationsHistoryResponse,\n  ConversationsListArguments,\n  ConversationsListResponse,\n} from \"@slack/web-api\";\nimport { Channel } from \"@slack/web-api/dist/response/ConversationsListResponse\";\nimport ora from \"ora\";\n\nimport { ArchiveMessage, Message, Users } from \"./interfaces.js\";\nimport { getMessages } from \"./data-load.js\";\nimport { isThread } from \"./threads.js\";\nimport { downloadUser, getName } from \"./users.js\";\nimport { getWebClient } from \"./web-client.js\";\n\nfunction isConversation(input: any): input is ConversationsHistoryResponse {\n  return !!input.messages;\n}\n\ninterface DownloadMessagesResult {\n  messages: Array<ArchiveMessage>;\n  new: number;\n}\n\nexport async function downloadMessages(\n  channel: Channel,\n  i: number,\n  channelCount: number\n): Promise<DownloadMessagesResult> {\n  let result: DownloadMessagesResult = {\n    messages: [],\n    new: 0,\n  };\n\n  if (!channel.id) {\n    console.warn(`Channel without id`, channel);\n    return result;\n  }\n\n  for (const message of await getMessages(channel.id)) {\n    result.messages.push(message);\n  }\n\n  const oldest =\n    result.messages.length > 0 ? parseInt(result.messages[0].ts || \"0\", 10) : 0;\n  const name =\n    channel.name || channel.id || channel.purpose?.value || \"Unknown channel\";\n\n  const spinner = ora(\n    `Downloading messages for channel ${i + 1}/${channelCount} (${name})...`\n  ).start();\n\n  for await (const page of getWebClient().paginate(\"conversations.history\", {\n    channel: channel.id,\n    oldest,\n  })) {\n    if (isConversation(page)) {\n      const pageLength = page.messages?.length || 0;\n      const fetched = `Fetched ${pageLength} messages`;\n      const total = `(total so far: ${result.messages.length + pageLength}`;\n\n      spinner.text = `Downloading ${\n        i + 1\n      }/${channelCount} ${name}: ${fetched} ${total})`;\n\n      result.new = result.new + (page.messages || []).length;\n\n      result.messages.unshift(...(page.messages || []));\n    }\n  }\n\n  spinner.succeed(\n    `Downloaded messages for channel ${i + 1}/${channelCount} (${name})`\n  );\n\n  return result;\n}\n\nexport async function downloadReplies(\n  channel: Channel,\n  message: ArchiveMessage\n): Promise<Array<Message>> {\n  if (!channel.id || !message.ts) {\n    console.warn(\"Could not find channel or message id\", channel, message);\n    return [];\n  }\n\n  if (!message.reply_count) {\n    console.warn(\"Message has no reply count\", message);\n    return [];\n  }\n\n  // Do we already have all replies?\n  if (message.replies && message.replies.length >= message.reply_count) {\n    return message.replies;\n  }\n\n  const replies = message.replies || [];\n  // Oldest is the last entry\n  const oldest = replies.length > 0 ? replies[replies.length - 1].ts : \"0\";\n  const result = await getWebClient().conversations.replies({\n    channel: channel.id,\n    ts: message.ts,\n    oldest,\n  });\n\n  // First message is the parent\n  return (result.messages || []).slice(1);\n}\n\nexport async function downloadExtras(\n  channel: Channel,\n  messages: Array<ArchiveMessage>,\n  users: Users\n) {\n  const spinner = ora(\n    `Downloading threads and users for ${channel.name || channel.id}...`\n  ).start();\n\n  // Then, all messages and threads\n  let processedThreads = 0;\n  const totalThreads = messages.filter(isThread).length;\n  for (const message of messages) {\n    // Download threads\n    if (isThread(message)) {\n      processedThreads++;\n      spinner.text = `Downloading threads (${processedThreads}/${totalThreads}) for ${\n        channel.name || channel.id\n      }...`;\n      message.replies = await downloadReplies(channel, message);\n    }\n\n    // Download users and avatars\n    if (message.user) {\n      await downloadUser(message, users);\n    }\n  }\n\n  spinner.succeed(\n    `Downloaded ${totalThreads} threads and users for ${\n      channel.name || channel.id\n    }.`\n  );\n}\n"
  },
  {
    "path": "src/reactions.ts",
    "content": "import { Message } from \"./interfaces\";\n\nexport function hasReactions(message: Message) {\n  return message.reactions && message.reactions.length > 0;\n}\n"
  },
  {
    "path": "src/retry.ts",
    "content": "export interface RetryOptions {\n  retries: number;\n  name?: string;\n}\n\nconst defaultOptions: RetryOptions = {\n  retries: 3,\n};\n\nexport async function retry<T>(\n  options: Partial<RetryOptions>,\n  operation: () => T,\n  attempt = 0\n): Promise<T> {\n  let mergedOptions = { ...defaultOptions, ...options };\n\n  try {\n    return operation();\n  } catch (error) {\n    if (attempt >= mergedOptions.retries) {\n      throw error;\n    }\n\n    const ms = 250 + attempt * 250;\n\n    if (mergedOptions.name) {\n      console.warn(`Operation \"${options.name}\" failed, retrying in ${ms}`);\n    }\n\n    await wait(ms);\n\n    return retry(options, operation, attempt + 1);\n  }\n}\n\nfunction wait(ms = 250) {\n  return new Promise((resolve) => {\n    setTimeout(resolve, ms);\n  });\n}\n"
  },
  {
    "path": "src/search.ts",
    "content": "import fs from \"fs-extra\";\nimport ora, { Ora } from \"ora\";\nimport { getChannelName } from \"./channels.js\";\n\nimport {\n  NO_SEARCH,\n  SEARCH_DATA_PATH,\n  SEARCH_PATH,\n  SEARCH_TEMPLATE_PATH,\n} from \"./config.js\";\nimport { SearchFile, SearchMessage, SearchPageIndex } from \"./interfaces\";\nimport {\n  getChannels,\n  getMessages,\n  getSearchFile,\n  getUsers,\n} from \"./data-load.js\";\n\n// Format:\n// channelId: [ timestamp0, timestamp1, timestamp2, ... ]\n//\n// channelId: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]\n// pages: {\n//   0: [ 10, 9, 8 ]\n//   1: [ 7, 6, 5 ]\n//   2: [ 4, 3, 2 ]\n//   3: [ 1, 0 ]\n// }\n// INDEX_OF_PAGES: {\n//   channelId: [8, 5, 2, 0]\n// }\n//\n// For channelId, a message older than timestamp 0 but younger than timestamp1 is on page 1.\n// In our example above, the message with timestamp 6 is older than 5 but younger than 8.\nconst INDEX_OF_PAGES: SearchPageIndex = {};\n\nexport function recordPage(channelId?: string, timestamp?: string) {\n  if (!channelId || !timestamp) {\n    console.warn(\n      `Search: Cannot record page: channelId: ${channelId} timestamp: ${timestamp}`\n    );\n    return;\n  }\n\n  if (!INDEX_OF_PAGES[channelId]) {\n    INDEX_OF_PAGES[channelId] = [];\n  }\n\n  INDEX_OF_PAGES[channelId].push(timestamp);\n}\n\nexport async function createSearch() {\n  if (NO_SEARCH) return;\n\n  const spinner = ora(`Creating search file...`).start();\n  spinner.render();\n\n  await createSearchFile(spinner);\n  await createSearchHTML();\n\n  spinner.succeed(`Search file created`);\n}\n\nasync function createSearchFile(spinner: Ora) {\n  const existingData = await getSearchFile();\n  const users = await getUsers();\n  const channels = await getChannels();\n  const result: SearchFile = {\n    channels: {},\n    users: {},\n    messages: {},\n    pages: { ...existingData.pages, ...INDEX_OF_PAGES },\n  };\n\n  // Users\n  for (const user in users) {\n    result.users[user] = users[user].name || users[user].real_name || \"Unknown\";\n  }\n\n  // Channels & Messages\n  for (const [i, channel] of channels.entries()) {\n    if (!channel.id) {\n      console.warn(\n        `Can't create search file for channel ${channel.name}: No id found`,\n        channel\n      );\n      continue;\n    }\n\n    const name = getChannelName(channel);\n\n    spinner.text = `Creating search messages for channel ${name}`;\n    spinner.render();\n\n    const messages = (await getMessages(channel.id, true)).map((message) => {\n      const searchMessage: SearchMessage = {\n        m: message.text,\n        u: message.user,\n        t: message.ts,\n      };\n\n      return searchMessage;\n    });\n\n    result.messages![channel.id] = messages;\n    result.channels[channel.id] = name;\n  }\n\n  const jsContent = `window.search_data = ${JSON.stringify(result)};`;\n  await fs.outputFile(SEARCH_DATA_PATH, jsContent);\n}\n\nasync function createSearchHTML() {\n  let template = fs.readFileSync(SEARCH_TEMPLATE_PATH, \"utf8\");\n\n  template = template.replace(\n    \"<!-- react -->\",\n    getScript(`react@18.2.0/umd/react.production.min.js`)\n  );\n  template = template.replace(\n    \"<!-- react-dom -->\",\n    getScript(`react-dom@18.2.0/umd/react-dom.production.min.js`)\n  );\n  template = template.replace(\n    `<!-- babel -->`,\n    getScript(`babel-standalone@6.26.0/babel.min.js`)\n  );\n  template = template.replace(\n    `<!-- minisearch -->`,\n    getScript(\"minisearch@5.0.0/dist/umd/index.min.js\")\n  );\n\n  template = template.replace(`<!-- Size -->`, getSize());\n\n  fs.outputFileSync(SEARCH_PATH, template);\n}\n\nfunction getSize() {\n  const mb = fs.statSync(SEARCH_DATA_PATH).size / 1048576; //MB\n  return `Loading ${Math.round(mb)}MB of data`;\n}\n\nfunction getScript(script: string) {\n  return `<script crossorigin src=\"https://cdn.jsdelivr.net/npm/${script}\"></script>`;\n}\n"
  },
  {
    "path": "src/threads.ts",
    "content": "import { Message } from \"./interfaces\";\n\nexport function isThread(message: Message) {\n  return message.reply_count && message.reply_count > 0;\n}\n"
  },
  {
    "path": "src/timestamp.ts",
    "content": "export function slackTimestampToJavaScriptTimestamp(ts?: string) {\n  if (!ts) {\n    return 0;\n  }\n\n  const splitTs = ts.split(\".\") || [];\n  const jsTs = parseInt(`${splitTs[0]}${splitTs[1].slice(0, 3)}`, 10);\n\n  return jsTs;\n}\n"
  },
  {
    "path": "src/users.ts",
    "content": "import path from \"path\";\n\nimport { getWebClient } from \"./web-client.js\";\nimport { Message, User, Users } from \"./interfaces.js\";\nimport { getAvatarFilePath } from \"./config.js\";\nimport { getUsers } from \"./data-load.js\";\nimport { downloadURL } from \"./download-files.js\";\nimport ora from \"ora\";\n\n// We'll redownload users every run, but only once per user\n// To keep track, we'll keep the ids in this array\nexport const usersRefetchedThisRun: Array<string> = [];\nexport const avatarsRefetchedThisRun: Array<string> = [];\n\nexport async function downloadUser(\n  item: Message | any,\n  users: Users\n): Promise<User | null> {\n  if (!item.user) return null;\n\n  // If we already have this user *and* downloaded them before,\n  // return cached version\n  if (users[item.user] && usersRefetchedThisRun.includes(item.user))\n    return users[item.user];\n\n  const spinner = ora(`Downloading info for user ${item.user}...`).start();\n  const user = (item.user === 'U00') ? {} as User : (\n      await getWebClient().users.info({\n        user: item.user,\n      })\n    ).user;\n\n  if (user) {\n    usersRefetchedThisRun.push(item.user);\n    spinner.succeed(`Downloaded info for user ${item.user} (${user.name})`);\n    return (users[item.user] = user);\n  }\n\n  return null;\n}\n\nexport async function downloadAvatars() {\n  const users = await getUsers();\n  const userIds = Object.keys(users);\n  const spinner = ora(`Downloading avatars (0/${userIds.length})`).start();\n\n  for (const [i, userId] of userIds.entries()) {\n    spinner.text = `Downloading avatars (${i + 1}/${userIds.length})`;\n    await downloadAvatarForUser(users[userId]);\n  }\n\n  spinner.stop();\n}\n\nexport async function downloadAvatarForUser(user?: User | null) {\n  if (!user || !user.id || avatarsRefetchedThisRun.includes(user.id)) {\n    return;\n  }\n\n  const { profile } = user;\n\n  if (!profile || !profile.image_512) {\n    return;\n  }\n\n  try {\n    const filePath = getAvatarFilePath(\n      user.id!,\n      path.extname(profile.image_512)\n    );\n    await downloadURL(profile.image_512, filePath, {\n      authorize: false,\n      force: true,\n    });\n    avatarsRefetchedThisRun.push(user.id!);\n  } catch (error) {\n    console.warn(`Failed to download avatar for user ${user.id!}`, error);\n  }\n}\n\nexport function getName(userId: string | undefined, users: Users) {\n  if (!userId) return \"Unknown\";\n  const user = users[userId];\n  if (!user) return userId;\n\n  return user.profile?.display_name || user.profile?.real_name || user.name;\n}\n"
  },
  {
    "path": "src/web-client.ts",
    "content": "import { WebClient } from \"@slack/web-api\";\n\nimport { config } from \"./config.js\";\n\nlet _webClient: WebClient;\nexport function getWebClient() {\n  if (_webClient) return _webClient;\n\n  const { token } = config;\n  return (_webClient = new WebClient(token));\n}\n\nexport async function authTest() {\n  return getWebClient().auth.test();\n}\n"
  },
  {
    "path": "static/scroll.js",
    "content": "if (window.location.hash) {\n  document.getElementById(window.location.hash).scrollTo();\n} else {\n  scrollBy({ top: 99999999 });\n}\n"
  },
  {
    "path": "static/search.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Message Search</title>\n    <link rel=\"stylesheet\" href=\"html/style.css\" />\n\n    <!-- react -->\n    <!-- react-dom -->\n    <!-- babel -->\n    <!-- minisearch -->\n  </head>\n\n  <body>\n    <script type=\"text/babel\" data-type=\"module\">\n      class App extends React.PureComponent {\n        constructor(props) {\n          super(props);\n\n          this.handleSearchChange = this.handleSearchChange.bind(this);\n          this.handleSearchClear = this.handleSearchClear.bind(this);\n          this.searchMessages = this.searchMessages.bind(this);\n          this.loadSearchData = this.loadSearchData.bind(this);\n          this.loadSearchDataWhenReady =\n            this.loadSearchDataWhenReady.bind(this);\n\n          this.searchInputRef = React.createRef();\n          this.state = {\n            matchingMessages: [],\n            searchValue: \"\",\n            ready: false,\n            miniSearch: null,\n          };\n        }\n\n        componentDidMount() {\n          this.loadSearchDataWhenReady();\n        }\n\n        loadSearchDataWhenReady() {\n          if (window.search_data) {\n            this.loadSearchData();\n          } else {\n            setTimeout(() => {\n              this.loadSearchDataWhenReady();\n            }, 100);\n          }\n        }\n\n        loadSearchData() {\n          const { channels, users, messages } = window.search_data;\n\n          console.time(`Loading minisearch`);\n          const allMessages = [];\n          const miniSearch = new MiniSearch({\n            idField: \"t\",\n            fields: [\"m\"], // fields to index for full-text search\n            storeFields: [\"t\", \"u\", \"m\", \"c\"], // fields to return with search results\n          });\n\n          for (const channel in messages) {\n            for (const message of messages[channel]) {\n              allMessages.push({ ...message, c: channel });\n            }\n          }\n\n          miniSearch.addAll(allMessages);\n          console.timeEnd(`Loading minisearch`);\n\n          this.setState({ ready: true, miniSearch });\n        }\n\n        handleSearchChange({ target: { value } }) {\n          this.setState({ searchValue: value });\n          const matchingMessages =\n            value.length > 1 ? this.searchMessages(value) : [];\n          this.setState({ matchingMessages });\n        }\n\n        handleSearchClear() {\n          this.setState({ searchValue: \"\", matchingMessages: [] });\n        }\n\n        searchMessages(query) {\n          const { miniSearch } = this.state;\n          return miniSearch.search(query).slice(0, 50);\n        }\n\n        render() {\n          const { matchingMessages, searchValue, ready } = this.state;\n          return (\n            <div className=\"App\">\n              <article className=\"main\">\n                {ready ? (\n                  <Header\n                    onChange={this.handleSearchChange}\n                    onKeyDown={this.handleSearchKeyDown}\n                    onSearchClear={this.handleSearchClear}\n                    value={searchValue}\n                    searchInputRef={this.searchInputRef}\n                  />\n                ) : (\n                  \"Loading\"\n                )}\n                {matchingMessages && matchingMessages.length > 0 ? (\n                  <MessagesList messages={matchingMessages} />\n                ) : (\n                  <p>This search is incredibly basic, but it works.</p>\n                )}\n              </article>\n            </div>\n          );\n        }\n      }\n\n      const MessagesList = ({ messages }) => (\n        <ul className=\"MessagesList\">\n          {messages.map(({ t, ...props }) => (\n            <Message {...props} t={t} key={t} />\n          ))}\n        </ul>\n      );\n\n      const Message = ({ m, u, t, c }) => {\n        // Let's find the page\n        const { pages } = window.search_data;\n        // Returns the index of the first timestamp that's\n        // smaller than the timestamp we passed in.\n        const channelPages = pages[c] ? pages[c] : null;\n        let href;\n\n        if (channelPages) {\n          const index = channelPages.findIndex((pageTs) => pageTs < t);\n          const page = `${c}-${index}`;\n          href = `index.html?c=${encodeURIComponent(page)}&ts=${t}`;\n        }\n\n        const message = (\n          <li className=\"Message\">\n            <p>\n              <Channel id={c} /> - <Timestamp timestamp={t} />\n            </p>\n            <p>\n              <User id={u} /> {m}\n            </p>\n          </li>\n        );\n\n        if (href) {\n          return (\n            <a href={href} target=\"_blank\">\n              {message}\n            </a>\n          );\n        } else {\n          return message;\n        }\n      };\n\n      const User = ({ id }) => (\n        <strong>@{window.search_data.users[id]}: </strong>\n      );\n\n      const Channel = ({ id }) => (\n        <span className=\"Channel\">#{window.search_data.channels[id]}</span>\n      );\n\n      const Timestamp = ({ timestamp }) => {\n        const splitTs = timestamp.split(\".\") || [];\n        const jsTs = parseInt(`${splitTs[0]}${splitTs[1].slice(0, 3)}`, 10);\n        const date = new Date(jsTs);\n\n        return (\n          <span class=\"timestamp\">\n            <span className=\"c-timestamp__label\">{date.toLocaleString()}</span>\n          </span>\n        );\n      };\n\n      const Header = (props) => (\n        <header className=\"Header\">\n          <h1>Message Search</h1>\n          <SearchBox {...props} />\n        </header>\n      );\n\n      const SearchBox = ({\n        onChange,\n        onSearchClear,\n        value,\n        searchInputRef,\n      }) => (\n        <div className=\"SearchBox\">\n          <div className=\"Search\">\n            <input\n              type=\"text\"\n              value={value}\n              onChange={onChange}\n              ref={searchInputRef}\n              autoComplete=\"none\"\n              autoCorrect=\"none\"\n              autoCapitalize=\"none\"\n              spellCheck=\"false\"\n            />\n            <button\n              className=\"clear\"\n              onClick={onSearchClear}\n              style={{ margin: 10 }}\n            >\n              &times;\n            </button>\n          </div>\n        </div>\n      );\n\n      ReactDOM.render(\n        React.createElement(App),\n        document.getElementById(\"search\")\n      );\n    </script>\n    <div id=\"search\">\n      Loading and indexing messages, please wait. If you have a large number of\n      messages, this might take a minute.\n      <p>\n        <!-- Size -->\n      </p>\n    </div>\n    <script defer src=\"data/search.js\" type=\"text/javascript\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "static/style.css",
    "content": "/* Reset */\n\n/* Box sizing rules */\n*,\n*::before,\n*::after {\n  box-sizing: border-box;\n}\n\n/* Remove default margin */\nbody,\nh1,\nh2,\nh3,\nh4,\np,\nfigure,\nblockquote,\ndl,\ndd {\n  margin: 0;\n}\n\n/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */\nul[role='list'],\nol[role='list'] {\n  list-style: none;\n}\n\n/* Set core root defaults */\nhtml:focus-within {\n  scroll-behavior: smooth;\n}\n\n/* Set core body defaults */\nbody {\n  min-height: 100vh;\n  text-rendering: optimizeSpeed;\n  line-height: 1.5;\n}\n\n/* A elements that don't have a class get default styles */\na:not([class]) {\n  text-decoration-skip-ink: auto;\n}\n\n/* Make images easier to work with */\nimg,\npicture {\n  max-width: 100%;\n  display: block;\n}\n\n/* Inherit fonts for inputs and buttons */\ninput,\nbutton,\ntextarea,\nselect {\n  font: inherit;\n}\n\n/* Remove all animations, transitions and smooth scroll for people that prefer not to see them */\n@media (prefers-reduced-motion: reduce) {\n  html:focus-within {\n   scroll-behavior: auto;\n  }\n  \n  *,\n  *::before,\n  *::after {\n    animation-duration: 0.01ms !important;\n    animation-iteration-count: 1 !important;\n    transition-duration: 0.01ms !important;\n    scroll-behavior: auto !important;\n  }\n}\n\n@font-face {\n  font-family: \"Lato\";\n  src: url('fonts/Lato-Regular.ttf') format('truetype');\n  font-weight: normal;\n  font-style: normal;\n}\n\n@font-face {\n  font-family: \"Lato\";\n  src: url('fonts/Lato-Bold.ttf') format('truetype');\n  font-weight: bold;\n  font-style: normal;\n}\n\nbody, html {\n  font-family: 'Lato', sans-serif;\n  font-size: 14px;\n  color: rgb(29, 28, 29);\n}\n\na {\n  color: rgb(18, 100, 163);\n}\n\naudio, video {\n  max-width: 400px;\n}\n\n.messages-list {\n  padding-bottom: 20px;\n}\n\n.messages-list .avatar {\n  height: 36px;\n  width: 36px;\n  border-radius: 7px;\n  margin-right: 10px;\n  background: #c1c1c1;\n}\n\n.message-gutter {\n  display: flex;\n  margin: 10px;\n  scroll-margin-top: 120px;\n}\n\n.message-gutter:target {\n  background-color: #fafafa;\n  border: 2px solid #39113E;\n  padding: 10px;\n  border-radius: 5px; \n}\n\n.message-gutter div:first-of-type {\n  flex-shrink: 0;\n}\n\n.message-gutter > .message-gutter {\n  /** i.e. replies in thread. Just here to be easily findable */\n}\n\n.sender {\n  font-weight: 800;\n  margin-right: 10px;\n}\n\n.timestamp {\n  font-weight: 200;\n  font-size: 13px;\n  color: rgb(97, 96, 97);\n}\n\n.header {\n  position: sticky;\n  background: #fff;\n  color: #616061;\n  top: 0;\n  left: 0;\n  padding: 10px;\n  min-height: 70px;\n  border-bottom: 1px solid #E2E2E2;\n  box-sizing: border-box;\n}\n\n.header h1 {\n  font-size: 16px;\n  color: #1D1C1D;\n  display: inline-block;\n}\n\n.header a {\n  color: #616061;\n}\n\n.header a:active, .header a.current {\n  color: #000;\n}\n\n.header .created {\n  float: right;\n}\n\n.jumper {\n  display: inline-block;\n}\n\n.jumper a {\n  margin: 2px;\n}\n\n.text {\n  overflow-wrap: break-word;\n}\n\n.file {\n  max-height: 270px;\n  margin-right: 10px;\n  margin-top: 10px;\n  border-radius: 4px;\n  border: 1px solid #80808045;\n  outline: none;\n}\n\n.reaction {\n  background-color: #eaeaea;\n  display: inline-block;\n  border-radius: 10px;\n  font-size: .7em;\n  padding-left: 6px;\n  padding-right: 6px;\n  padding-bottom: 4px;\n  margin-right: 5px;\n  padding-top: 4px;\n}\n\n.reaction img {\n  height: 16px;\n  width: 16px;\n  margin-right: 3px;\n  vertical-align: middle;\n  display: inline-block;\n}\n\n.reaction span {\n  position: relative;\n  top: 1px;\n}\n\n#index {\n  display: flex;\n  height: calc(100vh - 4px);\n}\n\n#channels {\n  background: #39113E;\n  width: 250px;\n  color: #CDC3CE;\n  padding-top: 10px;\n  overflow: scroll;\n  padding-bottom: 20px;\n}\n\n#channels ul {\n  margin: 0;\n  padding: 0;\n  list-style: none;\n}\n\n#channels p {\n  padding-left: 20px;\n}\n\n#channels .section {\n  font-weight: 800;\n  color: #fff;\n  margin-top: 10px;\n}\n\n#channels .section:first-of-type {\n  margin-top: 0;\n}\n\n#channels a {\n  padding: 5px;\n  display: block;\n  color: #CDC3CE;\n  text-decoration: none;\n  padding-left: 20px;\n  display: flex;\n  max-height: 28px;\n  white-space: pre;\n  text-overflow: ellipsis;\n  overflow: hidden;\n}\n\n#channels a .avatar {\n  height: 20px;\n  width: 20px;\n  border-radius: 3px;\n  margin-right: 10px;\n  object-fit: contain;\n}\n\n#channels a:hover {\n  background: #301034;\n  color: #edeced;\n}\n\n#messages {\n  flex-grow: 1;\n}\n\n#messages iframe {\n  height: 100%;\n  width: calc(100vw - 250px);\n  border: none;\n}\n\n#search {\n  margin: 10px;\n  text-align: center;\n}\n\n#search ul {\n  list-style: none;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n}\n\n#search li {\n  padding: 5px;\n  border-bottom: 1px solid #E2E2E2;\n  background: hsl(0deg 0% 98%);\n  border-radius: 5px;\n  width: 600px;\n  text-align: left;\n  margin-bottom: 5px;\n}\n\n#search a {\n  text-decoration: none;\n  color: unset;\n}"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"jsx\": \"react\",\n    /* Visit https://aka.ms/tsconfig.json to read more about this file */\n\n    /* Projects */\n    // \"incremental\": true,                              /* Enable incremental compilation */\n    // \"composite\": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */\n    // \"tsBuildInfoFile\": \"./\",                          /* Specify the folder for .tsbuildinfo incremental compilation files. */\n    // \"disableSourceOfProjectReferenceRedirect\": true,  /* Disable preferring source files instead of declaration files when referencing composite projects */\n    // \"disableSolutionSearching\": true,                 /* Opt a project out of multi-project reference checking when editing. */\n    // \"disableReferencedProjectLoad\": true,             /* Reduce the number of projects loaded automatically by TypeScript. */\n\n    /* Language and Environment */\n    \"target\": \"ES2020\",                                     /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */\n    // \"lib\": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */\n    // \"jsx\": \"preserve\",                                /* Specify what JSX code is generated. */\n    // \"experimentalDecorators\": true,                   /* Enable experimental support for TC39 stage 2 draft decorators. */\n    // \"emitDecoratorMetadata\": true,                    /* Emit design-type metadata for decorated declarations in source files. */\n    // \"jsxFactory\": \"\",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */\n    // \"jsxFragmentFactory\": \"\",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */\n    // \"jsxImportSource\": \"\",                            /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */\n    // \"reactNamespace\": \"\",                             /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */\n    // \"noLib\": true,                                    /* Disable including any library files, including the default lib.d.ts. */\n    // \"useDefineForClassFields\": true,                  /* Emit ECMAScript-standard-compliant class fields. */\n\n    /* Modules */\n    \"module\": \"ES2020\",                                /* Specify what module code is generated. */\n    // \"rootDir\": \"./\",                                  /* Specify the root folder within your source files. */\n    \"moduleResolution\": \"node\",                       /* Specify how TypeScript looks up a file from a given module specifier. */\n    // \"baseUrl\": \"./\",                                  /* Specify the base directory to resolve non-relative module names. */\n    // \"paths\": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */\n    // \"rootDirs\": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */\n    // \"typeRoots\": [],                                  /* Specify multiple folders that act like `./node_modules/@types`. */\n    // \"types\": [],                                      /* Specify type package names to be included without being referenced in a source file. */\n    // \"allowUmdGlobalAccess\": true,                     /* Allow accessing UMD globals from modules. */\n    // \"resolveJsonModule\": true,                        /* Enable importing .json files */\n    // \"noResolve\": true,                                /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */\n\n    /* JavaScript Support */\n    // \"allowJs\": true,                                  /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */\n    // \"checkJs\": true,                                  /* Enable error reporting in type-checked JavaScript files. */\n    // \"maxNodeModuleJsDepth\": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */\n\n    /* Emit */\n    // \"declaration\": true,                              /* Generate .d.ts files from TypeScript and JavaScript files in your project. */\n    // \"declarationMap\": true,                           /* Create sourcemaps for d.ts files. */\n    // \"emitDeclarationOnly\": true,                      /* Only output d.ts files and not JavaScript files. */\n    // \"sourceMap\": true,                                /* Create source map files for emitted JavaScript files. */\n    // \"outFile\": \"./\",                                  /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */\n    \"outDir\": \"./lib\",                                   /* Specify an output folder for all emitted files. */\n    // \"removeComments\": true,                           /* Disable emitting comments. */\n    // \"noEmit\": true,                                   /* Disable emitting files from a compilation. */\n    // \"importHelpers\": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */\n    // \"importsNotUsedAsValues\": \"remove\",               /* Specify emit/checking behavior for imports that are only used for types */\n    // \"downlevelIteration\": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */\n    // \"sourceRoot\": \"\",                                 /* Specify the root path for debuggers to find the reference source code. */\n    // \"mapRoot\": \"\",                                    /* Specify the location where debugger should locate map files instead of generated locations. */\n    // \"inlineSourceMap\": true,                          /* Include sourcemap files inside the emitted JavaScript. */\n    // \"inlineSources\": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */\n    // \"emitBOM\": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */\n    // \"newLine\": \"crlf\",                                /* Set the newline character for emitting files. */\n    // \"stripInternal\": true,                            /* Disable emitting declarations that have `@internal` in their JSDoc comments. */\n    // \"noEmitHelpers\": true,                            /* Disable generating custom helper functions like `__extends` in compiled output. */\n    // \"noEmitOnError\": true,                            /* Disable emitting files if any type checking errors are reported. */\n    // \"preserveConstEnums\": true,                       /* Disable erasing `const enum` declarations in generated code. */\n    // \"declarationDir\": \"./\",                           /* Specify the output directory for generated declaration files. */\n\n    /* Interop Constraints */\n    // \"isolatedModules\": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */\n    \"allowSyntheticDefaultImports\": true,                /* Allow 'import x from y' when a module doesn't have a default export. */\n    \"esModuleInterop\": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */\n    // \"preserveSymlinks\": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */\n    \"forceConsistentCasingInFileNames\": true,            /* Ensure that casing is correct in imports. */\n\n    /* Type Checking */\n    \"strict\": true,                                      /* Enable all strict type-checking options. */\n    // \"noImplicitAny\": true,                            /* Enable error reporting for expressions and declarations with an implied `any` type.. */\n    // \"strictNullChecks\": true,                         /* When type checking, take into account `null` and `undefined`. */\n    // \"strictFunctionTypes\": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */\n    // \"strictBindCallApply\": true,                      /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */\n    // \"strictPropertyInitialization\": true,             /* Check for class properties that are declared but not set in the constructor. */\n    // \"noImplicitThis\": true,                           /* Enable error reporting when `this` is given the type `any`. */\n    // \"useUnknownInCatchVariables\": true,               /* Type catch clause variables as 'unknown' instead of 'any'. */\n    // \"alwaysStrict\": true,                             /* Ensure 'use strict' is always emitted. */\n    // \"noUnusedLocals\": true,                           /* Enable error reporting when a local variables aren't read. */\n    // \"noUnusedParameters\": true,                       /* Raise an error when a function parameter isn't read */\n    // \"exactOptionalPropertyTypes\": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */\n    // \"noImplicitReturns\": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */\n    // \"noFallthroughCasesInSwitch\": true,               /* Enable error reporting for fallthrough cases in switch statements. */\n    // \"noUncheckedIndexedAccess\": true,                 /* Include 'undefined' in index signature results */\n    // \"noImplicitOverride\": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */\n    // \"noPropertyAccessFromIndexSignature\": true,       /* Enforces using indexed accessors for keys declared using an indexed type */\n    // \"allowUnusedLabels\": true,                        /* Disable error reporting for unused labels. */\n    // \"allowUnreachableCode\": true,                     /* Disable error reporting for unreachable code. */\n\n    /* Completeness */\n    // \"skipDefaultLibCheck\": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */\n    \"skipLibCheck\": true                                 /* Skip type checking all .d.ts files. */\n  },\n  \"ts-node\": {\n    \"esm\": true,\n    \"transpileOnly\": true\n  },\n}\n"
  }
]