Current section
Files
Jump to
Current section
Files
lib/threads.ex
defmodule Mastomation.Threads do
@moduledoc """
Thread-specific CLI logic: parse options, fetch thread context,
and print same-author toots in order.
"""
@doc """
Executes the `thread see` workflow and prints output only.
"""
@spec run_thread_see(map()) :: :ok
def run_thread_see(result) do
run_thread(result, export?: false)
end
@doc """
Executes the `thread export` workflow and writes export files.
"""
@spec run_thread_export(map()) :: :ok
def run_thread_export(result) do
run_thread(result, export?: true)
end
@doc """
Backward-compatible thread runner (defaults to see behavior).
"""
@spec run_thread(map()) :: :ok
def run_thread(result) do
run_thread_see(result)
end
@spec run_thread(map(), keyword()) :: :ok
defp run_thread(result, opts) do
:telemetry.execute([:mastomation, :thread, :export, :start], %{}, %{})
options = result.options
args = result.args
flags = result.flags
instance =
options.override_instance_url || options.instance_url ||
System.get_env("MASTODON_INSTANCE_URL")
token =
options.override_access_token || options.access_token ||
System.get_env("MASTODON_ACCESS_TOKEN")
source = args.source || Map.get(options, :source)
dry_run = Map.get(flags, :dry_run, false)
frontmatter = Map.get(flags, :frontmatter, false)
export? = Keyword.get(opts, :export?, false)
validate_required!(instance, token)
validate_source!(source)
source_id = extract_status_id(source)
source_status =
Mastomation.Client.get_json!("#{instance}/api/v1/statuses/#{source_id}", token)
context =
Mastomation.Client.get_json!("#{instance}/api/v1/statuses/#{source_id}/context", token)
ordered_statuses = order_thread_statuses(source_status, context)
print_thread_statuses(ordered_statuses, dry_run: dry_run)
if export? and not dry_run do
destination = export_folder!()
write_thread_markdown!(destination, source_id, source, token, ordered_statuses,
frontmatter: frontmatter
)
end
:telemetry.execute(
[:mastomation, :thread, :export, :stop],
%{count: Enum.count(ordered_statuses)},
%{dry_run: dry_run}
)
end
@doc """
Fetches a status by id.
"""
@spec get_status(String.t(), String.t(), String.t()) :: map()
def get_status(instance, token, status_id) do
Mastomation.Client.get_json!("#{instance}/api/v1/statuses/#{status_id}", token)
end
@doc """
Fetches the context (ancestors/descendants) for a status id.
"""
@spec get_status_context(String.t(), String.t(), String.t()) :: map()
def get_status_context(instance, token, status_id) do
Mastomation.Client.get_json!("#{instance}/api/v1/statuses/#{status_id}/context", token)
end
@doc """
Orders thread statuses as ancestors, source status, then descendants.
"""
@spec order_thread_statuses(map(), map()) :: [map()]
def order_thread_statuses(source_status, context) do
source_author_id = get_in(source_status, ["account", "id"])
same_author? = fn status ->
get_in(status, ["account", "id"]) == source_author_id
end
ancestors =
Map.get(context, "ancestors", [])
|> Enum.filter(same_author?)
|> Enum.sort_by(&Map.get(&1, "created_at", ""))
descendants =
Map.get(context, "descendants", [])
|> Enum.filter(same_author?)
descendants_by_parent =
Enum.group_by(descendants, &Map.get(&1, "in_reply_to_id"))
ancestors ++
[source_status] ++ ordered_descendants(source_status["id"], descendants_by_parent)
end
# Recursively orders descendants by parent relationship and timestamp.
@spec ordered_descendants(String.t(), map()) :: [map()]
defp ordered_descendants(parent_id, descendants_by_parent) do
children =
Map.get(descendants_by_parent, parent_id, [])
|> Enum.sort_by(&Map.get(&1, "created_at", ""))
Enum.flat_map(children, fn child ->
[child | ordered_descendants(child["id"], descendants_by_parent)]
end)
end
@doc """
Prints the ordered thread to standard output.
"""
@spec print_thread_statuses([map()], keyword()) :: :ok
def print_thread_statuses(statuses, opts \\ []) do
dry_run = Keyword.get(opts, :dry_run, false)
if dry_run do
IO.puts("dry-run enabled: printing thread only")
IO.puts("")
end
Enum.with_index(statuses, 1)
|> Enum.each(fn {status, idx} ->
content = Map.get(status, "content", "") |> render_markdown_content()
created_at = Map.get(status, "created_at", "unknown-time")
id = Map.get(status, "id", "unknown-id")
IO.puts("[#{idx}] #{created_at} ##{id}")
IO.puts(content)
IO.puts("")
end)
end
@doc """
Renders a markdown export for a thread and associated media references.
"""
@spec thread_markdown([map()], String.t(), map(), keyword()) :: String.t()
def thread_markdown(statuses, source_reference, media_refs_by_status_id \\ %{}, opts \\ []) do
frontmatter = Keyword.get(opts, :frontmatter, false)
sections =
Enum.with_index(statuses, 1)
|> Enum.map_join("\n", fn {status, idx} ->
content = Map.get(status, "content", "") |> render_markdown_content()
created_at = Map.get(status, "created_at", "unknown-time")
id = Map.get(status, "id", "unknown-id")
media_refs = Map.get(media_refs_by_status_id, id, [])
media_md = media_markdown(media_refs)
"""
## #{idx}. #{created_at} (#{id})
#{content}
#{media_md}
"""
end)
fm =
if frontmatter do
"""
---
title: Mastodon Thread
source_url: #{source_reference}
exported_at: #{DateTime.utc_now() |> DateTime.to_iso8601()}
count: #{Enum.count(statuses)}
---
"""
else
""
end
fm <>
"# Mastodon Thread\n\n" <>
sections <>
"\n---\n\n" <>
"Thread source: #{source_reference}\n"
end
@doc """
Extracts a status id from a raw id or a full Mastodon status URL.
"""
@spec extract_status_id(String.t()) :: String.t()
def extract_status_id(source) do
source = String.trim(source)
case URI.parse(source) do
%URI{scheme: nil} ->
source
%URI{path: path} ->
path
|> String.split("/", trim: true)
|> List.last()
end
end
@doc """
Converts Mastodon HTML content into markdown-friendly plain text.
"""
@spec render_markdown_content(String.t()) :: String.t()
def render_markdown_content(content) do
content
|> String.replace(~r/<br\s*\/?>/i, "\n")
|> String.replace(~r/<\/p>/i, "\n")
|> String.replace(~r/<img[^>]*alt="([^"]+)"[^>]*>/i, "\\1")
|> String.replace(~r/<a[^>]*class="[^"]*mention[^"]*"[^>]*>(.*?)<\/a>/i, "\\1")
|> String.replace(~r/<a[^>]*class="[^"]*hashtag[^"]*"[^>]*>(.*?)<\/a>/i, "\\1")
|> String.replace(~r/<[^>]+>/, "")
|> String.replace("&", "&")
|> String.replace("<", "<")
|> String.replace(">", ">")
|> String.replace(""", "\"")
|> String.replace("'", "'")
|> String.trim()
end
# Validates required instance/token inputs.
@spec validate_required!(String.t() | nil, String.t() | nil) :: :ok | no_return()
defp validate_required!(instance, token) do
cond do
is_nil(instance) or instance == "" ->
raise ArgumentError,
"missing instance URL (set --instance_url or MASTODON_INSTANCE_URL)"
is_nil(token) or token == "" ->
raise ArgumentError,
"missing access token (set --access_token or MASTODON_ACCESS_TOKEN)"
true ->
:ok
end
end
# Validates that a source toot id/url was provided.
@spec validate_source!(String.t() | nil) :: :ok | no_return()
defp validate_source!(source) do
if is_nil(source) or source == "" do
raise ArgumentError, "missing source toot (pass thread <ID_OR_URL> or --source)"
end
end
# Ensures the export folder exists in the current working directory.
@spec export_folder!() :: String.t()
defp export_folder! do
folder = Path.join(File.cwd!(), "export")
File.mkdir_p!(folder)
folder
end
# Writes thread markdown after downloading media assets.
@spec write_thread_markdown!(String.t(), String.t(), String.t(), String.t(), [map()], keyword()) ::
:ok
defp write_thread_markdown!(folder, source_id, source_reference, token, statuses, opts) do
timestamp =
NaiveDateTime.utc_now()
|> NaiveDateTime.to_iso8601()
|> String.replace(":", "-")
filename = "thread-#{source_id}-#{timestamp}.md"
path = Path.join(folder, filename)
{updated_statuses, media_refs_by_status_id} = download_thread_images(folder, token, statuses)
File.write!(
path,
thread_markdown(updated_statuses, source_reference, media_refs_by_status_id, opts)
)
IO.puts("thread markdown saved to #{path}")
end
# Downloads exportable media in parallel and returns refs grouped by status id.
@spec download_thread_images(String.t(), String.t(), [map()]) :: {[map()], map()}
defp download_thread_images(folder, token, statuses) do
images_dir = Path.join(folder, "images")
File.mkdir_p!(images_dir)
media_map =
statuses
|> Task.async_stream(
fn status ->
status_id = Map.get(status, "id", "unknown")
media_refs =
status
|> Map.get("media_attachments", [])
|> Enum.with_index(1)
|> Enum.filter(fn {media, _idx} -> exportable_media?(media) end)
|> Enum.map(fn {media, idx} ->
download_media(images_dir, token, status_id, media, idx)
end)
|> Enum.reject(&is_nil/1)
{status_id, media_refs}
end,
max_concurrency: 4,
timeout: :infinity
)
|> Enum.reduce(%{}, fn
{:ok, {status_id, media_refs}}, acc -> Map.put(acc, status_id, media_refs)
_, acc -> acc
end)
{statuses, media_map}
end
# Downloads one media attachment and returns a markdown reference map.
@spec download_media(String.t(), String.t(), String.t(), map(), pos_integer()) :: map() | nil
defp download_media(images_dir, token, status_id, media, idx) do
url = Map.get(media, "url") || Map.get(media, "preview_url")
if is_nil(url) or url == "" do
nil
else
extension = media_extension_from_url(url)
type = Map.get(media, "type", "media")
filename = "status-#{status_id}-#{type}-#{idx}.#{extension}"
path = Path.join(images_dir, filename)
case Req.get(url, headers: Mastomation.build_header(token)) do
{:ok, response} when is_binary(response.body) ->
File.write!(path, response.body)
description = Map.get(media, "description") || "#{type} #{idx}"
%{path: Path.join("images", filename), description: description, type: type}
{:ok, _response} ->
nil
{:error, _reason} ->
nil
end
end
end
# Guesses a filesystem extension from media URL.
@spec media_extension_from_url(String.t()) :: String.t()
defp media_extension_from_url(url) do
path = URI.parse(url).path || ""
ext = Path.extname(path) |> String.trim_leading(".") |> String.downcase()
if ext in ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg", "mp4", "webm", "mov"] do
ext
else
"bin"
end
end
@spec media_markdown(list()) :: String.t()
defp media_markdown([]), do: ""
# Renders markdown for media references attached to a status.
@spec media_markdown([map()]) :: String.t()
defp media_markdown(media_refs) do
media_refs
|> Enum.map_join("\n", fn ref ->
type = Map.get(ref, :type, "image")
case type do
type when type in ["image", "gifv"] -> ""
other -> "- [#{String.upcase(other)}: #{ref.description}](#{ref.path})"
end
end)
end
# Returns true when attachment should be exported as local media.
@spec exportable_media?(map()) :: boolean()
defp exportable_media?(media) do
type = Map.get(media, "type")
url = Map.get(media, "url") || Map.get(media, "preview_url") || ""
type in ["image", "gifv", "video"] or
String.match?(url, ~r/\.(jpg|jpeg|png|gif|webp|bmp|svg|mp4|webm|mov)(\?.*)?$/i)
end
end