Repository: felixrieseberg/slack-archive Branch: main Commit: 4ce7b36fe08f Files: 30 Total size: 87.7 KB Directory structure: gitextract_oionf7w6/ ├── .gitignore ├── .node-version ├── .npmignore ├── README.md ├── bin/ │ └── slack-archive.js ├── package.json ├── src/ │ ├── ambient.d.ts │ ├── archive-data.ts │ ├── backup.ts │ ├── channels.ts │ ├── cli.ts │ ├── config.ts │ ├── create-html.tsx │ ├── data-load.ts │ ├── data-write.ts │ ├── download-files.ts │ ├── emoji.ts │ ├── interfaces.ts │ ├── messages.ts │ ├── reactions.ts │ ├── retry.ts │ ├── search.ts │ ├── threads.ts │ ├── timestamp.ts │ ├── users.ts │ └── web-client.ts ├── static/ │ ├── scroll.js │ ├── search.html │ └── style.css └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ node_modules out slack-archive .DS_Store *.log .token lib ================================================ FILE: .node-version ================================================ 16.4.0 ================================================ FILE: .npmignore ================================================ node_modules out slack-archive .DS_Store *.log .token src ================================================ FILE: README.md ================================================ # Export your Slack workspace as static HTML Alright, so you want to export all your messages on Slack. You want them in a format that you can still enjoy in 20 years. This tool will help you do that. * **Completely static**: The generated files are pure HTML and will still work in 50 years. * **Everything you care about**: This tool downloads messages, files, and avatars. * **Nothing you do not care about**: Choose exactly which channels and DMs to download. * **All types of conversations**: We'll fetch public channels, private channels, DMs, and multi-person DMs. * **Incremental backups**: If you already have local data, we'll extend it - no need to download existing stuff again. * **JSON included**: All data is also stored as JSON, so you can consume it with other tools later. * **No cloud, free**: Do all of this for free, without giving anyone your information. * **Basic search**: Offers basic search functionality. Screen Shot 2021-09-09 at 6 43 55 PM ## Using it 1. Do you already have a user token for your workspace? If not, read on below on how to get a token. 2. Make sure you have [`node` and `npm`](https://nodejs.org/en/) installed, ideally something newer than Node v14. 3. Run `slack-archive`, which will interactively guide you through the options. ```sh npx slack-archive ``` ### Parameters ``` --automatic: Don't prompt and automatically fetch all messages from all channels. --use-previous-channel-config: Fetch messages from channels selected in previous run instead of prompting. --channel-types Comma-separated list of channel types to fetch messages from. (public_channel, private_channel, mpim, im) --exclude-channels Comma-separated list of channels to exclude, in automatic mode --no-backup: Don't create backups. Not recommended. --no-search: Don't create a search file, saving disk space. --no-file-download: Don't download files. --no-slack-connect: Don't connect to Slack, just generate HTML from local data. --force-html-generation: Force regeneration of HTML files. Useful after slack-archive upgrades. ``` ## Getting a token In order to download messages from private channels and direct messages, we will need a "user token". Slack uses the token to identify what permissions it'll give this app. We used to be able to just copy a token out of your Slack app, but now, we'll need to create a custom app and jump through a few hoops. This will be mostly painless, I promise. ### 1) Make a custom app Head over to https://api.slack.com/apps and `Create New App`. Select `From scratch`. Give it a name and choose the workspace you'd like to export. Then, from the `Features` menu on the left, select `OAuth & Permission`. As a redirect URL, enter something random that doesn't actually exist, or a domain you control. For instace: ``` https://notarealurl.com/ ``` (Note that redirects will take a _very_ long time if using a domain that doesn't actually exist) Then, add the following `User Token Scopes`: * channels:history * channels:read * files:read * groups:history * groups:read * im:history * im:read * mpim:history * mpim:read * remote_files:read * users:read Finally, head back to `Basic Information` and make a note of your app's `client id` and `client secret`. We'll need both later. ### 2) Authorize Make sure you have your Slack workspace `URL` (aka team name) and your app's `client id`. Then, in a browser, open this URL - replacing `{your-team-name}` and `{your-client-id}` with your values. ``` https://{your-team-name}.slack.com/oauth/authorize?client_id={your-client-id}&scope=client ``` Confirm everything until Slack sends you to the mentioned non-existent URL. Look at your browser's address bar - it should contain an URL that looks like this: ``` https://notarealurl.com/?code={code}&state= ``` Copy everything between `?code=` and `&state`. This is your `code`. We'll need it in the next step. Next, we'll exchange your code for a token. To do so, we'll also need your `client secret` from the first step when we created your app. In a browser, open this URL - replacing `{your-team-name}`, `{your-client-id}`, `{your-code}` and `{your-client-secret}` with your values. ``` https://{your-team-name}.slack.com/api/oauth.access?client_id={your-client-id}&client_secret={your-client-secret}&code={your-code} ``` Your 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. ================================================ FILE: bin/slack-archive.js ================================================ #!/usr/bin/env node import('../lib/cli.js') ================================================ FILE: package.json ================================================ { "name": "slack-archive", "version": "1.6.1", "description": "Create static HTML archives for your Slack workspaces", "scripts": { "prettier": "npx prettier --write src/*", "cli": "ts-node src/cli.ts", "html": "ts-node src/create-html.tsx", "test": "echo \"Error: no test specified\" && exit 1", "compile": "tsc", "watch": "tsc -w", "prepublishOnly": "npm run compile" }, "bin": { "slack-archive": "./bin/slack-archive.js" }, "type": "module", "keywords": [ "slack", "export", "download" ], "repository": { "type": "git", "url": "https://github.com/felixrieseberg/slack-archive.git" }, "author": "Felix Rieseberg ", "license": "MIT", "dependencies": { "@slack/web-api": "^6.7.2", "date-fns": "^2.28.0", "emoji-datasource": "^14.0.0", "es-main": "^1.0.2", "fs-extra": "^10.1.0", "inquirer": "^8.2.0", "lodash-es": "^4.17.21", "node-fetch": "^2.6.7", "ora": "^6.1.0", "react": "^17.0.2", "react-dom": "^17.0.2", "rimraf": "^5.0.5", "slack-markdown": "^0.2.0", "trash": "^8.1.0" }, "devDependencies": { "@types/date-fns": "^2.6.0", "@types/fs-extra": "^9.0.13", "@types/inquirer": "^8.1.3", "@types/lodash-es": "^4.17.5", "@types/node": "^17.0.5", "@types/node-fetch": "^2.5.12", "@types/react": "^17.0.38", "@types/react-dom": "^17.0.11", "ts-node": "^10.8.1", "tslib": "^2.4.0", "typescript": "^4.7.4" }, "ts-node": { "files": true } } ================================================ FILE: src/ambient.d.ts ================================================ declare module "slack-markdown"; declare module "es-main"; declare module "emoji-datasource"; ================================================ FILE: src/archive-data.ts ================================================ import fs from "fs-extra"; import { SLACK_ARCHIVE_DATA_PATH } from "./config.js"; import { readJSON } from "./data-load.js"; import { write } from "./data-write.js"; import { SlackArchiveData, User } from "./interfaces.js"; export async function getSlackArchiveData(): Promise { const returnIfEmpty: SlackArchiveData = { channels: {} }; if (!fs.existsSync(SLACK_ARCHIVE_DATA_PATH)) { return returnIfEmpty; } const result = await readJSON(SLACK_ARCHIVE_DATA_PATH); const merged = { channels: result.channels || {}, auth: result.auth }; return merged; } export async function setSlackArchiveData( newData: SlackArchiveData ): Promise { const oldData = await getSlackArchiveData(); const dataToWrite = { channels: { ...oldData.channels, ...newData.channels }, auth: newData.auth, }; return write( SLACK_ARCHIVE_DATA_PATH, JSON.stringify(dataToWrite, undefined, 2) ); } ================================================ FILE: src/backup.ts ================================================ import fs from "fs-extra"; import inquirer from "inquirer"; import path from "path"; import trash from "trash"; import { rimraf } from "rimraf"; import { AUTOMATIC_MODE, DATA_DIR, NO_BACKUP, OUT_DIR } from "./config.js"; const { prompt } = inquirer; let backupDir = `${DATA_DIR}_backup_${Date.now()}`; export async function createBackup() { if (NO_BACKUP || !fs.existsSync(DATA_DIR)) { return; } const hasFiles = fs.readdirSync(DATA_DIR); if (hasFiles.length === 0) { return; } console.log(`Existing data directory found. Creating backup: ${backupDir}`); await fs.copy(DATA_DIR, backupDir); console.log(`Backup created.\n`); } export async function deleteBackup() { if (!fs.existsSync(backupDir)) { return; } console.log( `Cleaning up backup: If anything went wrong, you'll find it in your system's trash.` ); try { // NB: trash doesn't work on many Linux distros await trash(backupDir); return; } catch (error) { console.log('Moving backup to trash failed.'); } if (!process.env['TRASH_HARDER']) { console.log(`Set TRASH_HARDER=1 to delete files permanently.`); return; } try { await rimraf(backupDir); } catch (error) { console.log(`Deleting backup permanently failed. Aborting here.`); } } export async function deleteOlderBackups() { try { const oldBackupNames: Array = []; const oldBackupPaths: Array = []; for (const entry of fs.readdirSync(OUT_DIR)) { const isBackup = entry.startsWith("data_backup_"); if (!isBackup) continue; const dir = path.join(OUT_DIR, entry); const { isDirectory } = fs.statSync(dir); if (!isDirectory) continue; oldBackupPaths.push(dir); oldBackupNames.push(entry); } if (oldBackupPaths.length === 0) return; if (AUTOMATIC_MODE) { console.log( `Found existing older backups, but in automatic mode: Proceeding without deleting them.` ); return; } const { del } = await prompt([ { type: "confirm", default: true, name: "del", message: `We've found existing backups (${oldBackupNames.join( ", " )}). Do you want to delete them?`, }, ]); if (del) { oldBackupPaths.forEach((v) => fs.removeSync(v)); } } catch (error) { // noop } } ================================================ FILE: src/channels.ts ================================================ import { ConversationsListArguments, ConversationsListResponse, } from "@slack/web-api"; import ora from "ora"; import { NO_SLACK_CONNECT } from "./config.js"; import { Channel, Users } from "./interfaces.js"; import { downloadUser, getName } from "./users.js"; import { getWebClient } from "./web-client.js"; export function getChannelName(channel: Channel) { return ( channel.name || channel.id || channel.purpose?.value || "Unknown channel" ); } export function isPublicChannel(channel: Channel) { return !channel.is_private && !channel.is_mpim && !channel.is_im; } export function isPrivateChannel(channel: Channel) { return channel.is_private && !channel.is_im && !channel.is_mpim; } export function isDmChannel(channel: Channel, users: Users) { return channel.is_im && channel.user && !users[channel.user]?.is_bot; } export function isBotChannel(channel: Channel, users: Users) { return channel.user && users[channel.user]?.is_bot; } function isChannels(input: any): input is ConversationsListResponse { return !!input.channels; } export async function downloadChannels( options: ConversationsListArguments, users: Users ): Promise> { const channels: Array = []; if (NO_SLACK_CONNECT) { return channels; } const spinner = ora("Downloading channels").start(); for await (const page of getWebClient().paginate( "conversations.list", options )) { if (isChannels(page)) { spinner.text = `Found ${page.channels?.length} channels (found so far: ${ channels.length + (page.channels?.length || 0) })`; const pageChannels = (page.channels || []).filter((c) => !!c.id); for (const channel of pageChannels) { if (channel.is_im) { const user = await downloadUser(channel, users); channel.name = channel.name || `${getName(user?.id, users)} (${user?.name})`; } if (channel.is_mpim) { channel.name = channel.purpose?.value; } } channels.push(...pageChannels); } } spinner.succeed(`Found ${channels.length} channels`); return channels; } ================================================ FILE: src/cli.ts ================================================ import { uniqBy } from "lodash-es"; import inquirer from "inquirer"; import fs from "fs-extra"; import { User } from "@slack/web-api/dist/response/UsersInfoResponse"; import { Channel } from "@slack/web-api/dist/response/ConversationsListResponse"; import ora from "ora"; import { CHANNELS_DATA_PATH, USERS_DATA_PATH, getChannelDataFilePath, OUT_DIR, config, TOKEN_FILE, AUTOMATIC_MODE, USE_PREVIOUS_CHANNEL_CONFIG, CHANNEL_TYPES, DATE_FILE, EMOJIS_DATA_PATH, NO_SLACK_CONNECT, EXCLUDE_CHANNELS, } from "./config.js"; import { downloadExtras } from "./messages.js"; import { downloadMessages } from "./messages.js"; import { downloadFilesForChannel } from "./download-files.js"; import { createHtmlForChannels, getChannelsToCreateFilesFor, } from "./create-html.js"; import { createBackup, deleteBackup, deleteOlderBackups } from "./backup.js"; import { isValid, parseISO } from "date-fns"; import { createSearch } from "./search.js"; import { write, writeAndMerge } from "./data-write.js"; import { messagesCache, getUsers, getChannels } from "./data-load.js"; import { getSlackArchiveData, setSlackArchiveData } from "./archive-data.js"; import { downloadEmojiList, downloadEmojis } from "./emoji.js"; import { downloadAvatars } from "./users.js"; import { downloadChannels } from "./channels.js"; import { authTest } from "./web-client.js"; import { SlackArchiveChannelData } from "./interfaces.js"; const { prompt } = inquirer; async function selectMergeFiles(): Promise { const defaultResponse = true; if (!fs.existsSync(CHANNELS_DATA_PATH)) { return false; } // We didn't download any data. Merge. if (AUTOMATIC_MODE || NO_SLACK_CONNECT) { return defaultResponse; } const { merge } = await prompt([ { type: "confirm", default: defaultResponse, name: "merge", 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.`, }, ]); if (!merge) { fs.emptyDirSync(OUT_DIR); } return merge; } async function selectChannels( channels: Array, previouslyDownloadedChannels: Record ): Promise> { if (USE_PREVIOUS_CHANNEL_CONFIG) { const selectedChannels: Array = channels.filter( (channel) => channel.id && channel.id in previouslyDownloadedChannels ); const selectedChannelNames = selectedChannels.map( (channel) => channel.name || channel.id || "Unknown" ); console.log( `Downloading channels selected previously: ${selectedChannelNames}.` ); const previousChannelIds = Object.keys(previouslyDownloadedChannels); if (previousChannelIds.length != selectedChannels.length) { console.warn( "WARNING: Did not find all previously selected channel IDs." ); console.log( `Expected to find ${previousChannelIds.length} channels, but only ${selectedChannels.length} matched.` ); // Consider Looking up the user-facing names of the missing channels in the saved data. const availableChannelIds = new Set( channels.map((channel) => channel.id || "") ); const missingChannelIds = previousChannelIds.filter( (cId) => !availableChannelIds.has(cId) ); //console.log(availableChannelIds); console.log(`Missing channel ids: ${missingChannelIds}`); } else { console.log( `Matched all ${previousChannelIds.length} previously selected channels out of ${channels.length} total channels available.` ); } return selectedChannels; } const choices = channels.map((channel) => ({ name: channel.name || channel.id || "Unknown", value: channel, })); if (AUTOMATIC_MODE || NO_SLACK_CONNECT) { if (EXCLUDE_CHANNELS) { const excludeChannels = EXCLUDE_CHANNELS.split(','); return channels.filter((channel) => !excludeChannels.includes(channel.name || '')); } return channels; } const result = await prompt([ { type: "checkbox", loop: true, name: "channels", message: "Which channels do you want to download?", choices, }, ]); return result.channels; } async function selectChannelTypes(): Promise> { const choices = [ { name: "Public Channels", value: "public_channel", }, { name: "Private Channels", value: "private_channel", }, { name: "Multi-Person Direct Message", value: "mpim", }, { name: "Direct Messages", value: "im", }, ]; if (CHANNEL_TYPES) { return CHANNEL_TYPES.split(","); } if (AUTOMATIC_MODE || USE_PREVIOUS_CHANNEL_CONFIG || NO_SLACK_CONNECT) { return ["public_channel", "private_channel", "mpim", "im"]; } const result = await prompt([ { type: "checkbox", loop: true, name: "channel-types", message: `Which channel types do you want to download?`, choices, }, ]); return result["channel-types"]; } async function getToken() { if (NO_SLACK_CONNECT) { return; } if (config.token) { console.log(`Using token ${config.token}`); return; } if (fs.existsSync(TOKEN_FILE)) { config.token = fs.readFileSync(TOKEN_FILE, "utf-8").trim(); return; } const result = await prompt([ { name: "token", type: "input", message: "Please enter your Slack token (xoxp-...). See README for more details.", }, ]); config.token = result.token; } async function writeLastSuccessfulArchive() { const now = new Date(); write(DATE_FILE, now.toISOString()); } function getLastSuccessfulRun() { if (!fs.existsSync(DATE_FILE)) { return ""; } const lastSuccessfulArchive = fs.readFileSync(DATE_FILE, "utf-8"); let date = null; try { date = parseISO(lastSuccessfulArchive); } catch (error) { return ""; } if (date && isValid(date)) { return `. Last successful run: ${date.toLocaleString()}`; } return ""; } async function getAuthTest() { if (NO_SLACK_CONNECT) { return; } const spinner = ora("Testing authentication with Slack...").start(); const result = await authTest(); if (!result.ok) { spinner.fail(`Authentication with Slack failed.`); console.log( `Authentication with Slack failed. The error was: ${result.error}` ); console.log( `The provided token was ${config.token}. Double-check the token and try again.` ); console.log( `For more information on the error code, see the error table at https://api.slack.com/methods/auth.test` ); console.log(`This tool will now exit.`); await deleteBackup(); process.exit(-1); } else { spinner.succeed(`Successfully authorized with Slack as ${result.user}\n`); } return result; } export async function main() { console.log(`Welcome to slack-archive${getLastSuccessfulRun()}`); if (AUTOMATIC_MODE) { console.log(`Running in fully automatic mode without prompts`); } if (NO_SLACK_CONNECT) { console.log(`Not connecting to Slack and skipping all Slack API calls`); } await getToken(); await createBackup(); const slackArchiveData = await getSlackArchiveData(); const users: Record = await getUsers(); const channelTypes = (await selectChannelTypes()).join(","); slackArchiveData.auth = await getAuthTest(); const channels = await downloadChannels({ types: channelTypes }, users); const selectedChannels = await selectChannels( channels, slackArchiveData.channels ); const newMessages: Record = {}; // Emoji // We don't actually download the images here, we'll // do that as needed const emojis = await downloadEmojiList(); await writeAndMerge(EMOJIS_DATA_PATH, emojis); // Do we want to merge data? await selectMergeFiles(); await writeAndMerge(CHANNELS_DATA_PATH, selectedChannels); // Download messages and extras for each channel await downloadEachChannel(); // Save data await setSlackArchiveData(slackArchiveData); // Create HTML, but only for channels with new messages // - or channels that we didn't make HTML for yet const channelsToCreateFilesFor = await getChannelsToCreateFilesFor( selectedChannels, newMessages ); await createHtmlForChannels(channelsToCreateFilesFor); // Create search file await createSearch(); // Cleanup and finalize await deleteBackup(); await deleteOlderBackups(); await writeLastSuccessfulArchive(); console.log(`All done.`); async function downloadEachChannel() { if (NO_SLACK_CONNECT) return; for (const [i, channel] of selectedChannels.entries()) { if (!channel.id) { console.warn(`Selected channel does not have an id`, channel); continue; } // Do we already have everything? slackArchiveData.channels[channel.id] = slackArchiveData.channels[channel.id] || {}; if (slackArchiveData.channels[channel.id].fullyDownloaded) { continue; } // Download messages & users let downloadData = await downloadMessages( channel, i, selectedChannels.length ); let result = downloadData.messages; newMessages[channel.id] = downloadData.new; await downloadExtras(channel, result, users); await downloadEmojis(result, emojis); await downloadAvatars(); // Sort messages const spinner = ora( `Saving message data for ${channel.name || channel.id} to disk` ).start(); spinner.render(); result = uniqBy(result, "ts"); result = result.sort((a, b) => { return parseFloat(b.ts || "0") - parseFloat(a.ts || "0"); }); await writeAndMerge(USERS_DATA_PATH, users); fs.outputFileSync( getChannelDataFilePath(channel.id), JSON.stringify(result, undefined, 2) ); // Download files. This needs to run after the messages are saved to disk // since it uses the message data to find which files to download. await downloadFilesForChannel(channel.id!, spinner); // Update the data load cache messagesCache[channel.id!] = result; // Update the data const { is_archived, is_im, is_user_deleted } = channel; if (is_archived || (is_im && is_user_deleted)) { slackArchiveData.channels[channel.id].fullyDownloaded = true; } slackArchiveData.channels[channel.id].messages = result.length; spinner.succeed(`Saved message data for ${channel.name || channel.id}`); } } } main(); ================================================ FILE: src/config.ts ================================================ import path from "path"; import { dirname } from "path"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); export const config = { token: process.env.SLACK_TOKEN, }; function findCliParameter(param: string) { const args = process.argv; for (const arg of args) { if (arg === param) { return true; } } return false; } function getCliParameter(param: string) { const args = process.argv; for (const [i, arg] of args.entries()) { if (arg === param) { return args[i + 1]; } } return null; } export const AUTOMATIC_MODE = findCliParameter("--automatic"); export const USE_PREVIOUS_CHANNEL_CONFIG = findCliParameter( "--use-previous-channel-config" ); export const CHANNEL_TYPES = getCliParameter("--channel-types"); export const NO_BACKUP = findCliParameter("--no-backup"); export const NO_SEARCH = findCliParameter("--no-search"); export const NO_FILE_DOWNLOAD = findCliParameter("--no-file-download"); export const NO_SLACK_CONNECT = findCliParameter("--no-slack-connect"); export const FORCE_HTML_GENERATION = findCliParameter( "--force-html-generation" ); export const EXCLUDE_CHANNELS = getCliParameter("--exclude-channels"); export const BASE_DIR = process.cwd(); export const OUT_DIR = path.join(BASE_DIR, "slack-archive"); export const TOKEN_FILE = path.join(OUT_DIR, ".token"); export const DATE_FILE = path.join(OUT_DIR, ".last-successful-run"); export const DATA_DIR = path.join(OUT_DIR, "data"); export const HTML_DIR = path.join(OUT_DIR, "html"); export const FILES_DIR = path.join(HTML_DIR, "files"); export const AVATARS_DIR = path.join(HTML_DIR, "avatars"); export const EMOJIS_DIR = path.join(HTML_DIR, "emojis"); export const INDEX_PATH = path.join(OUT_DIR, "index.html"); export const SEARCH_PATH = path.join(OUT_DIR, "search.html"); export const MESSAGES_JS_PATH = path.join(__dirname, "../static/scroll.js"); export const SEARCH_TEMPLATE_PATH = path.join( __dirname, "../static/search.html" ); export const CHANNELS_DATA_PATH = path.join(DATA_DIR, "channels.json"); export const USERS_DATA_PATH = path.join(DATA_DIR, "users.json"); export const EMOJIS_DATA_PATH = path.join(DATA_DIR, "emojis.json"); export const SLACK_ARCHIVE_DATA_PATH = path.join( DATA_DIR, "slack-archive.json" ); export const SEARCH_DATA_PATH = path.join(DATA_DIR, "search.js"); export function getChannelDataFilePath(channelId: string) { return path.join(DATA_DIR, `${channelId}.json`); } export function getChannelUploadFilePath(channelId: string, fileName: string) { return path.join(FILES_DIR, channelId, fileName); } export function getHTMLFilePath(channelId: string, index: number) { return path.join(HTML_DIR, `${channelId}-${index}.html`); } export function getAvatarFilePath(userId: string, extension: string) { return path.join(AVATARS_DIR, `${userId}${extension}`); } ================================================ FILE: src/create-html.tsx ================================================ import { format } from "date-fns"; import fs from "fs-extra"; import path from "path"; import React from "react"; import ReactDOMServer from "react-dom/server.js"; import ora, { Ora } from "ora"; import { chunk, sortBy } from "lodash-es"; import { dirname } from "path"; import { fileURLToPath } from "url"; import esMain from "es-main"; import slackMarkdown from "slack-markdown"; import { getChannels, getMessages, getUsers } from "./data-load.js"; import { ArchiveMessage, Channel, ChunksInfo, Message, Reaction, SlackArchiveData, User, Users, } from "./interfaces.js"; import { getHTMLFilePath, INDEX_PATH, OUT_DIR, MESSAGES_JS_PATH, FORCE_HTML_GENERATION, } from "./config.js"; import { slackTimestampToJavaScriptTimestamp } from "./timestamp.js"; import { recordPage } from "./search.js"; import { write } from "./data-write.js"; import { getSlackArchiveData } from "./archive-data.js"; import { getEmojiFilePath, getEmojiUnicode, isEmojiUnicode } from "./emoji.js"; import { getName } from "./users.js"; import { isBotChannel, isDmChannel, isPrivateChannel, isPublicChannel, } from "./channels.js"; const _dirname = dirname(fileURLToPath(import.meta.url)); const MESSAGE_CHUNK = 1000; // This used to be a prop on the components, but passing it around // was surprisingly slow. Global variables are cool again! // Set by createHtmlForChannels(). let users: Users = {}; let slackArchiveData: SlackArchiveData = { channels: {} }; let me: User | null; // Little hack to switch between ./index.html and ./html/... let base = ""; function formatTimestamp(message: Message, dateFormat = "PPPPpppp") { const jsTs = slackTimestampToJavaScriptTimestamp(message.ts); const ts = format(jsTs, dateFormat); return ts; } interface FilesProps { message: Message; channelId: string; } const Files: React.FunctionComponent = (props) => { const { message, channelId } = props; const { files } = message; if (!files || files.length === 0) return null; const fileElements = files.map((file) => { const { thumb_1024, thumb_720, thumb_480, thumb_pdf } = file as any; const thumb = thumb_1024 || thumb_720 || thumb_480 || thumb_pdf; let src = `files/${channelId}/${file.id}.${file.filetype}`; let href = src; if (file.mimetype?.startsWith("image")) { return ( ); } if (file.mimetype?.startsWith("video")) { return