defmodule Mastomation.Delete do @moduledoc """ Deletion-specific CLI logic and status deletion workflow. """ require Logger @default_delay_ms 60_000 @doc """ Parses delete command options for compatibility with older callers. """ @spec parse_options([String.t()]) :: map() def parse_options(args \\ []) do case Mastomation.Parser.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 || System.get_env("MASTODON_INSTANCE_URL") token = options.override_access_token || options.access_token || System.get_env("MASTODON_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 || System.get_env("MASTODON_INSTANCE_URL") token = options.override_access_token || options.access_token || System.get_env("MASTODON_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 || System.get_env("MASTODON_INSTANCE_URL") token = options.override_access_token || options.access_token || System.get_env("MASTODON_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()) :: String.t() def get_user_id(instance, token) do %{"id" => id} = Mastomation.Client.get_json!("#{instance}/api/v1/accounts/verify_credentials", token) id end @doc """ Fetches a page of account statuses. """ @spec get_statuses(String.t(), String.t(), String.t()) :: [map()] def get_statuses(instance, token, user_id) do Mastomation.Client.get_json!( "#{instance}/api/v1/accounts/#{user_id}/statuses?limit=40", token ) end @doc """ Fetches a paginated page of account statuses using `max_id`. """ @spec get_statuses(String.t(), String.t(), String.t(), String.t()) :: [map()] def get_statuses(instance, token, user_id, max_id) do Mastomation.Client.get_json!( "#{instance}/api/v1/accounts/#{user_id}/statuses?limit=40&max_id=#{max_id}", token ) end @doc """ Fetches all statuses for an account by traversing pages. """ @spec get_all_statuses(String.t(), String.t(), String.t()) :: [map()] def get_all_statuses(instance, token, user_id) do first_batch = get_statuses(instance, token, user_id) if first_batch == [] do [] else loop_statuses(instance, token, user_id, first_batch) end end @doc """ Continues paginated status retrieval until exhaustion. """ @spec loop_statuses(String.t(), String.t(), String.t(), [map()]) :: [map()] def loop_statuses(instance, token, user_id, accumulated) do last_id = get_last_status_id(accumulated) next_batch = get_statuses(instance, token, user_id, last_id) cond do next_batch == [] -> accumulated get_last_status_id(next_batch) == last_id -> accumulated true -> loop_statuses(instance, token, user_id, accumulated ++ next_batch) 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() def get_last_status_id(statuses) do %{"id" => id} = List.last(statuses) id end @spec delete_all_statuses(any(), any(), keyword()) :: :ok @doc """ Deletes statuses from the account based on configured filters/options. """ def delete_all_statuses(instance, token, opts \\ []) do :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) checkpoint = load_checkpoint(:delete_all, user_id) statuses = get_all_statuses(instance, token, user_id) |> 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)) :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) do Mastomation.Client.delete_status_with_retry(instance, token, id) end @doc """ Deletes only same-author statuses in the thread of `thread_source`. """ @spec delete_thread_replies(String.t(), String.t(), String.t(), keyword()) :: :ok def delete_thread_replies(instance, token, thread_source, opts \\ []) do :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) source_id = Mastomation.Threads.extract_status_id(thread_source) source_status = Mastomation.Threads.get_status(instance, token, source_id) context = Mastomation.Threads.get_status_context(instance, token, source_id) 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) ) :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()) :: MapSet.t(String.t()) 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() ) :: :ok defp process_delete_status(status, instance, token, user_id, delay_ms, dry_run) do process_delete_status(status, instance, token, user_id, delay_ms, dry_run, :delete_all) 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()} ) :: :ok defp process_delete_status(status, instance, token, user_id, delay_ms, dry_run, scope) do content = status["content"] || "" id = status["id"] action = delete_action_label(dry_run, scope) Logger.info("#{action} #{id}") Logger.info("status content: #{content}") unless dry_run do :ok = delete_status(id, instance, token) 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 # 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