Add machine translations config

This commit is contained in:
Simon Prévost 2023-02-07 09:21:48 -05:00
parent bb8979d325
commit a37b1831fe
54 changed files with 1201 additions and 342 deletions

View File

@ -29,7 +29,7 @@
{Credo.Check.Readability.TrailingBlankLine},
{Credo.Check.Readability.TrailingWhiteSpace},
{Credo.Check.Readability.VariableNames},
{Credo.Check.Refactor.ABCSize, max_size: 50},
{Credo.Check.Refactor.ABCSize, max_size: 60},
{Credo.Check.Refactor.CaseTrivialMatches},
{Credo.Check.Refactor.CondStatements},
{Credo.Check.Refactor.FunctionArity},
@ -39,7 +39,7 @@
{Credo.Check.Refactor.MapInto, false},
{Credo.Check.Refactor.NegatedConditionsInUnless},
{Credo.Check.Refactor.NegatedConditionsWithElse},
{Credo.Check.Refactor.Nesting},
{Credo.Check.Refactor.Nesting, max_nesting: 4},
{Credo.Check.Refactor.UnlessWithElse},
{Credo.Check.Warning.IExPry},
{Credo.Check.Warning.IoInspect},

View File

@ -129,6 +129,7 @@ Accent provides a default value for every required environment variable. This me
| `SENTRY_DSN` | _none_ | The _secret_ Sentry DSN used to collect API runtime errors |
| `WEBAPP_SENTRY_DSN` | _none_ | The _public_ Sentry DSN used to collect Webapp runtime errors |
| `CANONICAL_URL` | _none_ | The URL of the app. Used in sent emails and to redirect from external services to the app in the authentication flow. |
| `STATIC_URL` | _none_ | The URL of the app. Default to the CANONICAL_URL value. |
| `WEBAPP_SKIP_SUBRESOURCE_INTEGRITY` | _none_ | Remove integrity attributes on link and script tag. Useful when using a proxy that compress resources before serving them. |
| `DATABASE_SSL` | _false_ | If SSL should be used to connect to the database |
| `DATABASE_POOL_SIZE` | _10_ | The size of the pool used by the database connection module |

View File

@ -40,20 +40,10 @@ config :accent, Accent.Repo,
url: get_env("DATABASE_URL") || "postgres://localhost/accent_development",
socket_options: if(ecto_ipv6?, do: [:inet6], else: [])
google_translate_provider = {Accent.MachineTranslations.Adapter.GoogleTranslations, [key: get_env("GOOGLE_TRANSLATIONS_SERVICE_ACCOUNT_KEY")]}
translate_list_provider = if get_env("GOOGLE_TRANSLATIONS_SERVICE_ACCOUNT_KEY"), do: google_translate_provider, else: nil
translate_text_providers = []
translate_text_providers =
if get_env("GOOGLE_TRANSLATIONS_SERVICE_ACCOUNT_KEY"),
do: [google_translate_provider | translate_text_providers],
else: translate_text_providers
config :accent, Accent.MachineTranslations,
translate_list: translate_list_provider,
translate_text: translate_text_providers
default_providers_config: %{
"google_translate" => %{"key" => get_env("GOOGLE_TRANSLATIONS_SERVICE_ACCOUNT_KEY")}
}
providers = []
providers = if get_env("GOOGLE_API_CLIENT_ID"), do: [{:google, {Ueberauth.Strategy.Google, [scope: "email openid"]}} | providers], else: providers

View File

@ -8,6 +8,7 @@ defmodule Accent do
Accent.Endpoint,
Accent.Repo,
{Oban, oban_config()},
Accent.Vault,
{TelemetryUI, Accent.TelemetryUI.config()},
{Phoenix.PubSub, [name: Accent.PubSub, adapter: Phoenix.PubSub.PG2]}
]

View File

@ -20,7 +20,7 @@ defmodule Accent.APITokenManager do
token: Accent.Utils.SecureRandom.urlsafe_base64(70)
}
|> cast(params, [:custom_permissions])
|> validate_subset(:custom_permissions, Enum.map(RoleAbilities.actions_for(:all), &to_string/1))
|> validate_subset(:custom_permissions, Enum.map(RoleAbilities.actions_for(:all, project), &to_string/1))
|> cast_assoc(:user,
with: fn changeset, params ->
changeset

View File

@ -1,16 +1,12 @@
defimpl Canada.Can, for: Accent.User do
alias Accent.{User, Project, Revision, Comment}
alias Accent.{User, Project, Comment}
def can?(%User{permissions: permissions}, action, project_id) when is_binary(project_id) do
validate_role(permissions, action, project_id)
end
def can?(%User{permissions: permissions}, action, %Project{id: project_id}) when is_binary(project_id) do
validate_role(permissions, action, project_id)
end
def can?(%User{permissions: permissions}, action, %Revision{project_id: project_id}) when is_binary(project_id) do
validate_role(permissions, action, project_id)
def can?(%User{permissions: permissions}, action, project = %Project{}) do
validate_role(permissions, action, project)
end
def can?(%User{id: user_id}, _action, %Comment{user_id: comment_user_id}) do
@ -25,6 +21,12 @@ defimpl Canada.Can, for: Accent.User do
def validate_role(nil, _action, _project_id), do: false
def validate_role(permissions, action, project = %Project{}) do
permissions
|> Map.get(project.id)
|> Accent.RoleAbilities.can?(action, project)
end
def validate_role(permissions, action, project_id) do
permissions
|> Map.get(project_id)

View File

@ -1,8 +1,6 @@
defmodule Accent.RoleAbilities do
require Logger
alias Accent.MachineTranslations
@owner_role "owner"
@admin_role "admin"
@bot_role "bot"
@ -71,6 +69,8 @@ defmodule Accent.RoleAbilities do
delete_project_integration
create_version
update_version
save_project_machine_translations_config
delete_project_machine_translations_config
)a ++ @any_actions
@admin_actions ~w(
@ -87,68 +87,65 @@ defmodule Accent.RoleAbilities do
delete_project
)a ++ @developer_actions
@configurable_actions ~w(machine_translations_translate_document machine_translations_translate_file machine_translations_translate_text)a
@actions_with_target ~w(machine_translations_translate)a
def actions_for(:all), do: add_configurable_actions(@admin_actions, @owner_role)
def actions_for({:custom, permissions}), do: permissions
def actions_for(@owner_role), do: add_configurable_actions(@admin_actions, @owner_role)
def actions_for(@admin_role), do: add_configurable_actions(@admin_actions, @admin_role)
def actions_for(@bot_role), do: add_configurable_actions(@bot_actions, @bot_role)
def actions_for(@developer_role), do: add_configurable_actions(@developer_actions, @developer_role)
def actions_for(@reviewer_role), do: add_configurable_actions(@any_actions, @reviewer_role)
def actions_for(role, target)
def add_configurable_actions(actions, role) do
Enum.reduce(@configurable_actions, actions, fn action, actions ->
if can?(role, action), do: [action | actions], else: actions
def actions_for(:all, target), do: add_actions_with_target(@admin_actions, @owner_role, target)
def actions_for({:custom, permissions}, _target), do: permissions
def actions_for(@owner_role, target), do: add_actions_with_target(@admin_actions, @owner_role, target)
def actions_for(@admin_role, target), do: add_actions_with_target(@admin_actions, @admin_role, target)
def actions_for(@bot_role, target), do: add_actions_with_target(@bot_actions, @bot_role, target)
def actions_for(@developer_role, target), do: add_actions_with_target(@developer_actions, @developer_role, target)
def actions_for(@reviewer_role, target), do: add_actions_with_target(@any_actions, @reviewer_role, target)
defp add_actions_with_target(actions, role, target) do
Enum.reduce(@actions_with_target, actions, fn action, actions ->
if can?(role, action, target), do: [action | actions], else: actions
end)
end
def can?(role, :machine_translations_translate_document) when role in [@owner_role, @admin_role, @developer_role] do
MachineTranslations.translate_list_enabled?()
def can?(role, action, target \\ nil)
def can?(_role, :machine_translations_translate, nil), do: false
def can?(_role, :machine_translations_translate, project) do
Accent.MachineTranslations.enabled?(project.machine_translations_config)
end
def can?(_role, :machine_translations_translate_document), do: false
def can?(role, :machine_translations_translate_file) when role in [@owner_role, @admin_role, @developer_role] do
MachineTranslations.translate_list_enabled?()
end
def can?(_role, :machine_translations_translate_file), do: false
def can?(role, :machine_translations_translate_text) when role in [@owner_role, @admin_role, @developer_role] do
MachineTranslations.translate_text_enabled?()
end
def can?(_role, :machine_translations_translate_text), do: false
# Define abilities function at compile time to remove list lookup at runtime
def can?(@owner_role, _action), do: true
def can?(@owner_role, _action, _), do: true
def can?({:custom, permissions}, action) do
def can?({:custom, permissions}, action, _) do
to_string(action) in permissions
end
for action <- @admin_actions do
def can?(@admin_role, unquote(action)), do: true
def can?(@admin_role, unquote(action), _), do: true
end
for action <- @bot_actions do
def can?(@bot_role, unquote(action)), do: true
def can?(@bot_role, unquote(action), _), do: true
end
for action <- @developer_actions do
def can?(@developer_role, unquote(action)), do: true
def can?(@developer_role, unquote(action), _), do: true
end
for action <- @any_actions do
def can?(@reviewer_role, unquote(action)), do: true
def can?(@reviewer_role, unquote(action), _), do: true
end
# Fallback if no permission has been found for the user on the project
def can?(nil, _action), do: false
def can?(nil, _action, _), do: false
def can?(role, action) do
def can?(role, action, nil) do
Logger.warn("Unauthorized action: #{action} for #{role}")
false
end
def can?(role, action, target) do
Logger.warn("Unauthorized action: #{action} for #{role} on #{inspect(target)}")
false
end
end

View File

@ -0,0 +1,32 @@
defmodule Accent.MachineTranslationsConfigManager do
alias Accent.Repo
alias Ecto.Multi
import Ecto.Changeset
def save(project, params) do
params = %{
"machine_translations_config" => %{
"config" => %{
"key" => params[:config_key]
},
"provider" => params[:provider],
"use_platform" => params[:use_platform]
}
}
changeset = cast(project, params, [:machine_translations_config])
Multi.new()
|> Multi.update(:project, changeset)
|> Repo.transaction()
end
def delete(project) do
changeset = put_change(cast(project, %{}, []), :machine_translations_config, nil)
Multi.new()
|> Multi.update(:project, changeset)
|> Repo.transaction()
end
end

View File

@ -1,6 +1,10 @@
defmodule Accent.Project do
use Accent.Schema
defmodule MachineTranslationsConfig do
use Cloak.Ecto.Map, vault: Accent.Vault
end
schema "projects" do
field(:name, :string)
field(:main_color, :string)
@ -21,6 +25,8 @@ defmodule Accent.Project do
has_many(:all_collaborators, Accent.Collaborator)
belongs_to(:language, Accent.Language)
field :machine_translations_config, MachineTranslationsConfig
timestamps()
end

View File

@ -236,7 +236,7 @@ defmodule Accent.TelemetryUI do
header_color: "#28cb87",
primary_color: "#28cb87",
title: "Accent metrics",
share_key: "0123456789123456",
share_key: "012345678912345",
logo: """
<svg
viewBox="0 0 480 480"

21
lib/accent/vault.ex Normal file
View File

@ -0,0 +1,21 @@
defmodule Accent.Vault do
use Cloak.Vault, otp_app: :accent, json_library: Jason
@impl GenServer
def init(config) do
config =
Keyword.put(
config,
:ciphers,
default: {Cloak.Ciphers.AES.GCM, tag: "AES.GCM.V1", key: decode_env!("MACHINE_TRANSLATIONS_VAULT_KEY")}
)
{:ok, config}
end
defp decode_env!(key_name) do
key_name
|> System.get_env()
|> Base.decode64!()
end
end

View File

@ -4,8 +4,12 @@ defmodule Accent.GraphQL.ErrorReporting do
def run(%{result: %{errors: errors}, source: source} = blueprint, _) when not is_nil(errors) do
Logger.error("""
#{operation_name(Absinthe.Blueprint.current_operation(blueprint))}
Errors: #{inspect(errors)}
Source: #{inspect(source)}
Errors:
#{inspect(errors)}
Source:
#{inspect(source)}
""")
{:ok, blueprint}

View File

@ -30,12 +30,12 @@ defmodule Accent.GraphQL.Helpers.Authorization do
def project_authorize(action, func, id \\ :id) do
fn
project = %Project{}, args, info ->
authorize(action, project.id, info, do: func.(project, args, info))
authorize(action, project, info, do: func.(project, args, info))
_, args, info ->
project = Repo.get(Project, args[id]) || %{id: nil}
authorize(action, project.id, info, do: func.(project, args, info))
authorize(action, project, info, do: func.(project, args, info))
end
end

View File

@ -0,0 +1,24 @@
defmodule Accent.GraphQL.Mutations.MachineTranslationsConfig do
use Absinthe.Schema.Notation
import Accent.GraphQL.Helpers.Authorization
alias Accent.GraphQL.Resolvers.MachineTranslationsConfig, as: Resolver
object :machine_translations_config_mutations do
field :save_project_machine_translations_config, :mutated_project do
arg(:project_id, non_null(:id))
arg(:provider, non_null(:string))
arg(:use_platform, non_null(:boolean))
arg(:config_key, :string)
resolve(project_authorize(:save_project_machine_translations_config, &Resolver.save/3, :project_id))
end
field :delete_project_machine_translations_config, :mutated_project do
arg(:project_id, non_null(:id))
resolve(project_authorize(:delete_project_machine_translations_config, &Resolver.delete/3, :project_id))
end
end
end

View File

@ -1,8 +1,8 @@
defmodule Accent.GraphQL.Plugins.Authorization do
defmacro authorize(action, id, info, do: do_clause) do
defmacro authorize(action, target, info, do: do_clause) do
quote do
with current_user when not is_nil(current_user) <- unquote(info).context[:conn].assigns[:current_user],
true <- Canada.Can.can?(current_user, unquote(action), unquote(id)) do
true <- Canada.Can.can?(current_user, unquote(action), unquote(target)) do
unquote(do_clause)
else
_ -> {:ok, nil}

View File

@ -17,7 +17,10 @@ defmodule Accent.GraphQL.Resolvers.MachineTranslation do
source_language = slug_language(project.id, args.source_language_slug)
target_language = slug_language(project.id, args.target_language_slug)
{:ok, MachineTranslations.translate_text(args.text, source_language, target_language)}
case MachineTranslations.translate([%{value: args.text}], source_language, target_language, project.machine_translations_config) do
[%{value: text}] -> {:ok, %{text: text}}
_ -> {:ok, nil}
end
end
defp slug_language(project_id, slug) do

View File

@ -0,0 +1,29 @@
defmodule Accent.GraphQL.Resolvers.MachineTranslationsConfig do
alias Accent.{
MachineTranslationsConfigManager,
Plugs.GraphQLContext,
Project
}
@spec save(Project.t(), any(), GraphQLContext.t()) :: {:ok, Project.t() | nil}
def save(project, args, _info) do
case MachineTranslationsConfigManager.save(project, args) do
{:ok, %{project: project}} ->
{:ok, %{project: project, errors: nil}}
{:error, _reason, _, _} ->
{:ok, %{project: nil, errors: ["unprocessable_entity"]}}
end
end
@spec delete(Project.t(), any(), GraphQLContext.t()) :: {:ok, Project.t() | nil}
def delete(project, _args, _info) do
case MachineTranslationsConfigManager.delete(project) do
{:ok, %{project: project}} ->
{:ok, %{project: project, errors: nil}}
{:error, _reason, _, _} ->
{:ok, %{project: nil, errors: ["unprocessable_entity"]}}
end
end
end

View File

@ -20,7 +20,7 @@ defmodule Accent.GraphQL.Resolvers.Permission do
permissions =
context[:conn].assigns[:current_user].permissions
|> Map.get(project.id)
|> Accent.RoleAbilities.actions_for()
|> Accent.RoleAbilities.actions_for(project)
{:ok, permissions}
end

View File

@ -56,6 +56,7 @@ defmodule Accent.GraphQL.Schema do
mutation do
# Mutation types
import_types(Accent.GraphQL.Mutations.APIToken)
import_types(Accent.GraphQL.Mutations.MachineTranslationsConfig)
import_types(Accent.GraphQL.Mutations.Translation)
import_types(Accent.GraphQL.Mutations.Comment)
import_types(Accent.GraphQL.Mutations.Collaborator)
@ -67,6 +68,7 @@ defmodule Accent.GraphQL.Schema do
import_types(Accent.GraphQL.Mutations.Version)
import_fields(:api_token_mutations)
import_fields(:machine_translations_config_mutations)
import_fields(:comment_mutations)
import_fields(:translation_mutations)
import_fields(:collaborator_mutations)

View File

@ -12,10 +12,15 @@ defmodule Accent.GraphQL.Types.Project do
end
object :project_translated_text do
field(:id, non_null(:id))
field(:text, :string)
end
object :machine_translations_config do
field(:provider, non_null(:string))
field(:use_platform, non_null(:boolean))
field(:use_config_key, non_null(:boolean))
end
object :project do
field(:id, :id)
field(:name, :string)
@ -27,6 +32,21 @@ defmodule Accent.GraphQL.Types.Project do
field(:conflicts_count, non_null(:integer))
field(:reviewed_count, non_null(:integer))
field(:machine_translations_config, :machine_translations_config,
resolve: fn project, _, _ ->
if project.machine_translations_config do
{:ok,
%{
provider: project.machine_translations_config["provider"],
use_platform: project.machine_translations_config["use_platform"],
use_config_key: not is_nil(project.machine_translations_config["config"]["key"])
}}
else
{:ok, nil}
end
end
)
field :last_activity, :activity do
arg(:action, :string)
resolve(&Accent.GraphQL.Resolvers.Project.last_activity/3)
@ -34,11 +54,11 @@ defmodule Accent.GraphQL.Types.Project do
field(:is_file_operations_locked, non_null(:boolean), resolve: field_alias(:locked_file_operations))
field :translated_text, list_of(non_null(:project_translated_text)) do
field :translated_text, :project_translated_text do
arg(:text, non_null(:string))
arg(:source_language_slug, non_null(:string))
arg(:target_language_slug, non_null(:string))
resolve(project_authorize(:machine_translations_translate_text, &Accent.GraphQL.Resolvers.MachineTranslation.translate_text/3))
resolve(project_authorize(:machine_translations_translate, &Accent.GraphQL.Resolvers.MachineTranslation.translate_text/3))
end
field :lint_translations, list_of(non_null(:lint_translation)) do

View File

@ -1,4 +0,0 @@
defmodule Accent.MachineTranslations.Adapter do
@callback translate_text(String.t(), String.t(), String.t(), Keyword.t()) :: {:ok, Accent.MachineTranslations.TranslatedText.t()} | any()
@callback translate_list([String.t()], String.t(), String.t(), Keyword.t()) :: {:ok, [Accent.MachineTranslations.TranslatedText.t()]} | any()
end

View File

@ -1,198 +0,0 @@
defmodule Accent.MachineTranslations.Adapter.GoogleTranslations do
@behaviour Accent.MachineTranslations.Adapter
alias Accent.MachineTranslations.TranslatedText
alias Tesla.Middleware
@supported_languages ~w(
af
sq
am
ar
hy
az
eu
be
bn
bs
bg
ca
ceb
zh-CN
zh
zh-TW
co
hr
cs
da
nl
en
eo
et
fi
fr
fy
gl
ka
de
el
gu
ht
ha
haw
he
iw
hi
hmn
hu
is
ig
id
ga
it
ja
jv
kn
kk
km
rw
ko
ku
ky
lo
lv
lt
lb
mk
mg
ms
ml
mt
mi
mr
mn
my
ne
no
ny
or
ps
fa
pl
pt
pa
ro
ru
sm
gd
sr
st
sn
sd
si
sk
sl
so
es
su
sw
sv
tl
tg
ta
tt
te
th
tr
tk
uk
ur
ug
uz
vi
cy
xh
yi
yo
zu
)
@impl Accent.MachineTranslations.Adapter
def translate_text(content, source, target, config) do
case translate_list([content], source, target, config) do
{:ok, [translation]} -> {:ok, translation}
error -> error
end
end
@impl Accent.MachineTranslations.Adapter
def translate_list(contents, source, target, config) do
target = to_language_code(target)
source = to_language_code(source)
case Tesla.post(client(config), ":translateText", %{contents: contents, mimeType: "text/plain", sourceLanguageCode: source, targetLanguageCode: target}) do
{:ok, %{body: %{"translations" => translations}}} ->
{:ok, Enum.map(translations, &%TranslatedText{text: &1["translatedText"]})}
{:ok, %{status: status, body: body}} when status > 201 ->
{:error, body}
error ->
error
end
end
defmodule Auth do
@behaviour Tesla.Middleware
@impl Tesla.Middleware
def call(env, next, opts) do
case auth_enabled?() && Goth.Token.for_scope(opts[:scope]) do
{:ok, %{token: token, type: type}} ->
env
|> Tesla.put_header("authorization", type <> " " <> token)
|> Tesla.run(next)
_ ->
Tesla.run(env, next)
end
end
defp auth_enabled? do
!Application.get_env(:goth, :disabled)
end
end
defp client(config) do
project_id = project_id_from_config(config)
middlewares =
List.flatten([
{Middleware.Timeout, [timeout: :infinity]},
{Middleware.BaseUrl, "https://translation.googleapis.com/v3/projects/#{project_id}"},
{Auth, [scope: "https://www.googleapis.com/auth/cloud-translation"]},
Middleware.DecodeJson,
Middleware.EncodeJson,
Middleware.Logger,
Middleware.Telemetry
])
Tesla.client(middlewares)
end
defp project_id_from_config(config) do
key = Keyword.fetch!(config, :key)
Jason.decode!(key)["project_id"]
end
defp to_language_code(language) when language in @supported_languages do
language
end
defp to_language_code(language) do
case String.split(language, "-", parts: 2) do
[prefix, _] when prefix in @supported_languages -> prefix
_ -> :unsupported
end
end
end

View File

@ -1,53 +1,61 @@
defmodule Accent.MachineTranslations do
alias Accent.MachineTranslations.Provider
alias Accent.MachineTranslations.TranslatedText
@translation_chunk_size 500
@translation_chunk_size 100
@untranslatable_string_length_limit 2000
@untranslatable_placeholder "_"
@untranslatable_placeholder "__^__"
def translate_list_enabled? do
not is_nil(provider_from_service(:translate_list))
def enabled?(config) do
provider = provider_from_config(config)
Provider.enabled?(provider)
end
def translate_text_enabled? do
not Enum.empty?(provider_from_service(:translate_text))
end
@spec translate([map()], String.t(), String.t(), struct()) :: [map()]
def translate(entries, source_language, target_language, config) do
provider = provider_from_config(config)
def translate_text(text, source_language, target_language) do
:translate_text
|> provider_from_service()
|> Enum.map(fn {module, config} ->
case module.translate_text(text, source_language.slug, target_language.slug, config) do
{:ok, %TranslatedText{} = translated} -> %{translated | id: to_id(text)}
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
end
def translate_entries(entries, source_language, target_language) do
entries
|> Enum.map(&filter_long_value/1)
|> Enum.chunk_every(@translation_chunk_size)
|> Enum.flat_map(fn entries ->
values = Enum.map(entries, &filter_long_value/1)
|> Enum.flat_map(fn values ->
case Provider.translate(provider, values, source_language.slug, target_language.slug) do
{:ok, translated_values} ->
entries
|> Enum.zip(translated_values)
|> Enum.map(fn {entry, translated_text} ->
case translated_text do
%TranslatedText{text: @untranslatable_placeholder} -> entry
%TranslatedText{text: text} -> %{entry | value: text}
_ -> entry
end
end)
with {module, config} <- provider_from_service(:translate_list),
{:ok, translated_values} <- module.translate_list(values, source_language.slug, target_language.slug, config) do
entries
|> Enum.zip(translated_values)
|> Enum.map(fn {entry, translated_text} ->
case translated_text do
%TranslatedText{text: @untranslatable_placeholder} -> entry
%TranslatedText{text: text} -> %{entry | value: text}
_ -> entry
end
end)
else
_ -> entries
_ ->
entries
end
end)
end
defp provider_from_config(nil), do: %Provider.NotImplemented{}
defp provider_from_config(machine_translations_config) do
struct_module =
case machine_translations_config["provider"] do
"google_translate" -> Provider.GoogleTranslate
"deepl" -> Provider.Deepl
_ -> Provider.NotImplemented
end
struct!(struct_module, config: fetch_config(machine_translations_config))
end
defp fetch_config(%{"provider" => provider, "use_platform" => true}) do
Map.get(Application.get_env(:accent, __MODULE__)[:default_providers_config], provider)
end
defp fetch_config(%{"config" => config}), do: config
defp filter_long_value(%{value: value}) when value in ["", nil], do: @untranslatable_placeholder
defp filter_long_value(entry) do
@ -57,15 +65,4 @@ defmodule Accent.MachineTranslations do
entry.value
end
end
defp to_id(text) do
:md5
|> :crypto.hash(text)
|> :base64.encode_to_string()
|> to_string()
end
defp provider_from_service(service) do
Application.get_env(:accent, __MODULE__)[service]
end
end

View File

@ -0,0 +1,96 @@
defmodule Accent.MachineTranslations.Provider.Deepl do
defstruct config: nil
defimpl Accent.MachineTranslations.Provider do
@supported_languages ~w(
bg
cs
da
de
el
en
es
et
fi
fr
hu
id
it
ja
ko
lt
lv
nb
nl
pl
pt
ro
ru
sk
sl
sv
tr
uk
zh
)
alias Accent.MachineTranslations.TranslatedText
alias Tesla.Middleware
def enabled?(%{config: %{"key" => key}}), do: not is_nil(key)
def enabled?(_), do: false
def translate(provider, contents, source, target) do
target = String.upcase(to_language_code(target))
source = String.upcase(to_language_code(source))
case Tesla.post(client(provider.config["key"]), "translate", %{text: contents, source_lang: source, target_lang: target}) do
{:ok, %{body: %{"translations" => translations}}} ->
{:ok, Enum.map(translations, &%TranslatedText{text: &1["text"]})}
{:ok, %{status: status, body: body}} when status > 201 ->
{:error, body}
error ->
error
end
end
defmodule Auth do
@behaviour Tesla.Middleware
@impl Tesla.Middleware
def call(env, next, opts) do
env
|> Tesla.put_header("authorization", "DeepL-Auth-Key #{opts[:key]}")
|> Tesla.run(next)
end
end
defp client(key) do
middlewares =
List.flatten([
{Middleware.Timeout, [timeout: :infinity]},
{Middleware.BaseUrl, "https://api-free.deepl.com/v2/"},
{Auth, [key: key]},
Middleware.DecodeJson,
Middleware.EncodeJson,
Middleware.Logger,
Middleware.Telemetry
])
Tesla.client(middlewares)
end
defp to_language_code(language) when language in @supported_languages do
language
end
defp to_language_code(language) do
case String.split(language, "-", parts: 2) do
[prefix, _] when prefix in @supported_languages -> prefix
_ -> :unsupported
end
end
end
end

View File

@ -0,0 +1,196 @@
defmodule Accent.MachineTranslations.Provider.GoogleTranslate do
defstruct config: nil
defimpl Accent.MachineTranslations.Provider do
@supported_languages ~w(
af
sq
am
ar
hy
az
eu
be
bn
bs
bg
ca
ceb
zh-CN
zh
zh-TW
co
hr
cs
da
nl
en
eo
et
fi
fr
fy
gl
ka
de
el
gu
ht
ha
haw
he
iw
hi
hmn
hu
is
ig
id
ga
it
ja
jv
kn
kk
km
rw
ko
ku
ky
lo
lv
lt
lb
mk
mg
ms
ml
mt
mi
mr
mn
my
ne
no
ny
or
ps
fa
pl
pt
pa
ro
ru
sm
gd
sr
st
sn
sd
si
sk
sl
so
es
su
sw
sv
tl
tg
ta
tt
te
th
tr
tk
uk
ur
ug
uz
vi
cy
xh
yi
yo
zu
)
alias Accent.MachineTranslations.TranslatedText
alias Tesla.Middleware
def enabled?(%{config: %{"key" => key}}) do
not is_nil(key) and match?({:ok, %{"project_id" => _}}, Jason.decode(key))
end
def enabled?(_), do: false
def translate(provider, contents, source, target) do
target = to_language_code(target)
source = to_language_code(source)
case Tesla.post(client(provider.config), ":translateText", %{contents: contents, mimeType: "text/plain", sourceLanguageCode: source, targetLanguageCode: target}) do
{:ok, %{body: %{"translations" => translations}}} ->
{:ok, Enum.map(translations, &%TranslatedText{text: &1["translatedText"]})}
{:ok, %{status: status, body: body}} when status > 201 ->
{:error, body}
error ->
error
end
end
defmodule Auth do
@behaviour Tesla.Middleware
@impl Tesla.Middleware
def call(env, next, opts) do
case auth_enabled?() && Goth.Token.for_scope(opts[:scope]) do
{:ok, %{token: token, type: type}} ->
env
|> Tesla.put_header("authorization", type <> " " <> token)
|> Tesla.run(next)
_ ->
Tesla.run(env, next)
end
end
defp auth_enabled? do
!Application.get_env(:goth, :disabled)
end
end
defp client(config) do
project_id = project_id_from_config(config)
middlewares =
List.flatten([
{Middleware.Timeout, [timeout: :infinity]},
{Middleware.BaseUrl, "https://translation.googleapis.com/v3/projects/#{project_id}"},
{Auth, [scope: "https://www.googleapis.com/auth/cloud-translation"]},
Middleware.DecodeJson,
Middleware.EncodeJson,
Middleware.Logger,
Middleware.Telemetry
])
Tesla.client(middlewares)
end
defp project_id_from_config(config) do
key = Map.fetch!(config, "key")
Jason.decode!(key)["project_id"]
end
defp to_language_code(language) when language in @supported_languages do
language
end
defp to_language_code(language) do
case String.split(language, "-", parts: 2) do
[prefix, _] when prefix in @supported_languages -> prefix
_ -> :unsupported
end
end
end
end

View File

@ -0,0 +1,8 @@
defmodule Accent.MachineTranslations.Provider.NotImplemented do
defstruct config: nil
defimpl Accent.MachineTranslations.Provider do
def enabled?(_provider), do: false
def translate(_provider, entries, _source_language, _target_language), do: entries
end
end

View File

@ -0,0 +1,4 @@
defprotocol Accent.MachineTranslations.Provider do
def enabled?(provider)
def translate(provider, entries, source_language, target_language)
end

View File

@ -1,3 +1,3 @@
defmodule Accent.MachineTranslations.TranslatedText do
defstruct id: nil, text: nil
defstruct text: nil
end

View File

@ -7,13 +7,12 @@ defmodule Accent.MachineTranslationsController do
alias Accent.Scopes.Revision, as: RevisionScope
alias Accent.Scopes.Translation, as: TranslationScope
plug(Plug.Assign, %{canary_action: :machine_translations_translate_file} when action === :translate_file)
plug(Plug.Assign, %{canary_action: :machine_translations_translate_document} when action === :translate_document)
plug(Plug.Assign, %{canary_action: :machine_translations_translate})
plug(:load_and_authorize_resource, model: Project, id_name: "project_id")
plug(:fetch_master_revision)
plug(:load_resource, model: Language, id_name: "language", as: :source_language)
plug(:load_resource, model: Language, id_name: "to_language_id", as: :target_language)
plug(:load_resource, model: Document, id_name: "document_id", as: :document, only: [:machine_translations_translate_document])
plug(:load_resource, model: Document, id_name: "document_id", as: :document, only: [:machine_translations_translate])
plug(:fetch_order when action === :translate_document)
plug(:fetch_format when action === :translate_document)
plug(Accent.Plugs.MovementContextParser when action === :translate_file)
@ -54,10 +53,11 @@ defmodule Accent.MachineTranslationsController do
|> Enum.map(&Translation.to_langue_entry(&1, nil, true, conn.assigns[:source_language].slug))
entries =
Accent.MachineTranslations.translate_entries(
Accent.MachineTranslations.translate(
entries,
conn.assigns[:source_language],
conn.assigns[:target_language]
conn.assigns[:target_language],
conn.assigns[:project].machine_translations_config
)
%{render: render} =
@ -97,10 +97,11 @@ defmodule Accent.MachineTranslationsController do
"""
def translate_file(conn, _params) do
entries =
Accent.MachineTranslations.translate_entries(
Accent.MachineTranslations.translate(
conn.assigns[:movement_context].entries,
conn.assigns[:source_language],
conn.assigns[:target_language]
conn.assigns[:target_language],
conn.assigns[:project].machine_translations_config
)
%{render: render} =

View File

@ -54,6 +54,7 @@ defmodule Accent.Mixfile do
{:ecto_sql, "~> 3.2"},
{:ecto_dev_logger, "~> 0.4"},
{:postgrex, "~> 0.14"},
{:cloak_ecto, "~> 1.2"},
# Phoenix data helpers
{:phoenix_ecto, "~> 4.0"},
@ -100,7 +101,7 @@ defmodule Accent.Mixfile do
{:new_relic_agent, "~> 1.27"},
{:new_relic_absinthe, "~> 0.0"},
{:telemetry, "~> 1.0", override: true},
{:telemetry_ui, path: "../mirego/telemetry_ui"},
{:telemetry_ui, "~> 2.0"},
# Mock testing
{:mox, "~> 0.3", only: :test},

View File

@ -9,6 +9,8 @@
"canary": {:hex, :canary, "1.1.1", "4138d5e05db8497c477e4af73902eb9ae06e49dceaa13c2dd9f0b55525ded48b", [:mix], [{:canada, "~> 1.0.1", [hex: :canada, repo: "hexpm", optional: false]}, {:ecto, ">= 1.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f348d9848693c830a65b707bba9e4dfdd6434e8c356a8d4477e4535afb0d653b"},
"castore": {:hex, :castore, "0.1.22", "4127549e411bedd012ca3a308dede574f43819fe9394254ca55ab4895abfa1a2", [:mix], [], "hexpm", "c17576df47eb5aa1ee40cc4134316a99f5cad3e215d5c77b8dd3cfef12a22cac"},
"certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"},
"cloak": {:hex, :cloak, "1.1.2", "7e0006c2b0b98d976d4f559080fabefd81f0e0a50a3c4b621f85ceeb563e80bb", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "940d5ac4fcd51b252930fd112e319ea5ae6ab540b722f3ca60a85666759b9585"},
"cloak_ecto": {:hex, :cloak_ecto, "1.2.0", "e86a3df3bf0dc8980f70406bcb0af2858bac247d55494d40bc58a152590bd402", [:mix], [{:cloak, "~> 1.1.1", [hex: :cloak, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "8bcc677185c813fe64b786618bd6689b1707b35cd95acaae0834557b15a0c62f"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
"corsica": {:hex, :corsica, "1.3.0", "bbec02ccbeca1fdf44ee23b25a8ae32f7c6c28fc127ef8836dd8420e8f65bd9b", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "8847ec817554047e9aa6d9933539cacb10c4ee60b58e0c15c3b380c5b737b35f"},
@ -61,7 +63,7 @@
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.18.11", "c50eac83dae6b5488859180422dfb27b2c609de87f4aa5b9c926ecd0501cd44f", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "76c99a0ffb47cd95bf06a917e74f282a603f3e77b00375f3c2dd95110971b102"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"},
"phoenix_template": {:hex, :phoenix_template, "1.0.0", "c57bc5044f25f007dc86ab21895688c098a9f846a8dda6bc40e2d0ddc146e38f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "1b066f99a26fd22064c12b2600a9a6e56700f591bf7b20b418054ea38b4d4357"},
"phoenix_template": {:hex, :phoenix_template, "1.0.1", "85f79e3ad1b0180abb43f9725973e3b8c2c3354a87245f91431eec60553ed3ef", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "157dc078f6226334c91cb32c1865bf3911686f8bcd6bcff86736f6253e6993ee"},
"phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"},
"php_assoc_map": {:hex, :php_assoc_map, "0.5.2", "8b55283c2ffa762f8703cb30ef40085bf5c06ee08d5a82e38405fa4b949b2a6b", [:mix], [], "hexpm", "c95f27f74075cdd5908e4217db96887709334a9fe1da30fc98706c225f3ceafd"},
"plug": {:hex, :plug, "1.10.4", "41eba7d1a2d671faaf531fa867645bd5a3dce0957d8e2a3f398ccff7d2ef017f", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad1e233fe73d2eec56616568d260777b67f53148a999dc2d048f4eb9778fe4a0"},

View File

@ -0,0 +1,9 @@
defmodule Accent.Repo.Migrations.AddMachineTranslationsConfigOnProject do
use Ecto.Migration
def change do
alter table(:projects) do
add(:machine_translations_config, :binary)
end
end
end

View File

@ -368,7 +368,17 @@
"manage_languages": "Manage languages",
"manage_languages_text": "Add/remove languages translated in the project",
"jipt": "Just In Place Translations",
"jipt_text": "Live translation on any applications, with builtin Javascript library"
"jipt_text": "Live translation on any applications, with builtin Javascript library",
"machine_translations": "Machine translations",
"machine_translations_text": "Configure an external service to translate languages automatically"
},
"machine_translations": {
"title": "Machine translations",
"text": "Add a valid configuration to enable translating directly in the Review page. You can also translate your projects file as well as arbirtary files that you upload on Accent.",
"use_platform_label": "Use the platform config if available",
"config_key_help_present": "Config key has been set but the value will never be exposed via the API.",
"config_key_help_not_present": "Once saved, the config key will never be shown.",
"config_key_placeholder": "Paste the config key here…"
},
"delete_form": {
"title": "Danger zone",
@ -868,6 +878,10 @@
"BOT": "API Token",
"REVIEWER": "Reviewer"
},
"machine_translations_providers": {
"google_translate": "Google Translate",
"deepl": "DeepL"
},
"integration_services": {
"DISCORD": "Discord",
"GITHUB": "GitHub",
@ -976,6 +990,8 @@
"integration_update_success": "The integration has been updated with success",
"integration_remove_error": "The integration could not be removed",
"integration_remove_success": "The integration has been removed with success",
"machine_translations_config_error": "The machine translations config could not be saved",
"machine_translations_config_success": "The machine translations config has been saved with success",
"update_error": "The project could not be updated",
"update_success": "The project has been updated with success",
"delete_error": "The project could not be deleted",
@ -994,6 +1010,9 @@
"jipt": {
"loading_content": "Fetching projects Just In Place Translations settings…"
},
"machine_translations": {
"loading_content": "Fetching projects machine translations settings…"
},
"service_integrations": {
"loading_content": "Fetching projects service & integrations…"
}

View File

@ -22,7 +22,7 @@
{{inline-svg "assets/eye.svg" local-class="item-actions-icon"}}
</LinkTo>
{{#if (get @permissions "machine_translations_translate_text")}}
{{#if (get @permissions "machine_translations_translate")}}
<button
title={{t "components.conflict_item.translate_tooltip"}}
class="tooltip tooltip--top"

View File

@ -14,6 +14,7 @@
{{else}}
<div local-class="form">
{{inline-svg this.logoService local-class="logo"}}
<AccSelect
@searchEnabled={{false}}
@selected={{this.serviceValue}}

View File

@ -60,6 +60,7 @@
opacity: 0.6;
stroke: var(--color-black);
&.link-icon--machine-translations path:nth-child(2),
&.link-icon--jipt path:nth-child(2),
&.link-icon--integrations circle:nth-child(3),
&.link-icon--badges path:nth-child(2) {

View File

@ -23,5 +23,11 @@
<strong>{{t "components.project_settings.links_list.jipt"}}</strong>
<p>{{t "components.project_settings.links_list.jipt_text"}}</p>
</LinkTo>
<LinkTo @route="logged-in.project.edit.machine-translations" local-class="link">
{{inline-svg "assets/machine-translations.svg" local-class="link-icon link-icon--machine-translations"}}
<strong>{{t "components.project_settings.links_list.machine_translations"}}</strong>
<p>{{t "components.project_settings.links_list.machine_translations_text"}}</p>
</LinkTo>
</div>
</div>

View File

@ -0,0 +1,112 @@
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import Component from '@glimmer/component';
import {tracked} from '@glimmer/tracking';
import GlobalState from 'accent-webapp/services/global-state';
import FlashMessages from 'ember-cli-flash/services/flash-messages';
import {dropTask} from 'ember-concurrency-decorators';
import {taskFor} from 'ember-concurrency-ts';
import IntlService from 'ember-intl/services/intl';
interface Args {
project: any;
onDelete: () => void;
onSave: ({
provider,
usePlatform,
configKey,
}: {
provider: string;
usePlatform: boolean;
configKey: string | null;
}) => Promise<any>;
}
const PROVIDERS = ['google_translate', 'deepl'];
const LOGOS = {
deepl: 'assets/machine_translations_providers/deepl.svg',
google_translate:
'assets/machine_translations_providers/google_translate.svg',
};
export default class ProjectSettingsMachineTranslations extends Component<Args> {
@service('global-state')
globalState: GlobalState;
@service('flash-messages')
flashMessages: FlashMessages;
@service('intl')
intl: IntlService;
@tracked
provider =
this.args.project.machineTranslationsConfig?.provider || 'google_translate';
@tracked
usePlatform =
this.args.project.machineTranslationsConfig?.usePlatform || false;
@tracked
configKey: any;
get providerValue() {
return this.mappedProviders.find(({value}) => value === this.provider);
}
get isSubmitting() {
return taskFor(this.submit).isRunning;
}
get isRemoving() {
return taskFor(this.remove).isRunning;
}
get mappedProviders() {
return PROVIDERS.map((value) => {
return {
label: this.intl.t(`general.machine_translations_providers.${value}`),
value,
};
});
}
get logoProvider() {
const provider: keyof typeof LOGOS = this.provider as any;
return LOGOS[provider];
}
@action
setProvider({value}: {value: string}) {
this.provider = value;
}
@action
onUsePlatformChange(event: InputEvent) {
const checked = (event.target as HTMLInputElement).checked;
if (checked) this.configKey = null;
this.usePlatform = checked;
}
@action
onConfigKeyChange(event: InputEvent) {
this.configKey = (event.target as HTMLInputElement).value;
}
@dropTask
*submit() {
yield this.args.onSave({
provider: this.provider,
usePlatform: this.usePlatform,
configKey: this.configKey,
});
}
@dropTask
*remove() {
yield this.args.onDelete();
}
}

View File

@ -0,0 +1,76 @@
.project-settings-form {
margin-top: 25px;
max-width: 550px;
}
.form {
margin-top: 25px;
padding: 12px;
border-radius: 3px;
background: var(--background-light);
border: 1px solid var(--background-light-highlight);
}
.select {
flex-grow: 1;
max-width: 200px;
}
.select select {
background: var(--body-background);
border: 1px solid var(--background-light-highlight);
padding: 7px 10px;
font-weight: bold;
font-size: 13px;
}
.text {
max-width: 490px;
margin: 10px 0 15px;
font-size: 13px;
font-style: italic;
}
.logo {
width: 25px;
}
.config-key-help {
margin-top: 10px;
font-size: 12px;
color: var(--color-warning);
padding: 6px 0;
font-weight: bold;
}
.provider {
display: flex;
align-items: center;
gap: 16px;
}
.textInput {
@extend %textInput;
flex-grow: 1;
flex-shrink: 0;
padding: 8px 10px;
margin-right: 10px;
width: 100%;
font-family: var(--font-monospace);
font-size: 12px;
}
.platform-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: bold;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}

View File

@ -0,0 +1,75 @@
<div local-class="project-settings-form">
<ProjectSettings::Title
@title={{t "components.project_settings.machine_translations.title"}}
/>
<p local-class="text">
{{t "components.project_settings.machine_translations.text"}}
</p>
<div local-class="form">
<div local-class="provider">
{{inline-svg this.logoProvider local-class="logo"}}
<AccSelect
local-class="select"
@searchEnabled={{false}}
@selected={{this.providerValue}}
@renderInPlace={{true}}
@options={{this.mappedProviders}}
@onchange={{fn this.setProvider}}
/>
<label local-class="platform-checkbox">
<input
value={{this.usePlatform}}
type="checkbox"
checked={{this.usePlatform}}
{{on "change" (fn this.onUsePlatformChange)}}
>
{{t "components.project_settings.machine_translations.use_platform_label"}}
</label>
</div>
{{#unless this.usePlatform}}
{{#if @project.machineTranslationsConfig.useConfigKey}}
<p local-class="config-key-help">
{{t "components.project_settings.machine_translations.config_key_help_present"}}
</p>
{{else}}
<p local-class="config-key-help">
{{t "components.project_settings.machine_translations.config_key_help_not_present"}}
</p>
{{/if}}
<textarea
placeholder={{if @project.machineTranslationsConfig.useConfigKey "••••••••••••••" (t "components.project_settings.machine_translations.config_key_placeholder")}}
local-class="textInput"
rows={{if @project.machineTranslationsConfig.useConfigKey 1 8}}
{{on "change" (fn this.onConfigKeyChange)}}
>{{this.configKey}}</textarea>
{{/unless}}
</div>
<div local-class="actions">
{{#if @project.machineTranslationsConfig}}
<AsyncButton
@onClick={{perform this.remove}}
@loading={{this.isRemoving}}
class="button button--red button--borderless"
>
{{t "components.project_settings.integrations.delete"}}
</AsyncButton>
{{/if}}
<AsyncButton
@onClick={{perform this.submit}}
@loading={{this.isSubmitting}}
class="button button--filled"
>
{{t "components.project_settings.integrations.save"}}
</AsyncButton>
</div>
</div>

View File

@ -0,0 +1,93 @@
import {inject as service} from '@ember/service';
import {action} from '@ember/object';
import {readOnly, equal, and} from '@ember/object/computed';
import Controller from '@ember/controller';
import IntlService from 'ember-intl/services/intl';
import GlobalState from 'accent-webapp/services/global-state';
import FlashMessages from 'ember-cli-flash/services/flash-messages';
import ApolloMutate from 'accent-webapp/services/apollo-mutate';
import machineTranslationsConfigSaveQuery from 'accent-webapp/queries/save-project-machine-translations-config';
import machineTranslationsConfigDeleteQuery from 'accent-webapp/queries/delete-project-machine-translations-config';
const FLASH_MESSAGE_PREFIX = 'pods.project.edit.flash_messages.';
const FLASH_MESSAGE_CONFIG_SUCCESS = `${FLASH_MESSAGE_PREFIX}machine_translations_config_success`;
const FLASH_MESSAGE_CONFIG_ERROR = `${FLASH_MESSAGE_PREFIX}machine_translations_config_error`;
const FLASH_MESSAGE_CONFIG_REMOVE_SUCCESS = `${FLASH_MESSAGE_PREFIX}machine_translations_config_remove_success`;
const FLASH_MESSAGE_CONFIG_REMOVE_ERROR = `${FLASH_MESSAGE_PREFIX}machine_translations_config_remove_error`;
export default class MachineTranslationController extends Controller {
@service('intl')
intl: IntlService;
@service('global-state')
globalState: GlobalState;
@service('apollo-mutate')
apolloMutate: ApolloMutate;
@service('flash-messages')
flashMessages: FlashMessages;
@readOnly('model.project')
project: any;
@readOnly('globalState.permissions')
permissions: any;
@equal('model.project.name', undefined)
emptyData: boolean;
@and('emptyData', 'model.loading')
showLoading: boolean;
@action
async saveMachineTranslationsConfig(config: {
provider: string;
usePlatform: boolean;
configKey: string | null;
}) {
return this.mutateResource({
mutation: machineTranslationsConfigSaveQuery,
successMessage: FLASH_MESSAGE_CONFIG_SUCCESS,
errorMessage: FLASH_MESSAGE_CONFIG_ERROR,
variables: {projectId: this.project.id, ...config},
});
}
@action
async deleteMachineTranslationsConfig() {
return this.mutateResource({
mutation: machineTranslationsConfigDeleteQuery,
successMessage: FLASH_MESSAGE_CONFIG_REMOVE_SUCCESS,
errorMessage: FLASH_MESSAGE_CONFIG_REMOVE_ERROR,
variables: {projectId: this.project.id},
});
}
private async mutateResource({
mutation,
variables,
successMessage,
errorMessage,
}: {
mutation: any;
variables: any;
successMessage: string;
errorMessage: string;
}) {
const response = await this.apolloMutate.mutate({
mutation,
variables,
refetchQueries: ['ProjectMachineTranslationsConfig'],
});
if (response.errors) {
this.flashMessages.error(this.intl.t(errorMessage));
} else {
this.flashMessages.success(this.intl.t(successMessage));
}
return response;
}
}

View File

@ -0,0 +1,44 @@
import {inject as service} from '@ember/service';
import Route from '@ember/routing/route';
import projectMachineTranslationsConfigQuery from 'accent-webapp/queries/project-machine-translations-config';
import RouteParams from 'accent-webapp/services/route-params';
import ApolloSubscription, {
Subscription,
} from 'accent-webapp/services/apollo-subscription';
import Transition from '@ember/routing/-private/transition';
export default class MachineTranslationsRoute extends Route {
@service('apollo-subscription')
apolloSubscription: ApolloSubscription;
@service('route-params')
routeParams: RouteParams;
subscription: Subscription;
model(_params: any, transition: Transition) {
this.subscription = this.apolloSubscription.graphql(
() => this.modelFor(this.routeName),
projectMachineTranslationsConfigQuery,
{
props: (data) => ({
project: data.viewer.project,
}),
options: {
fetchPolicy: 'cache-and-network',
variables: {
projectId: this.routeParams.fetch(transition, 'logged-in.project')
.projectId,
},
},
}
);
return this.subscription.currentResult();
}
deactivate() {
this.apolloSubscription.clearSubscription(this.subscription);
}
}

View File

@ -0,0 +1,11 @@
{{#if this.showLoading}}
<LoadingContent @label={{t "pods.project.edit.machine_translations.loading_content"}} />
{{else}}
<ProjectSettings::BackLink @project={{this.project}} />
<ProjectSettings::MachineTranslations
@project={{this.project}}
@onSave={{fn this.saveMachineTranslationsConfig}}
@onDelete={{fn this.deleteMachineTranslationsConfig}}
/>
{{/if}}

View File

@ -22,7 +22,7 @@
<DocumentsAddButton @project={{this.model.project}} />
{{/if}}
{{#if (get this.permissions "machine_translations_translate_file")}}
{{#if (get this.permissions "machine_translations_translate")}}
<DocumentsMachineTranslationsButton @project={{this.model.project}} />
{{/if}}

View File

@ -84,8 +84,8 @@ export default class ConflictsController extends Controller {
},
});
if (data.viewer?.project?.translatedText?.[0]) {
return data.viewer.project.translatedText[0];
if (data.viewer?.project?.translatedText) {
return data.viewer.project.translatedText;
} else {
return {text: null};
}

View File

@ -0,0 +1,25 @@
import gql from 'graphql-tag';
export interface DeleteProjectMachineTranslationsConfigVariables {
projectId: string;
}
export interface DeleteProjectMachineTranslationsConfigResponse {
project: {
id: string;
};
errors: any;
}
export default gql`
mutation ProjectMachineTranslationsConfigDelete($projectId: ID!) {
deleteProjectMachineTranslationsConfig(projectId: $projectId) {
project {
id
}
errors
}
}
`;

View File

@ -0,0 +1,16 @@
import gql from 'graphql-tag';
export default gql`
query ProjectMachineTranslationsConfig($projectId: ID!) {
viewer {
project(id: $projectId) {
id
machineTranslationsConfig {
provider
usePlatform
useConfigKey
}
}
}
}
`;

View File

@ -0,0 +1,38 @@
import gql from 'graphql-tag';
export interface SaveProjectMachineTranslationsConfigVariables {
name: string;
projectId: string;
pictureUrl: string;
permissions: string[];
}
export interface SaveProjectMachineTranslationsConfigResponse {
project: {
id: string;
};
errors: any;
}
export default gql`
mutation ProjectMachineTranslationsConfigSave(
$provider: String!
$configKey: String
$usePlatform: Boolean!
$projectId: ID!
) {
saveProjectMachineTranslationsConfig(
provider: $provider
configKey: $configKey
usePlatform: $usePlatform
projectId: $projectId
) {
project {
id
}
errors
}
}
`;

View File

@ -15,7 +15,6 @@ export default gql`
sourceLanguageSlug: $sourceLanguageSlug
targetLanguageSlug: $targetLanguageSlug
) {
id
text
}
}

View File

@ -31,6 +31,7 @@ export default Router.map(function () {
this.route('api-token');
this.route('service-integrations');
this.route('jipt');
this.route('machine-translations');
});
this.route('collaborators');

View File

@ -10,16 +10,20 @@ export default class ApolloMutate extends Service {
apollo: Apollo;
async mutate(args: any) {
const {data} = await this.apollo.client.mutate(args);
const operationName = Object.keys(data)[0];
try {
const {data} = await this.apollo.client.mutate(args);
const operationName = Object.keys(data)[0];
if (!data[operationName]?.errors?.length) {
data[operationName].errors = null;
if (!data[operationName]?.errors?.length) {
data[operationName].errors = null;
return data[operationName];
}
return data[operationName];
} catch {
return {errors: ['internal_server_error'], data: null};
}
return data[operationName];
}
}

View File

@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M4 17l9.21-9.244C14.405 6.306 15 5.054 15 4M1 3.5h15M8.5 1v3M6.234 9c-.877 4.463.712 6.796 4.766 7"
/>
<path stroke-linejoin="round" d="M14 21.87L18.584 12 23 22" />
<path stroke-linejoin="bevel" d="M16 18.5h7" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

View File

@ -0,0 +1,23 @@
<svg
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
viewBox="0 0 54 68"
>
<path
fill="#0F2B46"
d="M.188 17.274v26.81c0 1.393.73 2.67 1.921 3.367l23.063 13.386a3.797 3.797 0 0 0 3.844 0L52.078 47.45A3.887 3.887 0 0 0 54 44.085v-26.81c0-1.394-.73-2.67-1.922-3.367L29.016.522a3.797 3.797 0 0 0-3.844 0L2.109 13.947a3.871 3.871 0 0 0-1.921 3.327Z"
/>
<path fill="#0F2B46" d="m36.703 67.53-.038-5.803.038-5.339-13.453 3.327" />
<path
fill="#142C46"
d="m36.088 55.924 2.537-.658-.961.542c-.577.348-.961.967-.961 1.663v1.084l-.615-2.631Z"
/>
<path
fill="#fff"
d="M17.79 18.474a3.95 3.95 0 0 1 5.535 0 4.016 4.016 0 0 1 0 5.804 3.95 3.95 0 0 1-5.535 0 4.016 4.016 0 0 1 0-5.804ZM35.087 28.572a3.95 3.95 0 0 1 5.535 0 4.016 4.016 0 0 1 0 5.803 3.95 3.95 0 0 1-5.535 0 4.016 4.016 0 0 1 0-5.803ZM17.79 39.25a3.95 3.95 0 0 1 5.535 0 4.016 4.016 0 0 1 0 5.803 3.95 3.95 0 0 1-5.535 0 4.016 4.016 0 0 1 0-5.803Z"
/>
<path
fill="#fff"
d="m22.48 23.542 11.532 6.693 1.922-1.083-11.532-6.732-1.922 1.122ZM34.78 35.148l-10.378 6.035-1.922-1.121 10.379-5.997 1.922 1.083Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,56 @@
<svg
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
viewBox="0 0 998.1 998.3"
>
<path
fill="#DBDBDB"
d="M931.7 998.3c36.5 0 66.4-29.4 66.4-65.4V265.8c0-36-29.9-65.4-66.4-65.4H283.6l260.1 797.9h388z"
/>
<path
fill="#DCDCDC"
d="M931.7 230.4c9.7 0 18.9 3.8 25.8 10.6 6.8 6.7 10.6 15.5 10.6 24.8v667.1c0 9.3-3.7 18.1-10.6 24.8-6.9 6.8-16.1 10.6-25.8 10.6H565.5L324.9 230.4h606.8m0-30H283.6l260.1 797.9h388c36.5 0 66.4-29.4 66.4-65.4V265.8c0-36-29.9-65.4-66.4-65.4z"
/>
<path fill="#4352B8" d="m482.3 809.8 61.4 188.5 170.7-188.5z" />
<path
fill="#607988"
d="M936.1 476.1V437H747.6v-63.2h-61.2V437H566.1v39.1h239.4c-12.8 45.1-41.1 87.7-68.7 120.8-48.9-57.9-49.1-76.7-49.1-76.7h-50.8s2.1 28.2 70.7 108.6c-22.3 22.8-39.2 36.3-39.2 36.3l15.6 48.8s23.6-20.3 53.1-51.6c29.6 32.1 67.8 70.7 117.2 116.7l32.1-32.1c-52.9-48-91.7-86.1-120.2-116.7 38.2-45.2 77-102.1 85.2-154.2H936v.1z"
/>
<path
fill="#4285F4"
d="M66.4 0C29.9 0 0 29.9 0 66.5v677c0 36.5 29.9 66.4 66.4 66.4h648.1L454.4 0h-388z"
/>
<linearGradient
id="a"
x1="534.3"
x2="998.1"
y1="433.2"
y2="433.2"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stop-color="#fff" stop-opacity=".2" />
<stop offset="1" stop-color="#fff" stop-opacity=".02" />
</linearGradient>
<path
fill="url(#a)"
d="M534.3 200.4h397.4c36.5 0 66.4 29.4 66.4 65.4V666L534.3 200.4z"
/>
<path
fill="#EEE"
d="M371.4 430.6c-2.5 30.3-28.4 75.2-91.1 75.2-54.3 0-98.3-44.9-98.3-100.2s44-100.2 98.3-100.2c30.9 0 51.5 13.4 63.3 24.3l41.2-39.6c-27.1-25-62.4-40.6-104.5-40.6-86.1 0-156 69.9-156 156s69.9 156 156 156c90.2 0 149.8-63.3 149.8-152.6 0-12.8-1.6-22.2-3.7-31.8h-146v53.4l91 .1z"
/>
<radialGradient
id="b"
cx="65.208"
cy="19.366"
r="1398.271"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stop-color="#fff" stop-opacity=".1" />
<stop offset="1" stop-color="#fff" stop-opacity="0" />
</radialGradient>
<path
fill="url(#b)"
d="M931.7 200.4H518.8L454.4 0h-388C29.9 0 0 29.9 0 66.5v677c0 36.5 29.9 66.4 66.4 66.4h415.9l61.4 188.4h388c36.5 0 66.4-29.4 66.4-65.4V265.8c0-36-29.9-65.4-66.4-65.4z"
/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB