mirror of
https://git.pleroma.social/pleroma/pleroma.git
synced 2025-04-24 11:54:12 +00:00
Merge branch 'translate-posts' into 'develop'
Support translation providers (DeepL, LibreTranslate) See merge request pleroma/pleroma!4102
This commit is contained in:
commit
81960dccf2
23 changed files with 815 additions and 6 deletions
1
changelog.d/translate-posts.add
Normal file
1
changelog.d/translate-posts.add
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Support translation providers (DeepL, LibreTranslate)
|
|
@ -3522,5 +3522,49 @@ config :pleroma, :config_description, [
|
||||||
suggestions: ["/usr/share/fasttext/lid.176.bin"]
|
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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -172,7 +172,8 @@ defmodule Pleroma.Application do
|
||||||
limit: 500_000
|
limit: 500_000
|
||||||
),
|
),
|
||||||
build_cachex("rel_me", limit: 2500),
|
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
|
end
|
||||||
|
|
||||||
|
|
|
@ -202,11 +202,24 @@ defmodule Pleroma.ApplicationRequirements do
|
||||||
false
|
false
|
||||||
end
|
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?(
|
if Enum.all?(
|
||||||
[
|
[
|
||||||
preview_proxy_commands_status,
|
preview_proxy_commands_status,
|
||||||
language_detector_commands_status
|
language_detector_commands_status,
|
||||||
| filter_commands_statuses
|
translation_commands_status | filter_commands_statuses
|
||||||
],
|
],
|
||||||
& &1
|
& &1
|
||||||
) do
|
) do
|
||||||
|
|
127
lib/pleroma/language/translation.ex
Normal file
127
lib/pleroma/language/translation.ex
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
# Pleroma: A lightweight social networking server
|
||||||
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||||
|
# 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
|
123
lib/pleroma/language/translation/deepl.ex
Normal file
123
lib/pleroma/language/translation/deepl.ex
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
# Pleroma: A lightweight social networking server
|
||||||
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||||
|
# 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
|
93
lib/pleroma/language/translation/libretranslate.ex
Normal file
93
lib/pleroma/language/translation/libretranslate.ex
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
# Pleroma: A lightweight social networking server
|
||||||
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||||
|
# 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
|
40
lib/pleroma/language/translation/provider.ex
Normal file
40
lib/pleroma/language/translation/provider.ex
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
# Pleroma: A lightweight social networking server
|
||||||
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||||
|
# 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
|
|
@ -57,6 +57,29 @@ defmodule Pleroma.Web.ApiSpec.InstanceOperation do
|
||||||
}
|
}
|
||||||
end
|
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
|
defp instance do
|
||||||
%Schema{
|
%Schema{
|
||||||
type: :object,
|
type: :object,
|
||||||
|
|
|
@ -427,6 +427,38 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
|
||||||
}
|
}
|
||||||
end
|
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
|
def favourites_operation do
|
||||||
%Operation{
|
%Operation{
|
||||||
tags: ["Timelines"],
|
tags: ["Timelines"],
|
||||||
|
@ -819,4 +851,32 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -30,4 +30,9 @@ defmodule Pleroma.Web.MastodonAPI.InstanceController do
|
||||||
def rules(conn, _params) do
|
def rules(conn, _params) do
|
||||||
render(conn, "rules.json")
|
render(conn, "rules.json")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc "GET /api/v1/instance/translation_languages"
|
||||||
|
def translation_languages(conn, _params) do
|
||||||
|
render(conn, "translation_languages.json")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -13,6 +13,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
|
||||||
alias Pleroma.Activity
|
alias Pleroma.Activity
|
||||||
alias Pleroma.Bookmark
|
alias Pleroma.Bookmark
|
||||||
alias Pleroma.BookmarkFolder
|
alias Pleroma.BookmarkFolder
|
||||||
|
alias Pleroma.Language.Translation
|
||||||
alias Pleroma.Object
|
alias Pleroma.Object
|
||||||
alias Pleroma.Repo
|
alias Pleroma.Repo
|
||||||
alias Pleroma.ScheduledActivity
|
alias Pleroma.ScheduledActivity
|
||||||
|
@ -44,6 +45,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action == :translate)
|
||||||
|
|
||||||
plug(
|
plug(
|
||||||
OAuthScopesPlug,
|
OAuthScopesPlug,
|
||||||
%{scopes: ["write:statuses"]}
|
%{scopes: ["write:statuses"]}
|
||||||
|
@ -85,7 +88,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
|
||||||
%{scopes: ["write:bookmarks"]} when action in [:bookmark, :unbookmark]
|
%{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(
|
plug(
|
||||||
RateLimiter,
|
RateLimiter,
|
||||||
|
@ -549,6 +552,41 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
|
||||||
end
|
end
|
||||||
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"
|
@doc "GET /api/v1/favourites"
|
||||||
def favourites(
|
def favourites(
|
||||||
%{assigns: %{user: %User{} = user}, private: %{open_api_spex: %{params: params}}} = conn,
|
%{assigns: %{user: %User{} = user}, private: %{open_api_spex: %{params: params}}} = conn,
|
||||||
|
|
|
@ -90,6 +90,15 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
|
||||||
}
|
}
|
||||||
end
|
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
|
defp common_information(instance) do
|
||||||
%{
|
%{
|
||||||
languages: Keyword.get(instance, :languages, ["en"]),
|
languages: Keyword.get(instance, :languages, ["en"]),
|
||||||
|
@ -246,7 +255,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
|
||||||
},
|
},
|
||||||
vapid: %{
|
vapid: %{
|
||||||
public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key)
|
public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key)
|
||||||
}
|
},
|
||||||
|
translation: %{enabled: Pleroma.Language.Translation.configured?()}
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -259,7 +269,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
|
||||||
fields_limits: fields_limits(),
|
fields_limits: fields_limits(),
|
||||||
post_formats: Config.get([:instance, :allowed_post_formats]),
|
post_formats: Config.get([:instance, :allowed_post_formats]),
|
||||||
birthday_required: Config.get([:instance, :birthday_required]),
|
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()},
|
stats: %{mau: Pleroma.User.active_user_count()},
|
||||||
vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key)
|
vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key)
|
||||||
|
@ -285,4 +296,29 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -681,6 +681,14 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
|
||||||
}
|
}
|
||||||
end
|
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
|
def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
|
||||||
object = Object.normalize(activity, fetch: false)
|
object = Object.normalize(activity, fetch: false)
|
||||||
|
|
||||||
|
|
|
@ -740,6 +740,7 @@ defmodule Pleroma.Web.Router do
|
||||||
post("/statuses/:id/unbookmark", StatusController, :unbookmark)
|
post("/statuses/:id/unbookmark", StatusController, :unbookmark)
|
||||||
post("/statuses/:id/mute", StatusController, :mute_conversation)
|
post("/statuses/:id/mute", StatusController, :mute_conversation)
|
||||||
post("/statuses/:id/unmute", StatusController, :unmute_conversation)
|
post("/statuses/:id/unmute", StatusController, :unmute_conversation)
|
||||||
|
post("/statuses/:id/translate", StatusController, :translate)
|
||||||
|
|
||||||
post("/push/subscription", SubscriptionController, :create)
|
post("/push/subscription", SubscriptionController, :create)
|
||||||
get("/push/subscription", SubscriptionController, :show)
|
get("/push/subscription", SubscriptionController, :show)
|
||||||
|
@ -787,6 +788,7 @@ defmodule Pleroma.Web.Router do
|
||||||
get("/instance", InstanceController, :show)
|
get("/instance", InstanceController, :show)
|
||||||
get("/instance/peers", InstanceController, :peers)
|
get("/instance/peers", InstanceController, :peers)
|
||||||
get("/instance/rules", InstanceController, :rules)
|
get("/instance/rules", InstanceController, :rules)
|
||||||
|
get("/instance/translation_languages", InstanceController, :translation_languages)
|
||||||
|
|
||||||
get("/statuses", StatusController, :index)
|
get("/statuses", StatusController, :index)
|
||||||
get("/statuses/:id", StatusController, :show)
|
get("/statuses/:id", StatusController, :show)
|
||||||
|
|
1
test/fixtures/tesla_mock/deepl-languages-list.json
vendored
Normal file
1
test/fixtures/tesla_mock/deepl-languages-list.json
vendored
Normal file
|
@ -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}]
|
1
test/fixtures/tesla_mock/deepl-translation.json
vendored
Normal file
1
test/fixtures/tesla_mock/deepl-translation.json
vendored
Normal file
|
@ -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!!!"}]}
|
37
test/pleroma/language/translation/deepl_test.exs
Normal file
37
test/pleroma/language/translation/deepl_test.exs
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
# Pleroma: A lightweight social networking server
|
||||||
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||||
|
# 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
|
28
test/pleroma/language/translation_test.exs
Normal file
28
test/pleroma/language/translation_test.exs
Normal file
|
@ -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
|
|
@ -152,4 +152,13 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
|
||||||
}
|
}
|
||||||
] = result["rules"]
|
] = result["rules"]
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -2483,4 +2483,62 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
|
||||||
|> json_response_and_validate_schema(:not_found)
|
|> json_response_and_validate_schema(:not_found)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
@ -1706,6 +1706,24 @@ defmodule HttpRequestMock do
|
||||||
}}
|
}}
|
||||||
end
|
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
|
def post(url, query, body, headers) do
|
||||||
{:error,
|
{:error,
|
||||||
"Mock response not implemented for POST #{inspect(url)}, #{query}, #{inspect(body)}, #{inspect(headers)}"}
|
"Mock response not implemented for POST #{inspect(url)}, #{query}, #{inspect(body)}, #{inspect(headers)}"}
|
||||||
|
|
43
test/support/translation_mock.ex
Normal file
43
test/support/translation_mock.ex
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
# Pleroma: A lightweight social networking server
|
||||||
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||||
|
# 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
|
Loading…
Reference in a new issue