diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc new file mode 100644 index 0000000000..7a50d55cb5 --- /dev/null +++ b/.cursor/rules/general.mdc @@ -0,0 +1,4 @@ +--- +alwaysApply: true +--- +Always start by reading .github/copilot-instructions.md and any docs/llm-support that are required. \ No newline at end of file diff --git a/Gemfile b/Gemfile index adba2a9f03..d366e23d2e 100644 --- a/Gemfile +++ b/Gemfile @@ -38,6 +38,7 @@ gem 'octokit' # GitHub gem 'mandate', '~> 2.0' gem 'kaminari' gem 'oj', '~> 3.14.0' +gem 'i18n', '>= 0.5.0' # Setup dependencies gem 'exercism-config', '>= 0.130.0' diff --git a/Gemfile.lock b/Gemfile.lock index 8714e0ca93..1c8b0d5141 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -665,6 +665,7 @@ DEPENDENCIES haml_lint hamlit humanize + i18n (>= 0.5.0) image_processing (~> 1.2) jsbundling-rails kaminari diff --git a/Procfile.dev b/Procfile.dev index 7c1d4a9116..cfb2f68212 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,6 +1,6 @@ server: bin/rails server -p 3020 anycable: bundle exec anycable -sidekiq: bundle exec sidekiq +#sidekiq: bundle exec sidekiq ws: anycable-go --host='local.exercism.io' --rpc_host='local.exercism.io:50051' --port=3334 css: yarn build:css --watch js: yarn build --watch diff --git a/app/assemblers/assemble_localization_originals.rb b/app/assemblers/assemble_localization_originals.rb new file mode 100644 index 0000000000..d69cff3c62 --- /dev/null +++ b/app/assemblers/assemble_localization_originals.rb @@ -0,0 +1,30 @@ +class AssembleLocalizationOriginals + include Mandate + + initialize_with :user, :params + + def self.keys + %i[criteria status page] + end + + def call + SerializePaginatedCollection.( + translations, + serializer: SerializeLocalizationOriginals, + serializer_args: user, + meta: { + unscoped_total: Localization::Original.count + } + ) + end + + memoize + def translations + Localization::Original::Search.( + user, + criteria: params[:criteria], + status: params[:status], + page: params[:page] + ) + end +end diff --git a/app/channels/localization_translation_channel.rb b/app/channels/localization_translation_channel.rb new file mode 100644 index 0000000000..1dd252315c --- /dev/null +++ b/app/channels/localization_translation_channel.rb @@ -0,0 +1,22 @@ +class LocalizationTranslationChannel < ApplicationCable::Channel + def self.broadcast!(translation) + ActionCable.server.broadcast( + channel_name(translation.locale), + { + key: translation.key, + locale: translation.locale, + value: translation.value + } + ) + end + + def subscribed + stream_from self.class.channel_name(params[:locale]) + end + + def unsubscribed; end + + def self.channel_name(locale) + "localization_translations_#{locale}" + end +end diff --git a/app/commands/cache/key_for_footer.rb b/app/commands/cache/key_for_footer.rb index 1876a39af0..a0610ccca2 100644 --- a/app/commands/cache/key_for_footer.rb +++ b/app/commands/cache/key_for_footer.rb @@ -9,7 +9,8 @@ def call parts << ::Track.num_active parts << user_part parts << stripe_version - parts << "v3" + parts << I18n.available_locales.count + parts << "3" # Basic expiry key parts.join(':') end diff --git a/app/commands/generic_exercise/create_or_update.rb b/app/commands/generic_exercise/create_or_update.rb index ddfa5ae68e..ffadac1648 100644 --- a/app/commands/generic_exercise/create_or_update.rb +++ b/app/commands/generic_exercise/create_or_update.rb @@ -1,20 +1,44 @@ class GenericExercise::CreateOrUpdate include Mandate - initialize_with :slug, :title, :blurb, :source, :source_url, :deep_dive_youtube_id, :deep_dive_blurb, :status + initialize_with :repo_exercise def call create!.tap do |exercise| exercise.update!(attributes) + + localize!(:generic_exercise_instructions, exercise.instructions, exercise) + localize!(:generic_exercise_introduction, exercise.introduction, exercise) + localize!(:generic_exercise_title, exercise.title, exercise) + localize!(:generic_exercise_blurb, exercise.blurb, exercise) + localize!(:generic_exercise_source, exercise.source, exercise) end end private def create! - GenericExercise.find_create_or_find_by!(slug:) do |exercise| + GenericExercise.find_create_or_find_by!(slug: repo_exercise[:slug]) do |exercise| exercise.attributes = attributes end end - def attributes = { title:, blurb:, source:, source_url:, deep_dive_youtube_id:, deep_dive_blurb:, status: } + def localize!(type, content, exercise) + return unless content.present? + + Localization::Text::AddToLocalization.defer(type, content, exercise) + end + + memoize + def attributes + { + slug: repo_exercise[:slug], + title: repo_exercise[:title], + blurb: repo_exercise[:blurb], + source: repo_exercise[:source], + source_url: repo_exercise[:source_url], + deep_dive_youtube_id: repo_exercise[:deep_dive_youtube_id], + deep_dive_blurb: repo_exercise[:deep_dive_blurb], + status: repo_exercise[:deprecated] ? :deprecated : :active + } + end end diff --git a/app/commands/git/sync_blog.rb b/app/commands/git/sync_blog.rb index 66166098a7..84e9c46843 100644 --- a/app/commands/git/sync_blog.rb +++ b/app/commands/git/sync_blog.rb @@ -35,8 +35,19 @@ def create_or_update_post(data) end post.update!(attributes) - rescue StandardError => e - Github::Issue::OpenForBlogSyncFailure.(e, repo.head_commit.oid) + + localize!(:post_title, post.title, post.id) + localize!(:post_description, post.description, post.id) + localize!(:post_content, post.content, post.id) + + # rescue StandardError => e + # Github::Issue::OpenForBlogSyncFailure.(e, repo.head_commit.oid) + end + + def localize!(type, content, post_id) + return unless content.present? + + Localization::Text::AddToLocalization.defer(type, content, post_id) end def create_or_update_story(data) diff --git a/app/commands/git/sync_concept.rb b/app/commands/git/sync_concept.rb index fa85334af9..56a0a90a9a 100644 --- a/app/commands/git/sync_concept.rb +++ b/app/commands/git/sync_concept.rb @@ -20,11 +20,22 @@ def call Git::SyncConceptAuthors.(concept) Git::SyncConceptContributors.(concept) + + localize!(:concept_name, concept.name) + localize!(:concept_blurb, concept.blurb) + localize!(:concept_introduction, concept.introduction) + localize!(:concept_about, concept.about) end private attr_reader :concept, :force_sync + def localize!(type, content) + return unless content.present? + + Localization::Text::AddToLocalization.defer(type, content, concept.id) + end + def concept_needs_updating? track_config_concept_modified? || concept_config_modified? end diff --git a/app/commands/git/sync_concept_exercise.rb b/app/commands/git/sync_concept_exercise.rb index 805d738dd6..05e00e69c2 100644 --- a/app/commands/git/sync_concept_exercise.rb +++ b/app/commands/git/sync_concept_exercise.rb @@ -31,11 +31,23 @@ def call Git::SyncExerciseArticles.(exercise) ::Exercise::UpdateHasApproaches.(exercise) SiteUpdates::ProcessNewExerciseUpdate.(exercise) + + localize!(:exercise_instructions, exercise.instructions, exercise) + localize!(:exercise_introduction, exercise.introduction, exercise) + localize!(:exercise_title, exercise.title, exercise) + localize!(:exercise_blurb, exercise.blurb, exercise) + localize!(:exercise_source, exercise.source, exercise) end private attr_reader :exercise, :force_sync + def localize!(type, content, exercise) + return unless content.present? + + Localization::Text::AddToLocalization.defer(type, content, exercise) + end + def exercise_needs_updating? track_config_exercise_modified? || exercise_config_modified? || exercise_files_modified? end diff --git a/app/commands/git/sync_practice_exercise.rb b/app/commands/git/sync_practice_exercise.rb index 3f19a0b7e7..058d22c3af 100644 --- a/app/commands/git/sync_practice_exercise.rb +++ b/app/commands/git/sync_practice_exercise.rb @@ -32,11 +32,23 @@ def call Git::SyncExerciseArticles.(exercise) ::Exercise::UpdateHasApproaches.(exercise) SiteUpdates::ProcessNewExerciseUpdate.(exercise) + + localize!(:exercise_instructions, exercise.instructions, exercise) + localize!(:exercise_introduction, exercise.introduction, exercise) + localize!(:exercise_title, exercise.title, exercise) + localize!(:exercise_blurb, exercise.blurb, exercise) + localize!(:exercise_source, exercise.source, exercise) end private attr_reader :exercise, :force_sync + def localize!(type, content, exercise) + return unless content.present? + + Localization::Text::AddToLocalization.defer(type, content, exercise) + end + def exercise_needs_updating? track_config_exercise_modified? || exercise_config_modified? || exercise_files_modified? end diff --git a/app/commands/git/sync_problem_specifications.rb b/app/commands/git/sync_problem_specifications.rb index 5995f62429..d8be405526 100644 --- a/app/commands/git/sync_problem_specifications.rb +++ b/app/commands/git/sync_problem_specifications.rb @@ -7,14 +7,9 @@ def call repo.update! repo.exercises.each do |exercise| - GenericExercise::CreateOrUpdate.( - exercise.slug, exercise.title, exercise.blurb, - exercise.source, exercise.source_url, - exercise.deep_dive_youtube_id, exercise.deep_dive_blurb, - exercise.deprecated? ? :deprecated : :active - ) - rescue StandardError => e - Bugsnag.notify(e) + GenericExercise::CreateOrUpdate.(exercise) + # rescue StandardError => e + # Bugsnag.notify(e) end end diff --git a/app/commands/llm/exec.rb b/app/commands/llm/exec.rb new file mode 100644 index 0000000000..0f41bcee65 --- /dev/null +++ b/app/commands/llm/exec.rb @@ -0,0 +1,26 @@ +class LLM::Exec + include Mandate + + initialize_with :service, :model, :prompt, :spi_endpoint, :stream_channel + + def call + raise "Service cannot be nil" if service.nil? + raise "Model cannot be nil" if model.nil? + raise "Prompt cannot be nil" if prompt.nil? + raise "SPI endpoint cannot be nil" if spi_endpoint.nil? + + RestClient.post( + proxy_url, + { + service: service, + model: model, + spi_endpoint: "llm_responses/#{spi_endpoint}", + stream_channel: stream_channel, + prompt: prompt + }.to_json, + { content_type: :json, accept: :json } + ) + end + + def proxy_url = "http://localhost:8080/exec" +end diff --git a/app/commands/llm/exec_gemini_flash.rb b/app/commands/llm/exec_gemini_flash.rb new file mode 100644 index 0000000000..c4376e45d7 --- /dev/null +++ b/app/commands/llm/exec_gemini_flash.rb @@ -0,0 +1,9 @@ +class LLM::ExecGeminiFlash + include Mandate + + initialize_with :prompt, :spi_endpoint, stream_channel: nil + + def call + LLM::Exec.(:gemini, :flash, prompt, spi_endpoint, stream_channel) + end +end diff --git a/app/commands/localization/cache/retrieve.rb b/app/commands/localization/cache/retrieve.rb new file mode 100644 index 0000000000..387bd8a771 --- /dev/null +++ b/app/commands/localization/cache/retrieve.rb @@ -0,0 +1,11 @@ +class Localization::Cache::Retrieve + include Mandate + + initialize_with :locale, :key + + def call + return unless Localization.use_cache + + Localization.translations.dig(locale.to_sym, key) + end +end diff --git a/app/commands/localization/cache/store.rb b/app/commands/localization/cache/store.rb new file mode 100644 index 0000000000..9d432269e1 --- /dev/null +++ b/app/commands/localization/cache/store.rb @@ -0,0 +1,12 @@ +class Localization::Cache::Store + include Mandate + + initialize_with :locale, :key, :value + + def call + return unless Localization.use_cache + + Localization.translations[locale.to_sym] ||= {} + Localization.translations[locale.to_sym][key] = value + end +end diff --git a/app/commands/localization/content/translate_exercise_instructions.rb b/app/commands/localization/content/translate_exercise_instructions.rb new file mode 100644 index 0000000000..ecd763e050 --- /dev/null +++ b/app/commands/localization/content/translate_exercise_instructions.rb @@ -0,0 +1,23 @@ +class Localization::Content::TranslateExerciseInstructions + include Mandate + + initialize_with :exercise, locale: nil + + def call + # Exit early if someone is actively requesting the English + # version so that we don't go through extra lookups etc. + return instructions if locale == :en + + # Look this up here, so we don't do the work of creating the context etc. + existing = Localization::Translation.find_by(key: key, locale: locale)&.value.presence + return existing if existing + + # If we don't have it, then translate it. We should rarely get here. + Localization::Text::Translate.(type, instructions, exercise, locale) + end + + private + delegate :instructions, to: :exercise + def type = :exercise_instructions + def key = Localization::Text::GenerateKey.(instructions) +end diff --git a/app/commands/localization/content/translate_exercise_introduction.rb b/app/commands/localization/content/translate_exercise_introduction.rb new file mode 100644 index 0000000000..d332ef73fa --- /dev/null +++ b/app/commands/localization/content/translate_exercise_introduction.rb @@ -0,0 +1,23 @@ +class Localization::Content::TranslateExerciseIntroduction + include Mandate + + initialize_with :exercise, locale: nil + + def call + # Exit early if someone is actively requesting the English + # version so that we don't go through extra lookups etc. + return introduction if locale == :en + + # Look this up here, so we don't do the work of creating the context etc. + existing = Localization::Translation.find_by(key: key, locale: locale)&.value.presence + return existing if existing + + # If we don't have it, then translate it. We should rarely get here. + Localization::Text::Translate.(type, introduction, exercise, locale) + end + + private + delegate :introduction, to: :exercise + def type = :exercise_introduction + def key = Localization::Text::GenerateKey.(introduction) +end diff --git a/app/commands/localization/original/create.rb b/app/commands/localization/original/create.rb new file mode 100644 index 0000000000..59c14b1db9 --- /dev/null +++ b/app/commands/localization/original/create.rb @@ -0,0 +1,19 @@ +class Localization::Original::Create + include Mandate + + initialize_with :type, :key, :value, :about + + def call + Localization::Original.create!( + key: key, + value: value, + type: type, + about: about + ).tap do |original| + original.translations.create!( + locale: "en", + value: value + ) + end + end +end diff --git a/app/commands/localization/original/prompts/exercise_blurb.rb b/app/commands/localization/original/prompts/exercise_blurb.rb new file mode 100644 index 0000000000..9f00b31fbf --- /dev/null +++ b/app/commands/localization/original/prompts/exercise_blurb.rb @@ -0,0 +1,9 @@ +class Localization::Original::Prompts::ExerciseBlurb + include Mandate + + initialize_with :original, :locale + + def call + Localization::Original::Prompts::GenericExerciseBlurb.(original, locale) + end +end diff --git a/app/commands/localization/original/prompts/exercise_description.rb b/app/commands/localization/original/prompts/exercise_description.rb new file mode 100644 index 0000000000..7de740ed9f --- /dev/null +++ b/app/commands/localization/original/prompts/exercise_description.rb @@ -0,0 +1,23 @@ +class Localization::Original::Prompts::ExerciseDescription + include Mandate + + initialize_with :original, :locale + + def call + Localization::Original::Prompts::GenericPrompt.(original, locale, context) + end + + private + def context + <<~PROMPT + ## Context + + You are translating the introduction/instructions for an Exercism exercise. + The title of the exercise is #{exercise.title}. + It is on the #{exercise.track.title} track. + PROMPT + end + + memoize + def exercise = original.about +end diff --git a/app/commands/localization/original/prompts/exercise_instructions.rb b/app/commands/localization/original/prompts/exercise_instructions.rb new file mode 100644 index 0000000000..54e62da114 --- /dev/null +++ b/app/commands/localization/original/prompts/exercise_instructions.rb @@ -0,0 +1,28 @@ +class Localization::Original::Prompts::ExerciseInstructions + include Mandate + + initialize_with :original, :locale + + def call + Localization::Original::Prompts::GenericPrompt.(original, locale, context) + end + + private + def context + <<~PROMPT + ## Context + + You are translating the instructions to an Exercism exercise. + The title of the exercise is #{exercise.title}. + It is on the #{exercise.track.title} track. + + For your context (DO NOT TRANSLATE THIS), here is some introductory text the user has also seen for this exercise: + ~~~~~~ + #{exercise.introduction} + ~~~~~~ + PROMPT + end + + memoize + def exercise = original.about +end diff --git a/app/commands/localization/original/prompts/exercise_introduction.rb b/app/commands/localization/original/prompts/exercise_introduction.rb new file mode 100644 index 0000000000..3c2af8001d --- /dev/null +++ b/app/commands/localization/original/prompts/exercise_introduction.rb @@ -0,0 +1,28 @@ +class Localization::Original::Prompts::ExerciseIntroduction + include Mandate + + initialize_with :original, :locale + + def call + Localization::Original::Prompts::GenericPrompt.(original, locale, context) + end + + private + def context + <<~PROMPT + ## Context + + You are translating the introduction to an Exercism exercise. + The title of the exercise is #{exercise.title}. + It is on the #{exercise.track.title} track. + + For your context (DO NOT TRANSLATE THIS), here are some instructions that comes straight after this: + ~~~~~~ + #{exercise.instructions} + ~~~~~~ + PROMPT + end + + memoize + def exercise = original.about +end diff --git a/app/commands/localization/original/prompts/exercise_source.rb b/app/commands/localization/original/prompts/exercise_source.rb new file mode 100644 index 0000000000..3ff6aa334a --- /dev/null +++ b/app/commands/localization/original/prompts/exercise_source.rb @@ -0,0 +1,9 @@ +class Localization::Original::Prompts::ExerciseSource + include Mandate + + initialize_with :original, :locale + + def call + Localization::Original::Prompts::GenericExerciseSource.(original, locale) + end +end diff --git a/app/commands/localization/original/prompts/exercise_title.rb b/app/commands/localization/original/prompts/exercise_title.rb new file mode 100644 index 0000000000..af2ddc3454 --- /dev/null +++ b/app/commands/localization/original/prompts/exercise_title.rb @@ -0,0 +1,9 @@ +class Localization::Original::Prompts::ExerciseTitle + include Mandate + + initialize_with :original, :locale + + def call + Localization::Original::Prompts::GenericExerciseTitle.(original, locale) + end +end diff --git a/app/commands/localization/original/prompts/general.rb b/app/commands/localization/original/prompts/general.rb new file mode 100644 index 0000000000..905e3d8b2e --- /dev/null +++ b/app/commands/localization/original/prompts/general.rb @@ -0,0 +1,20 @@ +class Localization::Original::Prompts::General + include Mandate + + initialize_with :original, :locale + + def call + Localization::Original::Prompts::GenericPrompt.(original, locale, context) + end + + private + def context + <<~PROMPT + ## Context + + This is a string that's rendered on the website. + + #{original.context} + PROMPT + end +end diff --git a/app/commands/localization/original/prompts/generic_exercise_blurb.rb b/app/commands/localization/original/prompts/generic_exercise_blurb.rb new file mode 100644 index 0000000000..75e49a541b --- /dev/null +++ b/app/commands/localization/original/prompts/generic_exercise_blurb.rb @@ -0,0 +1,22 @@ +class Localization::Original::Prompts::GenericExerciseBlurb + include Mandate + + initialize_with :original, :locale + + def call + Localization::Original::Prompts::GenericPrompt.(original, locale, context) + end + + private + def context + <<~PROMPT + ## Context + + You are translating the blurb of the #{exercise.title} exercise. + This is the short string that is used to describe the exercise around the site. + PROMPT + end + + memoize + def exercise = original.about +end diff --git a/app/commands/localization/original/prompts/generic_exercise_description.rb b/app/commands/localization/original/prompts/generic_exercise_description.rb new file mode 100644 index 0000000000..79d4720ab6 --- /dev/null +++ b/app/commands/localization/original/prompts/generic_exercise_description.rb @@ -0,0 +1,23 @@ +class Localization::Original::Prompts::GenericExerciseDescription + include Mandate + + initialize_with :original, :locale + + def call + Localization::Original::Prompts::GenericPrompt.(original, locale, context) + end + + private + def context + <<~PROMPT + ## Context + + You are translating the introduction/instructions for an Exercism exercise. + The title of the exercise is #{exercise.title}. + These are the cross-track generic instructions. + PROMPT + end + + memoize + def exercise = original.about +end diff --git a/app/commands/localization/original/prompts/generic_exercise_instructions.rb b/app/commands/localization/original/prompts/generic_exercise_instructions.rb new file mode 100644 index 0000000000..cc16b58235 --- /dev/null +++ b/app/commands/localization/original/prompts/generic_exercise_instructions.rb @@ -0,0 +1,28 @@ +class Localization::Original::Prompts::GenericExerciseInstructions + include Mandate + + initialize_with :original, :locale + + def call + Localization::Original::Prompts::GenericPrompt.(original, locale, context) + end + + private + def context + <<~PROMPT + ## Context + + You are translating the instructions to an Exercism exercise. + The title of the exercise is #{exercise.title}. + These are the cross-track generic instructions. + + For your context (DO NOT TRANSLATE THIS), here is some introductory text the user has also seen for this exercise: + ~~~~~~ + #{exercise.introduction} + ~~~~~~ + PROMPT + end + + memoize + def exercise = original.about +end diff --git a/app/commands/localization/original/prompts/generic_exercise_source.rb b/app/commands/localization/original/prompts/generic_exercise_source.rb new file mode 100644 index 0000000000..f9d0c30219 --- /dev/null +++ b/app/commands/localization/original/prompts/generic_exercise_source.rb @@ -0,0 +1,22 @@ +class Localization::Original::Prompts::GenericExerciseSource + include Mandate + + initialize_with :original, :locale + + def call + Localization::Original::Prompts::GenericPrompt.(original, locale, context) + end + + private + def context + <<~PROMPT + ## Context + + You are translating a string describing the source of the #{exercise.title} exercise. + This is the short string that is used to explain where the exercise originated from. + PROMPT + end + + memoize + def exercise = original.about +end diff --git a/app/commands/localization/original/prompts/generic_exercise_title.rb b/app/commands/localization/original/prompts/generic_exercise_title.rb new file mode 100644 index 0000000000..ab4721ffad --- /dev/null +++ b/app/commands/localization/original/prompts/generic_exercise_title.rb @@ -0,0 +1,18 @@ +class Localization::Original::Prompts::GenericExerciseTitle + include Mandate + + initialize_with :original, :locale + + def call + Localization::Original::Prompts::GenericPrompt.(original, locale, context) + end + + private + def context + <<~PROMPT + ## Context + + You are translating the title of an exercise. + PROMPT + end +end diff --git a/app/commands/localization/original/prompts/generic_prompt.rb b/app/commands/localization/original/prompts/generic_prompt.rb new file mode 100644 index 0000000000..bcb36bd514 --- /dev/null +++ b/app/commands/localization/original/prompts/generic_prompt.rb @@ -0,0 +1,84 @@ +class Localization::Original::Prompts::GenericPrompt + include Mandate + + initialize_with :original, :locale, :context + + def call + <<~PROMPT + ## Instructions + + You are a localization expert. Your task is to translate text from english to a given locale. + + Follow these rules carefully: + - Maintain the meaning of the original text. Do not improve or change the meaning. + - Maintain the tone of the original text, while adhering to the conventions of the target locale. + - Do not change the length of the text significantly. It should be roughly the same length as the original. + + When dealing with codeblocks: + - Do NOT change ANY code (including variable names). + - You CAN translate comments from english to `#{locale}` while refering to the correct variables etc if appropriate. + + The target locale is `#{locale}` + + #{context} + + #{previous_version_prompt} + + ## Text to translate + + These is the English value, which YOU SHOULD TRANSLATE: + ~~~~~~ + #{original.value} + ~~~~~~ + + Respond with JSON containing one field: + - `value`: The translated text. + PROMPT + end + + private + memoize + def previous_version_prompt + return nil unless previous_english_version.present? + + <<~PROMPT + ## Previous Version + + There was a previous version of this text that was translated into this `#{locale}` version and checked/improved by humans.#{' '} + This text has now been updated, but it is essential to honour the previous translation to avoid regressions (where humans have fixed/improved LLM versions). + So you should reuse existing translations where possible and only add your own translations to the areas that have changed in the English. + + This was the previous English version: + ~~~~~~ + #{previous_english_version} + ~~~~~~ + + This was the previous `#{locale}` version: + ~~~~~~ + #{previous_translated_version} + ~~~~~~ + + In the next section where you see the text to translate, you should reuse information from this previous translation where possible. + PROMPT + end + + memoize + def previous_english_version + return unless previous_translated_version + + previous_translated_version.original.translations.find_by(locale: :en) + end + + memoize + def previous_translated_version + Localization::Translation.where( + locale: locale, + original: Localization::Original.where( + type: original.type, + about_type: original.about_type, + about_id: original.about_id + ), + status: :checked + ).last + end +end diff --git a/app/commands/localization/original/search.rb b/app/commands/localization/original/search.rb new file mode 100644 index 0000000000..896c53d16d --- /dev/null +++ b/app/commands/localization/original/search.rb @@ -0,0 +1,63 @@ +class Localization::Original::Search + include Mandate + + DEFAULT_PAGE = 1 + DEFAULT_PER = 24 + + def self.default_per + DEFAULT_PER + end + + def initialize(user, page: nil, per: nil, criteria: nil, status: nil) + @user = user + + @criteria = criteria + @status = status + @page = page.present? && page.to_i.positive? ? page.to_i : DEFAULT_PAGE + @per = per.present? && per.to_i.positive? ? per.to_i : self.class.default_per + end + + def call + @translations = Localization::Translation.where(locale: locales) + + filter_criteria! + filter_status! + + # Get all of the english versions paginated. + # We do an inner query so things like the criteria work on + # the inner query. + originals = Localization::Original.where(key: @translations.select(:key)) + + paginated_originals = originals.page(page).per(per) + + Kaminari.paginate_array( + paginated_originals, + total_count: originals.count + ).page(page).per(per) + end + + memoize + def locales = user.translator_locales - [:en] + + private + attr_reader :user, :per, :page, :criteria, :status, :track_slug, :solutions + + def filter_criteria! + return if criteria.blank? + + @translations = @translations.where("localization_translations.value LIKE ?", "%#{criteria}%") + end + + def filter_status! + return if status.blank? + + @translations = @translations.where("localization_translations.status": status) + end + + memoize + def track + return nil if track_slug.blank? + + Track.find_by(slug: track_slug) + end +end diff --git a/app/commands/localization/original/translate.rb b/app/commands/localization/original/translate.rb new file mode 100644 index 0000000000..17ad0b49cc --- /dev/null +++ b/app/commands/localization/original/translate.rb @@ -0,0 +1,33 @@ +class Localization::Original::Translate + include Mandate + + initialize_with :original, :locale + + def call + begin + original.translations.create!(locale: locale) + rescue ActiveRecord::RecordNotUnique + # We just continue even if it exists + end + + LLM::ExecGeminiFlash.(prompt, endpoint) + end + + private + def endpoint + "localization_translated?original_uuid=#{original.uuid}&locale=#{locale}" + end + + def prompt + # Use the helper class to generate a prompt + case original.type + when :unknown, :website_server_side, :website_client_side + klass_name = "general" + else + klass_name = original.type + end + + klass = "localization/original/prompts/#{klass_name}".camelize.constantize + klass.(original, locale) + end +end diff --git a/app/commands/localization/text/add_to_localization.rb b/app/commands/localization/text/add_to_localization.rb new file mode 100644 index 0000000000..faaf373dd1 --- /dev/null +++ b/app/commands/localization/text/add_to_localization.rb @@ -0,0 +1,49 @@ +class Localization::Text::AddToLocalization + include Mandate + + initialize_with :type, :text, :about, priority_locale: nil + + def call + original.tap do + trigger_priority_locale! + queue_all_locales! + end + end + + private + # We sometimes have a priority locale where there is a user waiting + # for this translation to be generated. In those cases, let's make + # sure that we do this first! We do this inline to get it to the front + # of the queue. + def trigger_priority_locale! + return unless priority_locale.present? + + Localization::Original::Translate.(original, priority_locale) + # rescue StandardError + # We catch errors (as this be duplicated in the all + # locales below in sidekiq anyway) + end + + # Then we trigger all locales to be translated. + # We don't want to redo any that already exist. + def queue_all_locales! + (I18n.available_locales + I18n.wip_locales).each do |locale| + next if existing_locales.include?(locale.to_sym) + + Localization::Original::Translate.defer(original, locale) + end + end + + memoize + def original + Localization::Original::Create.(type, key, text, about) + rescue ActiveRecord::RecordNotUnique + Localization::Original.find_by!(key: key) + end + + memoize + def existing_locales = original.translations.pluck(:locale).map(&:to_sym) + + memoize + def key = Localization::Text::GenerateKey.(text) +end diff --git a/app/commands/localization/text/generate_key.rb b/app/commands/localization/text/generate_key.rb new file mode 100644 index 0000000000..e312a4a44a --- /dev/null +++ b/app/commands/localization/text/generate_key.rb @@ -0,0 +1,14 @@ +class Localization::Text::GenerateKey + include Mandate + + initialize_with :text + + def call + raise ArgumentError unless text.present? + + "arbitary.#{digest}" + end + + memoize + def digest = Digest::SHA256.hexdigest(text) +end diff --git a/app/commands/localization/text/retrieve.rb b/app/commands/localization/text/retrieve.rb new file mode 100644 index 0000000000..c5e4f64945 --- /dev/null +++ b/app/commands/localization/text/retrieve.rb @@ -0,0 +1,17 @@ +class Localization::Text::Retrieve + include Mandate + + initialize_with :text, :locale + + def call + # Exit early if someone is actively requesting the English + # version so that we don't go through extra lookups etc. + return text if locale.to_sym == :en + + # Look this up here, so we don't do the work of creating the context etc. + Localization::Translation.find_by(key: key, locale: locale)&.value.presence + end + + private + def key = Localization::Text::GenerateKey.(text) +end diff --git a/app/commands/localization/text/translate.rb b/app/commands/localization/text/translate.rb new file mode 100644 index 0000000000..24d3e28fc1 --- /dev/null +++ b/app/commands/localization/text/translate.rb @@ -0,0 +1,16 @@ +class Localization::Text::Translate + include Mandate + + initialize_with :type, :text, :about, :locale + + def call + Localization::Translation.find_by!(key: key, locale: locale)&.value.presence + rescue ActiveRecord::RecordNotFound + # Add it - which will kick off translations + Localization::Text::AddToLocalization.(type, text, about, priority_locale: locale) + nil + end + + memoize + def key = Localization::Text::GenerateKey.(text) +end diff --git a/app/commands/localization/translation/approve_llm_version.rb b/app/commands/localization/translation/approve_llm_version.rb new file mode 100644 index 0000000000..fad957737c --- /dev/null +++ b/app/commands/localization/translation/approve_llm_version.rb @@ -0,0 +1,16 @@ +class Localization::Translation::ApproveLLMVersion + include Mandate + + initialize_with :translation, :user + + def call + ActiveRecord::Base.transaction do + translation.proposals.create!( + proposer: user, + modified_from_llm: false, + value: translation.value + ) + translation.update!(status: :proposed) + end + end +end diff --git a/app/commands/localization/translation/update_value.rb b/app/commands/localization/translation/update_value.rb new file mode 100644 index 0000000000..d801dab74c --- /dev/null +++ b/app/commands/localization/translation/update_value.rb @@ -0,0 +1,26 @@ +class Localization::Translation::UpdateValue + include Mandate + + initialize_with :key, :locale, :value + + def call + # Don't override things that might have already been done + return unless translation.value.blank? && %i[generating unchecked].include?(translation.status) + + translation.update!( + value: value, + status: :unchecked + ) + broadcast!(translation) + end + + private + memoize + def translation + Localization::Translation.find_by!(key: key, locale: locale) + end + + def broadcast!(translation) + LocalizationTranslationChannel.broadcast!(translation) + end +end diff --git a/app/commands/localization/translation_proposal/approve.rb b/app/commands/localization/translation_proposal/approve.rb new file mode 100644 index 0000000000..f3b5fcad19 --- /dev/null +++ b/app/commands/localization/translation_proposal/approve.rb @@ -0,0 +1,20 @@ +class Localization::TranslationProposal::Approve + include Mandate + + initialize_with :proposal, :user + + def call + ActiveRecord::Base.transaction do + proposal.update!( + status: :approved, + reviewer: user + ) + translation.update!( + status: :checked, + value: proposal.value + ) + end + end + + delegate :translation, to: :proposal +end diff --git a/app/commands/localization/translation_proposal/create.rb b/app/commands/localization/translation_proposal/create.rb new file mode 100644 index 0000000000..fdddb0eafa --- /dev/null +++ b/app/commands/localization/translation_proposal/create.rb @@ -0,0 +1,20 @@ +class Localization::TranslationProposal + class Create + include Mandate + + initialize_with :translation, :user, :value + + def call + ActiveRecord::Base.transaction do + translation.update!(status: :proposed) + translation.proposals.create!( + proposer: user, + value: value, + modified_from_llm: true + ) + end.tap do |proposal| # rubocop:disable Style/MultilineBlockChain + VerifyWithLLM.(proposal) + end + end + end +end diff --git a/app/commands/localization/translation_proposal/reject.rb b/app/commands/localization/translation_proposal/reject.rb new file mode 100644 index 0000000000..84f8055f39 --- /dev/null +++ b/app/commands/localization/translation_proposal/reject.rb @@ -0,0 +1,17 @@ +class Localization::TranslationProposal::Reject + include Mandate + + initialize_with :proposal, :user + + def call + ActiveRecord::Base.transaction do + proposal.update!( + status: :rejected, + reviewer: user + ) + translation.update!(status: :unchecked) unless translation.proposals.pending.exists? + end + end + + delegate :translation, to: :proposal +end diff --git a/app/commands/localization/translation_proposal/update_value.rb b/app/commands/localization/translation_proposal/update_value.rb new file mode 100644 index 0000000000..bfa18c9f0d --- /dev/null +++ b/app/commands/localization/translation_proposal/update_value.rb @@ -0,0 +1,9 @@ +class Localization::TranslationProposal::UpdateValue + include Mandate + + initialize_with :proposal, :user, :value + + def call + proposal.update!(value: value) + end +end diff --git a/app/commands/localization/translation_proposal/verify_with_llm.rb b/app/commands/localization/translation_proposal/verify_with_llm.rb new file mode 100644 index 0000000000..644c266659 --- /dev/null +++ b/app/commands/localization/translation_proposal/verify_with_llm.rb @@ -0,0 +1,50 @@ +class Localization::TranslationProposal::VerifyWithLLM + include Mandate + + initialize_with :proposal + + def call + LLM::ExecGeminiFlash.(prompt, endpoint) + end + + def endpoint + "localization_verify_llm_proposal?proposal_uuid=#{proposal.uuid}" + end + + def prompt + <<~PROMPT + You are a localization expert. Your task is to verify the quality of a translation proposal. + + Respond with JSON containing two fields: + - `result`: "approved", "rejected" or "spam" based on the quality of the translation. + - `reason`: A brief explanation of your decision. + + You should use "approved" if the quality of translation is generally ok, and all placeholders are EXACTLY the same as in the original. + You should use "rejected" if the translation has serious issues that need to be fixed, such as grammatical or spelling mistakes, or ANY placeholders have changed. + You should use "spam" if the translation is a serious step away from the original. For example, if it is trying to inject spam onto the website, or contains profanity etc. Use this with care as it will automatically block the user that proposed the change. + + The target locale is `#{locale}` + + The original English text was: + ~~~~~~ + #{original.value} + ~~~~~~ + + This is information about how it's used: + ~~~~~~ + #{original.usage_details} + ~~~~~~ + + The proposed translation is: + ~~~~~~ + #{proposal.value} + ~~~~~~ + + Respond with JSON. + PROMPT + end + + delegate :translation, to: :proposal + delegate :original, to: :translation + delegate :locale, to: :translation +end diff --git a/app/controllers/api/localization/originals_controller.rb b/app/controllers/api/localization/originals_controller.rb new file mode 100644 index 0000000000..0542f70d03 --- /dev/null +++ b/app/controllers/api/localization/originals_controller.rb @@ -0,0 +1,22 @@ +class API::Localization::OriginalsController < API::BaseController + before_action :use_original, except: [:index] + + def index + render json: AssembleLocalizationOriginals.(current_user, params) + end + + def show + render json: { + original: SerializeLocalizationOriginal.(@original, current_user) + } + end + + def approve_llm_version + Localization::Translation::ApproveLLMVersion.(@original, current_user) + end + + private + def use_original + @original = Localization::Original.find_by!(uuid: params[:id]) + end +end diff --git a/app/controllers/api/localization/translation_proposals_controller.rb b/app/controllers/api/localization/translation_proposals_controller.rb new file mode 100644 index 0000000000..b0655cfb7b --- /dev/null +++ b/app/controllers/api/localization/translation_proposals_controller.rb @@ -0,0 +1,51 @@ +class API::Localization::TranslationProposalsController < API::BaseController + before_action :use_translation + before_action :use_proposal, except: [:create] + + def create + Localization::TranslationProposal::Create.(@translation, current_user, params[:value]) + + render json: { + translation: SerializeLocalizationTranslation.(@translation) + + }, status: :created + end + + def approve + Localization::TranslationProposal::Approve.(@translation, current_user) + + render json: { + translation: SerializeLocalizationTranslation.(@translation) + } + end + + def reject + Localization::TranslationProposal::Reject.(@translation, current_user) + + render json: { + translation: SerializeLocalizationTranslation.(@translation) + } + end + + def update + if @proposal.proposer == current_user + Localization::TranslationProposal::UpdateValue.(@proposal, current_user, params[:value]) + else + Localization::TranslationProposal::Reject.(@proposal, current_user) + Localization::TranslationProposal::Create.(@translation, current_user, params[:value]) + end + + render json: { + translation: SerializeLocalizationTranslation.(@translation) + } + end + + private + def use_translation + @translation = Localization::Translation.find_by!(uuid: params[:translation_id]) + end + + def use_proposal + @proposal = @translation.proposals.find_by!(uuid: params[:id]) + end +end diff --git a/app/controllers/api/localization/translations_controller.rb b/app/controllers/api/localization/translations_controller.rb new file mode 100644 index 0000000000..1753393731 --- /dev/null +++ b/app/controllers/api/localization/translations_controller.rb @@ -0,0 +1,16 @@ +class API::Localization::TranslationsController < API::BaseController + before_action :use_translation + + def approve_llm_version + Localization::Translation::ApproveLLMVersion.(@translation, current_user) + + render json: { + translation: SerializeLocalizationTranslation.(@translation) + } + end + + private + def use_translation + @translation = Localization::Translation.find_by!(uuid: params[:id]) + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index aa035066fa..5391393e20 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base include Turbo::Redirection include Turbo::CustomFrameRequest include BodyClassConcern + include LocaleRouting include UserRateLimitConcern # around_action :set_log_level @@ -12,6 +13,7 @@ class ApplicationController < ActionController::Base before_action :ensure_onboarded! around_action :switch_locale! around_action :mark_notifications_as_read! + around_action :switch_locale! before_action :set_request_context after_action :set_user_id_cookie after_action :skip_empty_session_cookie @@ -50,11 +52,6 @@ def current_user end # rubocop:enable Naming/MemoizedInstanceVariableName - def switch_locale!(&action) - locale = params[:locale] || I18n.default_locale - I18n.with_locale(locale, &action) - end - def ensure_onboarded! return unless user_signed_in? return if current_user.onboarded? diff --git a/app/controllers/concerns/locale_routing.rb b/app/controllers/concerns/locale_routing.rb new file mode 100644 index 0000000000..8f90b52bfa --- /dev/null +++ b/app/controllers/concerns/locale_routing.rb @@ -0,0 +1,44 @@ +module LocaleRouting + extend ActiveSupport::Concern + extend Mandate::Memoize + include LocaleSupport + + included do + # Order matters: + before_action :maybe_redirect_user_to_correct_locale! + before_action :maybe_redirect_en_to_naked! + around_action :switch_locale! + end + + def maybe_redirect_user_to_correct_locale! + return unless user_signed_in? # Only signed in users. + return unless html_request? # Only HTML requests. + return unless request.get? # Only GETs + return if locale_from_path.present? # Already on /xx/ path + return if params[LocaleSupport::QUERY_PARAM].present? # guard against loops (localStorage or link adds this once) + + user_locale = current_user.locale + return unless user_locale.present? + + user_locale = normalize_locale(user_locale) + return if user_locale.to_s.starts_with?("en") + + response.headers['Cache-Control'] = 'no-store' + redirect_to url_for_locale(user_locale, request.fullpath), allow_other_host: false, status: :found + end + + def maybe_redirect_en_to_naked! + return unless html_request? # Only HTML requests. + return unless request.get? # Only GETs + + locale = locale_from_path + return unless locale == "en" + + redirect_to request.fullpath.sub(%r{^/en}, ''), allow_other_host: false, status: :found + end + + def switch_locale!(&action) + locale = specified_locale || default_locale + I18n.with_locale(locale, &action) + end +end diff --git a/app/controllers/concerns/locale_support.rb b/app/controllers/concerns/locale_support.rb new file mode 100644 index 0000000000..2ae07a7752 --- /dev/null +++ b/app/controllers/concerns/locale_support.rb @@ -0,0 +1,93 @@ +module LocaleSupport + extend ActiveSupport::Concern + + QUERY_PARAM = :_lr # Loop-breaker for one-time redirects + + included do + helper_method :current_locale, :default_locale, :supported_locales, + :url_for_locale + end + + def supported_locales = I18n.available_locales + def default_locale = I18n.default_locale + def current_locale = I18n.locale + + # Map things like "pt-br" -> :"pt-BR", "es-419" -> :"es" + def normalize_locale(code) + return if code.blank? + + code = code.to_s.tr('_', '-') + + # Try exact match first (case-sensitive for region) + return code.to_sym if supported_locales.map(&:to_s).include?(code) + + # Try language-only fallback (e.g., "pt-br" -> "pt") + lang = code.split('-').first + return lang.to_sym if supported_locales.map { _1.to_s.split('-').first }.include?(lang) + + nil + end + + def html_request? + request.format.html? && !request.xhr? + end + + # Locale extracted from leading path segment if present and supported, excluding :en + def specified_locale + locale = params[:locale] + return nil unless locale.present? + return nil if locale == default_locale # English lives at root + + locale + end + + def locale_from_path + return unless params[:locale] + + locale = request.path.to_s.sub(%r{^/}, '').split('/').first + + return unless supported_locales.map(&:to_s).include?(locale) + + locale + end + + # Build a locale-scoped URL preserving the rest of the path/query + def url_for_locale(loc, fullpath, path_only: false, add_loop_breaker_query_param: true) + uri = begin + Addressable::URI.parse(fullpath) + rescue StandardError + nil + end + path = uri&.path || fullpath + + # Break into segments, ignore blanks (so /foo/ -> ["foo"]) + segments = path.split("/").reject(&:empty?) + + # Drop existing locale if present + segments.shift if segments.first && normalize_locale(segments.first).present? + + # Skip prefix if using default locale + loc = "" if loc == default_locale + + # Build path string (no leading slash yet) + new_path = ([loc] + segments).reject(&:empty?).join("/") + + # Parse query params + query_string = uri&.query.to_s + + if add_loop_breaker_query_param + query_hash = Rack::Utils.parse_nested_query(query_string) + qp = LocaleSupport::QUERY_PARAM.to_s + query_hash[qp] ||= "1" # only add if not already present + query_string = Rack::Utils.build_query(query_hash) + end + + if path_only + ["/#{new_path.presence}", query_string.presence].compact.join("?") + elsif new_path.empty? + [request.base_url, query_string.presence].compact.join("?") + else + ["#{request.base_url}/#{new_path}", query_string.presence].compact.join("?") + end + end +end diff --git a/app/controllers/localization/originals_controller.rb b/app/controllers/localization/originals_controller.rb new file mode 100644 index 0000000000..78d4234d5c --- /dev/null +++ b/app/controllers/localization/originals_controller.rb @@ -0,0 +1,12 @@ +class Localization::OriginalsController < ApplicationController + def index + @originals = AssembleLocalizationOriginals.(current_user, params)[:results] + @originals_params = params.permit(:criteria, :status, :page) + end + + def show + original = Localization::Original.find_by!(uuid: params[:id]) + + @original = SerializeLocalizationOriginal.(original, current_user) + end +end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 885743ab9f..79bc9ee3fa 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -65,4 +65,22 @@ def javascript_browser_test_runner_worker render file: file_path, content_type: 'application/javascript' end + + def javascript_i18n + expires_in 5.minutes, public: true + + # Do not rename this param to locale as that's reserved + params[:i18n_locale] + + # TOOD: Check locale is a valid string + # TODO: Whenever a JS translation is updated, we need to regenerate this. + # filepath = Rails.root.join('public', 'i18n', 'javascript', "#{locale.to_sym}.js") + # File.read(filepath) + + # TOOD: Pivot on the params[:locale] + render json: { + 'diggingDeeper.editViaGitHub': 'Edit via GitHub', + 'diggingDeeper.linkOpensInNewTab': 'The link opens in a new window or tab' + } + end end diff --git a/app/controllers/sitemaps_controller.rb b/app/controllers/sitemaps_controller.rb index 2f55fe9163..c0483f3923 100644 --- a/app/controllers/sitemaps_controller.rb +++ b/app/controllers/sitemaps_controller.rb @@ -127,6 +127,10 @@ def pages_to_xml(pages) xml.lastmod page[1].xmlschema xml.changefreq page[2] xml.priority page[3] + supported_locales.each do |locale| + xml.xhtml(:link, rel: 'alternate', hreflang: locale, + href: url_for_locale(locale, page[0], add_loop_breaker_query_param: false)) + end end end end diff --git a/app/controllers/spi/llm_responses_controller.rb b/app/controllers/spi/llm_responses_controller.rb new file mode 100644 index 0000000000..70f9d93f3b --- /dev/null +++ b/app/controllers/spi/llm_responses_controller.rb @@ -0,0 +1,20 @@ +module SPI + class LLMResponsesController < BaseController + def localization_verify_llm_proposal + proposal = Localization::TranslationProposal.find_by!(uuid: params[:proposal_uuid]) + feedback = JSON.parse(params[:resp], symbolize_names: true) + proposal.update!(llm_feedback: feedback) + + return unless feedback[:result] == "spam" + + Localization::TranslationProposal::Reject.(proposal, User.find(User::SYSTEM_USER_ID)) + # TODO: Alert iHiD + end + + def localization_translated + original = Localization::Original.find_by!(uuid: params[:original_uuid]) + resp = JSON.parse(params[:resp], symbolize_names: true) + Localization::Translation::UpdateValue.(original.key, params[:locale], resp[:value]) + end + end +end diff --git a/app/css/components/prominent-link.css b/app/css/components/prominent-link.css index b7d06fce29..db275bf056 100644 --- a/app/css/components/prominent-link.css +++ b/app/css/components/prominent-link.css @@ -20,4 +20,7 @@ @apply py-8 px-24; background: var(--c-prominent-link-bg); } + &.--inline { + @apply inline-flex; + } } diff --git a/app/css/components/site-footer.css b/app/css/components/site-footer.css index afd977cfe6..ef63029903 100644 --- a/app/css/components/site-footer.css +++ b/app/css/components/site-footer.css @@ -208,27 +208,22 @@ body.namespace-.controller-insiders { } } - & .socials { - @apply flex items-center justify-center; - & .icon { - height: 48px; - width: 48px; - @apply grid place-items-center; - @apply rounded-circle; - @apply mx-12; - & .c-icon { - height: 24px; - width: 24px; - filter: var(--filter-FFFFFF); - } - &.twitter { - background: #1da1f2; - } - &.facebook { - background: #4267b2; - } - &.github { - background: #6e82aa; + .locales { + & h3 { + @apply text-16 leading-150 font-semibold; + @apply text-white; + @apply mb-8; + } + hr { + @apply mb-20; + } + } + .locales { + .locale { + @apply text-16 font-medium leading-150 text-[#c8d5ef]; + @apply flex items-center; + .flag { + @apply text-24 mr-6; } } } diff --git a/app/css/packs/signed-in.css b/app/css/packs/signed-in.css index 2b6e00a1d0..511e903d32 100644 --- a/app/css/packs/signed-in.css +++ b/app/css/packs/signed-in.css @@ -59,6 +59,10 @@ @import "../pages/settings"; @import "../modals/crop-avatar"; +/* Localization */ +@import "../pages/localization/show"; +@import "../pages/localization/index"; + /* Misc */ @import "../modals/bug-report"; @import "../components/markdown-editor"; diff --git a/app/css/pages/localization/index.css b/app/css/pages/localization/index.css new file mode 100644 index 0000000000..99ed5bf641 --- /dev/null +++ b/app/css/pages/localization/index.css @@ -0,0 +1,121 @@ +#page-localization-index { + .originals-table { + .tabs { + @apply mb-20; + + @apply flex items-center; + @apply flex-grow; + + .c-tab { + line-height: 40px; + } + } + .container { + @apply shadow-lg bg-backgroundColorA rounded-8; + + header.c-search-bar { + @apply py-16 px-24; + @apply border-backgroundColorC border-b-1; + + .c-track-filter { + @apply mr-16; + } + .search { + @apply mr-16; + width: 60%; + max-width: 450px; + } + } + + .no-results { + @apply flex flex-col items-center; + @apply py-48; + .c-icon { + height: 128px; + width: 128px; + @apply mb-12; + } + h3 { + @apply text-18 leading-150 font-medium; + @apply mb-20; + } + } + footer { + .c-pagination { + @apply px-24 py-16; + } + @apply border-lightGray border-t-1; + } + } + + .original { + @apply border-backgroundColorC border-t-1; + @apply flex items-center gap-20; + @apply py-8 px-20; + + &:hover { + @apply bg-backgroundColorE; + } + &:first-child { + @apply border-t-0; + } + + .translations-statuses { + @apply flex items-center; + @apply gap-16; + } + .translation-status { + @apply border-2 rounded-8; + @apply text-[25px]; + @apply p-4 items-center; + + &.generating { + @apply border-blue-400 bg-blue-300; + } + &.unchecked { + @apply border-red bg-red-100; + } + + &.proposed { + @apply border-launchingYellow bg-yellowPrompt; + } + + &.done { + @apply border-green-400 bg-green-100; + } + } + + .info { + .original-key { + @apply flex items-center; + @apply font-medium text-16 text-textColor2 leading-160; + width: 300px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + .original-uuid { + @apply text-14 text-textColor6 leading-150; + } + } + + .rhs { + width: 50%; + @apply ml-auto; + @apply flex items-center; + .translation-glimpse { + @apply flex items-center h-48; + @apply text-right leading-150 text-textColor6; + @apply truncate; + } + + .action-icon { + @apply ml-36 text-textColor6; + + height: 16px; + width: 16px; + } + } + } + } +} diff --git a/app/css/pages/localization/show.css b/app/css/pages/localization/show.css new file mode 100644 index 0000000000..5e2755909e --- /dev/null +++ b/app/css/pages/localization/show.css @@ -0,0 +1,144 @@ +body.namespace-localization.controller-originals.action-show { + #site-header, + #site-footer { + display: none !important; + } +} + +#page-localization-show { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + + header.header { + position: sticky; + top: 0; + @apply border-b-1 border-borderColor5 bg-backgroundColorA; + .container { + @apply flex flex-row gap-8 items-center; + } + + .close-btn { + @apply flex p-16; + .c-icon { + width: 24px; + width: 24px; + } + } + .info { + @apply flex flex-col; + .intro { + @apply text-14 leading-140 text-textColor6; + } + .key { + @apply text-15 leading-140 text-textColor1 font-mono font-semibold; + } + } + } + .body-container { + @apply flex flex-1; + @apply bg-backgroundColorA; + } + + .lhs { + max-width: 800px; + @apply border-r-1 border-borderColor6; + @apply pr-32; + @apply pt-20; + } + .rhs { + max-width: 600px; + @apply border-l-1 border-borderColor6; + @apply pl-32; + @apply pt-20; + } + + .locale-value { + @apply border-1 border-borderColor5 rounded-8; + @apply p-16; + @apply text-16 leading-140 whitespace-pre-wrap; + @apply bg-backgroundColorE; + } + .original { + p { + @apply text-16 leading-140 mb-8; + } + } + + .translations { + @apply mb-16; + .locale { + &.unchecked { + --locale-background-color: #ffeeee; + --locale-border-color: red; + } + &.proposed { + --locale-background-color: #ffefdc; + --locale-border-color: darkorange; + } + &.checked { + --locale-background-color: #e4ffe4; + --locale-border-color: green; + } + + @apply border-1 rounded-8 overflow-hidden; + border-color: var(--locale-border-color); + + .header { + @apply border-b-1; + @apply py-12 px-16; + @apply flex items-center gap-8; + border-color: var(--locale-border-color); + background-color: var(--locale-background-color); + + .status { + @apply text-12 font-semibold uppercase; + @apply text-white py-6 px-8 rounded-16; + @apply flex items-center gap-6; + background: var(--locale-border-color); + img { + height: 16px; + width: 16px; + } + } + .flag { + @apply ml-auto text-24; + } + } + .body { + @apply p-16; + .llm-feedback { + &.rejected { + --llm-feedback-bg-color: #ffeeee; + --llm-feedback-border-color: red; + } + &.approved { + --llm-feedback-bg-color: #e4ffe4; + --llm-feedback-border-color: rgb(0, 128, 0, 0.5); + } + + @apply flex gap-8; + @apply border-1; + @apply p-16 mb-16 rounded-8; + @apply text-16 leading-140; + + background: var(--llm-feedback-bg-color); + border-color: var(--llm-feedback-border-color); + + .img { + font-size: 20px; + margin-top: 5px; + } + .byline { + @apply text-14 mt-4 text-textColor6 italic; + } + } + .buttons { + @apply flex gap-8 items-center justify-between; + @apply text-textColor6; + } + } + } + } +} diff --git a/app/helpers/bootcamp_helper.rb b/app/helpers/bootcamp_helper.rb index 476d6120c9..cdc5f43314 100644 --- a/app/helpers/bootcamp_helper.rb +++ b/app/helpers/bootcamp_helper.rb @@ -4,4 +4,24 @@ def flag_for_country_code(country_code) map { |char| (127_397 + char.ord).chr("UTF-8") }. join end + + def flag_for_locale(locale) + country = locale.to_s.split("-").last + case country + when "en" + country = "us" + end + flag_for_country_code(country) + end + + def name_for_locale(locale) + case locale.to_s + when "en" + "English" + when "hu" + "Hungarian" + else + I18n.t("locales.#{locale}", default: locale.to_s.upcase) + end + end end diff --git a/app/helpers/localization_helper.rb b/app/helpers/localization_helper.rb new file mode 100644 index 0000000000..ea37e94452 --- /dev/null +++ b/app/helpers/localization_helper.rb @@ -0,0 +1,49 @@ +module LocalizationHelper + def translate_exercise_introduction(exercise, markdown: false, solution: nil) + # We're not syncing old exercise versions, so if someone + # has an old version, encourage them to upgrade instead + return translation_or_out_of_date_guard(solution.introduction, markdown:) if solution&.out_of_date? + + translation = Localization::Content::TranslateExerciseIntroduction.(exercise, locale: I18n.locale) + + return maybe_parse_as_markdown(translation, markdown) if translation + + render ReactComponents::Common::TranslationPlaceholder.new(I18n.locale) + end + + def translate_exercise_instructions(exercise, markdown: false, solution: nil) + # We're not syncing old exercise versions, so if someone + # has an old version, encourage them to upgrade instead + return translation_or_out_of_date_guard(solution.instructions, markdown:) if solution&.out_of_date? + + translation = Localization::Content::TranslateExerciseInstructions.(exercise, locale: I18n.locale) + return maybe_parse_as_markdown(translation, markdown) if translation + + render ReactComponents::Common::TranslationPlaceholder.new(I18n.locale) + end + + def translate_text(text, markdown: false) + return maybe_parse_as_markdown(text, markdown) if I18n.locale == :en + + translation = Localization::Text::Translate.(text, I18n.locale) + return unless translation.present? + + return maybe_parse_as_markdown(translation, markdown) if translation + + render ReactComponents::Common::TranslationPlaceholder.new(I18n.locale) + end + + private + def maybe_parse_as_markdown(text, markdown) + (markdown ? Markdown::Parse.(text) : text).html_safe + end + + def translation_or_out_of_date_guard(text, markdown: false) + translation = Localization::Text::Retrieve.(text, I18n.locale) + + return maybe_parse_as_markdown(translation, markdown) if translation + + # TODO: Render instructions to update + the english version + maybe_parse_as_markdown(text, markdown) + end +end diff --git a/app/helpers/react_components/common/translation_placeholder.rb b/app/helpers/react_components/common/translation_placeholder.rb new file mode 100644 index 0000000000..16b968e567 --- /dev/null +++ b/app/helpers/react_components/common/translation_placeholder.rb @@ -0,0 +1,11 @@ +module ReactComponents + module Common + class TranslationPlaceholder < ReactComponent + initialize_with :locale + + def to_s + super("common-translation-placeholder", { locale: }) + end + end + end +end diff --git a/app/helpers/react_components/localization/originals_list.rb b/app/helpers/react_components/localization/originals_list.rb new file mode 100644 index 0000000000..f3941176dd --- /dev/null +++ b/app/helpers/react_components/localization/originals_list.rb @@ -0,0 +1,43 @@ +module ReactComponents + module Localization + class OriginalsList < ReactComponent + initialize_with :originals, :params + + def to_s + super( + "localization-originals-list", + { + originals:, + links: { + localization_originals_path: Exercism::Routes.localization_originals_path, + endpoint: Exercism::Routes.api_localization_originals_path + }, + request: originals_list_request + } + ) + end + + private + def originals_list_request + { + endpoint: Exercism::Routes.api_localization_originals_path, + query: originals_list_params, + options: { + initial_data: originals_list + } + } + end + + memoize + def originals_list_params + { + criteria: params.fetch(:criteria, ''), + status: params[:status], + page: params[:page] + }.compact + end + + def originals_list = AssembleLocalizationOriginals.(current_user, originals_list_params) + end + end +end diff --git a/app/helpers/react_components/localization/originals_show.rb b/app/helpers/react_components/localization/originals_show.rb new file mode 100644 index 0000000000..d94e1471ee --- /dev/null +++ b/app/helpers/react_components/localization/originals_show.rb @@ -0,0 +1,22 @@ +module ReactComponents + module Localization + class OriginalsShow < ReactComponent + initialize_with :original + + def to_s + super("localization-originals-show", { + original:, + current_user_id: current_user&.id, + links: { + originals_list_page: Exercism::Routes.localization_originals_url, + approve_llm_translation: Exercism::Routes.approve_llm_version_api_localization_translation_url(original[:uuid]), + create_proposal: Exercism::Routes.api_localization_translation_proposals_url(translation_id: "TRANSLATION_ID"), + approve_proposal: Exercism::Routes.approve_api_localization_translation_proposal_url(translation_id: "TRANSLATION_ID", id: "PROPOSAL_ID"), # rubocop:disable Layout/LineLength + reject_proposal: Exercism::Routes.reject_api_localization_translation_proposal_url(translation_id: "TRANSLATION_ID", id: "PROPOSAL_ID"), # rubocop:disable Layout/LineLength + update_proposal: Exercism::Routes.api_localization_translation_proposal_url(translation_id: "TRANSLATION_ID", id: "PROPOSAL_ID") # rubocop:disable Layout/LineLength + } + }) + end + end + end +end diff --git a/app/helpers/view_components/localization/header.rb b/app/helpers/view_components/localization/header.rb new file mode 100644 index 0000000000..269d2adffd --- /dev/null +++ b/app/helpers/view_components/localization/header.rb @@ -0,0 +1,46 @@ +module ViewComponents + module Localization + class Header < ViewComponent + def to_s + tag.nav(class: 'c-mentor-header') do + tag.div(class: 'lg-container container') do + top + bottom + end + end + end + + def top + tag.nav(class: "top") do + tag.div(class: "title") do + graphical_icon(:translate, hex: true) + + tag.span("Localization") + end + stats + end + end + + def bottom + tag.nav(class: "bottom") do + tag.div(safe_join(tabs), class: 'tabs') + + link_to(Exercism::Routes.docs_section_path(:mentoring), class: "c-tab-2 guides") do + graphical_icon(:guides) + tag.span("Translation Guides") + end + end + end + + def stats + tag.div(class: "stats") do + tag.div("77 languages", class: "stat") + end + end + + def tabs = [originals_tab] + + def originals_tab + tag.div(class: "c-tab-2 selected") do + graphical_icon(:overview) + + tag.span("Originals") + end + end + end + end +end diff --git a/app/helpers/view_components/site_footer.rb b/app/helpers/view_components/site_footer.rb index ee72b39d97..4e314cca34 100644 --- a/app/helpers/view_components/site_footer.rb +++ b/app/helpers/view_components/site_footer.rb @@ -3,7 +3,7 @@ class SiteFooter < ViewComponent extend Mandate::Memoize def to_s - Rails.cache.fetch(cache_key, expires_in: 1.day) do + base = Rails.cache.fetch(cache_key, expires_in: 1.day) do tag.footer(id: "site-footer") do parts = [] parts << render(template: 'components/footer/external') unless user_signed_in? @@ -11,9 +11,24 @@ def to_s safe_join(parts) end end + + base.gsub("{{LOCALES}}", locales).html_safe end private + def locales + fullpath = request.fullpath + + links = supported_locales.map do |locale| + link_to url_for_locale(locale, fullpath, add_loop_breaker_query_param: true), class: 'locale' do + tag.span(flag_for_locale(locale), class: 'flag') + + tag.span(I18n.t(:language_version, locale: locale)) + end + end + + safe_join(links) + end + def cache_key Cache::KeyForFooter.(current_user) end diff --git a/app/helpers/view_components/view_component.rb b/app/helpers/view_components/view_component.rb index 4292310bae..092733e6bf 100644 --- a/app/helpers/view_components/view_component.rb +++ b/app/helpers/view_components/view_component.rb @@ -8,6 +8,7 @@ class ViewComponent :tag, :link_to, :external_link_to, :button_to, :image_tag, :image_url, :time_ago_in_words, :pluralize, :number_with_delimiter, :graphical_icon, :icon, :track_icon, :exercise_icon, :avatar, + :supported_locales, :url_for_locale, :flag_for_locale, :capture_haml, :showing_modal?, :showing_modal!, :javascript_include_tag, :request, diff --git a/app/images/icons/translate.svg b/app/images/icons/translate.svg new file mode 100644 index 0000000000..248917e52a --- /dev/null +++ b/app/images/icons/translate.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/javascript/channels/localizationTranslationChannel.ts b/app/javascript/channels/localizationTranslationChannel.ts new file mode 100644 index 0000000000..61f95bd801 --- /dev/null +++ b/app/javascript/channels/localizationTranslationChannel.ts @@ -0,0 +1,29 @@ +import consumer from '../utils/action-cable-consumer' + +export type ChannelResponse = { + key: string + locale: string + value: string +} + +export class LocalizationTranslationChannel { + subscription: ActionCable.Channel + + constructor(locale: string, onReceive: (response: ChannelResponse) => void) { + this.subscription = consumer.subscriptions.create( + { + channel: `LocalizationTranslationChannel`, + locale: locale, + }, + { + received: (response: ChannelResponse) => { + onReceive(response) + }, + } + ) + } + + disconnect(): void { + this.subscription.unsubscribe() + } +} diff --git a/app/javascript/components/common/TranslationPlaceholder.tsx b/app/javascript/components/common/TranslationPlaceholder.tsx new file mode 100644 index 0000000000..b541a32d43 --- /dev/null +++ b/app/javascript/components/common/TranslationPlaceholder.tsx @@ -0,0 +1,106 @@ +import React, { useEffect } from 'react' +import { marked } from 'marked' +import { LocalizationTranslationChannel } from '@/channels/localizationTranslationChannel' +import Icon from './Icon' + +// fallback +const languageNames: Record = { + en: 'English', + fr: 'French', + es: 'Spanish', + de: 'German', + it: 'Italian', + pt: 'Portuguese', + zh: 'Chinese', + ja: 'Japanese', + ko: 'Korean', + ru: 'Russian', + ar: 'Arabic', + hi: 'Hindi', + tr: 'Turkish', + nl: 'Dutch', + pl: 'Polish', + sv: 'Swedish', + no: 'Norwegian', + da: 'Danish', + fi: 'Finnish', + cs: 'Czech', + el: 'Greek', + he: 'Hebrew', + hu: 'Hungarian', + ro: 'Romanian', + uk: 'Ukrainian', + id: 'Indonesian', + vi: 'Vietnamese', + th: 'Thai', +} + +function getLanguageName(locale: string): string { + console.log('LOCALE', locale) + if (!locale) return 'selected language' + + const langCode = locale.toLowerCase().split('-')[0].replace('_', '-') + + try { + const displayNames = new Intl.DisplayNames(['en'], { type: 'language' }) + const name = displayNames.of(langCode) + if (name) return name + } catch (e) { + console.warn('Intl.DisplayNames is not supported in this environment.') + } + + return languageNames[langCode] || locale +} + +export type TranslationPlaceholderProps = { + locale: string + uuid: string +} + +export default function TranslationPlaceholder({ + locale, +}: TranslationPlaceholderProps) { + const languageName = getLanguageName(locale) + + const [translationValue, setTranslationValue] = React.useState( + null + ) + + useEffect(() => { + const solutionChannel = new LocalizationTranslationChannel( + locale, + (response) => { + console.log('Translation received', response) + setTranslationValue(response.value) + } + ) + + return () => { + solutionChannel.disconnect() + } + }, []) + + if (translationValue) { + return ( +
+ ) + } + + return ( +
+ 💬 Translating to {languageName}… + +
+ ) +} diff --git a/app/javascript/components/localization/originals/list/OriginalsTableList.tsx b/app/javascript/components/localization/originals/list/OriginalsTableList.tsx new file mode 100644 index 0000000000..74bf4f91bb --- /dev/null +++ b/app/javascript/components/localization/originals/list/OriginalsTableList.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { FilterFallback, Pagination } from '@/components/common' +import { OriginalsListContext } from '.' +import { OriginalsTableListElement } from './OriginalsTableListElement' +import { FetchingBoundary } from '@/components/FetchingBoundary' + +export function OriginalsTableList() { + const { setPage, resolvedData, request, error, status } = + React.useContext(OriginalsListContext) + + return ( + +
+ {resolvedData && + resolvedData.results && + resolvedData.results.length > 0 ? ( + resolvedData.results.map((original, key) => ( + + )) + ) : ( + + )} +
+
+ +
+
+ ) +} diff --git a/app/javascript/components/localization/originals/list/OriginalsTableListElement.tsx b/app/javascript/components/localization/originals/list/OriginalsTableListElement.tsx new file mode 100644 index 0000000000..56b119c38d --- /dev/null +++ b/app/javascript/components/localization/originals/list/OriginalsTableListElement.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { GraphicalIcon } from '@/components/common' +import { OriginalsListContext } from '.' +import { assembleClassNames } from '@/utils/assemble-classnames' +import { flagForLocale } from '@/utils/flag-for-locale' + +export function OriginalsTableListElement({ + original, +}: { + original: Original +}) { + const { links } = React.useContext(OriginalsListContext) + return ( + +
+
{original.title}
+
{original.prettyType}
+
+ + + +
+
{original.value}
+ +
+
+ ) +} + +export function TranslationsWithStatus({ + translations, +}: { + translations: Original['translations'] +}) { + return ( +
+ {translations.map((translation) => ( +
+ {flagForLocale(translation.locale)} +
+ ))} +
+ ) +} diff --git a/app/javascript/components/localization/originals/list/Table.tsx b/app/javascript/components/localization/originals/list/Table.tsx new file mode 100644 index 0000000000..144efdc2a1 --- /dev/null +++ b/app/javascript/components/localization/originals/list/Table.tsx @@ -0,0 +1,35 @@ +import { SearchInput } from '@/components/common' +import { useDebounce } from '@uidotdev/usehooks' +import React, { useEffect } from 'react' +import { OriginalsListContext } from '.' +import { OriginalsTableList } from './OriginalsTableList' +import { Tabs } from './Tabs' + +export function Table() { + const { setCriteria, request } = React.useContext(OriginalsListContext) + + const [inputValue, setInputValue] = React.useState( + request.query.criteria || '' + ) + const debouncedValue = useDebounce(inputValue, 300) + + useEffect(() => { + setCriteria(debouncedValue) + }, [debouncedValue, setCriteria]) + + return ( +
+ +
+
+ +
+ +
+
+ ) +} diff --git a/app/javascript/components/localization/originals/list/Tabs.tsx b/app/javascript/components/localization/originals/list/Tabs.tsx new file mode 100644 index 0000000000..494744a3bf --- /dev/null +++ b/app/javascript/components/localization/originals/list/Tabs.tsx @@ -0,0 +1,32 @@ +import { assembleClassNames } from '@/utils/assemble-classnames' +import React from 'react' +import { OriginalsListContext } from '.' + +const TABS = [ + { + value: 'unchecked', + label: 'Needs translating', + }, + { value: 'proposed', label: 'Needs reviewing' }, + { value: 'checked', label: 'Done' }, +] + +export function Tabs() { + const { request, setQuery } = React.useContext(OriginalsListContext) + return ( +
+ {TABS.map((tab) => ( + + ))} +
+ ) +} diff --git a/app/javascript/components/localization/originals/list/index.tsx b/app/javascript/components/localization/originals/list/index.tsx new file mode 100644 index 0000000000..6c8c4bc6fe --- /dev/null +++ b/app/javascript/components/localization/originals/list/index.tsx @@ -0,0 +1,44 @@ +/// +import React, { createContext } from 'react' +import { usePaginatedRequestQuery } from '@/hooks/request-query' +import { useList } from '@/hooks/use-list' +import { removeEmpty, useHistory } from '@/hooks/use-history' +import { Table } from './Table' + +export const OriginalsListContext = createContext( + {} as OriginalsListContextType +) +const CACHE_KEY = 'localization-originals-list' +export default function OriginalsList({ + originals, + links, + request: originalsRequest, +}: OriginalsListProps) { + const { request, setCriteria, setPage, setQuery } = useList(originalsRequest) + const { + status, + error, + data: resolvedData, + } = usePaginatedRequestQuery([CACHE_KEY, request], request) + + useHistory({ pushOn: removeEmpty(request.query) }) + + return ( + + + + ) +} diff --git a/app/javascript/components/localization/originals/show/Checked.tsx b/app/javascript/components/localization/originals/show/Checked.tsx new file mode 100644 index 0000000000..bf9d17b710 --- /dev/null +++ b/app/javascript/components/localization/originals/show/Checked.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { Icon } from '@/components/common' +import { nameForLocale } from '@/utils/name-for-locale' + +export function Checked({ translation }: { translation: Translation }) { + return ( +
+
+
+ {nameForLocale(translation.locale)} ({translation.locale}) +
+
+ + Checked +
+
+
+

+ This translation has been signed off by two translators. No action is + needed. +

+
{translation.value}
+
+
+ ) +} diff --git a/app/javascript/components/localization/originals/show/Proposed/FeedbackBlock.tsx b/app/javascript/components/localization/originals/show/Proposed/FeedbackBlock.tsx new file mode 100644 index 0000000000..eb2fdca330 --- /dev/null +++ b/app/javascript/components/localization/originals/show/Proposed/FeedbackBlock.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import { LLMFeedback } from '.' + +export function FeedbackBlock({ feedback }: { feedback: LLMFeedback }) { + const isApproved = feedback.result === 'approved' + + return ( +
+
+ {isApproved ? '✅' : '❌'} +
+
+ {feedback.reason} +
This is automatically generated feedback.
+
+
+ ) +} diff --git a/app/javascript/components/localization/originals/show/Proposed/ProposalDescription.tsx b/app/javascript/components/localization/originals/show/Proposed/ProposalDescription.tsx new file mode 100644 index 0000000000..53c5498045 --- /dev/null +++ b/app/javascript/components/localization/originals/show/Proposed/ProposalDescription.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { Proposal } from '.' + +export function ProposalDescription({ + proposal, + locale, +}: { + proposal: Proposal + locale: string +}) { + return ( +

+ {proposal.modifiedFromLLM ? ( + <> + This is a proposal from a translator who has modified the + LLM-generated translation. + + ) : ( + <> + This is the {locale} version of the English text on the + right. It was generated by an LLM and reviewed by another translator + who has marked it as correct. + + )}{' '} + Please compare it to the original and either approve it, reject it, or + edit it further. +

+ ) +} diff --git a/app/javascript/components/localization/originals/show/Proposed/index.tsx b/app/javascript/components/localization/originals/show/Proposed/index.tsx new file mode 100644 index 0000000000..385051cd3a --- /dev/null +++ b/app/javascript/components/localization/originals/show/Proposed/index.tsx @@ -0,0 +1,343 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react' +import { flagForLocale } from '@/utils/flag-for-locale' +import { nameForLocale } from '@/utils/name-for-locale' +import { sendRequest } from '@/utils/send-request' +import { redirectTo } from '@/utils' +import { ProposalDescription } from './ProposalDescription' +import { FeedbackBlock } from './FeedbackBlock' +import { OriginalsShowContext } from '..' + +export type LLMFeedback = { + result: 'approved' | 'rejected' + reason: string +} + +export type Proposal = { + uuid: string + value: string + proposerId: string | number + modifiedFromLLM?: boolean + llmFeedback?: LLMFeedback | null + reviewerId?: string | number | null +} + +type Translation = { + status: 'proposed' + locale: string + proposals: Proposal[] + uuid: string +} + +type ProposedProps = { + translation: Translation + currentUserId: string | number + onApproveProposal: (params: { locale: string; proposalIndex: number }) => void + onRejectProposal: (params: { locale: string; proposalIndex: number }) => void + onEditProposal: (params: { locale: string; proposalIndex: number }) => void +} + +type ProposalCardContextType = { + editMode: boolean + setEditMode: (editMode: boolean) => void +} + +const ProposalCardContext = createContext( + {} as ProposalCardContextType +) + +/* +approve_llm_version_api_localization_translation PATCH /api/v2/localization/translations/:id/approve_llm_version(.:format) api/localization/translations#approve_llm_version +approve_api_localization_translation_proposal PATCH /api/v2/localization/translations/:translation_id/proposals/:id/approve(.:format) api/localization/translation_proposals#approve +reject_api_localization_translation_proposal PATCH /api/v2/localization/translations/:translation_id/proposals/:id/reject(.:format) api/localization/translation_proposals#reject +api_localization_translation_proposals POST /api/v2/localization/translations/:translation_id/proposals(.:format) api/localization/translation_proposals#create +api_localization_translation_proposal PATCH /api/v2/localization/translations/:translation_id/proposals/:id(.:format) api/localization/translation_proposals#update +*/ + +// Approve: PATCH approve_api_localization_translation_proposal +// Reject: PATCH reject_api_localization_translation_proposal +// after editing Update proposal: PATCH api_localization_translation_proposal +export function Proposed({ translation }: ProposedProps) { + const { locale, proposals } = translation + + return ( +
+ +
+ {proposals.length > 1 && ( +

+ There have been multiple proposals for this translation. Please + review the proposals. If one is correct, please approve it (which + will reject the others). Or if none are correct, reject each of + them, then edit the original LLM output yourself. +

+ )} + +
+ {proposals.map((proposal, idx) => ( + 1} + translationUuid={translation.uuid} + /> + ))} +
+
+
+ ) +} + +function TranslationHeader({ locale }: { locale: string }) { + return ( +
+
+ {nameForLocale(locale)} ({locale}) +
+
Needs Reviewing
+
{flagForLocale(locale)}
+
+ ) +} + +function ProposalCard({ + proposal, + locale, + isMultiple, + translationUuid, +}: { + proposal: Proposal + locale: string + isMultiple: boolean + translationUuid: string +}) { + const { currentUserId } = useContext(OriginalsShowContext) + const isOwn = String(proposal.proposerId) === String(currentUserId) + + const cardClasses = isMultiple + ? 'border-1 border-borderColor5 p-12 rounded-8 bg-backgroundColorF shadow-keystroke' + : '' + + const [editMode, setEditMode] = useState(false) + const [proposalValue, setProposalValue] = useState(proposal.value) + const [editorValue, setEditorValue] = useState(proposal.value) + + const hasBeenEdited = useMemo(() => { + return proposalValue !== proposal.value + }, [proposalValue]) + + const onSaveEditing = useCallback(() => { + setProposalValue(editorValue) + setEditMode(false) + }, [editorValue]) + const onCancelEditing = useCallback(() => { + setEditMode(false) + setEditorValue(proposalValue) + }, [proposalValue]) + + const onResetChanges = useCallback(() => { + setEditorValue(proposal.value) + setProposalValue(proposal.value) + }, []) + + return ( + +
+ {!isMultiple && ( + + )} + + {editMode ? ( +