defmodule Mastomation.Threads do @moduledoc """ Thread-specific CLI logic: parse options, fetch thread context, and print same-author toots in order. """ require Logger alias Mastomation @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 || Mastomation.instance_url() token = options.override_access_token || options.access_token || Mastomation.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(), keyword()) :: map() def get_status(instance, token, status_id, req_opts \\ []) do Mastomation.Client.get_json!("#{instance}/api/v1/statuses/#{status_id}", token, req_opts) end @doc """ Fetches the context (ancestors/descendants) for a status id. """ @spec get_status_context(String.t(), String.t(), String.t(), keyword()) :: map() def get_status_context(instance, token, status_id, req_opts \\ []) do Mastomation.Client.get_json!( "#{instance}/api/v1/statuses/#{status_id}/context", token, req_opts ) 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 Logger.info("dry-run enabled: printing thread only") 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") Logger.info("[#{idx}] #{created_at} ##{id}") Logger.info(content) 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//i, "\n") |> String.replace(~r/<\/p>/i, "\n") |> String.replace(~r/]*alt="([^"]+)"[^>]*>/i, "\\1") |> String.replace(~r/]*class="[^"]*mention[^"]*"[^>]*>(.*?)<\/a>/i, "\\1") |> String.replace(~r/]*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 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) ) Logger.info("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"] -> "![#{ref.description}](#{ref.path})" 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