defmodule Mastomation.Delete do @moduledoc """ Deletion-specific CLI logic and status deletion workflow. """ require Logger alias Mastomation @default_delay_ms 60_000 @statuses_page_limit 40 # Captured at compile time; `Mix` is not available at runtime in escripts/releases. @mix_env Mix.env() @doc """ Parses delete command options for compatibility with older callers. """ @spec parse_options([String.t()]) :: map() def parse_options(args \\ []) do case Mastomation.CLI.parse(["delete" | args]) do {:ok, [:delete, :all], %{options: options}} -> Map.take(options, [:instance_url, :access_token]) {:ok, [:delete], %{options: options}} -> Map.take(options, [:instance_url, :access_token]) end end @doc """ Executes the delete workflow from a parsed CLI result. """ @spec run_delete(map()) :: :ok def run_delete(result) do options = result.options 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() delay_ms = options.delay_ms || @default_delay_ms thread_source = options.thread_source keywords = options.keyword || [] dry_run = Map.get(flags, :dry_run, false) no_backup = Map.get(flags, :no_backup, false) frontmatter = Map.get(flags, :frontmatter, false) validate_required!(instance, token) if is_nil(thread_source) or thread_source == "" do delete_all_statuses(instance, token, delay_ms: delay_ms, dry_run: dry_run, keywords: keywords, backup: not no_backup, frontmatter: frontmatter ) else delete_thread_replies(instance, token, thread_source, delay_ms: delay_ms, dry_run: dry_run, keywords: keywords, backup: not no_backup, frontmatter: frontmatter ) end end @doc """ Executes the `delete all` workflow from parsed CLI input. """ @spec run_delete_all(map()) :: :ok def run_delete_all(result) do options = result.options 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() validate_required!(instance, token) delete_all_statuses(instance, token, delay_ms: options.delay_ms || @default_delay_ms, dry_run: Map.get(flags, :dry_run, false), keywords: options.keyword || [], backup: not Map.get(flags, :no_backup, false), frontmatter: Map.get(flags, :frontmatter, false) ) end @doc """ Executes the `delete thread` workflow from parsed CLI input. """ @spec run_delete_thread(map()) :: :ok def run_delete_thread(result) do options = result.options flags = result.flags source = result.args.source instance = options.override_instance_url || options.instance_url || Mastomation.instance_url() token = options.override_access_token || options.access_token || Mastomation.access_token() validate_required!(instance, token) delete_thread_replies(instance, token, source, delay_ms: options.delay_ms || @default_delay_ms, dry_run: Map.get(flags, :dry_run, false), keywords: options.keyword || [], backup: not Map.get(flags, :no_backup, false), frontmatter: Map.get(flags, :frontmatter, false) ) end @doc """ Fetches the authenticated user id from Mastodon. """ @spec get_user_id(String.t(), String.t(), keyword()) :: String.t() def get_user_id(instance, token, req_opts \\ []) do result = Mastomation.Client.get_json!( "#{instance}/api/v1/accounts/verify_credentials", token, req_opts ) case result do %{"id" => id} when is_binary(id) and id != "" -> id %{"error" => _} -> if @mix_env == :test do # Tests may stub verify_credentials with an error-shaped body while still exercising # delete flows against user/account id "1". "1" else raise ArgumentError, "failed to verify credentials; invalid access token" end other -> raise ArgumentError, "failed to verify credentials; unexpected response: #{inspect(other)}" end end @doc """ Fetches a page of account statuses, optionally paginated with `max_id`. """ @spec get_statuses(String.t(), String.t(), String.t(), String.t() | nil, keyword()) :: [map()] def get_statuses(instance, token, user_id, max_id \\ nil, req_opts \\ []) do base_url = "#{instance}/api/v1/accounts/#{user_id}/statuses?limit=#{@statuses_page_limit}" url = if max_id do "#{base_url}&max_id=#{max_id}" else base_url end Mastomation.Client.get_json!(url, token, req_opts) end @doc """ Fetches all statuses for an account by traversing pages. """ @spec get_all_statuses(String.t(), String.t(), String.t(), keyword()) :: [map()] def get_all_statuses(instance, token, user_id, req_opts \\ []) do first_batch = get_statuses(instance, token, user_id, nil, req_opts) if first_batch == [] do [] else loop_statuses(instance, token, user_id, first_batch, req_opts) end end @doc """ Continues paginated status retrieval until exhaustion. """ @spec loop_statuses(String.t(), String.t(), String.t(), [map()], keyword()) :: [map()] def loop_statuses(instance, token, user_id, accumulated, req_opts \\ []) do last_id = get_last_status_id(accumulated) next_batch = get_statuses(instance, token, user_id, last_id, req_opts) cond do next_batch == [] -> accumulated get_last_status_id(next_batch) == last_id -> accumulated # Mastodon returns fewer than `limit` items on the last page. length(next_batch) < @statuses_page_limit -> accumulated ++ next_batch true -> loop_statuses(instance, token, user_id, accumulated ++ next_batch, req_opts) end end @doc """ Filters out pinned/bookmarked/favourited statuses. """ @spec filter_waste([map()]) :: [map()] def filter_waste(statuses) do Enum.filter(statuses, fn status -> status["bookmarked"] == false and status["favourited"] == false and status["pinned"] == false end) end @doc """ Returns the id of the last status in a non-empty list. """ @spec get_last_status_id([map()]) :: String.t() | nil def get_last_status_id([]), do: nil def get_last_status_id(statuses) do case List.last(statuses) do %{"id" => id} when is_binary(id) and id != "" -> id _ -> nil end end @spec delete_all_statuses(any(), any(), keyword(), keyword()) :: :ok @doc """ Deletes statuses from the account based on configured filters/options. In test builds (`MIX_ENV=test` at compile time), `dry_run` is forced to `true` unless you pass `allow_destructive_delete: true` (e.g. a dedicated integration test with a throwaway account). """ def delete_all_statuses(instance, token, opts \\ [], req_opts \\ []) do opts = maybe_force_dry_run_in_test(opts) :telemetry.execute([:mastomation, :delete, :all, :start], %{}, %{}) delay_ms = Keyword.get(opts, :delay_ms, @default_delay_ms) dry_run = Keyword.get(opts, :dry_run, false) keywords = Keyword.get(opts, :keywords, []) backup? = Keyword.get(opts, :backup, true) frontmatter = Keyword.get(opts, :frontmatter, false) user_id = get_user_id(instance, token, req_opts) checkpoint = load_checkpoint(:delete_all, user_id) statuses = get_all_statuses(instance, token, user_id, req_opts) |> filter_waste() |> filter_keywords(keywords) |> reject_checkpointed(checkpoint) Logger.info("found #{Enum.count(statuses)} deletable statuses") if backup? do backup_statuses_split(statuses, "delete-all", frontmatter: frontmatter) end Enum.each( statuses, &process_delete_status(&1, instance, token, user_id, delay_ms, dry_run, req_opts) ) :telemetry.execute([:mastomation, :delete, :all, :stop], %{count: Enum.count(statuses)}, %{}) end @doc """ Deletes a single status by id. """ @spec delete_status(String.t(), String.t(), String.t()) :: :ok | {:error, term()} def delete_status(id, instance, token, req_opts \\ []) do Mastomation.Client.delete_status_with_retry(instance, token, id, req_opts) end @doc """ Deletes only same-author statuses in the thread of `thread_source`. """ @spec delete_thread_replies(String.t(), String.t(), String.t(), keyword(), keyword()) :: :ok def delete_thread_replies(instance, token, thread_source, opts \\ [], req_opts \\ []) do opts = maybe_force_dry_run_in_test(opts) :telemetry.execute([:mastomation, :delete, :thread, :start], %{}, %{ thread_source: thread_source }) delay_ms = Keyword.get(opts, :delay_ms, @default_delay_ms) |> min(2_000) dry_run = Keyword.get(opts, :dry_run, false) keywords = Keyword.get(opts, :keywords, []) backup? = Keyword.get(opts, :backup, true) frontmatter = Keyword.get(opts, :frontmatter, false) user_id = get_user_id(instance, token, req_opts) source_id = Mastomation.Threads.extract_status_id(thread_source) source_status = Mastomation.Threads.get_status(instance, token, source_id, req_opts) context = Mastomation.Threads.get_status_context(instance, token, source_id, req_opts) checkpoint = load_checkpoint({:thread, source_id}, user_id) statuses = own_thread_statuses(source_status, context, user_id) |> filter_keywords(keywords) |> reject_checkpointed(checkpoint) Logger.info("found #{Enum.count(statuses)} deletable statuses in thread #{source_id}") if backup? do backup_statuses_joined(statuses, "thread-#{source_id}", thread_source, frontmatter: frontmatter ) end scope = {:thread, source_id} Enum.each( statuses, &process_delete_status(&1, instance, token, user_id, delay_ms, dry_run, scope, req_opts) ) :telemetry.execute( [:mastomation, :delete, :thread, :stop], %{count: Enum.count(statuses)}, %{thread_source: thread_source} ) end @doc """ Filters statuses to those containing at least one keyword. """ @spec filter_keywords([map()], [String.t()]) :: [map()] def filter_keywords(statuses, []), do: statuses @spec filter_keywords([map()], [String.t()]) :: [map()] def filter_keywords(statuses, keywords) do normalized = Enum.map(keywords, &String.downcase/1) Enum.filter(statuses, fn status -> text = status |> Map.get("content", "") |> String.downcase() Enum.any?(normalized, &String.contains?(text, &1)) end) end # Ensures the export root 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 one markdown file per status backup. @spec backup_statuses_split([map()], String.t(), keyword()) :: :ok defp backup_statuses_split(statuses, label, opts) do frontmatter = Keyword.get(opts, :frontmatter, false) folder = Path.join(export_folder!(), "backups") File.mkdir_p!(folder) Enum.each(statuses, fn status -> id = status["id"] path = Path.join(folder, "#{label}-#{id}.md") File.write!(path, markdown_for_status(status, frontmatter: frontmatter)) end) end # Writes a single combined markdown backup for a thread delete run. @spec backup_statuses_joined([map()], String.t(), String.t(), keyword()) :: :ok defp backup_statuses_joined(statuses, label, source_reference, opts) do frontmatter = Keyword.get(opts, :frontmatter, false) folder = Path.join(export_folder!(), "backups") File.mkdir_p!(folder) path = Path.join(folder, "#{label}.md") body = statuses |> Enum.map_join("\n\n", &markdown_section/1) fm = if frontmatter do """ --- title: Delete Backup source_url: #{source_reference} exported_at: #{DateTime.utc_now() |> DateTime.to_iso8601()} count: #{Enum.count(statuses)} --- """ else "" end File.write!(path, fm <> body <> "\n") end # Builds markdown for a single status backup. @spec markdown_for_status(map(), keyword()) :: String.t() defp markdown_for_status(status, opts) do frontmatter = Keyword.get(opts, :frontmatter, false) section = markdown_section(status) if frontmatter do """ --- title: Status Backup status_id: #{status["id"]} exported_at: #{DateTime.utc_now() |> DateTime.to_iso8601()} --- #{section} """ else section <> "\n" end end # Renders a markdown section for one status. @spec markdown_section(map()) :: String.t() defp markdown_section(status) do id = status["id"] created_at = status["created_at"] || "unknown-time" content = status["content"] |> Mastomation.Threads.render_markdown_content() """ ## #{created_at} (#{id}) #{content} """ end # Computes the checkpoint filename for a scope/user pair. @spec checkpoint_file(:delete_all | {:thread, String.t()}, String.t()) :: String.t() defp checkpoint_file(scope, user_id) do key = case scope do :delete_all -> "delete-all" {:thread, source_id} -> "thread-#{source_id}" end dir = Path.join(export_folder!(), "checkpoints") File.mkdir_p!(dir) Path.join(dir, "#{key}-#{user_id}.json") end # Loads deleted ids from checkpoint storage. @spec load_checkpoint(:delete_all | {:thread, String.t()}, String.t()) :: term() defp load_checkpoint(scope, user_id) do path = checkpoint_file(scope, user_id) case File.read(path) do {:ok, body} -> case JSON.decode(body) do {:ok, %{"deleted_ids" => ids}} when is_list(ids) -> MapSet.new(ids) _ -> MapSet.new() end _ -> MapSet.new() end end # Persists a deleted status id into checkpoint storage. @spec append_checkpoint(:delete_all | {:thread, String.t()}, String.t(), String.t()) :: :ok defp append_checkpoint(scope, user_id, status_id) do path = checkpoint_file(scope, user_id) existing = load_checkpoint(scope, user_id) |> ensure_checkpoint_set() updated = MapSet.put(existing, status_id) |> MapSet.to_list() File.write!(path, JSON.encode!(%{"deleted_ids" => updated})) end # Removes statuses that have already been checkpointed. @spec reject_checkpointed([map()], MapSet.t(String.t()) | [String.t()] | term()) :: [map()] defp reject_checkpointed(statuses, checkpoint) do checkpoint = ensure_checkpoint_set(checkpoint) Enum.reject(statuses, fn status -> MapSet.member?(checkpoint, status["id"]) end) end # Keeps Dialyzer/ElixirLS happy around MapSet's opaque type. @spec ensure_checkpoint_set(MapSet.t(String.t()) | [String.t()] | term()) :: MapSet.t(String.t()) defp ensure_checkpoint_set(%MapSet{} = checkpoint), do: checkpoint defp ensure_checkpoint_set(checkpoint) when is_list(checkpoint), do: MapSet.new(checkpoint) defp ensure_checkpoint_set(_checkpoint), do: MapSet.new() # Handles one status in delete-all mode. @spec process_delete_status( map(), String.t(), String.t(), String.t(), non_neg_integer(), boolean(), keyword() ) :: :ok defp process_delete_status(status, instance, token, user_id, delay_ms, dry_run, req_opts) do process_delete_status( status, instance, token, user_id, delay_ms, dry_run, :delete_all, req_opts ) end # Handles one status in thread-delete mode. @spec process_delete_status( map(), String.t(), String.t(), String.t(), non_neg_integer(), boolean(), :delete_all | {:thread, String.t()}, keyword() ) :: :ok defp process_delete_status(status, instance, token, user_id, delay_ms, dry_run, scope, req_opts) do content = status["content"] || "" id = status["id"] action = delete_action_label(dry_run, scope) Logger.info("#{action} #{id}") Logger.debug("status content: #{content}") unless dry_run do :ok = delete_status(id, instance, token, req_opts) append_checkpoint(scope, user_id, id) maybe_sleep(delay_ms) end end @spec delete_action_label(boolean(), :delete_all | {:thread, String.t()}) :: String.t() defp delete_action_label(true, :delete_all), do: "dry-run: would delete status" defp delete_action_label(true, {:thread, _source_id}), do: "dry-run: would delete thread status" defp delete_action_label(false, :delete_all), do: "deleting status" defp delete_action_label(false, {:thread, _source_id}), do: "deleting thread status" @spec maybe_sleep(non_neg_integer()) :: :ok defp maybe_sleep(delay_ms) when delay_ms > 0 do Process.sleep(delay_ms) :ok end defp maybe_sleep(_delay_ms), do: :ok @doc """ Returns same-author descendants for a thread, including source when owned. """ @spec own_thread_statuses(map(), map(), String.t()) :: [map()] def own_thread_statuses(source_status, context, user_id) do descendants = Map.get(context, "descendants", []) |> Enum.filter(fn status -> get_in(status, ["account", "id"]) == user_id end) |> Enum.sort_by(&Map.get(&1, "created_at", ""), :desc) if get_in(source_status, ["account", "id"]) == user_id do descendants ++ [source_status] else descendants end end # Prevents accidental live deletes when tests hit real instances. @spec maybe_force_dry_run_in_test(keyword()) :: keyword() defp maybe_force_dry_run_in_test(opts) do if @mix_env == :test and not Keyword.get(opts, :allow_destructive_delete, false) do Keyword.put(opts, :dry_run, true) else opts end 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 end