Add Google Translate service with machine translations feature

This commit is contained in:
Simon Prévost 2020-11-05 12:24:34 -05:00
parent 820d9ed2bf
commit 6e0c3e129a
109 changed files with 1697 additions and 330 deletions

View File

@ -32,6 +32,10 @@ config :sentry,
root_source_code_path: File.cwd!(),
release: version
config :tesla,
auth_enabled: true,
adapter: Tesla.Adapter.Hackney
config :ueberauth, Ueberauth, providers: []
import_config "#{Mix.env()}.exs"

View File

@ -42,6 +42,21 @@ end
config :accent, Accent.Repo, url: System.get_env("DATABASE_URL") || "postgres://localhost/accent_development"
google_translate_provider = {Accent.MachineTranslations.Adapter.GoogleTranslations, [key: System.get_env("GOOGLE_TRANSLATIONS_SERVICE_ACCOUNT_KEY")]}
translate_list_provider = if System.get_env("GOOGLE_TRANSLATIONS_SERVICE_ACCOUNT_KEY"), do: google_translate_provider, else: nil
translate_text_providers = []
translate_text_providers =
if System.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
providers = []
providers = if System.get_env("GOOGLE_API_CLIENT_ID"), do: [{:google, {Ueberauth.Strategy.Google, [scope: "email openid"]}} | providers], else: providers
providers = if System.get_env("SLACK_CLIENT_ID"), do: [{:slack, {Ueberauth.Strategy.Slack, [team: System.get_env("SLACK_TEAM_ID")]}} | providers], else: providers
@ -73,6 +88,14 @@ config :sentry,
dsn: System.get_env("SENTRY_DSN"),
environment_name: System.get_env("SENTRY_ENVIRONMENT_NAME")
if System.get_env("GOOGLE_TRANSLATIONS_SERVICE_ACCOUNT_KEY") do
config :goth, json: System.get_env("GOOGLE_TRANSLATIONS_SERVICE_ACCOUNT_KEY")
else
config :goth, disabled: true
end
config :tesla, logger_enabled: true
if !System.get_env("SENTRY_DSN") do
config :sentry, included_environments: []
end

View File

@ -16,4 +16,7 @@ config :accent, Accent.Hook,
outbounds: [{Accent.Hook.Outbounds.Mock, events: events}],
inbounds: [{Accent.Hook.Inbounds.Mock, events: events}]
config :goth, disabled: true
config :tesla, logger_enabled: false, adapter: Tesla.Mock
config :logger, level: :warn

View File

@ -73,11 +73,27 @@ defmodule Accent.RoleAbilities do
delete_project
)a ++ @developer_actions
def actions_for(@owner_role), do: @admin_actions
def actions_for(@admin_role), do: @admin_actions
def actions_for(@bot_role), do: @bot_actions
def actions_for(@developer_role), do: @developer_actions
def actions_for(@reviewer_role), do: @any_actions
@configurable_actions ~w(machine_translations_translate_file machine_translations_translate_text)a
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 add_configurable_actions(actions, role) do
Enum.reduce(@configurable_actions, actions, fn action, actions ->
if can?(role, action), do: [action | actions], else: actions
end)
end
def can?(role, :machine_translations_translate_file) when role in [@owner_role, @admin_role, @developer_role] do
Accent.MachineTranslations.translate_list_enabled?()
end
def can?(role, :machine_translations_translate_text) when role in [@owner_role, @admin_role, @developer_role] do
Accent.MachineTranslations.translate_text_enabled?()
end
# Define abilities function at compile time to remove list lookup at runtime
def can?(@owner_role, _action), do: true

View File

@ -16,6 +16,21 @@ defmodule Accent.Scopes.Operation do
from(query, where: [user_id: ^user_id])
end
@doc """
## Examples
iex> Accent.Scopes.Operation.filter_from_project(Accent.Operation, nil)
Accent.Operation
iex> Accent.Scopes.Operation.filter_from_project(Accent.Operation, "test")
#Ecto.Query<from o0 in Accent.Operation, left_join: r1 in assoc(o0, :revision), where: r1.project_id == ^"test" or o0.project_id == ^"test">
"""
@spec filter_from_project(Ecto.Queryable.t(), String.t() | nil) :: Ecto.Queryable.t()
def filter_from_project(query, nil), do: query
def filter_from_project(query, project_id) do
from(o in query, left_join: r in assoc(o, :revision), where: r.project_id == ^project_id or o.project_id == ^project_id)
end
@doc """
## Examples

View File

@ -1,21 +1,20 @@
defmodule Accent.TranslationsRenderer do
alias Langue
def render(args) do
{:ok, serializer} = Langue.serializer_from_format(args[:document].format)
entries = entries(args)
def render_entries(args) do
{:ok, serializer} = Langue.serializer_from_format(args.document.format)
serialzier_input = %Langue.Formatter.ParserResult{
entries: entries,
entries: args.entries,
language: %Langue.Language{
slug: args[:language].slug,
plural_forms: args[:language].plural_forms
slug: args.language.slug,
plural_forms: args.language.plural_forms
},
document: %Langue.Document{
path: args[:document].path,
master_language: Accent.Revision.language(args[:master_revision]).slug,
top_of_the_file_comment: args[:document].top_of_the_file_comment,
header: args[:document].header
path: args.document.path,
master_language: args.master_language.slug,
top_of_the_file_comment: args.document.top_of_the_file_comment,
header: args.document.header
}
}
@ -26,10 +25,20 @@ defmodule Accent.TranslationsRenderer do
end
end
def entries(args) do
translations = args[:translations]
master_translations = Enum.group_by(args[:master_translations], & &1.key)
def render_translations(args) do
value_map = Map.get(args, :value_map, & &1.corrected_text)
entries = translations_to_entries(args[:translations], args[:master_translations], value_map)
render_entries(%{
entries: entries,
document: args[:document],
language: args[:language],
master_language: args[:master_language]
})
end
defp translations_to_entries(translations, master_translations, value_map) do
master_translations = Enum.group_by(master_translations || [], & &1.key)
Enum.map(translations, fn translation ->
master_translation = Map.get(master_translations, translation.key)

View File

@ -0,0 +1,34 @@
defmodule Accent.GraphQL.Resolvers.MachineTranslation do
require Ecto.Query
alias Ecto.Query
alias Accent.Scopes.Revision, as: RevisionScope
alias Accent.{
Language,
MachineTranslations,
Project,
Repo,
Revision
}
@spec translate_text(Project.t(), %{text: String.t(), source_language_slug: String.t(), target_language_slug: String.t()}, GraphQLContext.t()) :: nil
def translate_text(project, args, _info) 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)}
end
defp slug_language(project_id, slug) do
revision =
Revision
|> RevisionScope.from_project(project_id)
|> Query.where(slug: ^slug)
|> Repo.one()
language = Repo.get_by(Language, slug: slug)
revision || language
end
end

View File

@ -2,6 +2,7 @@ defmodule Accent.GraphQL.Resolvers.Project do
require Ecto.Query
alias Accent.Scopes.Project, as: ProjectScope
alias Accent.Scopes.Operation, as: OperationScope
alias Accent.{
GraphQL.Paginated,
@ -105,11 +106,11 @@ defmodule Accent.GraphQL.Resolvers.Project do
end
@spec last_activity(Project.t(), any(), GraphQLContext.t()) :: {:ok, Operation.t() | nil}
def last_activity(project, _, _) do
def last_activity(project, args, _) do
Operation
|> Query.join(:left, [o], r in assoc(o, :revision))
|> Query.where([o, r], r.project_id == ^project.id or o.project_id == ^project.id)
|> Query.order_by([o], desc: o.inserted_at)
|> OperationScope.filter_from_project(project.id)
|> OperationScope.filter_from_action(args[:action])
|> OperationScope.order_last_to_first()
|> Query.limit(1)
|> Repo.one()
|> (&{:ok, &1}).()

View File

@ -11,6 +11,11 @@ defmodule Accent.GraphQL.Types.Project do
field(:nodes, list_of(:project))
end
object :project_translated_text do
field(:id, non_null(:id))
field(:text, :string)
end
object :project do
field(:id, :id)
field(:name, :string)
@ -22,9 +27,20 @@ defmodule Accent.GraphQL.Types.Project do
field(:conflicts_count, non_null(:integer))
field(:reviewed_count, non_null(:integer))
field(:last_activity, :activity, resolve: &Accent.GraphQL.Resolvers.Project.last_activity/3)
field :last_activity, :activity do
arg(:action, :string)
resolve(&Accent.GraphQL.Resolvers.Project.last_activity/3)
end
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
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))
end
field :access_token, :string do
resolve(project_authorize(:show_project_access_token, &Accent.GraphQL.Resolvers.AccessToken.show_project/3))
end

View File

@ -0,0 +1,4 @@
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

@ -0,0 +1,69 @@
defmodule Accent.MachineTranslations.Adapter.GoogleTranslations do
@behaviour Accent.MachineTranslations.Adapter
alias Tesla.Middleware
alias Accent.MachineTranslations.TranslatedText
@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
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"]})}
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
end

View File

@ -0,0 +1,52 @@
defmodule Accent.MachineTranslations do
alias Accent.MachineTranslations.TranslatedText
def translate_list_enabled? do
not is_nil(provider_from_service(:translate_list))
end
def translate_text_enabled? do
not Enum.empty?(provider_from_service(:translate_text))
end
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
values = Enum.map(entries, & &1.value)
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.with_index()
|> Enum.map(fn {entry, index} ->
case Enum.at(translated_values, index) do
%TranslatedText{text: text} -> %{entry | value: text}
_ -> entry
end
end)
else
_ -> entries
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,3 @@
defmodule Accent.MachineTranslations.TranslatedText do
defstruct id: nil, text: nil
end

View File

@ -157,10 +157,10 @@ defmodule Accent.ExportController do
_
) do
%{render: render} =
Accent.TranslationsRenderer.render(%{
Accent.TranslationsRenderer.render_translations(%{
master_translations: master_translations,
master_revision: master_revision,
translations: translations,
master_language: Accent.Revision.language(master_revision),
language: Accent.Revision.language(revision),
document: document
})

View File

@ -123,11 +123,10 @@ defmodule Accent.ExportJIPTController do
revision = Enum.at(project.revisions, 0)
%{render: render} =
Accent.TranslationsRenderer.render(%{
master_translations: [],
Accent.TranslationsRenderer.render_translations(%{
translations: translations,
master_revision: revision,
document: document,
master_language: Accent.Revision.language(revision),
language: Accent.Revision.language(revision),
value_map: &"{^#{&1.key}@#{document.path}}"
})

View File

@ -0,0 +1,69 @@
defmodule Accent.MachineTranslationsController do
use Phoenix.Controller
import Canary.Plugs
alias Accent.{Language, Project, Repo}
alias Accent.Scopes.Revision, as: RevisionScope
plug(Plug.Assign, canary_action: :machine_translations_translate_file)
plug(:load_and_authorize_resource, model: Project, id_name: "project_id")
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(Accent.Plugs.MovementContextParser)
plug(:fetch_master_revision)
@doc """
Translate a file from a language to another language.
## Endpoint
POST /machine-translations/translate-file
### Required params
- `project_id`
- `from_language_id`
- `to_language_id`
- `file`
- `document_format`
### Response
#### Success
`200` - A file containing the rendered document.
#### Error
`404` - Unknown project, language or format
"""
def translate_file(conn, _params) do
entries =
Accent.MachineTranslations.translate_entries(
conn.assigns[:movement_context].entries,
conn.assigns[:source_language],
conn.assigns[:target_language]
)
%{render: render} =
Accent.TranslationsRenderer.render_entries(%{
entries: entries,
language: conn.assigns[:target_language],
master_language: Accent.Revision.language(conn.assigns[:master_revision]),
document: conn.assigns[:movement_context].assigns[:document]
})
conn
|> put_resp_header("content-type", "text/plain")
|> send_resp(:ok, render)
end
defp fetch_master_revision(conn = %{assigns: %{project: project}}, _) do
revision =
project
|> Ecto.assoc(:revisions)
|> RevisionScope.master()
|> Repo.one()
|> Repo.preload(:language)
assign(conn, :master_revision, revision)
end
end

View File

@ -7,9 +7,9 @@ defmodule Accent.Plugs.MovementContextParser do
alias Movement.Context
plug(:validate_params)
plug(:assign_document_format)
plug(:assign_document_parser)
plug(:assign_document_path)
plug(:assign_document_format)
plug(:assign_version)
plug(:assign_movement_context)
plug(:assign_movement_document)
@ -19,7 +19,7 @@ defmodule Accent.Plugs.MovementContextParser do
def validate_params(conn = %{params: %{"document_format" => _format, "file" => _file, "language" => _language}}, _), do: conn
def validate_params(conn, _), do: conn |> send_resp(:unprocessable_entity, "file, language and document_format are required") |> halt
def assign_document_parser(conn = %{params: %{"document_format" => document_format}}, _) do
def assign_document_parser(conn = %{assigns: %{document_format: document_format}}, _) do
case Langue.parser_from_format(document_format) do
{:ok, parser} -> assign(conn, :document_parser, parser)
{:error, _reason} -> conn |> send_resp(:unprocessable_entity, "document_format is invalid") |> halt
@ -27,7 +27,7 @@ defmodule Accent.Plugs.MovementContextParser do
end
def assign_document_format(conn = %{params: %{"document_format" => format}}, _) do
assign(conn, :document_format, format)
assign(conn, :document_format, String.downcase(format))
end
def assign_document_path(conn = %{params: %{"document_path" => path}}, _) when path !== "" and not is_nil(path) do

View File

@ -42,6 +42,7 @@ defmodule Accent.Router do
post("/add-translations/peek", PeekController, :merge, as: :peek_add_translations)
post("/merge", MergeController, [])
post("/merge/peek", PeekController, :merge, as: :peek_merge)
post("/machine-translations/translate-file", MachineTranslationsController, :translate_file, as: :translate_file)
# File export
get("/export", ExportController, [])

View File

@ -106,6 +106,12 @@ defmodule Accent.Mixfile do
{:mix_gleam, "~> 0.1", only: [:dev, :test]},
{:gleam_stdlib, "~> 0.11", only: [:dev, :test]},
# Google API authentication
{:goth, "~> 1.1"},
# Network request
{:tesla, "~> 1.3"},
# Dev
{:dialyxir, "~> 1.0", only: ~w(dev test)a, runtime: false},
{:credo, ">= 0.0.0", only: ~w(dev test)a},

View File

@ -29,10 +29,13 @@
"gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"},
"gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"},
"gleam_stdlib": {:hex, :gleam_stdlib, "0.11.0", "9b1089739574cdf78a1c25a463d770a98f59e63a92324d17095ad67e867ee549", [:rebar3], [], "hexpm", "5508e169e20369fc8d400481d3f37ccb2cfd880509f63d6e2b6d85f939bc991b"},
"goth": {:hex, :goth, "1.2.0", "92d6d926065a72a7e0da8818cc3a133229b56edf378022c00d9886c4125ce769", [:mix], [{:httpoison, "~> 0.11 or ~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.0", [hex: :joken, repo: "hexpm", optional: false]}], "hexpm", "4974932ab3b782c99a6fdeb0b968ddd61436ef14de5862bd6bb0227386c63b26"},
"hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"},
"httpoison": {:hex, :httpoison, "1.7.0", "abba7d086233c2d8574726227b6c2c4f6e53c4deae7fe5f6de531162ce9929a0", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "975cc87c845a103d3d1ea1ccfd68a2700c211a434d8428b10c323dc95dc5b980"},
"idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"},
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
"joken": {:hex, :joken, "2.3.0", "62a979c46f2c81dcb8ddc9150453b60d3757d1ac393c72bb20fc50a7b0827dc6", [:mix], [{:jose, "~> 1.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "57b263a79c0ec5d536ac02d569c01e6b4de91bd1cb825625fe90eab4feb7bc1e"},
"jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"},
"jsone": {:hex, :jsone, "1.5.2", "87adea283c9cf24767b4deed44602989a5331156df5d60a2660e9c9114d54046", [:rebar3], [], "hexpm", "170c171ce7f6dd70c858065154a3305b8564833c6dcca17e10b676ca31ea976f"},
"meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
@ -65,6 +68,7 @@
"sentry": {:hex, :sentry, "7.2.5", "570db92c3bbacd6ad02ac81cba8ac5af11235a55d65ac4375e3ec833975b83d3", [:mix], [{:hackney, "~> 1.8 or 1.6.5", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "ea84ed6848505ff2a246567df562f465d2b34c317d3ecba7c7df58daa56e5e5d"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"},
"tesla": {:hex, :tesla, "1.3.3", "26ae98627af5c406584aa6755ab5fc96315d70d69a24dd7f8369cfcb75094a45", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "2648f1c276102f9250299e0b7b57f3071c67827349d9173f34c281756a1b124c"},
"ueberauth": {:hex, :ueberauth, "0.6.3", "d42ace28b870e8072cf30e32e385579c57b9cc96ec74fa1f30f30da9c14f3cc0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "afc293d8a1140d6591b53e3eaf415ca92842cb1d32fad3c450c6f045f7f91b60"},
"ueberauth_discord": {:hex, :ueberauth_discord, "0.5.2", "afc5d68879575c365972fd4d7cf7b01c16f7d062fc6bf7e86e2595736ac41127", [:mix], [{:oauth2, "~> 1.0 or ~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.6.3", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "bbd7701d8fef02623fb106ed7c24427ed2327ef769bcb1d2eba5670e54716cdc"},
"ueberauth_github": {:hex, :ueberauth_github, "0.8.0", "2216c8cdacee0de6245b422fb397921b64a29416526985304e345dab6a799d17", [:mix], [{:oauth2, "~> 1.0 or ~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.6.0", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "b65ccc001a7b0719ba069452f3333d68891f4613ae787a340cce31e2a43307a3"},

View File

@ -42,9 +42,9 @@ defmodule AccentTest.TranslationsRenderer do
|> Repo.insert!()
%{render: render} =
TranslationsRenderer.render(%{
TranslationsRenderer.render_translations(%{
master_translations: [],
master_revision: revision,
master_language: revision.language,
translations: [translation],
document: document,
language: revision.language
@ -82,9 +82,9 @@ defmodule AccentTest.TranslationsRenderer do
|> Enum.map(&Repo.insert!/1)
%{render: render} =
TranslationsRenderer.render(%{
TranslationsRenderer.render_translations(%{
master_translations: [],
master_revision: revision,
master_language: revision.language,
translations: translations,
document: document,
language: revision.language
@ -107,9 +107,9 @@ defmodule AccentTest.TranslationsRenderer do
|> Repo.insert!()
%{render: render} =
TranslationsRenderer.render(%{
TranslationsRenderer.render_translations(%{
master_translations: [],
master_revision: revision,
master_language: revision.language,
translations: [translation],
document: document,
language: %Language{slug: "fr"}
@ -138,14 +138,14 @@ defmodule AccentTest.TranslationsRenderer do
|> Repo.insert!()
%{render: render} =
TranslationsRenderer.render(%{
TranslationsRenderer.render_translations(%{
master_translations: [
%Translation{
key: "a",
corrected_text: "master A"
}
],
master_revision: revision,
master_language: Accent.Revision.language(revision),
translations: [translation],
document: document,
language: %Language{slug: "fr"}

View File

@ -37,6 +37,7 @@ declare const config: {
MERGE_REVISION_PATH: string;
EXPORT_DOCUMENT: string;
JIPT_EXPORT_DOCUMENT: string;
MACHINE_TRANSLATIONS_TRANSLATE_FILE_PROJECT_PATH: string;
PERCENTAGE_REVIEWED_BADGE_SVG_PROJECT_PATH: string;
JIPT_SCRIPT_PATH: string;
};

View File

@ -17,9 +17,18 @@
"title_count": "{count, plural, =1 {This element contains 1 string} other {This element contains # strings}}"
}
},
"machine_translations_translate_upload_form": {
"translate": "Translate",
"reset": "Reset",
"step_1": "Select or drop a file here to translate it.",
"step_2": "Choose the correct format and submit to translate <code>{name}</code> in the chosen target language"
},
"documents_add_button": {
"link": "Synchronize a new file"
},
"documents_machine_translations_button": {
"link": "Translate with machine translations"
},
"versions_add_button": {
"link": "Create new version"
},
@ -155,6 +164,7 @@
"overview_link_title": "Overview"
},
"dashboard_revisions": {
"last_synced_at_label": "Last sync:",
"manage_languages_link_title": "Manage languages",
"new_language_link_title": "New language",
"new_language_link_text": "With another language, you can keep track of translation based on the master language.",
@ -309,7 +319,7 @@
"update": "updated a string",
"batch_update": "updated multiple strings",
"update_proposed": "updated an uploaded text reference",
"sync": "synced a file",
"sync": "synced a file: {document}",
"batch_sync": "synced files",
"new_slave": "added a new language",
"uncorrect_all": "put all strings back to review",
@ -680,7 +690,6 @@
"uncorrect_button": "Put back to review",
"uneditable": "The text is not editable because it has been marked as reviewed",
"update_text": "Update text",
"last_updated_label": "Last updated:",
"form": {
"true_option": "true",
"false_option": "false",
@ -724,6 +733,7 @@
"plural_label": "plural",
"conflicted_label": "in review",
"master_label": "master",
"last_updated_label": "Last updated:",
"removed_label": "This string was removed {removedAt}"
},
"translations_filter": {
@ -738,7 +748,8 @@
"empty": "Text is empty (\"\" or null value)",
"not_empty": "Text is not empty",
"added_last_sync": "Strings added last sync",
"commented_on": "Strings with comments"
"commented_on": "Strings with comments",
"conflicted": "In review"
}
},
"translations_list": {
@ -746,6 +757,7 @@
"save": "Save",
"empty_text": "Empty text",
"in_review_label": "in review",
"lint_messages_label": "{count, plural, =0 {No linting warnings} =1 {1 linting warning} other {# linting warnings}}",
"last_updated_label": "Last updated: ",
"maybe_sync_before": "Maybe try to",
"maybe_sync_link": "sync some files →",

View File

@ -1,7 +1,8 @@
.badge {
transition: 0.2s ease-in-out;
transition-property: background;
display: inline-block;
display: inline-flex;
align-items: center;
padding: 1px 6px 1px 5px;
background: var(--background-light);
border-radius: 3px;
@ -17,11 +18,25 @@
color: #fff;
}
.warning {
color: var(--color-warning);
}
.danger {
background: var(--color-error);
color: #fff;
}
.icon {
background: transparent;
position: relative;
top: 2px;
svg {
height: 12px;
}
}
.link {
padding: 0;

View File

@ -1,3 +1,6 @@
<div local-class="badge {{if @link "link"}} {{if @primary "primary"}} {{if @danger "danger"}}">
<div
local-class="badge {{if @icon "icon"}} {{if @link "link"}} {{if @warning "warning"}} {{if @primary "primary"}} {{if @danger "danger"}}"
...attributes
>
{{yield}}
</div>

View File

@ -12,6 +12,7 @@ const ACTIONS_ICON_PATHS = {
add_to_version: 'assets/tag.svg',
create_version: 'assets/tag.svg',
batch_sync: 'assets/sync.svg',
batch_update: 'assets/pencil.svg',
sync: 'assets/sync.svg',
merge: 'assets/merge.svg',
batch_merge: 'assets/merge.svg',

View File

@ -13,7 +13,8 @@ export default class AsyncButton extends Component<Args> {
}
@action
onClick() {
onClick(event: Event) {
event.preventDefault();
if (this.args.disabled) return;
if (typeof this.args.onClick === 'function') this.args.onClick();

View File

@ -10,6 +10,7 @@
type="text"
placeholder={{t "components.conflicts_filters.input_placeholder_text"}}
value={{this.debouncedQuery}}
{{on-key "Enter" (fn this.submitForm)}}
{{on "keyup" this.setDebouncedQuery}}
>
</div>

View File

@ -5,7 +5,12 @@ interface Args {
project: any;
conflicts: any;
query: any;
onCorrect: () => Promise<void>;
onCorrect: (conflict: any, textInput: string) => Promise<void>;
onCopyTranslation: (
text: string,
sourceLanguageSlug: string,
targetLanguageSlug: string
) => void;
}
export default class ConflictsItems extends Component<Args> {}

View File

@ -1,8 +1,8 @@
import {action} from '@ember/object';
import {empty} from '@ember/object/computed';
import Component from '@glimmer/component';
import parsedKeyProperty from 'accent-webapp/computed-macros/parsed-key';
import {dropTask} from 'ember-concurrency-decorators';
import {tracked} from '@glimmer/tracking';
interface Args {
@ -10,6 +10,11 @@ interface Args {
project: any;
conflict: any;
onCorrect: (conflict: any, textInput: string) => Promise<void>;
onCopyTranslation: (
text: string,
sourceLanguageSlug: string,
targetLanguageSlug: string
) => Promise<{text: string | null}>;
}
export default class ConflictItem extends Component<Args> {
@ -29,6 +34,17 @@ export default class ConflictItem extends Component<Args> {
resolved = false;
conflictKey = parsedKeyProperty(this.args.conflict.key);
textOriginal = this.args.conflict.correctedText;
get showTextDiff() {
if (!this.args.conflict.conflictedText) return false;
return this.textInput !== this.args.conflict.conflictedText;
}
get showOriginalButton() {
return this.textInput !== this.textOriginal;
}
get revisionName() {
return (
@ -42,6 +58,24 @@ export default class ConflictItem extends Component<Args> {
this.textInput = text;
}
@action
setOriginalText() {
this.textInput = this.textOriginal;
}
@dropTask
*copyTranslationTask(text: string, sourceLanguageSlug: string) {
const copyTranslation = yield this.args.onCopyTranslation(
text,
sourceLanguageSlug,
this.revisionSlug
);
if (copyTranslation.text) {
this.textInput = copyTranslation.text;
}
}
@action
async correct() {
this.onLoading();
@ -68,4 +102,11 @@ export default class ConflictItem extends Component<Args> {
this.resolved = true;
this.loading = false;
}
private get revisionSlug() {
return (
this.args.conflict.revision.slug ||
this.args.conflict.revision.language.slug
);
}
}

View File

@ -1,8 +1,10 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
interface Args {
project: any;
translation: any;
onCopyTranslation: (text: string, languageSlug: string) => void;
}
const MAX_TEXT_LENGTH = 600;
@ -26,4 +28,19 @@ export default class ConflictItemRelatedTranslation extends Component<Args> {
this.args.translation.revision.language.name
);
}
@action
translate() {
this.args.onCopyTranslation(
this.args.translation.correctedText,
this.revisionSlug
);
}
private get revisionSlug() {
return (
this.args.translation.revision.slug ||
this.args.translation.revision.language.slug
);
}
}

View File

@ -95,15 +95,6 @@
}
}
.item-actions-flag--link {
&:focus,
&:hover {
color: var(--body-background);
border-color: var(--color-primary);
background: var(--color-primary);
}
}
.item-actions-icon {
width: 12px;
height: 12px;

View File

@ -24,7 +24,16 @@
{{inline-svg "assets/eye.svg" local-class="item-actions-icon"}}
</LinkTo>
{{#if relatedTranslation.isConflicted}}
{{#if (get @permissions "machine_translations_translate_text")}}
<button
local-class="item-actions-link"
{{on "click" this.translate}}
>
{{inline-svg "assets/language.svg" local-class="item-actions-icon"}}
</button>
{{/if}}
{{#if @translation.isConflicted}}
<LinkTo
@route="logged-in.project.revision.conflicts"
@models={{array @project.id @translation.revision.id}}

View File

@ -186,12 +186,12 @@
font-size: 12px;
}
.itemInput-actions {
.button-submit {
display: flex;
justify-content: flex-end;
position: absolute;
top: 5px;
right: 5px;
top: 7px;
right: 7px;
z-index: 3;
}
@ -247,3 +247,27 @@
.conflictedText-references {
width: 100%;
}
.conflictedText-references-conflicted {
display: flex;
align-items: center;
font-size: 12px;
color: var(--color-black);
}
.conflictedText-references-conflicted-label {
display: flex;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
width: 90px;
margin-right: 10px;
font-weight: 500;
font-size: 11px;
text-align: right;
}
.conflictedText-references-conflicted-icon {
width: 12px;
height: 12px;
}

View File

@ -65,31 +65,54 @@
@showTypeHints={{false}}
@onKeyUp={{fn this.changeTranslationText}}
@onSubmit={{fn this.correct}}
/>
as |form|
>
{{#component form.submit}}
<div local-class="button-submit">
{{#if this.showOriginalButton}}
<AsyncButton
@onClick={{fn this.setOriginalText}}
class="button button--iconOnly button--white"
>
{{inline-svg "/assets/revert.svg" class="button-icon"}}
</AsyncButton>
{{/if}}
{{#if (get @permissions "correct_translation")}}
<AsyncButton
@onClick={{fn this.correct}}
@loading={{this.loading}}
class="button button--iconOnly button--filled button--green"
>
{{inline-svg "/assets/check.svg" class="button-icon"}}
</AsyncButton>
{{/if}}
</div>
{{/component}}
</TranslationEdit::Form>
</div>
<div local-class="itemInput-actions">
{{#if (get @permissions "correct_translation")}}
<AsyncButton
@onClick={{fn this.correct}}
@loading={{this.loading}}
class="button button--iconOnly button--filled button--green"
>
{{inline-svg "/assets/check.svg" class="button-icon"}}
</AsyncButton>
<div local-class="conflictedText-references">
{{#if this.showTextDiff}}
<div local-class="conflictedText-references-conflicted">
<span local-class="conflictedText-references-conflicted-label">
{{inline-svg "/assets/diff.svg" local-class="conflictedText-references-conflicted-icon"}}
</span>
{{string-diff this.textInput @conflict.conflictedText}}
</div>
{{/if}}
</div>
{{#if @conflict.relatedTranslations}}
<div local-class="conflictedText-references">
{{#if @conflict.relatedTranslations}}
{{#each @conflict.relatedTranslations key="id" as |relatedTranslation|}}
<ConflictsList::Item::RelatedTranslation
@project={{@project}}
@translation={{relatedTranslation}}
@permissions={{@permissions}}
@onCopyTranslation={{perform this.copyTranslationTask}}
/>
{{/each}}
</div>
{{/if}}
{{/if}}
</div>
</div>
</div>
{{/if}}

View File

@ -2,3 +2,21 @@
position: relative;
margin-top: 20px;
}
.all-reviewed {
max-width: 400px;
margin: 100px auto 20px;
text-align: center;
}
.all-reviewed-title {
margin-top: 10px;
font-weight: bold;
font-size: 22px;
color: var(--color-primary);
}
.all-reviewed-subtitle {
font-size: 12px;
color: var(--color-grey);
}

View File

@ -5,17 +5,24 @@
@project={{@project}}
@conflict={{conflict}}
@onCorrect={{@onCorrect}}
@onCopyTranslation={{@onCopyTranslation}}
/>
{{else if query}}
{{else if @query}}
<EmptyContent
@iconPath="assets/empty.svg"
@text={{t "components.conflicts_items.no_translations" query=@query}}
/>
{{else}}
<EmptyContent
@success={{true}}
@iconPath="assets/thumbs-up.svg"
@text={{t "components.conflicts_items.review_completed"}}
/>
<div local-class="all-reviewed">
<img local-class="all-reviewed-image" src="/assets/all-reviewed-splash.svg" />
<div local-class="all-reviewed-title">
All reviewed
</div>
<div local-class="all-reviewed-subtitle">
Every strings have been marked as reviewed
</div>
</div>
{{/each}}
</ul>

View File

@ -15,6 +15,7 @@ interface Args {
referenceRevision: any;
referenceRevisions: any;
onCorrect: () => void;
onCopyTranslation: () => void;
onCorrectAll: () => void;
onSelectPage: () => void;
onChangeDocument: () => void;

View File

@ -24,6 +24,7 @@
@conflicts={{@translations.entries}}
@query={{@query}}
@onCorrect={{@onCorrect}}
@onCopyTranslation={{@onCopyTranslation}}
/>
<ResourcePagination
@meta={{@translations.meta}}

View File

@ -1,6 +1,6 @@
.dashboard-revisions-item {
transition: 0.2s ease-in-out;
transition-property: border-color;
transition-property: box-shadow;
position: relative;
flex: 1 1 calc(50% - 10px);
justify-content: space-between;
@ -9,7 +9,7 @@
margin-right: 10px;
padding: 8px 15px 12px;
border-radius: 3px;
box-shadow: 0 2px 4px var(--shadow-color);
box-shadow: 0 9px 19px var(--shadow-color);
&:nth-child(even) {
flex: 1 1 50%;
@ -18,7 +18,7 @@
&:focus,
&:hover {
border-color: #ddd;
box-shadow: 0 3px 10px var(--shadow-color);
.actionsButton {
opacity: 1;

View File

@ -169,11 +169,17 @@
.activities-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0;
font-size: 14px;
font-weight: 300;
}
.activities-title-text {
display: flex;
align-items: center;
}
.activities-title-icon {
stroke: #bbb;
width: 15px;
@ -187,6 +193,12 @@
text-align: center !important;
}
.activities-last-sync {
font-size: 11px;
text-decoration: none;
opacity: 0.8;
}
@media (max-width: var(--screen-md)) {
.dashboard-revisions > .content {
width: 100%;

View File

@ -140,8 +140,21 @@
{{#if @activities}}
<div local-class="activities">
<h2 local-class="activities-title">
{{inline-svg "assets/activity.svg" local-class="activities-title-icon"}}
{{t "components.dashboard_revisions.activities_title"}}
<span local-class="activities-title-text">
{{inline-svg "assets/activity.svg" local-class="activities-title-icon"}}
{{t "components.dashboard_revisions.activities_title"}}
</span>
{{#if @project.lastActivitySync.insertedAt}}
<LinkTo
@route="logged-in.project.activity"
@models={{array @project.id @project.lastActivitySync.id}}
local-class="activities-last-sync"
>
{{t "components.dashboard_revisions.last_synced_at_label"}}
<TimeAgoInWordsTag @date={{@project.lastActivitySync.insertedAt}} />
</LinkTo>
{{/if}}
</h2>
<ProjectActivitiesList
@permissions={{@permissions}}

View File

@ -1,13 +1,17 @@
.documents-list-item {
position: relative;
display: flex;
margin-bottom: 30px;
padding-bottom: 30px;
width: 100%;
&:hover {
.deleteDocumentButton {
opacity: 1;
}
.links {
transform: translate3d(0, 0, 0);
}
}
}
@ -115,6 +119,10 @@
}
.item-form {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
width: 100%;
&.item-form--editing {
@ -123,6 +131,12 @@
}
}
.item-form-content {
flex-grow: 1;
min-width: 300px;
margin-right: 30px;
}
.item-form-label {
display: block;
margin-bottom: 8px;
@ -199,6 +213,9 @@
display: flex;
flex-wrap: wrap;
justify-content: space-between;
transform: translate3d(30px, 0, 0);
transition: 0.2s ease-in-out;
transition-property: transform;
:global(.button) {
margin-right: 6px;
@ -209,11 +226,21 @@
}
}
.links--editing {
width: 100%;
justify-content: flex-start;
transform: translate3d(0, 0, 0);
}
.deleteDocumentButton {
opacity: 0;
padding: 2px 6px !important;
}
:global(.button).button-sync {
color: var(--color-primary);
}
@media (max-width: (800px)) {
.item {
margin-right: 0;

View File

@ -1,135 +1,136 @@
<li local-class="documents-list-item {{if this.lowPercentage "low-percentage"}} {{if this.mediumPercentage "medium-percentage"}} {{if this.highPercentage "high-percentage"}}">
<form local-class="item-form {{if this.isDeleting "item-form--deleting"}}" {{on "submit" (fn this.updateDocument)}}>
<div local-class="item-form-inputs">
<h2 local-class="item-title">
{{#if this.isEditing}}
<label local-class="item-form-label">
{{t "components.documents_list.item.path_label"}}
<small local-class="item-form-help">
{{{t "components.documents_list.item.path_help"}}}
</small>
<div local-class="item-form-content">
<div local-class="item-form-inputs">
<h2 local-class="item-title">
{{#if this.isEditing}}
<label local-class="item-form-label">
{{t "components.documents_list.item.path_label"}}
<small local-class="item-form-help">
{{{t "components.documents_list.item.path_help"}}}
</small>
<input
{{on-key "cmd+Enter" (fn this.updateDocument)}}
{{on "input" (fn this.changePath)}}
local-class="textInput"
value={{this.renamedDocumentPath}}
>
</label>
{{else}}
<span local-class="item-toggle-edit">
{{#if this.isDeleting}}
<span local-class="item-document-deleting-title">
{{inline-svg "/assets/loading.svg" local-class="item-document-deleting-title-loading"}}
{{t "components.documents_list.item.deleting_label" path=@document.path extension=this.documentFormatItem.extension}}
</span>
{{else}}
<button local-class="item-edit-button" {{on "click" (fn this.toggleEdit)}}>
{{inline-svg "assets/pencil.svg" local-class="item-edit-icon"}}
</button>
<LinkTo
@route="logged-in.project.files.export"
@models={{array @project.id @document.id}}
local-class="item-document"
<input
{{on-key "cmd+Enter" (fn this.updateDocument)}}
{{on "input" (fn this.changePath)}}
local-class="textInput"
value={{this.renamedDocumentPath}}
>
{{@document.path}}
<span local-class="item-subtitle">.{{this.documentFormatItem.extension}}</span>
</LinkTo>
{{/if}}
</label>
{{else}}
<span local-class="item-toggle-edit">
{{#if this.isDeleting}}
<span local-class="item-document-deleting-title">
{{inline-svg "/assets/loading.svg" local-class="item-document-deleting-title-loading"}}
{{t "components.documents_list.item.deleting_label" path=@document.path extension=this.documentFormatItem.extension}}
</span>
{{else}}
<button local-class="item-edit-button" {{on "click" (fn this.toggleEdit)}}>
{{inline-svg "assets/pencil.svg" local-class="item-edit-icon"}}
</button>
<LinkTo
@route="logged-in.project.files.export"
@models={{array @project.id @document.id}}
local-class="item-document"
>
{{@document.path}}
<span local-class="item-subtitle">.{{this.documentFormatItem.extension}}</span>
</LinkTo>
{{/if}}
</span>
{{/if}}
</h2>
</div>
{{#unless this.isEditing}}
<div local-class="stat">
<span local-class="reviewedPercentage">
{{this.correctedKeysPercentage}}
%
</span>
{{/if}}
</h2>
<span local-class="reviewedStats">
<span local-class="reviewedStats-reviewedCount">
{{this.reviewsCount}}
</span>
/
<span local-class="reviewedStats-translationsCount">
{{@document.translationsCount}}
</span>
</span>
</div>
<div local-class="progress">
<ReviewProgressBar @correctedKeysPercentage={{this.correctedKeysPercentage}} />
</div>
{{/unless}}
</div>
{{#unless this.isEditing}}
<div local-class="stat">
<span local-class="reviewedPercentage">
{{this.correctedKeysPercentage}}
%
</span>
<span local-class="reviewedStats">
<span local-class="reviewedStats-reviewedCount">
{{this.reviewsCount}}
</span>
/
<span local-class="reviewedStats-translationsCount">
{{@document.translationsCount}}
</span>
</span>
{{#if this.isEditing}}
<div local-class="links links--editing">
<AsyncButton
class="button button--filled"
@loading={{this.isUpdating}}
@onClick={{fn this.updateDocument}}
>
{{t "components.documents_list.save_button"}}
</AsyncButton>
<button
class="button button--filled button--white"
{{on "click" (fn this.toggleEdit)}}
>
{{t "components.documents_list.cancel_button"}}
</button>
</div>
<div local-class="progress">
<ReviewProgressBar @correctedKeysPercentage={{this.correctedKeysPercentage}} />
</div>
{{/unless}}
<div local-class="links">
{{#if this.isEditing}}
<div>
<button
class="button button--filled button--white"
{{on "click" (fn this.toggleEdit)}}
>
{{t "components.documents_list.cancel_button"}}
</button>
<AsyncButton
class="button button--filled"
@loading={{this.isUpdating}}
@onClick={{fn this.updateDocument}}
>
{{t "components.documents_list.save_button"}}
</AsyncButton>
</div>
{{else}}
<div>
{{#if (get @permissions "sync")}}
<LinkTo
@route="logged-in.project.files.sync"
@models={{array @project.id @document.id}}
class="button button--filled button--white"
>
{{inline-svg "/assets/sync.svg" class="button-icon"}}
{{t "components.documents_list.sync"}}
</LinkTo>
{{/if}}
{{#if (get @permissions "merge")}}
<LinkTo
@route="logged-in.project.files.add-translations"
@models={{array @project.id @document.id}}
class="button button--filled button--white"
>
{{inline-svg "/assets/merge.svg" class="button-icon"}}
{{t "components.documents_list.merge"}}
</LinkTo>
{{/if}}
{{else}}
<div local-class="links">
{{#if (get @permissions "sync")}}
<LinkTo
@route="logged-in.project.files.export"
@route="logged-in.project.files.sync"
@models={{array @project.id @document.id}}
local-class="button-sync"
class="button button--filled button--white"
>
{{inline-svg "/assets/sync.svg" class="button-icon"}}
{{t "components.documents_list.sync"}}
</LinkTo>
{{/if}}
{{#if (get @permissions "merge")}}
<LinkTo
@route="logged-in.project.files.add-translations"
@models={{array @project.id @document.id}}
class="button button--filled button--white"
>
{{inline-svg "/assets/export.svg" class="button-icon"}}
{{t "components.documents_list.export"}}
{{inline-svg "/assets/merge.svg" class="button-icon"}}
{{t "components.documents_list.merge"}}
</LinkTo>
</div>
{{/if}}
<LinkTo
@route="logged-in.project.files.export"
@models={{array @project.id @document.id}}
class="button button--filled button--white"
>
{{inline-svg "/assets/export.svg" class="button-icon"}}
{{t "components.documents_list.export"}}
</LinkTo>
</div>
<div>
{{#if (get @permissions "delete_document")}}
{{#if this.canDeleteFile}}
<AsyncButton
@onClick={{fn this.deleteFile @document}}
@loading={{this.isDeleting}}
class="button button--small button--red button--borderless"
local-class="deleteDocumentButton"
>
{{inline-svg "/assets/x.svg" class="button-icon"}}
{{t "components.documents_list.delete_document"}}
</AsyncButton>
{{/if}}
<div>
{{#if (get @permissions "delete_document")}}
{{#if this.canDeleteFile}}
<AsyncButton
@onClick={{fn this.deleteFile @document}}
@loading={{this.isDeleting}}
class="button button--small button--red button--borderless"
local-class="deleteDocumentButton"
>
{{inline-svg "/assets/x.svg" class="button-icon"}}
{{t "components.documents_list.delete_document"}}
</AsyncButton>
{{/if}}
</div>
{{/if}}
</div>
{{/if}}
</div>
{{/if}}
</form>
</li>

View File

@ -0,0 +1,9 @@
import Component from '@glimmer/component';
interface Args {
project: any;
}
export default class DocumentsMachineTranslationsButton extends Component<
Args
> {}

View File

@ -0,0 +1,3 @@
:global(.button.button--filled).button {
margin-left: 15px;
}

View File

@ -0,0 +1,10 @@
<LinkTo
@route="logged-in.project.files.machine-translations"
@model={{@project.id}}
local-class="button"
class="button button--borderLess button--filled button--white"
>
{{inline-svg "/assets/language.svg" class="button-icon"}}
{{t "components.documents_machine_translations_button.link"}}
</LinkTo>

View File

@ -0,0 +1,142 @@
import Component from '@glimmer/component';
import {inject as service} from '@ember/service';
import {action} from '@ember/object';
import {tracked} from '@glimmer/tracking';
import {htmlSafe} from '@ember/string';
import {dropTask} from 'ember-concurrency-decorators';
import GlobalState from 'accent-webapp/services/global-state';
import LanguageSearcher from 'accent-webapp/services/language-searcher';
interface Args {
languages: any;
content: string | null;
onFileReset: () => void;
onFileChange: (
file: File,
fromLanguage: string,
toLanguage: string,
documentFormat: string
) => void;
}
const preventDefault = (event: Event) => event.preventDefault();
export default class MachineTranslationsTranslateUploadForm extends Component<
Args
> {
@service('global-state')
globalState: GlobalState;
@service('language-searcher')
languageSearcher: LanguageSearcher;
@tracked
documentFormat = this.mappedDocumentFormats[0];
@tracked
file: File | null;
@tracked
fromLanguage = this.mappedLanguages[0];
@tracked
toLanguage = this.mappedLanguages[1];
get mappedLanguages(): Array<{label: string; value: string}> {
return this.args.languages.map(
({id, name}: {id: string; name: string}) => ({
label: name,
value: id,
})
);
}
get mappedDocumentFormats(): Array<{value: string; label: string}> {
if (!this.globalState.documentFormats) return [];
return this.globalState.documentFormats.map(({slug, name}) => ({
value: slug,
label: name,
}));
}
@action
onSelectDocumentFormat(documentFormat: {label: string; value: string}) {
this.documentFormat = documentFormat;
}
@action
switchLanguages() {
const fromLanguage = this.fromLanguage;
this.fromLanguage = this.toLanguage;
this.toLanguage = fromLanguage;
}
@action
onSelectFromLanguage(langage: {label: string; value: string}) {
this.fromLanguage = langage;
}
@action
onSelectToLanguage(langage: {label: string; value: string}) {
this.toLanguage = langage;
}
@action
fileChange(files: File[]) {
this.file = files[0];
}
@action
deactivateDocumentDrop() {
document.addEventListener('dragover', preventDefault);
}
@action
activateDocumentDrop() {
document.removeEventListener('dragover', preventDefault);
}
@action
async searchLanguages(term: string) {
const languages = await this.languageSearcher.search({term});
return this.mapLanguages(languages);
}
@action
dropFile(event: DragEvent) {
event.preventDefault();
const file = event.dataTransfer?.files[0];
this.file = file || null;
}
@action
resetFile() {
this.file = null;
this.args.onFileReset();
}
@dropTask
*submitTask() {
if (!this.file) return;
yield this.args.onFileChange(
this.file,
this.fromLanguage.value,
this.toLanguage.value,
this.documentFormat.value
);
}
private mapLanguages(languages: any) {
return languages.map(
({id, name, slug}: {id: string; name: string; slug: string}) => {
const label = htmlSafe(`${name} <em>${slug}</em>`);
return {label, value: id};
}
);
}
}

View File

@ -0,0 +1,109 @@
.content {
background: var(--body-background);
}
.filters {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px;
background: var(--background-light);
border-bottom: 1px solid var(--background-light-highlight);
:global(.ember-power-select-trigger) {
min-height: 26px;
border: 1px solid var(--background-light-highlight);
background: var(--body-background);
padding: 4px 8px;
border-radius: 3px;
}
}
.filters-languages {
display: flex;
align-items: center;
}
.filters-file {
display: flex;
align-items: center;
}
.form {
position: relative;
display: flex;
justify-content: flex-end;
}
.form-content {
margin: 0 auto;
padding: 100px 10px;
max-width: 400px;
text-align: center;
font-size: 13px;
}
.form-content-icons {
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
.form-content-icon {
width: 40px;
opacity: 0.6;
stroke: var(--color-grey);
}
.form-content-icon--add {
width: 20px;
margin: 0 20px;
}
.form-content-icon--highlight {
stroke: var(--color-primary);
}
.fileInput {
opacity: 0;
overflow: hidden;
position: absolute;
top: 0;
left: 0;
background: red;
width: 100%;
height: 100%;
}
:global(.button).button-switch {
margin: 0 10px;
opacity: 0.6;
color: var(--color-black);
&:hover,
&:focus {
opacity: 0.7;
color: var(--color-black);
}
}
:global(.button).button-resubmit {
margin-left: 10px;
}
:global(.button).button-submit {
display: block;
margin: 30px auto 0;
:global(.label) {
padding: 15px 30px;
font-size: 14px;
}
}
:global(.button).button-reset {
position: absolute;
top: 10px;
right: 10px;
}

View File

@ -0,0 +1,104 @@
<div local-class="content"
{{did-insert (fn this.deactivateDocumentDrop)}}
{{will-destroy (fn this.activateDocumentDrop)}}
{{on "drop" (fn this.dropFile)}}
>
<div local-class="filters">
<div local-class="filters-languages">
<AccSelect
@searchEnabled={{true}}
@search={{fn this.searchLanguages}}
@selected={{this.fromLanguage}}
@options={{this.mappedLanguages}}
@onchange={{fn this.onSelectFromLanguage}}
/>
<button
{{on "click" (fn this.switchLanguages)}}
local-class="button-switch"
class="button button--iconOnly"
>
{{inline-svg "/assets/switch.svg" class="button-icon"}}
</button>
<AccSelect
@searchEnabled={{true}}
@search={{fn this.searchLanguages}}
@selected={{this.toLanguage}}
@options={{this.mappedLanguages}}
@onchange={{fn this.onSelectToLanguage}}
/>
</div>
<div local-class="filters-file">
<AccSelect
@searchEnabled={{false}}
@selected={{this.documentFormat}}
@options={{this.mappedDocumentFormats}}
@onchange={{fn this.onSelectDocumentFormat}}
/>
{{#if @content}}
<AsyncButton
local-class="button-resubmit"
class="button button--filled"
@loading={{this.submitTask.isRunning}}
@onClick={{perform this.submitTask}}
>
{{inline-svg "/assets/language.svg" class="button-icon"}}
{{t "components.machine_translations_translate_upload_form.translate"}}
</AsyncButton>
{{/if}}
</div>
</div>
<div local-class="form">
{{#if @content}}
<button
{{on "click" (fn this.resetFile)}}
local-class="button-reset"
class="button button--white button--filled"
>
{{t "components.machine_translations_translate_upload_form.reset"}}
</button>
{{else}}
{{#if this.file}}
<div local-class="form-content">
<div local-class="form-content-icons">
{{inline-svg "/assets/import.svg" local-class="form-content-icon"}}
{{inline-svg "/assets/add.svg" local-class="form-content-icon form-content-icon--add"}}
{{inline-svg "/assets/language.svg" local-class="form-content-icon form-content-icon--highlight"}}
</div>
{{{t "components.machine_translations_translate_upload_form.step_2" name=this.file.name}}}
<AsyncButton
local-class="button-submit"
class="button button--filled"
@loading={{this.submitTask.isRunning}}
@onClick={{perform this.submitTask}}
>
{{inline-svg "/assets/language.svg" class="button-icon"}}
{{t "components.machine_translations_translate_upload_form.translate"}}
</AsyncButton>
</div>
{{else}}
<FileInput
name="file-input"
id="file-input"
@onChange={{fn this.fileChange}}
local-class="fileInput"
/>
<div local-class="form-content">
<div local-class="form-content-icons">
{{inline-svg "/assets/import.svg" local-class="form-content-icon form-content-icon--highlight"}}
{{inline-svg "/assets/add.svg" local-class="form-content-icon form-content-icon--add"}}
{{inline-svg "/assets/language.svg" local-class="form-content-icon"}}
</div>
{{t "components.machine_translations_translate_upload_form.step_1"}}
</div>
{{/if}}
{{/if}}
</div>
</div>

View File

@ -0,0 +1,11 @@
.render {
padding: 15px;
min-height: 50px;
border-top: 0;
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.05);
background: var(--body-background);
overflow-x: scroll;
font-family: var(--font-monospace);
font-size: 11px;
line-height: 1.7;
}

View File

@ -0,0 +1,3 @@
{{#if @content}}
<pre local-class="render">{{@content}}</pre>
{{/if}}

View File

@ -1,3 +1,9 @@
:global(.filters).filters {
border: 1px solid #eee;
border-radius: 3px;
box-shadow: none;
}
.filterList {
display: flex;
align-items: center;

View File

@ -1,4 +1,4 @@
<div class="filters">
<div class="filters" local-class="filters">
<div class="filters-wrapper">
<ul local-class="filterList">
{{#unless @actionFilter}}

View File

@ -95,7 +95,8 @@ export default class ProjectActivity extends Component<Args> {
if (!this.args.activity.action) return;
return this.intl.t(
`components.project_activity.action_text.${this.args.activity.action}`
`components.project_activity.action_text.${this.args.activity.action}`,
{document: this.args.activity.document?.path}
);
}

View File

@ -201,7 +201,6 @@
{{t "components.project_activity.batch_operation_label"}}
</span>
<ActivityItem
@compact={{true}}
@permissions={{@permissions}}
@showTranslationLink={{true}}
@componentTranslationPrefix="project_activities_list_item"

View File

@ -3,7 +3,6 @@
}
.translationCommentsList {
background: var(--background-light);
border-radius: 3px;
}

View File

@ -11,30 +11,24 @@
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 30px;
padding: 20px;
margin: 0 30px 30px 0;
min-width: 225px;
min-width: 125px;
text-align: center;
background: var(--body-background);
border-radius: 3px;
color: #999;
box-shadow: 0 1px 4px var(--shadow-color);
border: 1px solid transparent;
color: var(--color-black);
box-shadow: 0 9px 19px var(--shadow-color);
text-decoration: none;
font-weight: 600;
font-size: 13px;
transition: 0.2s ease-in-out;
transition-property: background, border-color, color;
.link-icon {
stroke: #999;
}
transition-property: box-shadow, color;
&:focus,
&:hover {
color: var(--color-primary);
background: var(--background-light);
box-shadow: 0 3px 10px var(--shadow-color);
.link-icon {
stroke: var(--color-primary);
@ -45,9 +39,11 @@
.link-icon {
width: 20px;
height: 20px;
margin-bottom: 3px;
margin-right: 10px;
transition: 0.2s ease-in-out;
transition-property: stroke;
opacity: 0.3;
stroke: var(--color-black);
}
@media (max-width: var(--screen-md)) {

View File

@ -31,14 +31,14 @@
.projects-list-item {
padding: 12px 15px;
transition: 0.2s ease-in-out;
transition-property: background;
transition-property: box-shadow;
border-radius: 3px;
box-shadow: 0 2px 4px var(--shadow-color);
color: var(--color-black);
&:focus,
&:hover {
background: var(--background-light);
box-shadow: 0 9px 19px var(--shadow-color);
.projectName {
color: var(--color-primary);

View File

@ -18,6 +18,12 @@
flex-direction: column;
text-decoration: none;
margin: 0 10px 30px;
transition: 0.2s ease-in-out;
transition-property: background;
&:focus {
background: var(--background-light);
}
}
@media (max-width: var(--screen-md)) {

View File

@ -53,30 +53,21 @@
font-size: 12px;
}
.textEdit-cancel {
margin-right: 20px;
opacity: 0.6;
color: var(--color-black);
font-size: 12px;
cursor: pointer;
}
.textEdit-button {
padding: 1px 9px !important;
position: absolute !important;
top: 41px !important;
right: 14px !important;
}
.textEdit-actions {
display: flex;
justify-content: flex-end;
align-items: center;
position: absolute;
top: 14px;
right: 14px;
}
.textEdit-actions-warning {
margin-right: 10px;
font-style: italic;
font-size: 12px;
color: #bbb;
.textEdit-actions-translate {
margin-right: 5px;
background: none;
}
.textEdit-actions-translate-icon {
width: 12px;
height: 12px;
}

View File

@ -64,15 +64,19 @@
@value={{this.editText}}
@onKeyUp={{fn this.changeText}}
@onSubmit={{fn this.save}}
/>
<AsyncButton
@onClick={{fn this.save}}
@loading={{this.isSaving}}
@disabled={{not showSaveButton}}
class="button button--filled button--iconOnly"
local-class="textEdit-button"
as |form|
>
{{t "components.related_translations_list.save_button"}}
</AsyncButton>
{{#component form.submit}}
<div local-class="textEdit-actions">
<AsyncButton
@onClick={{fn this.save}}
@loading={{this.isSaving}}
@disabled={{not showSaveButton}}
class="button button--filled button--iconOnly"
>
{{t "components.related_translations_list.save_button"}}
</AsyncButton>
</div>
{{/component}}
</TranslationEdit::Form>
</li>

View File

@ -1,5 +1,5 @@
<div local-class="revision-export-options">
<div class="filters filters--white" local-class="filters">
<div class="filters" local-class="filters">
<div class="filters-wrapper">
<div local-class="exportOptions">
{{#if this.showRevisions}}

View File

@ -1,8 +1,7 @@
.translation-comments-list {
position: relative;
box-shadow: 0 1px 5px var(--shadow-color);
box-shadow: 0 9px 19px var(--shadow-color);
border-radius: 3px;
border: 1px solid var(--background-light-highlight);
}
.translation-comments-list.translationRemoved {

View File

@ -5,6 +5,7 @@ import {tracked} from '@glimmer/tracking';
interface Args {
translation: any;
text: string | null;
project: any;
permissions: Record<string, true>;
onChangeText?: (text: string) => void;
@ -45,6 +46,12 @@ export default class TranslationEdit extends Component<Args> {
return this.text === this.args.translation.correctedText;
}
@action
setOriginalText() {
this.text = this.args.translation.correctedText;
this.args.onChangeText?.(this.text);
}
@action
async correctConflict() {
this.isCorrectingConflict = true;
@ -88,6 +95,7 @@ export default class TranslationEdit extends Component<Args> {
@action
focusTextarea(element: HTMLElement) {
if (!this.text) return;
const focusable = element.querySelector('textarea');
focusable?.focus();
focusable?.setSelectionRange(this.text.length, this.text.length);

View File

@ -95,11 +95,13 @@ export default class TranslationEditForm extends Component<Args> {
@action
async changeHTML(value: string) {
const previousText = this.args.value;
this.args.onKeyUp?.(value);
}
if (previousText !== value)
await (this.fetchLintMessagesTask as Task).perform(value);
@action
async changeText(event: Event) {
const target = event.target as HTMLInputElement;
this.args.onKeyUp?.(target.value);
}
@action
@ -107,14 +109,9 @@ export default class TranslationEditForm extends Component<Args> {
this.args.onEscape?.();
}
@action
async changeText(event: Event) {
const target = event.target as HTMLInputElement;
const previousText = this.args.value;
this.args.onKeyUp?.(target.value);
if (previousText !== target.value)
await (this.fetchLintMessagesTask as Task).perform(target.value);
@restartableTask
*onUpdateValue(_element: HTMLElement, [value]: string[]) {
yield (this.fetchLintMessagesTask as Task).perform(value);
}
@restartableTask
@ -137,6 +134,5 @@ export default class TranslationEditForm extends Component<Args> {
@action
async replaceText(value: string) {
this.args.onKeyUp?.(value);
await (this.fetchLintMessagesTask as Task).perform(value);
}
}

View File

@ -22,6 +22,10 @@
}
}
.input-wrapper {
position: relative;
}
.label {
width: 100%;
padding: 5px 5px 5px 8px;

View File

@ -0,0 +1 @@
{{yield}}

View File

@ -1,4 +1,4 @@
<div local-class="translation-edit-form">
<div local-class="translation-edit-form" {{did-update (perform this.onUpdateValue) @value}}>
{{#if @placeholders}}
<div local-class="placeholders">
<span local-class="placeholders-title">
@ -84,21 +84,28 @@
{{/if}}
{{#if this.isHTMLType}}
<HtmlTextarea
@value={{@text}}
@onChange={{fn this.changeHTML}}
@wysiwygOptions={{this.wysiwygOptions}}
/>
<div local-class="input-wrapper">
<HtmlTextarea
@value={{@text}}
@onChange={{fn this.changeHTML}}
@wysiwygOptions={{this.wysiwygOptions}}
/>
{{yield (hash submit=(component "translation-edit/form/submit"))}}
</div>
{{else}}
<textarea
{{autoresize @value}}
{{on-key "Escape" (fn this.cancel)}}
{{on-key "cmd+Enter" @onSubmit}}
{{on "input" (fn this.changeText)}}
local-class="inputText"
value={{@value}}
...attributes
/>
<div local-class="input-wrapper">
<textarea
{{autoresize @value}}
{{on-key "Escape" (fn this.cancel)}}
{{on-key "cmd+Enter" @onSubmit}}
{{on "input" (fn this.changeText)}}
local-class="inputText"
value={{@value}}
...attributes
/>
{{yield (hash submit=(component "translation-edit/form/submit"))}}
</div>
{{/if}}
{{#each this.lintMessages as |message|}}

View File

@ -69,9 +69,10 @@
}
}
.actions-updatedAt {
margin-right: 10px;
color: var(--color-grey);
font-size: 11px;
font-style: italic;
.actions-button-revert {
opacity: 0.6;
:global(.label) {
padding: 3px 2px;
}
}

View File

@ -33,10 +33,16 @@
{{/if}}
</div>
<div local-class="actions-buttons">
<div local-class="actions-updatedAt">
{{t "components.translation_edit.last_updated_label"}}
<TimeAgoInWordsTag @date={{@translation.updatedAt}} />
</div>
{{#unless this.hasTextNotChanged}}
<AsyncButton
@onClick={{fn this.setOriginalText}}
local-class="actions-button-revert"
class="button button--iconOnly button--white"
>
{{inline-svg "/assets/revert.svg" class="button-icon"}}
</AsyncButton>
{{/unless}}
{{#if (get @permissions "update_translation")}}
<AsyncButton
@loading={{this.isUpdatingText}}
@ -75,10 +81,16 @@
{{/if}}
</div>
<div local-class="actions-buttons">
<div local-class="actions-updatedAt">
{{t "components.translation_edit.last_updated_label"}}
<TimeAgoInWordsTag @date={{@translation.updatedAt}} />
</div>
{{#unless this.hasTextNotChanged}}
<AsyncButton
@onClick={{fn this.setOriginalText}}
local-class="actions-button-revert"
class="button button--iconOnly button--white"
>
{{inline-svg "/assets/revert.svg" class="button-icon"}}
</AsyncButton>
{{/unless}}
{{#if (get @permissions "update_translation")}}
<AsyncButton
@loading={{this.isUpdatingText}}
@ -89,6 +101,7 @@
{{t "components.translation_edit.update_text"}}
</AsyncButton>
{{/if}}
{{#unless @translation.version}}
{{#if (get @permissions "uncorrect_translation")}}
<AsyncButton

View File

@ -21,6 +21,12 @@
}
}
.updatedAt {
color: var(--color-grey);
font-size: 11px;
font-style: italic;
}
.back-icon {
width: 11px;
height: 11px;

View File

@ -68,4 +68,20 @@
{{t "components.translation_splash_title.master_label"}}
</AccBadge>
{{/if}}
{{#if @translation.lintMessages}}
<AccBadge
@warning={{true}}
@icon={{true}}
class="tooltip"
title={{t "components.translations_list.lint_messages_label" count=@translation.lintMessages.length}}
>
{{inline-svg "/assets/warning.svg"}}
</AccBadge>
{{/if}}
<span local-class="updatedAt">
{{t "components.translation_splash_title.last_updated_label"}}
<TimeAgoInWordsTag @date={{@translation.updatedAt}} />
</span>
</div>

View File

@ -4,9 +4,15 @@ interface Args {
isTextEmptyFilter: boolean;
isTextNotEmptyFilter: boolean;
isAddedLastSyncFilter: boolean;
isConflictedFilter: boolean;
isCommentedOnFilter: boolean;
onChangeAdvancedFilterBoolean: (
key: 'isTextEmpty' | 'isTextNotEmpty' | 'isAddedLastSync' | 'isCommentedOn',
key:
| 'isTextEmpty'
| 'isTextNotEmpty'
| 'isAddedLastSync'
| 'isCommentedOn'
| 'isConflicted',
event: InputEvent
) => void;
}

View File

@ -14,13 +14,13 @@
.label {
display: flex;
align-items: center;
width: 50%;
width: 100%;
max-width: 250px;
margin-top: 10px;
color: var(--color-grey);
font-size: 12px;
input {
margin-right: 4px;
margin-right: 6px;
}
}

View File

@ -57,5 +57,18 @@
}}
</span>
</label>
<label local-class="label">
<input
type="checkbox"
checked={{@isConflictedFilter}}
{{on "change" (fn @onChangeAdvancedFilterBoolean "isConflicted")}}
>
<span>
{{t
"components.translations_filter.advanced_filters.conflicted"
}}
</span>
</label>
</div>
</div>

View File

@ -21,6 +21,7 @@ interface Args {
isTextNotEmptyFilter: boolean;
isAddedLastSyncFilter: boolean;
isCommentedOnFilter: boolean;
isConflictedFilter: boolean;
onChangeQuery: (query: string) => void;
onChangeDocument: () => void;
onChangeVersion: () => void;

View File

@ -15,6 +15,7 @@
"components.translations_filter.input_placeholder_text"
}}
value={{this.debouncedQuery}}
{{on-key "Enter" (fn this.submitForm)}}
{{on "keyup" (fn this.setDebouncedQuery)}}
>
</div>
@ -72,6 +73,7 @@
@isTextNotEmptyFilter={{@isTextNotEmptyFilter}}
@isAddedLastSyncFilter={{@isAddedLastSyncFilter}}
@isCommentedOnFilter={{@isCommentedOnFilter}}
@isConflictedFilter={{@isConflictedFilter}}
@onChangeAdvancedFilterBoolean={{@onChangeAdvancedFilterBoolean}}
/>
{{/if}}

View File

@ -100,6 +100,11 @@
font-weight: bold;
}
.item-meta {
display: flex;
align-items: center;
}
.item-text {
display: block;
width: 100%;

View File

@ -28,7 +28,7 @@
</LinkTo>
</span>
<span>
<span local-class="item-meta">
{{#if @translation.isConflicted}}
<AccBadge @link={{true}} @primary={{true}}>
<LinkTo
@ -41,6 +41,18 @@
</AccBadge>
{{/if}}
{{#if @translation.lintMessages}}
<AccBadge
{{on "click" (fn this.toggleEdit)}}
@warning={{true}}
@icon={{true}}
class="tooltip"
title={{t "components.translations_list.lint_messages_label" count=@translation.lintMessages.length}}
>
{{inline-svg "/assets/warning.svg"}}
</AccBadge>
{{/if}}
{{#if @translation.commentsCount}}
<AccBadge @link={{true}}>
<LinkTo

View File

@ -0,0 +1,50 @@
import {inject as service} from '@ember/service';
import {action} from '@ember/object';
import Controller from '@ember/controller';
import RouterService from '@ember/routing/router-service';
import MachineTranslations from 'accent-webapp/services/machine-translations';
import {tracked} from '@glimmer/tracking';
export default class MachineTranslationsController extends Controller {
@service('router')
router: RouterService;
@service('machine-translations')
machineTranslations: MachineTranslations;
@tracked
translatedFileContent = '';
@action
closeModal() {
this.router.transitionTo('logged-in.project.files.index');
}
get languages() {
return this.model.project.revisions.map(
(revision: {language: any}) => revision.language
);
}
@action
resetContent() {
this.translatedFileContent = '';
}
@action
async translate(
file: File,
fromLanguage: string,
toLanguage: string,
documentFormat: string
) {
const content = await this.machineTranslations.translateFile({
project: this.model.project,
file,
fromLanguage,
toLanguage,
documentFormat,
});
this.translatedFileContent = content;
}
}

View File

@ -0,0 +1,13 @@
import Route from '@ember/routing/route';
import MachineTranslationsController from 'accent-webapp/pods/logged-in/project/files/machine-translations/controller';
export default class MachineTranslationsRoute extends Route {
resetController(
controller: MachineTranslationsController,
isExiting: boolean
) {
if (isExiting) {
controller.translatedFileContent = '';
}
}
}

View File

@ -0,0 +1,13 @@
<AccModal @onClose={{fn this.closeModal}}>
<MachineTranslationsTranslateUploadForm
@languages={{this.languages}}
@onFileChange={{fn this.translate}}
@onFileReset={{fn this.resetContent}}
@content={{this.translatedFileContent}}
/>
<MachineTranslationsTranslatedFile
@content={{this.translatedFileContent}}
/>
</AccModal>

View File

@ -17,6 +17,10 @@
<DocumentsAddButton @project={{this.model.project}} />
{{/if}}
{{#if (get this.permissions "machine_translations_translate_file")}}
<DocumentsMachineTranslationsButton @project={{this.model.project}} />
{{/if}}
<ResourcePagination
@meta={{this.model.documents.meta}}
@onSelectPage={{fn this.selectPage}}

View File

@ -5,9 +5,11 @@ import Controller from '@ember/controller';
import translationCorrectQuery from 'accent-webapp/queries/correct-translation';
import IntlService from 'ember-intl/services/intl';
import FlashMessages from 'ember-cli-flash/services/flash-messages';
import Apollo from 'accent-webapp/services/apollo';
import ApolloMutate from 'accent-webapp/services/apollo-mutate';
import GlobalState from 'accent-webapp/services/global-state';
import {tracked} from '@glimmer/tracking';
import projectTranslateTextQuery from 'accent-webapp/queries/translate-text-project';
const FLASH_MESSAGE_CORRECT_SUCCESS =
'pods.project.conflicts.flash_messages.correct_success';
@ -21,6 +23,9 @@ export default class ConflictsController extends Controller {
@service('flash-messages')
flashMessages: FlashMessages;
@service('apollo')
apollo: Apollo;
@service('apollo-mutate')
apolloMutate: ApolloMutate;
@ -56,6 +61,34 @@ export default class ConflictsController extends Controller {
@and('emptyEntries', 'model.loading', 'emptyQuery', 'emptyDocument')
showSkeleton: boolean;
@action
async copyTranslation(
text: string,
sourceLanguageSlug: string,
targetLanguageSlug: string
) {
try {
const {data} = await this.apollo.client.query({
fetchPolicy: 'network-only',
query: projectTranslateTextQuery,
variables: {
text,
sourceLanguageSlug,
targetLanguageSlug,
projectId: this.model.project.id,
},
});
if (data.viewer?.project?.translatedText?.[0]) {
return data.viewer.project.translatedText[0];
} else {
return {text: null};
}
} catch (error) {
return {text: null};
}
}
@action
async correctConflict(conflict: any, text: string) {
try {

View File

@ -9,6 +9,7 @@
@showSkeleton={{this.showSkeleton}}
@query={{this.query}}
@onCorrect={{fn this.correctConflict}}
@onCopyTranslation={{fn this.copyTranslation}}
@onSelectPage={{fn this.selectPage}}
@onChangeDocument={{fn this.changeDocument}}
@onChangeQuery={{fn this.changeQuery}}

View File

@ -32,6 +32,7 @@ export default class TranslationsController extends Controller {
'isTextNotEmpty',
'isAddedLastSync',
'isCommentedOn',
'isConflictedFilter',
];
@tracked
@ -55,6 +56,9 @@ export default class TranslationsController extends Controller {
@tracked
isAddedLastSync: 'true' | null = null;
@tracked
isConflicted: 'true' | null = null;
@tracked
isCommentedOn: 'true' | null = null;
@ -76,6 +80,7 @@ export default class TranslationsController extends Controller {
this.isTextNotEmpty,
this.isAddedLastSync,
this.isCommentedOn,
this.isConflicted,
].filter((filter) => filter === 'true').length;
}
@ -87,7 +92,12 @@ export default class TranslationsController extends Controller {
@action
changeAdvancedFilterBoolean(
key: 'isTextEmpty' | 'isTextNotEmpty' | 'isAddedLastSync' | 'isCommentedOn',
key:
| 'isTextEmpty'
| 'isTextNotEmpty'
| 'isAddedLastSync'
| 'isCommentedOn'
| 'isConflicted',
event: InputEvent
) {
this[key] = (event.target as HTMLInputElement).checked ? 'true' : null;

View File

@ -46,6 +46,9 @@ export default class TranslationsRoute extends Route {
isCommentedOn: {
refreshModel: true,
},
isConflicted: {
refreshModel: true,
},
};
subscription: Subscription;
@ -55,6 +58,7 @@ export default class TranslationsRoute extends Route {
params.isTextNotEmpty = params.isTextNotEmpty === 'true' ? true : null;
params.isAddedLastSync = params.isAddedLastSync === 'true' ? true : null;
params.isCommentedOn = params.isCommentedOn === 'true' ? true : null;
params.isConflicted = params.isConflicted === 'true' ? true : null;
this.subscription = this.apolloSubscription.graphql(
() => this.modelFor(this.routeName),

View File

@ -9,6 +9,7 @@
@isTextNotEmptyFilter={{this.isTextNotEmpty}}
@isAddedLastSyncFilter={{this.isAddedLastSync}}
@isCommentedOnFilter={{this.isCommentedOn}}
@isConflictedFilter={{this.isConflicted}}
@onChangeQuery={{fn this.changeQuery}}
@onChangeAdvancedFilterBoolean={{fn this.changeAdvancedFilterBoolean}}
@onChangeDocument={{fn this.changeDocument}}

View File

@ -56,10 +56,12 @@ export default gql`
revision {
id
isMaster
slug
name
language {
id
slug
name
}
}
@ -72,10 +74,12 @@ export default gql`
id
isMaster
name
slug
language {
id
name
slug
}
}
}

View File

@ -90,6 +90,15 @@ export default gql`
path
}
revision {
id
name
language {
id
name
}
}
version {
id
tag

View File

@ -115,6 +115,12 @@ export default gql`
name
lastSyncedAt
lastActivitySync: lastActivity(action: "sync") {
id
action
insertedAt
}
documents {
entries {
id

View File

@ -8,6 +8,10 @@ export default gql`
revisions {
id
language {
id
name
}
}
documents(page: $page) {

View File

@ -0,0 +1,24 @@
import gql from 'graphql-tag';
export default gql`
query TranslateTextProject(
$projectId: ID!
$text: String
$sourceLanguageSlug: String!
$targetLanguageSlug: String!
) {
viewer {
project(id: $projectId) {
id
translatedText(
text: $text
sourceLanguageSlug: $sourceLanguageSlug
targetLanguageSlug: $targetLanguageSlug
) {
id
text
}
}
}
}
`;

View File

@ -62,10 +62,12 @@ export default gql`
revision {
id
name
slug
isMaster
language {
id
slug
name
}
}
@ -80,9 +82,11 @@ export default gql`
id
name
isMaster
slug
language {
id
slug
name
}
}

View File

@ -12,6 +12,7 @@ export default gql`
$isTextNotEmpty: Boolean
$isAddedLastSync: Boolean
$isCommentedOn: Boolean
$isConflicted: Boolean
) {
viewer {
project(id: $projectId) {
@ -42,6 +43,7 @@ export default gql`
isTextEmpty: $isTextEmpty
isTextNotEmpty: $isTextNotEmpty
isAddedLastSync: $isAddedLastSync
isConflicted: $isConflicted
isCommentedOn: $isCommentedOn
) {
meta {

View File

@ -41,6 +41,7 @@ export default Router.map(function () {
this.route('activities');
this.route('files', function () {
this.route('new-sync');
this.route('machine-translations');
this.route('sync', {path: ':fileId/sync'});
this.route('add-translations', {path: ':fileId/add-translations'});
this.route('export', {path: ':fileId/export'});

View File

@ -17,6 +17,10 @@ interface PeekOptions {
documentFormat: string;
}
interface MachineTranslationsTranslateFileOptions {
file: File;
}
export default class AuthenticatedRequest extends Service {
@service('session')
session: Session;
@ -25,6 +29,15 @@ export default class AuthenticatedRequest extends Service {
return this.postFile(url, options);
}
async machineTranslationsTranslateFile(
url: string,
options: MachineTranslationsTranslateFileOptions
) {
const response = await this.postFile(url, options);
return response.text();
}
async peek(url: string, options: PeekOptions) {
const response = await this.postFile(url, options);
@ -43,7 +56,13 @@ export default class AuthenticatedRequest extends Service {
return response.text();
}
private async postFile(url: string, options: PeekOptions | CommitOptions) {
private async postFile(
url: string,
options:
| PeekOptions
| CommitOptions
| MachineTranslationsTranslateFileOptions
) {
const fetchOptions: RequestInit = {};
fetchOptions.method = 'POST';
@ -63,19 +82,17 @@ export default class AuthenticatedRequest extends Service {
return response;
}
private setupFormFile({
file,
documentPath,
documentFormat,
}: {
private setupFormFile(options: {
file: File;
documentPath: string;
documentFormat: string;
documentPath?: string;
documentFormat?: string;
}) {
const formData = new FormData();
formData.append('file', file);
formData.append('document_path', documentPath);
formData.append('document_format', documentFormat);
formData.append('file', options.file);
if (options.documentPath)
formData.append('document_path', options.documentPath);
if (options.documentFormat)
formData.append('document_format', options.documentFormat);
return formData;
}

View File

@ -0,0 +1,43 @@
import fmt from 'simple-fmt';
import Service, {inject as service} from '@ember/service';
import config from 'accent-webapp/config/environment';
import AuthenticatedRequest from 'accent-webapp/services/authenticated-request';
interface TranslateFileOptions {
project: any;
file: File;
toLanguage: string;
fromLanguage: string;
documentFormat: string;
}
export default class MachineTranslations extends Service {
@service('authenticated-request')
authenticatedRequest: AuthenticatedRequest;
async translateFile({
project,
file,
toLanguage,
fromLanguage,
documentFormat,
}: TranslateFileOptions) {
const url = fmt(
config.API.MACHINE_TRANSLATIONS_TRANSLATE_FILE_PROJECT_PATH,
project.id,
fromLanguage,
toLanguage,
documentFormat
);
return this.authenticatedRequest.machineTranslationsTranslateFile(url, {
file,
});
}
}
declare module '@ember/service' {
interface Registry {
'machine-translations': MachineTranslations;
}
}

View File

@ -197,14 +197,16 @@ html {
--color-primary-hue: 155;
--color-primary-saturation: 67%;
--color-primary-darken-10: #1c9060;
--color-primary-opacity-10: #233e32;
--color-primary-opacity-50: #296349;
--color-primary-opacity-70: #238458;
--color-primary-opacity-10: rgba(40, 203, 135, 0.1);
--color-primary-opacity-50: rgba(40, 203, 135, 0.5);
--color-primary-opacity-70: rgba(40, 203, 135, 0.7);
--color-highlight-lighteness: 95%;
--color-grey: #adadad;
--color-black: #484848;
--background-tooltip: #484848;
--text-color-tooltip: #fff;
--color-green: #28cb87;
--color-green-hue: 155;
@ -228,7 +230,7 @@ html {
--color-error-hue: 0;
--color-error-saturation: 65%;
--shadow-color: rgba(0, 0, 0, 0.1);
--shadow-color: rgba(0, 0, 0, 0.07);
background: #eee;
}
@ -257,6 +259,9 @@ html[data-theme='dark'] {
--color-black: #adadad;
--color-grey: #616161;
--background-tooltip: #484848;
--text-color-tooltip: #fff;
background: #171717;
}
@ -300,6 +305,46 @@ a {
justify-content: space-between;
}
:global(.tooltip) {
position: relative;
&::before {
content: '';
margin-left: -5px;
border: 6px solid;
border-color: transparent var(--background-tooltip) transparent transparent;
}
&::after {
content: attr(title);
text-transform: none;
margin-left: 7px;
padding: 5px 10px;
white-space: nowrap;
border-radius: 4px;
background: var(--background-tooltip);
color: var(--text-color-tooltip);
text-align: left;
font-size: 11px;
}
&::before,
&::after {
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
transition: 0.3s opacity;
opacity: 0;
}
&:hover::before,
&:hover::after {
opacity: 1;
}
}
:global(.added) {
padding: 0 3px;
background: hsl(

Some files were not shown because too many files have changed in this diff Show More