├── .gitignore ├── README.md ├── config └── config.exs ├── lib ├── mix │ └── tasks │ │ └── slack.html.ex └── slack_to_html.ex ├── mix.exs ├── mix.lock ├── static └── style.css ├── templates ├── 404.html.eex ├── channel_index.html.eex ├── channel_messages.html.eex ├── index.html.eex └── layout.html.eex └── test ├── slack_to_html_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | 7 | /output 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SlackToHTML 2 | 3 | Export your Slack group JSON archive to a HTML website. 4 | 5 | You can see what the website will look like here: [http://slack.elixirhq.com/](http://slack.elixirhq.com/). 6 | 7 | ## How to use 8 | 9 | You'll need to have [Elixir](http://elixir-lang.org/) installed on your machine and a Slack export (you can request one [here](https://my-team.slack.com/services/export)). 10 | 11 | ``` 12 | $ git clone git@github.com:stevedomin/slack_to_html.git 13 | $ cd slack_to_html 14 | $ mix deps.get 15 | $ mix slack.html ./Elixir Slack export Dec 31 2015/ 16 | # this will generate HTML files in the output/ directory, upload them to a S3 or GC bucket 17 | ``` 18 | 19 | You can configure the output directory and the channels you want to ignore in `config/config.exs` 20 | 21 | ## Notes 22 | 23 | * This was built for the Elixir Slack group so you will probably want to edit some of the templates and the stylesheet. 24 | * I used `python -m SimpleHTTPServer` to serve the files in development. 25 | * That i-stay-in-the-middle-of-the-page-footer is awful I know, need to work on it. 26 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | config :slack_to_html, 6 | output_dir: "./output", 7 | excluded_channels: ~w(freenode), 8 | ga_tracking_id: nil # keep to nil if you don't want GA tracking 9 | 10 | -------------------------------------------------------------------------------- /lib/mix/tasks/slack.html.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Slack.Html do 2 | use Mix.Task 3 | 4 | require EEx 5 | 6 | @shortdoc "Export a Slack archive folder to HTML" 7 | 8 | @templates_root "templates" 9 | @static_root "static" 10 | 11 | EEx.function_from_file :def, :render_index, Path.join(@templates_root, "index.html.eex"), [:channels] 12 | EEx.function_from_file :def, :render_channel_index, Path.join(@templates_root, "channel_index.html.eex"), [:channel, :messages] 13 | EEx.function_from_file :def, :render_channel_messages, Path.join(@templates_root, "channel_messages.html.eex"), [:channel, :date, :users, :channels] 14 | EEx.function_from_file :def, :render_404, Path.join(@templates_root, "404.html.eex"), [] 15 | EEx.function_from_file :def, :render_within_layout, Path.join(@templates_root, "layout.html.eex"), [:content] 16 | 17 | def run(path) do 18 | Mix.shell.info "Export path: #{path}" 19 | 20 | output_dir = Application.get_env(:slack_to_html, :output_dir) 21 | 22 | users = load_users!(path) 23 | channels = load_channels!(path) 24 | |> Stream.map(fn channel -> 25 | {channel["id"], fill_with_messages!(channel, path)} 26 | end) 27 | |> Stream.filter(fn {_, channel} -> !(channel["name"] in Application.get_env(:slack_to_html, :excluded_channels)) end) 28 | |> Enum.into(%{}) 29 | 30 | setup_output_directory!(output_dir) 31 | generate_index!(output_dir, channels) 32 | generate_channels_messages!(output_dir, channels, users) 33 | generate_404(output_dir) 34 | copy_files!(output_dir, ~w(style.css)) 35 | end 36 | 37 | def load_users!(path) do 38 | users_path = Path.join(path, "users.json") 39 | Mix.shell.info "Loading users from #{users_path}" 40 | case File.read(users_path) do 41 | {:ok, body} -> 42 | users = 43 | Poison.decode!(body) 44 | |> Enum.map(fn user -> {user["id"], user} end) 45 | |> Enum.into(%{}) 46 | {:error, reason} -> 47 | Mix.shell.error "Error loading users: #{reason}" 48 | end 49 | end 50 | 51 | def load_channels!(path) do 52 | channels_path = Path.join(path, "channels.json") 53 | Mix.shell.info "Loading channels from #{channels_path}" 54 | case File.read(channels_path) do 55 | {:ok, body} -> Poison.decode!(body) 56 | {:error, reason} -> 57 | Mix.shell.error "Error loading channels: #{reason}" 58 | end 59 | end 60 | 61 | def fill_with_messages!(channel, path) do 62 | channel_messages_files = Path.join(path, channel["name"]) |> File.ls!() 63 | channel_messages = Enum.reduce(channel_messages_files, %{}, fn messages_path, new_channel_messages -> 64 | date = Path.basename(messages_path, ".json") 65 | messages = 66 | Path.join([path, channel["name"], messages_path]) 67 | |> File.read! 68 | |> Poison.decode! 69 | Map.put(new_channel_messages, date, messages) 70 | end) 71 | Map.put(channel, "messages", channel_messages) 72 | end 73 | 74 | def setup_output_directory!(path) do 75 | Mix.shell.info "Setting up output directory (#{path})" 76 | File.rm_rf!(path) 77 | File.mkdir_p!(path) 78 | end 79 | 80 | def generate_index!(path, channels) do 81 | Mix.shell.info "Generating index for channels" 82 | body = render_index(channels) |> render_within_layout 83 | Path.join(path, "index.html") |> File.write!(body) 84 | end 85 | 86 | def generate_channels_messages!(path, channels, users) do 87 | tasks = for {_channel_id, channel} <- channels do 88 | Mix.shell.info "Generating html for #{channel["name"]}" 89 | Task.async(__MODULE__, :generate_channel_messages!, [path, channel, users, channels]) 90 | end 91 | Task.yield_many(tasks, 60000) 92 | end 93 | 94 | def generate_channel_messages!(path, channel, users, channels) do 95 | channel_path = Path.join(path, channel["name"]) 96 | File.mkdir_p!(channel_path) 97 | 98 | messages = 99 | Enum.group_by(channel["messages"], fn {d, _m} -> Timex.DateFormat.parse!(d, "{YYYY}-{0M}-{0D}").year end) 100 | |> Enum.map(fn {year, messages} -> 101 | {year, Enum.group_by(messages, fn {d, _m} -> Timex.DateFormat.parse!(d, "{YYYY}-{0M}-{0D}").month end)} 102 | end) 103 | |> Enum.into(%{}) 104 | 105 | channel_index_body = render_channel_index(channel, messages) |> render_within_layout 106 | Path.join(channel_path, "index.html") |> File.write!(channel_index_body) 107 | 108 | for {date, _messages} <- channel["messages"] do 109 | messages_path = Path.join(channel_path, date) 110 | File.mkdir_p!(messages_path) 111 | channel_messages_body = 112 | render_channel_messages(channel, date, users, channels) 113 | |> render_within_layout 114 | Path.join(messages_path, "index.html") |> File.write!(channel_messages_body) 115 | end 116 | end 117 | 118 | def generate_404(path) do 119 | Mix.shell.info "Generating 404 page" 120 | error_content = render_404() |> render_within_layout() 121 | Path.join(path, "404.html") |> File.write!(error_content) 122 | end 123 | 124 | def copy_files!(path, files) do 125 | Mix.shell.info "Copying files from #{@static_root}" 126 | for file <- files do 127 | Path.join(@static_root, file) |> File.copy!(Path.join(path, file)) 128 | end 129 | end 130 | 131 | def cleanup_message(message, channels, users) do 132 | message 133 | |> cleanup_system_messages() 134 | |> cleanup_users(users) 135 | |> cleanup_channels(channels) 136 | |> cleanup_urls() 137 | end 138 | 139 | def cleanup_system_messages(message) when message != nil do 140 | message = Regex.replace(~r/<@\S+> (has (joined|left) the channel)/, message, "\\1") 141 | message = Regex.replace(~r/<@\S+> (set the channel purpose: .*)/, message, "\\1") 142 | message = Regex.replace(~r/<@\S+> (uploaded a file: .*)/, message, "\\1") 143 | message = Regex.replace(~r/<@\S+> (pinned a message: .*)/, message, "\\1") 144 | Regex.replace(~r/<@\S+> (archived the group)/, message, "\\1") 145 | end 146 | def cleanup_system_messages(message), do: message 147 | 148 | def cleanup_users(message, users) when message != nil do 149 | ids = Regex.scan(~r/<@([\w\d]+)\|?.*>/, message, capture: :all_but_first) 150 | Enum.reduce(ids, message, fn [id|_tail], new_message -> 151 | username = users[id]["name"] 152 | String.replace(new_message, "<@#{id}>", username) 153 | end) 154 | end 155 | def cleanup_users(message, _users), do: message 156 | 157 | def cleanup_channels(message, channels) when message != nil do 158 | ids = Regex.scan(~r/<\#(\S+)>/, message, capture: :all_but_first) 159 | Enum.reduce(ids, message, fn [id|_tail], new_message -> 160 | channel = channels[id]["name"] 161 | String.replace(new_message, "<\##{id}>", "##{channel}") 162 | end) 163 | end 164 | def cleanup_channels(message, _channels), do: message 165 | 166 | def cleanup_urls(message) when message != nil do 167 | urls = Regex.scan(~r/<(http[^>]*)>/, message, capture: :all_but_first) 168 | Enum.reduce(urls, message, fn [url|_tail], new_message -> 169 | String.replace(new_message, "<#{url}>", ~s(#{url})) 170 | end) 171 | end 172 | def cleanup_urls(message), do: message 173 | end 174 | -------------------------------------------------------------------------------- /lib/slack_to_html.ex: -------------------------------------------------------------------------------- 1 | defmodule SlackToHTML do 2 | end 3 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SlackToHTML.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :slack_to_html, 6 | version: "0.0.1", 7 | elixir: "~> 1.1", 8 | build_embedded: Mix.env == :prod, 9 | start_permanent: Mix.env == :prod, 10 | deps: deps] 11 | end 12 | 13 | # Configuration for the OTP application 14 | # 15 | # Type "mix help compile.app" for more information 16 | def application do 17 | [applications: [:logger]] 18 | end 19 | 20 | # Dependencies can be Hex packages: 21 | # 22 | # {:mydep, "~> 0.3.0"} 23 | # 24 | # Or git/path repositories: 25 | # 26 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 27 | # 28 | # Type "mix help deps" for more examples and options 29 | defp deps do 30 | [{:poison, "~> 4.0"}, 31 | {:timex, "~> 3.2"}, 32 | {:tzdata, "== 0.5.19", override: true}] 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "calendar": {:hex, :calendar, "0.12.1"}, 3 | "certifi": {:hex, :certifi, "2.4.2", "75424ff0f3baaccfd34b1214184b6ef616d89e420b258bb0a5ea7d7bc628f7f0", [], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"}, 5 | "cowboy": {:hex, :cowboy, "1.0.4"}, 6 | "cowlib": {:hex, :cowlib, "1.0.2"}, 7 | "gettext": {:hex, :gettext, "0.16.1", "e2130b25eebcbe02bb343b119a07ae2c7e28bd4b146c4a154da2ffb2b3507af2", [:mix], [], "hexpm"}, 8 | "hackney": {:hex, :hackney, "1.15.0", "287a5d2304d516f63e56c469511c42b016423bcb167e61b611f6bad47e3ca60e", [], [{:certifi, "2.4.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [], [], "hexpm"}, 11 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, 12 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [], [], "hexpm"}, 13 | "plug": {:hex, :plug, "1.0.3"}, 14 | "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm"}, 15 | "ranch": {:hex, :ranch, "1.2.0"}, 16 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [], [], "hexpm"}, 17 | "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.5", "2e73e068cd6393526f9fa6d399353d7c9477d6886ba005f323b592d389fb47be", [:make], [], "hexpm"}, 18 | "timex": {:hex, :timex, "3.5.0", "b0a23167da02d0fe4f1a4e104d1f929a00d348502b52432c05de875d0b9cffa5", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, 19 | "tzdata": {:hex, :tzdata, "0.5.19", "7962a3997bf06303b7d1772988ede22260f3dae1bf897408ebdac2b4435f4e6a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 20 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [], [], "hexpm"}, 21 | } 22 | -------------------------------------------------------------------------------- /static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | margin: 0; 4 | } 5 | 6 | .header { 7 | width: 100%; 8 | margin-bottom: 30px; 9 | color: #444; 10 | } 11 | 12 | .container { 13 | padding: 8px; 14 | } 15 | 16 | .footer { 17 | width: 100%; 18 | height: 60px; 19 | line-height: 60px; 20 | background-color: #f5f5f5; 21 | text-align: center; 22 | color: #818a91; 23 | margin-top: 30px; 24 | } 25 | 26 | .channels a { 27 | padding: 5px; 28 | display: inline-block; 29 | margin-bottom: 4px; 30 | } 31 | 32 | a { 33 | color: #4e2a8e; 34 | text-decoration: none; 35 | } 36 | 37 | a:link, a:visited { 38 | color: #4e2a8e; 39 | } 40 | 41 | a:active, a:focus, a:hover { 42 | color: #4e2a8e; 43 | text-decoration: underline; 44 | } 45 | 46 | h1, h2, h3, h4, h5, h6 { 47 | margin: 0; 48 | } 49 | 50 | .channel-header { 51 | margin-bottom: 20px; 52 | } 53 | 54 | .channel-name { 55 | margin: 5px 0 5px 0; 56 | } 57 | 58 | .channel-purpose { 59 | margin: 5px 0 5px 0; 60 | color: #333; 61 | font-style: italic; 62 | } 63 | 64 | .channel-year { 65 | margin-top: 25px; 66 | } 67 | 68 | .channel-month { 69 | padding: 15px 0 5px 0; 70 | } 71 | 72 | .channel-days a { 73 | display: inline-block; 74 | padding: 0 3px 0 3px; 75 | } 76 | 77 | .messages-date { 78 | padding-bottom: 15px; 79 | } 80 | 81 | .messages { 82 | width: 100%; 83 | height: 100%; 84 | position: relative; 85 | } 86 | 87 | .message { 88 | width: 100%; 89 | color: #2c2d30; 90 | font-size: 15px; 91 | line-height: 20px; 92 | padding: 4px 0 4px 0; 93 | display: inline-flex; 94 | } 95 | 96 | .message-avatar { 97 | display: inline-block; 98 | width: 36px; 99 | height: 36px; 100 | border-radius: 0.2em; 101 | margin-right: 0.45em; 102 | vertical-align: top; 103 | } 104 | 105 | .message-content { 106 | display: inline-block; 107 | } 108 | 109 | .message-username { 110 | display: inline; 111 | font-weight: bold; 112 | color: #2c2d30; 113 | line-height: 18px; 114 | } 115 | 116 | .message-timestamp { 117 | display: inline; 118 | color: #9e9ea6; 119 | font-size: 9pt; 120 | } 121 | 122 | .message-body { 123 | display: block; 124 | } 125 | -------------------------------------------------------------------------------- /templates/404.html.eex: -------------------------------------------------------------------------------- 1 |
Not sure what you are looking for but it doesn't look like it exists here!
3 | Go back to the channel index 4 |Welcome to the archive for the Slack group dedicated to elixir-lang.
3 |