diff --git a/changelog.d/translate-posts.add b/changelog.d/translate-posts.add
new file mode 100644
index 000000000..e7a9317a1
--- /dev/null
+++ b/changelog.d/translate-posts.add
@@ -0,0 +1 @@
+Support translation providers (DeepL, LibreTranslate)
\ No newline at end of file
diff --git a/config/description.exs b/config/description.exs
index 1bbe1249f..2f7dc30a0 100644
--- a/config/description.exs
+++ b/config/description.exs
@@ -3522,5 +3522,49 @@ config :pleroma, :config_description, [
suggestions: ["/usr/share/fasttext/lid.176.bin"]
}
]
+ },
+ %{
+ group: :pleroma,
+ key: Pleroma.Language.Translation,
+ type: :group,
+ description: "Translation providers",
+ children: [
+ %{
+ key: :provider,
+ type: :module,
+ suggestions: [
+ Pleroma.Language.Translation.Deepl,
+ Pleroma.Language.Translation.Libretranslate
+ ]
+ },
+ %{
+ group: {:subgroup, Pleroma.Language.Translation.Deepl},
+ key: :base_url,
+ label: "DeepL base URL",
+ type: :string,
+ suggestions: ["https://api-free.deepl.com", "https://api.deepl.com"]
+ },
+ %{
+ group: {:subgroup, Pleroma.Language.Translation.Deepl},
+ key: :api_key,
+ label: "DeepL API Key",
+ type: :string,
+ suggestions: ["YOUR_API_KEY"]
+ },
+ %{
+ group: {:subgroup, Pleroma.Language.Translation.Libretranslate},
+ key: :base_url,
+ label: "LibreTranslate instance URL",
+ type: :string,
+ suggestions: ["https://libretranslate.com"]
+ },
+ %{
+ group: {:subgroup, Pleroma.Language.Translation.Libretranslate},
+ key: :api_key,
+ label: "LibreTranslate API Key",
+ type: :string,
+ suggestions: ["YOUR_API_KEY"]
+ }
+ ]
}
]
diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex
index 78ac0443f..497623ee1 100644
--- a/lib/pleroma/application.ex
+++ b/lib/pleroma/application.ex
@@ -172,7 +172,8 @@ defmodule Pleroma.Application do
limit: 500_000
),
build_cachex("rel_me", limit: 2500),
- build_cachex("host_meta", default_ttl: :timer.minutes(120), limit: 5000)
+ build_cachex("host_meta", default_ttl: :timer.minutes(120), limit: 5_000),
+ build_cachex("translations", default_ttl: :timer.hours(24), limit: 5_000)
]
end
diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex
index adcd5a075..87ecb7e2d 100644
--- a/lib/pleroma/application_requirements.ex
+++ b/lib/pleroma/application_requirements.ex
@@ -202,11 +202,24 @@ defmodule Pleroma.ApplicationRequirements do
false
end
+ translation_commands_status =
+ if Pleroma.Language.Translation.missing_dependencies() == [] do
+ true
+ else
+ Logger.error(
+ "The following dependencies required by the currently enabled " <>
+ "translation provider are not installed: " <>
+ inspect(Pleroma.Language.Translation.missing_dependencies())
+ )
+
+ false
+ end
+
if Enum.all?(
[
preview_proxy_commands_status,
- language_detector_commands_status
- | filter_commands_statuses
+ language_detector_commands_status,
+ translation_commands_status | filter_commands_statuses
],
& &1
) do
diff --git a/lib/pleroma/language/translation.ex b/lib/pleroma/language/translation.ex
new file mode 100644
index 000000000..3706e76eb
--- /dev/null
+++ b/lib/pleroma/language/translation.ex
@@ -0,0 +1,127 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Language.Translation do
+ @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
+
+ def configured? do
+ provider = get_provider()
+
+ !!provider and provider.configured?
+ end
+
+ def missing_dependencies do
+ provider = get_provider()
+
+ if provider do
+ provider.missing_dependencies()
+ else
+ []
+ end
+ end
+
+ def translate(text, source_language, target_language) do
+ cache_key = get_cache_key(text, source_language, target_language)
+
+ case @cachex.get(:translations_cache, cache_key) do
+ {:ok, nil} ->
+ provider = get_provider()
+
+ result =
+ if !configured?() do
+ {:error, :not_found}
+ else
+ provider.translate(text, source_language, target_language)
+ |> scrub_html()
+ end
+
+ store_result(result, cache_key)
+
+ result
+
+ {:ok, result} ->
+ {:ok, result}
+
+ {:error, error} ->
+ {:error, error}
+ end
+ end
+
+ def supported_languages(type) when type in [:source, :target] do
+ provider = get_provider()
+
+ cache_key = "#{type}_languages/#{provider.name()}"
+
+ case @cachex.get(:translations_cache, cache_key) do
+ {:ok, nil} ->
+ result =
+ if !configured?() do
+ {:error, :not_found}
+ else
+ provider.supported_languages(type)
+ end
+
+ store_result(result, cache_key)
+
+ result
+
+ {:ok, result} ->
+ {:ok, result}
+
+ {:error, error} ->
+ {:error, error}
+ end
+ end
+
+ def languages_matrix do
+ provider = get_provider()
+
+ cache_key = "languages_matrix/#{provider.name()}"
+
+ case @cachex.get(:translations_cache, cache_key) do
+ {:ok, nil} ->
+ result =
+ if !configured?() do
+ {:error, :not_found}
+ else
+ provider.languages_matrix()
+ end
+
+ store_result(result, cache_key)
+
+ result
+
+ {:ok, result} ->
+ {:ok, result}
+
+ {:error, error} ->
+ {:error, error}
+ end
+ end
+
+ defp get_provider, do: Pleroma.Config.get([__MODULE__, :provider])
+
+ defp get_cache_key(text, source_language, target_language) do
+ "#{source_language}/#{target_language}/#{content_hash(text)}"
+ end
+
+ defp store_result({:ok, result}, cache_key) do
+ @cachex.put(:translations_cache, cache_key, result)
+ end
+
+ defp store_result(_, _), do: nil
+
+ defp content_hash(text), do: :crypto.hash(:sha256, text) |> Base.encode64()
+
+ defp scrub_html({:ok, %{content: content} = result}) when is_binary(content) do
+ scrubbers = Pleroma.Config.get([:markup, :scrub_policy])
+
+ content
+ |> Pleroma.HTML.filter_tags(scrubbers)
+
+ {:ok, %{result | content: content}}
+ end
+
+ defp scrub_html(result), do: result
+end
diff --git a/lib/pleroma/language/translation/deepl.ex b/lib/pleroma/language/translation/deepl.ex
new file mode 100644
index 000000000..e027035b4
--- /dev/null
+++ b/lib/pleroma/language/translation/deepl.ex
@@ -0,0 +1,123 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Language.Translation.Deepl do
+ import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1]
+
+ alias Pleroma.Language.Translation.Provider
+
+ use Provider
+
+ @behaviour Provider
+
+ @name "DeepL"
+
+ @impl Provider
+ def configured?, do: not_empty_string(base_url()) and not_empty_string(api_key())
+
+ @impl Provider
+ def translate(content, source_language, target_language) do
+ endpoint =
+ base_url()
+ |> URI.merge("/v2/translate")
+ |> URI.to_string()
+
+ case Pleroma.HTTP.post(
+ endpoint <>
+ "?" <>
+ URI.encode_query(%{
+ text: content,
+ source_lang: source_language |> String.upcase(),
+ target_lang: target_language,
+ tag_handling: "html"
+ }),
+ "",
+ [
+ {"Content-Type", "application/x-www-form-urlencoded"},
+ {"Authorization", "DeepL-Auth-Key #{api_key()}"}
+ ]
+ ) do
+ {:ok, %{status: 429}} ->
+ {:error, :too_many_requests}
+
+ {:ok, %{status: 456}} ->
+ {:error, :quota_exceeded}
+
+ {:ok, %{status: 200} = res} ->
+ %{
+ "translations" => [
+ %{"text" => content, "detected_source_language" => detected_source_language}
+ ]
+ } = Jason.decode!(res.body)
+
+ {:ok,
+ %{
+ content: content,
+ detected_source_language: detected_source_language,
+ provider: @name
+ }}
+
+ _ ->
+ {:error, :internal_server_error}
+ end
+ end
+
+ @impl Provider
+ def supported_languages(type) when type in [:source, :target] do
+ endpoint =
+ base_url()
+ |> URI.merge("/v2/languages")
+ |> URI.to_string()
+
+ case Pleroma.HTTP.post(
+ endpoint <> "?" <> URI.encode_query(%{type: type}),
+ "",
+ [
+ {"Content-Type", "application/x-www-form-urlencoded"},
+ {"Authorization", "DeepL-Auth-Key #{api_key()}"}
+ ]
+ ) do
+ {:ok, %{status: 200} = res} ->
+ languages =
+ Jason.decode!(res.body)
+ |> Enum.map(fn %{"language" => language} -> language |> String.downcase() end)
+ |> Enum.map(fn language ->
+ if String.contains?(language, "-") do
+ [language, language |> String.split("-") |> Enum.at(0)]
+ else
+ language
+ end
+ end)
+ |> List.flatten()
+ |> Enum.uniq()
+
+ {:ok, languages}
+
+ _ ->
+ {:error, :internal_server_error}
+ end
+ end
+
+ @impl Provider
+ def languages_matrix do
+ with {:ok, source_languages} <- supported_languages(:source),
+ {:ok, target_languages} <- supported_languages(:target) do
+ {:ok,
+ Map.new(source_languages, fn language -> {language, target_languages -- [language]} end)}
+ else
+ {:error, error} -> {:error, error}
+ end
+ end
+
+ @impl Provider
+ def name, do: @name
+
+ defp base_url do
+ Pleroma.Config.get([__MODULE__, :base_url])
+ end
+
+ defp api_key do
+ Pleroma.Config.get([__MODULE__, :api_key])
+ end
+end
diff --git a/lib/pleroma/language/translation/libretranslate.ex b/lib/pleroma/language/translation/libretranslate.ex
new file mode 100644
index 000000000..fd727d1cf
--- /dev/null
+++ b/lib/pleroma/language/translation/libretranslate.ex
@@ -0,0 +1,93 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Language.Translation.Libretranslate do
+ import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1]
+
+ alias Pleroma.Language.Translation.Provider
+
+ use Provider
+
+ @behaviour Provider
+
+ @name "LibreTranslate"
+
+ @impl Provider
+ def configured?, do: not_empty_string(base_url()) and not_empty_string(api_key())
+
+ @impl Provider
+ def translate(content, source_language, target_language) do
+ case Pleroma.HTTP.post(
+ base_url() <> "/translate",
+ Jason.encode!(%{
+ q: content,
+ source: source_language |> String.upcase(),
+ target: target_language,
+ format: "html",
+ api_key: api_key()
+ }),
+ [
+ {"Content-Type", "application/json"}
+ ]
+ ) do
+ {:ok, %{status: 429}} ->
+ {:error, :too_many_requests}
+
+ {:ok, %{status: 403}} ->
+ {:error, :quota_exceeded}
+
+ {:ok, %{status: 200} = res} ->
+ %{
+ "translatedText" => content
+ } = Jason.decode!(res.body)
+
+ {:ok,
+ %{
+ content: content,
+ detected_source_language: source_language,
+ provider: @name
+ }}
+
+ _ ->
+ {:error, :internal_server_error}
+ end
+ end
+
+ @impl Provider
+ def supported_languages(_) do
+ case Pleroma.HTTP.get(base_url() <> "/languages") do
+ {:ok, %{status: 200} = res} ->
+ languages =
+ Jason.decode!(res.body)
+ |> Enum.map(fn %{"code" => code} -> code end)
+
+ {:ok, languages}
+
+ _ ->
+ {:error, :internal_server_error}
+ end
+ end
+
+ @impl Provider
+ def languages_matrix do
+ with {:ok, source_languages} <- supported_languages(:source),
+ {:ok, target_languages} <- supported_languages(:target) do
+ {:ok,
+ Map.new(source_languages, fn language -> {language, target_languages -- [language]} end)}
+ else
+ {:error, error} -> {:error, error}
+ end
+ end
+
+ @impl Provider
+ def name, do: @name
+
+ defp base_url do
+ Pleroma.Config.get([__MODULE__, :base_url])
+ end
+
+ defp api_key do
+ Pleroma.Config.get([__MODULE__, :api_key], "")
+ end
+end
diff --git a/lib/pleroma/language/translation/provider.ex b/lib/pleroma/language/translation/provider.ex
new file mode 100644
index 000000000..533b5355a
--- /dev/null
+++ b/lib/pleroma/language/translation/provider.ex
@@ -0,0 +1,40 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Language.Translation.Provider do
+ alias Pleroma.Language.Translation.Provider
+
+ @callback missing_dependencies() :: [String.t()]
+
+ @callback configured?() :: boolean()
+
+ @callback translate(
+ content :: String.t(),
+ source_language :: String.t(),
+ target_language :: String.t()
+ ) ::
+ {:ok,
+ %{
+ content: String.t(),
+ detected_source_language: String.t(),
+ provider: String.t()
+ }}
+ | {:error, atom()}
+
+ @callback supported_languages(type :: :string | :target) ::
+ {:ok, [String.t()]} | {:error, atom()}
+
+ @callback languages_matrix() :: {:ok, Map.t()} | {:error, atom()}
+
+ @callback name() :: String.t()
+
+ defmacro __using__(_opts) do
+ quote do
+ @impl Provider
+ def missing_dependencies, do: []
+
+ defoverridable missing_dependencies: 0
+ end
+ end
+end
diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex
index 7d7a5ecc1..84e5b314d 100644
--- a/lib/pleroma/web/api_spec/operations/instance_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex
@@ -57,6 +57,29 @@ defmodule Pleroma.Web.ApiSpec.InstanceOperation do
}
end
+ def translation_languages_operation do
+ %Operation{
+ tags: ["Instance misc"],
+ summary: "Retrieve supported languages matrix",
+ operationId: "InstanceController.translation_languages",
+ responses: %{
+ 200 =>
+ Operation.response(
+ "Translation languages matrix",
+ "application/json",
+ %Schema{
+ type: :object,
+ additionalProperties: %Schema{
+ type: :array,
+ items: %Schema{type: :string},
+ description: "Supported target languages for a source language"
+ }
+ }
+ )
+ }
+ }
+ end
+
defp instance do
%Schema{
type: :object,
diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex
index ef828feee..75ecda321 100644
--- a/lib/pleroma/web/api_spec/operations/status_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/status_operation.ex
@@ -427,6 +427,38 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
}
end
+ def translate_operation do
+ %Operation{
+ tags: ["Retrieve status information"],
+ summary: "Translate status",
+ description: "Translate status with an external API",
+ operationId: "StatusController.translate",
+ security: [%{"oAuth" => ["read:statuses"]}],
+ parameters: [id_param()],
+ requestBody:
+ request_body(
+ "Parameters",
+ %Schema{
+ type: :object,
+ properties: %{
+ lang: %Schema{
+ type: :string,
+ nullable: true,
+ description: "Translation target language."
+ }
+ }
+ },
+ required: false
+ ),
+ responses: %{
+ 200 => Operation.response("Translation", "application/json", translation()),
+ 400 => Operation.response("Error", "application/json", ApiError),
+ 404 => Operation.response("Error", "application/json", ApiError),
+ 503 => Operation.response("Error", "application/json", ApiError)
+ }
+ }
+ end
+
def favourites_operation do
%Operation{
tags: ["Timelines"],
@@ -819,4 +851,32 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
}
}
end
+
+ defp translation do
+ %Schema{
+ title: "StatusTranslation",
+ description: "Represents status translation with related information.",
+ type: :object,
+ required: [:content, :detected_source_language, :provider],
+ properties: %{
+ content: %Schema{
+ type: :string,
+ description: "Translated status content"
+ },
+ detected_source_language: %Schema{
+ type: :string,
+ description: "Detected source language"
+ },
+ provider: %Schema{
+ type: :string,
+ description: "Translation provider service name"
+ }
+ },
+ example: %{
+ "content" => "Software für die nächste Generation der sozialen Medien.",
+ "detected_source_language" => "en",
+ "provider" => "Deepl"
+ }
+ }
+ end
end
diff --git a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex
index b97b0e476..0f74c1dff 100644
--- a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex
@@ -30,4 +30,9 @@ defmodule Pleroma.Web.MastodonAPI.InstanceController do
def rules(conn, _params) do
render(conn, "rules.json")
end
+
+ @doc "GET /api/v1/instance/translation_languages"
+ def translation_languages(conn, _params) do
+ render(conn, "translation_languages.json")
+ end
end
diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex
index d5aef5ad2..10549fb20 100644
--- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex
@@ -13,6 +13,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
alias Pleroma.Activity
alias Pleroma.Bookmark
alias Pleroma.BookmarkFolder
+ alias Pleroma.Language.Translation
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.ScheduledActivity
@@ -44,6 +45,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
]
)
+ plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action == :translate)
+
plug(
OAuthScopesPlug,
%{scopes: ["write:statuses"]}
@@ -85,7 +88,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
%{scopes: ["write:bookmarks"]} when action in [:bookmark, :unbookmark]
)
- @rate_limited_status_actions ~w(reblog unreblog favourite unfavourite create delete)a
+ @rate_limited_status_actions ~w(reblog unreblog favourite unfavourite create delete translate)a
plug(
RateLimiter,
@@ -549,6 +552,41 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
end
end
+ @doc "POST /api/v1/statuses/:id/translate"
+ def translate(
+ %{
+ assigns: %{user: user},
+ private: %{open_api_spex: %{body_params: params, params: %{id: status_id}}}
+ } = conn,
+ _
+ ) do
+ with %Activity{object: object} <- Activity.get_by_id_with_object(status_id),
+ {:visibility, visibility} when visibility in ["public", "unlisted"] <-
+ {:visibility, Visibility.get_visibility(object)},
+ {:language, language} when is_binary(language) <-
+ {:language, Map.get(params, :lang) || user.language},
+ {:ok, result} <-
+ Translation.translate(
+ object.data["content"],
+ object.data["language"],
+ language
+ ) do
+ render(conn, "translation.json", result)
+ else
+ {:language, nil} ->
+ render_error(conn, :bad_request, "Language not specified")
+
+ {:visibility, _} ->
+ render_error(conn, :not_found, "Record not found")
+
+ {:error, :not_found} ->
+ render_error(conn, :not_found, "Translation service not configured")
+
+ {:error, error} when error in [:unexpected_response, :quota_exceeded, :too_many_requests] ->
+ render_error(conn, :service_unavailable, "Translation service not available")
+ end
+ end
+
@doc "GET /api/v1/favourites"
def favourites(
%{assigns: %{user: %User{} = user}, private: %{open_api_spex: %{params: params}}} = conn,
diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex
index 00ca06243..4b0480f66 100644
--- a/lib/pleroma/web/mastodon_api/views/instance_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex
@@ -90,6 +90,15 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
}
end
+ def render("translation_languages.json", _) do
+ with true <- Pleroma.Language.Translation.configured?(),
+ {:ok, languages} <- Pleroma.Language.Translation.languages_matrix() do
+ languages
+ else
+ _ -> %{}
+ end
+ end
+
defp common_information(instance) do
%{
languages: Keyword.get(instance, :languages, ["en"]),
@@ -246,7 +255,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
},
vapid: %{
public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key)
- }
+ },
+ translation: %{enabled: Pleroma.Language.Translation.configured?()}
})
end
@@ -259,7 +269,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
fields_limits: fields_limits(),
post_formats: Config.get([:instance, :allowed_post_formats]),
birthday_required: Config.get([:instance, :birthday_required]),
- birthday_min_age: Config.get([:instance, :birthday_min_age])
+ birthday_min_age: Config.get([:instance, :birthday_min_age]),
+ translation: supported_languages()
},
stats: %{mau: Pleroma.User.active_user_count()},
vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key)
@@ -285,4 +296,29 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
})
})
end
+
+ defp supported_languages do
+ enabled = Pleroma.Language.Translation.configured?()
+
+ source_languages =
+ with true <- enabled,
+ {:ok, languages} <- Pleroma.Language.Translation.supported_languages(:source) do
+ languages
+ else
+ _ -> nil
+ end
+
+ target_languages =
+ with true <- enabled,
+ {:ok, languages} <- Pleroma.Language.Translation.supported_languages(:target) do
+ languages
+ else
+ _ -> nil
+ end
+
+ %{
+ source_languages: source_languages,
+ target_languages: target_languages
+ }
+ end
end
diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex
index 10966edd6..4b5ac9c3b 100644
--- a/lib/pleroma/web/mastodon_api/views/status_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/status_view.ex
@@ -681,6 +681,14 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
}
end
+ def render("translation.json", %{
+ content: content,
+ detected_source_language: detected_source_language,
+ provider: provider
+ }) do
+ %{content: content, detected_source_language: detected_source_language, provider: provider}
+ end
+
def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
object = Object.normalize(activity, fetch: false)
diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex
index bf8ebf3e4..f2f9d7246 100644
--- a/lib/pleroma/web/router.ex
+++ b/lib/pleroma/web/router.ex
@@ -740,6 +740,7 @@ defmodule Pleroma.Web.Router do
post("/statuses/:id/unbookmark", StatusController, :unbookmark)
post("/statuses/:id/mute", StatusController, :mute_conversation)
post("/statuses/:id/unmute", StatusController, :unmute_conversation)
+ post("/statuses/:id/translate", StatusController, :translate)
post("/push/subscription", SubscriptionController, :create)
get("/push/subscription", SubscriptionController, :show)
@@ -787,6 +788,7 @@ defmodule Pleroma.Web.Router do
get("/instance", InstanceController, :show)
get("/instance/peers", InstanceController, :peers)
get("/instance/rules", InstanceController, :rules)
+ get("/instance/translation_languages", InstanceController, :translation_languages)
get("/statuses", StatusController, :index)
get("/statuses/:id", StatusController, :show)
diff --git a/test/fixtures/tesla_mock/deepl-languages-list.json b/test/fixtures/tesla_mock/deepl-languages-list.json
new file mode 100644
index 000000000..03d47d2ec
--- /dev/null
+++ b/test/fixtures/tesla_mock/deepl-languages-list.json
@@ -0,0 +1 @@
+[{"language":"BG","name":"Bulgarian","supports_formality":false},{"language":"CS","name":"Czech","supports_formality":false},{"language":"DA","name":"Danish","supports_formality":false},{"language":"DE","name":"German","supports_formality":true},{"language":"EL","name":"Greek","supports_formality":false},{"language":"EN-GB","name":"English (British)","supports_formality":false},{"language":"EN-US","name":"English (American)","supports_formality":false},{"language":"ES","name":"Spanish","supports_formality":true},{"language":"ET","name":"Estonian","supports_formality":false},{"language":"FI","name":"Finnish","supports_formality":false},{"language":"FR","name":"French","supports_formality":true},{"language":"HU","name":"Hungarian","supports_formality":false},{"language":"ID","name":"Indonesian","supports_formality":false},{"language":"IT","name":"Italian","supports_formality":true},{"language":"JA","name":"Japanese","supports_formality":false},{"language":"LT","name":"Lithuanian","supports_formality":false},{"language":"LV","name":"Latvian","supports_formality":false},{"language":"NL","name":"Dutch","supports_formality":true},{"language":"PL","name":"Polish","supports_formality":true},{"language":"PT-BR","name":"Portuguese (Brazilian)","supports_formality":true},{"language":"PT-PT","name":"Portuguese (European)","supports_formality":true},{"language":"RO","name":"Romanian","supports_formality":false},{"language":"RU","name":"Russian","supports_formality":true},{"language":"SK","name":"Slovak","supports_formality":false},{"language":"SL","name":"Slovenian","supports_formality":false},{"language":"SV","name":"Swedish","supports_formality":false},{"language":"TR","name":"Turkish","supports_formality":false},{"language":"UK","name":"Ukrainian","supports_formality":false},{"language":"ZH","name":"Chinese (simplified)","supports_formality":false}]
\ No newline at end of file
diff --git a/test/fixtures/tesla_mock/deepl-translation.json b/test/fixtures/tesla_mock/deepl-translation.json
new file mode 100644
index 000000000..fef7bb215
--- /dev/null
+++ b/test/fixtures/tesla_mock/deepl-translation.json
@@ -0,0 +1 @@
+{"translations":[{"detected_source_language":"PL","text":"REMOVE THE FOLLOWER!Paste this on your follower. If we get 70% of nk users...they will remove the follower!!!"}]}
\ No newline at end of file
diff --git a/test/pleroma/language/translation/deepl_test.exs b/test/pleroma/language/translation/deepl_test.exs
new file mode 100644
index 000000000..3a7265622
--- /dev/null
+++ b/test/pleroma/language/translation/deepl_test.exs
@@ -0,0 +1,37 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Language.Translation.DeeplTest do
+ use Pleroma.Web.ConnCase
+
+ alias Pleroma.Language.Translation.Deepl
+
+ test "it translates text" do
+ Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
+ clear_config([Pleroma.Language.Translation.Deepl, :base_url], "https://api-free.deepl.com")
+ clear_config([Pleroma.Language.Translation.Deepl, :api_key], "API_KEY")
+
+ {:ok, res} =
+ Deepl.translate(
+ "USUNĄĆ ŚLEDZIKA!Wklej to na swojego śledzika. Jeżeli uzbieramy 70% użytkowników nk...to usuną śledzika!!!",
+ "pl",
+ "en"
+ )
+
+ assert %{
+ detected_source_language: "PL",
+ provider: "DeepL"
+ } = res
+ end
+
+ test "it returns languages list" do
+ Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
+ clear_config([Pleroma.Language.Translation.Deepl, :base_url], "https://api-free.deepl.com")
+ clear_config([Pleroma.Language.Translation.Deepl, :api_key], "API_KEY")
+
+ assert {:ok, [language | _languages]} = Deepl.supported_languages(:target)
+
+ assert is_binary(language)
+ end
+end
diff --git a/test/pleroma/language/translation_test.exs b/test/pleroma/language/translation_test.exs
new file mode 100644
index 000000000..0be7a8d60
--- /dev/null
+++ b/test/pleroma/language/translation_test.exs
@@ -0,0 +1,28 @@
+defmodule Pleroma.Language.TranslationTest do
+ use Pleroma.Web.ConnCase
+
+ alias Pleroma.Language.Translation
+
+ setup do: clear_config([Pleroma.Language.Translation, :provider], TranslationMock)
+
+ test "it translates text" do
+ assert {:ok,
+ %{
+ content: "txet emos",
+ detected_source_language: _,
+ provider: _
+ }} = Translation.translate("some text", "en", "uk")
+ end
+
+ test "it stores translation result in cache" do
+ Translation.translate("some text", "en", "uk")
+
+ assert {:ok, result} =
+ Cachex.get(
+ :translations_cache,
+ "en/uk/#{:crypto.hash(:sha256, "some text") |> Base.encode64()}"
+ )
+
+ assert result.content == "txet emos"
+ end
+end
diff --git a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs
index 373a84303..38b547770 100644
--- a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs
+++ b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs
@@ -152,4 +152,13 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
}
] = result["rules"]
end
+
+ test "translation languages matrix", %{conn: conn} do
+ clear_config([Pleroma.Language.Translation, :provider], TranslationMock)
+
+ assert %{"en" => ["pl"], "pl" => ["en"]} =
+ conn
+ |> get("/api/v1/instance/translation_languages")
+ |> json_response_and_validate_schema(200)
+ end
end
diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs
index a4bca6cf9..25a17d5c1 100644
--- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs
+++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs
@@ -2483,4 +2483,62 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
|> json_response_and_validate_schema(:not_found)
end
end
+
+ describe "translating statuses" do
+ setup do: clear_config([Pleroma.Language.Translation, :provider], TranslationMock)
+
+ test "it translates a status to user language" do
+ user = insert(:user, language: "fr")
+ %{conn: conn} = oauth_access(["read:statuses"], user: user)
+ another_user = insert(:user)
+
+ {:ok, activity} =
+ CommonAPI.post(another_user, %{
+ status: "Cześć!",
+ visibility: "public",
+ language: "pl"
+ })
+
+ response =
+ conn
+ |> post("/api/v1/statuses/#{activity.id}/translate")
+ |> json_response_and_validate_schema(200)
+
+ assert response == %{
+ "content" => "!ćśezC",
+ "detected_source_language" => "pl",
+ "provider" => "TranslationMock"
+ }
+ end
+
+ test "it returns an error if no target language provided" do
+ %{conn: conn} = oauth_access(["read:statuses"])
+ another_user = insert(:user)
+
+ {:ok, activity} =
+ CommonAPI.post(another_user, %{
+ status: "Cześć!",
+ language: "pl"
+ })
+
+ assert conn
+ |> post("/api/v1/statuses/#{activity.id}/translate")
+ |> json_response_and_validate_schema(400)
+ end
+
+ test "it doesn't translate non-public statuses" do
+ %{conn: conn, user: user} = oauth_access(["read:statuses"])
+
+ {:ok, activity} =
+ CommonAPI.post(user, %{
+ status: "Cześć!",
+ visibility: "private",
+ language: "pl"
+ })
+
+ assert conn
+ |> post("/api/v1/statuses/#{activity.id}/translate")
+ |> json_response_and_validate_schema(404)
+ end
+ end
end
diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex
index a8f954af9..f8d11e602 100644
--- a/test/support/http_request_mock.ex
+++ b/test/support/http_request_mock.ex
@@ -1706,6 +1706,24 @@ defmodule HttpRequestMock do
}}
end
+ def post("https://api-free.deepl.com/v2/translate" <> _, _, _, _) do
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ body: File.read!("test/fixtures/tesla_mock/deepl-translation.json"),
+ headers: [{"content-type", "application/json"}]
+ }}
+ end
+
+ def post("https://api-free.deepl.com/v2/languages" <> _, _, _, _) do
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ body: File.read!("test/fixtures/tesla_mock/deepl-languages-list.json"),
+ headers: [{"content-type", "application/json"}]
+ }}
+ end
+
def post(url, query, body, headers) do
{:error,
"Mock response not implemented for POST #{inspect(url)}, #{query}, #{inspect(body)}, #{inspect(headers)}"}
diff --git a/test/support/translation_mock.ex b/test/support/translation_mock.ex
new file mode 100644
index 000000000..84ed8f696
--- /dev/null
+++ b/test/support/translation_mock.ex
@@ -0,0 +1,43 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule TranslationMock do
+ alias Pleroma.Language.Translation.Provider
+
+ use Provider
+
+ @behaviour Provider
+
+ @name "TranslationMock"
+
+ @impl Provider
+ def configured?, do: true
+
+ @impl Provider
+ def translate(content, source_language, _target_language) do
+ {:ok,
+ %{
+ content: content |> String.reverse(),
+ detected_source_language: source_language,
+ provider: @name
+ }}
+ end
+
+ @impl Provider
+ def supported_languages(_) do
+ {:ok, ["en", "pl"]}
+ end
+
+ @impl Provider
+ def languages_matrix do
+ {:ok,
+ %{
+ "en" => ["pl"],
+ "pl" => ["en"]
+ }}
+ end
+
+ @impl Provider
+ def name, do: @name
+end