diff --git a/CHANGELOG.md b/CHANGELOG.md index eb7d75b11..0735ffd5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -_None_ +- Introduce `fluent_to_po_action` and `po_to_fluent_action` for bidirectional conversion between Fluent (.ftl) and PO (.po) localization files to facilitate GlotPress integration workflows. [#650] ### Bug Fixes diff --git a/Gemfile.lock b/Gemfile.lock index 2136c6ee5..917786014 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,6 +7,7 @@ PATH chroma (= 0.2.0) diffy (~> 3.3) fastlane (~> 2.213) + gettext (~> 3.4) git (~> 1.3) google-cloud-storage (~> 1.31) java-properties (~> 0.3.0) @@ -16,7 +17,11 @@ PATH plist (~> 3.1) progress_bar (~> 1.3) rake (>= 12.3, < 14.0) - rake-compiler (~> 1.0) + rake-compiler-dock (~> 1.0) + rqrcode (~> 2.0) + rubocop (~> 1.50) + ruby-progressbar (~> 1.11) + twitter_cldr (~> 6.11) xcodeproj (~> 1.22) GEM @@ -70,12 +75,15 @@ GEM buildkit (1.6.1) sawyer (>= 0.6) buildkite-test_collector (2.9.0) + camertron-eprun (1.1.1) chroma (0.2.0) + chunky_png (1.4.0) claide (1.1.0) claide-plugins (0.9.2) cork nap open4 (~> 1.3) + cldr-plurals-runtime-rb (1.1.0) cocoapods (1.16.2) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) @@ -161,6 +169,7 @@ GEM dotenv (2.8.1) drb (2.2.1) emoji_regex (3.2.3) + erubi (1.13.1) escape (0.0.4) ethon (0.16.0) ffi (>= 1.15.0) @@ -241,8 +250,15 @@ GEM fastlane-sirp (1.0.0) sysrandom (~> 1.0) ffi (1.17.1) + forwardable (1.3.3) fourflusher (2.3.1) fuzzy_match (2.0.4) + gettext (3.5.1) + erubi + locale (>= 2.0.5) + prime + racc + text (>= 1.3.0) gh_inspector (1.1.3) git (1.19.1) addressable (~> 2.8) @@ -302,6 +318,7 @@ GEM kramdown (~> 2.0) language_server-protocol (3.17.0.4) lint_roller (1.1.0) + locale (2.1.4) logger (1.6.6) method_source (0.9.2) mini_magick (4.13.2) @@ -334,6 +351,9 @@ GEM racc pkg-config (1.6.0) plist (3.7.2) + prime (0.1.3) + forwardable + singleton progress_bar (1.3.4) highline (>= 1.6) options (~> 2.3.0) @@ -345,8 +365,7 @@ GEM racc (1.8.1) rainbow (3.1.1) rake (13.2.1) - rake-compiler (1.2.9) - rake + rake-compiler-dock (1.9.1) rchardet (1.9.0) regexp_parser (2.10.0) representable (3.2.0) @@ -359,6 +378,10 @@ GEM observer (~> 0.1) pkg-config (~> 1.4) rouge (3.28.0) + rqrcode (2.2.0) + chunky_png (~> 1.0) + rqrcode_core (~> 1.0) + rqrcode_core (1.2.0) rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -411,15 +434,22 @@ GEM json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) + singleton (0.3.0) sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) + text (1.3.1) trailblazer-option (0.1.2) tty-cursor (0.7.1) tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) + twitter_cldr (6.14.0) + base64 + camertron-eprun + cldr-plurals-runtime-rb (~> 1.1) + tzinfo typhoeus (1.4.1) ethon (>= 0.9.0) tzinfo (2.0.6) diff --git a/fastlane-plugin-wpmreleasetoolkit.gemspec b/fastlane-plugin-wpmreleasetoolkit.gemspec index 045b252f4..8cfcb1944 100644 --- a/fastlane-plugin-wpmreleasetoolkit.gemspec +++ b/fastlane-plugin-wpmreleasetoolkit.gemspec @@ -5,7 +5,7 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'fastlane/plugin/wpmreleasetoolkit/version' Gem::Specification.new do |spec| - spec.name = 'fastlane-plugin-wpmreleasetoolkit' + spec.name = Fastlane::Wpmreleasetoolkit::NAME spec.version = Fastlane::Wpmreleasetoolkit::VERSION spec.author = 'Automattic' spec.email = 'mobile@automattic.com' @@ -31,6 +31,7 @@ Gem::Specification.new do |spec| spec.add_dependency 'chroma', '0.2.0' spec.add_dependency 'diffy', '~> 3.3' spec.add_dependency 'fastlane', '~> 2.213' + spec.add_dependency 'gettext', '~> 3.4' spec.add_dependency 'git', '~> 1.3' spec.add_dependency 'java-properties', '~> 0.3.0' spec.add_dependency 'nokogiri', '~> 1.11' @@ -39,7 +40,11 @@ Gem::Specification.new do |spec| spec.add_dependency 'plist', '~> 3.1' spec.add_dependency 'progress_bar', '~> 1.3' spec.add_dependency 'rake', '>= 12.3', '< 14.0' - spec.add_dependency 'rake-compiler', '~> 1.0' + spec.add_dependency 'rake-compiler-dock', '~> 1.0' + spec.add_dependency 'rqrcode', '~> 2.0' + spec.add_dependency 'rubocop', '~> 1.50' + spec.add_dependency 'ruby-progressbar', '~> 1.11' + spec.add_dependency 'twitter_cldr', '~> 6.11' spec.add_dependency 'xcodeproj', '~> 1.22' # `google-cloud-storage` is required by fastlane, but we pin it in case it's not in the future diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/fluent_to_po_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/fluent_to_po_action.rb new file mode 100644 index 000000000..f5cfdfb40 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/fluent_to_po_action.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'gettext' +require 'gettext/po_parser' +require 'gettext/po' +require 'twitter_cldr' + +module Fastlane + module Actions + class FluentToPoAction < Action + def self.run(params) + require_relative '../../helper/fluent_localization_helper' + + UI.message('Converting Fluent file to PO format...') + + input_path = File.expand_path(params[:input_file]) + output_path = File.expand_path(params[:output_file]) + + UI.user_error!("Input file does not exist: #{input_path}") unless File.exist?(input_path) + + # Parse the Fluent file + fluent_entries = Helper::FluentLocalizationHelper.parse_fluent_file(input_path) + UI.message("Found #{fluent_entries.length} entries in Fluent file") + + # Convert to PO format + po_content = Helper::FluentLocalizationHelper.fluent_to_po( + fluent_file: File.basename(input_path), + fluent_entries: fluent_entries, + locale: params[:locale], + project_name: params[:project_name], + project_version: params[:project_version] + ) + + File.write(output_path, po_content, encoding: 'utf-8') + + UI.success("Successfully converted Fluent file to PO: #{output_path}") + + output_path + end + + def self.description + 'Convert Fluent (.ftl) files to PO (.po) format for GlotPress integration' + end + + def self.authors + ['Automattic'] + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :input_file, + description: 'Path to the input Fluent (.ftl) file', + optional: false, + type: String + ), + FastlaneCore::ConfigItem.new( + key: :output_file, + description: 'Path to the output PO (.po) file', + optional: false, + type: String + ), + FastlaneCore::ConfigItem.new( + key: :locale, + description: 'Target locale (e.g., en-US, es-ES)', + optional: false, + type: String + ), + FastlaneCore::ConfigItem.new( + key: :project_name, + description: 'Project name for the PO header', + optional: true, + type: String, + default_value: '' + ), + FastlaneCore::ConfigItem.new( + key: :project_version, + description: 'Project version for the PO header', + optional: true, + type: String, + default_value: '' + ), + ] + end + + def self.is_supported?(platform) + true + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/po_to_fluent_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/po_to_fluent_action.rb new file mode 100644 index 000000000..533f3d86b --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/po_to_fluent_action.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'gettext' +require 'gettext/po_parser' +require 'gettext/po' + +module Fastlane + module Actions + class PoToFluentAction < Action + def self.run(params) + require_relative '../../helper/fluent_localization_helper' + + UI.message('Converting PO file to Fluent format...') + + input_path = File.expand_path(params[:input_file]) + output_path = File.expand_path(params[:output_file]) + + UI.user_error!("Input file does not exist: #{input_path}") unless File.exist?(input_path) + + # Parse the PO file + po_entries = Helper::FluentLocalizationHelper.parse_po_file(input_path) + + # Convert to Fluent format + fluent_content = Helper::FluentLocalizationHelper.po_to_fluent(po_entries) + + if !params[:allow_empty_file] && fluent_content.strip.empty? + UI.message('No translated content found in PO file') + return + end + + File.write(output_path, fluent_content, encoding: 'utf-8') + UI.success("Successfully converted PO file to Fluent: #{output_path}") + + output_path + end + + def self.description + 'Convert PO (.po) files to Fluent (.ftl) format after translation' + end + + def self.authors + ['Automattic'] + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :input_file, + description: 'Path to the input PO (.po) file', + optional: false, + type: String + ), + FastlaneCore::ConfigItem.new( + key: :output_file, + description: 'Path to the output Fluent (.ftl) file', + optional: false, + type: String + ), + FastlaneCore::ConfigItem.new( + key: :allow_empty_file, + description: 'Whether to allow an empty file when no translated content is found', + optional: true, + type: Boolean, + default_value: false + ), + ] + end + + def self.is_supported?(platform) + true + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/fluent_localization_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/fluent_localization_helper.rb new file mode 100644 index 000000000..9c443b5e7 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/fluent_localization_helper.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'gettext' +require 'gettext/po_parser' +require 'gettext/po' +require 'twitter_cldr' + +module Fastlane + module Helper + class FluentLocalizationHelper + # Represents a parsed Fluent entry + FluentEntry = Struct.new(:key, :value, :comment, :line_number) do + def initialize(key:, value:, comment: nil, line_number: nil) + super(key, value, comment, line_number) + end + end + + # Parse a Fluent (.ftl) file and return an array of FluentEntry objects + def self.parse_fluent_file(file_path) + entries = [] + current_comment = nil + + File.readlines(file_path, encoding: 'utf-8').each_with_index do |line, index| + line = line.strip + line_number = index + 1 + + # Reset comment when encountering blank lines + if line.empty? + current_comment = nil + next + end + + # Handle comments + if line.start_with?('#') + current_comment = current_comment ? "#{current_comment}\n#{line[1..].strip}" : line[1..].strip + next + end + + # Handle key-value pairs + next unless line.include?('=') + + key, value = line.split('=', 2) + key = key.strip + value = value.strip + + entries << FluentEntry.new( + key: key, + value: value, + comment: current_comment, + line_number: line_number + ) + + current_comment = nil + end + + entries + end + + # Convert Fluent entries to PO format + def self.fluent_to_po(fluent_file:, fluent_entries:, locale:, project_name:, project_version:) + po = GetText::PO.new + + # Create header entry + header_content = generate_po_header(locale: locale, project_name: project_name, project_version: project_version) + header_entry = GetText::POEntry.new(:normal) + header_entry.msgid = '' + header_entry.msgstr = header_content + header_entry.translator_comment = "Generated by #{Fastlane::Wpmreleasetoolkit::NAME} (#{Fastlane::Wpmreleasetoolkit::VERSION})" + po[nil, ''] = header_entry + + fluent_entries.each do |entry| + po_entry = GetText::POEntry.new(:normal) + po_entry.msgid = entry.key + po_entry.msgstr = entry.value + po_entry.references = ["#{fluent_file}:#{entry.line_number}"] + po_entry.translator_comment = entry.comment + + po[nil, entry.key] = po_entry + end + + # Format options for output + format_options = { + max_line_width: -1 + } + + po.to_s(format_options) + end + + # Parse a PO (.po) file and return array of entries + def self.parse_po_file(file_path) + po = GetText::PO.new + parser = GetText::POParser.new + parser.ignore_fuzzy = false + parser.report_warning = true + parser.parse_file(file_path, po) + + # Filter out header entry and return only translated entries + po.entries.reject { |entry| entry.msgid.to_s.empty? } + end + + # Convert PO entries back to Fluent format + def self.po_to_fluent(po_entries) + fluent_content = '' + + po_entries.each do |entry| + # Skip untranslated entries + next if entry.msgstr.to_s.empty? + + # Add translator comments if present + if entry.translator_comment && !entry.translator_comment.to_s.empty? + entry.translator_comment.to_s.split("\n").each do |comment_line| + next if comment_line.strip.empty? + + fluent_content += "# #{comment_line.strip}\n" + end + end + + # Add the Fluent entry + fluent_content += "#{entry.msgid} = #{entry.msgstr}\n" + fluent_content += "\n" + end + + fluent_content + end + + # Generate PO file header lines for GetText::PO + def self.generate_po_header(locale:, project_name:, project_version:) + timestamp = Time.now.strftime('%Y-%m-%d %H:%M%z') + plural_rule = get_plural_rule(locale) + + [ + "Project-Id-Version: #{project_name} #{project_version}\n", + "POT-Creation-Date: #{timestamp}\n", + "PO-Revision-Date: #{timestamp}\n", + "Language: #{locale}\n", + "MIME-Version: 1.0\n", + "Content-Type: text/plain; charset=UTF-8\n", + "Content-Transfer-Encoding: 8bit\n", + "Plural-Forms: #{plural_rule || 'nplurals=2; plural=(n != 1);'}\n", + ].join + end + + # Check if a string contains Fluent variables (e.g., {$variable}) + def self.contains_variables?(text) + text.match?(/\{\$[^}]+\}/) + end + + # Get plural rule for a locale using TwitterCLDR data + def self.get_plural_rule(locale) + # Normalize locale (e.g., en-US -> en, pt-BR -> pt) + lang_code = locale.split('-').first.to_sym + + # Get plural categories for the language from TwitterCldr + plural_categories = TwitterCldr::Formatters::Plurals::Rules.all_for(lang_code) + return nil if plural_categories.empty? + + nplurals = plural_categories.length + + # Use a simple mapping based on the number of plural categories + # This covers the most common cases without hardcoding complex expressions + case nplurals + when 1 + # Languages like Chinese, Japanese, Korean + 'nplurals=1; plural=0;' + when 3 + # Slavic languages (Russian, Polish, etc.) + # Use a generic 3-form rule that works for most Slavic languages + 'nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);' + when 4 + # Languages like Slovenian + 'nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3);' + when 6 + # Arabic and a few other languages + 'nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5);' + else + # Most Germanic and Romance languages (English, German, Spanish, etc.) and fallback + 'nplurals=2; plural=(n != 1);' + end + rescue StandardError + # If TwitterCldr fails for any reason, fallback to English-style rule + 'nplurals=2; plural=(n != 1);' + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/version.rb b/lib/fastlane/plugin/wpmreleasetoolkit/version.rb index c5392c65b..662d0e641 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/version.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/version.rb @@ -2,6 +2,7 @@ module Fastlane module Wpmreleasetoolkit + NAME = 'fastlane-plugin-wpmreleasetoolkit' VERSION = '13.2.0' end end diff --git a/spec/fluent_localization_helper_spec.rb b/spec/fluent_localization_helper_spec.rb new file mode 100644 index 000000000..4fb2c741b --- /dev/null +++ b/spec/fluent_localization_helper_spec.rb @@ -0,0 +1,518 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Fastlane::Helper::FluentLocalizationHelper do + let(:test_data_dir) { File.join(__dir__, 'test-data', 'fluent_localization') } + let(:tmpdir) { Dir.mktmpdir('a8c-fluent-localization-helper-spec-') } + + after do + FileUtils.remove_entry tmpdir + end + + def fixture_path(filename) + File.join(test_data_dir, filename) + end + + def temp_file_path(filename) + File.join(tmpdir, filename) + end + + describe '.parse_fluent_file' do + context 'with a valid Fluent file' do + let(:fluent_content) do + <<~FLUENT + # Welcome message for new users + welcome-message = Welcome to our application! + + # Error messages + error-network = Unable to connect to the network. + error-invalid-input = The input you provided is invalid. + + # Dynamic content with variables + greeting = Hello, {$name}! + item-count = You have {$count} items in your cart. + + simple-message = This is a simple message. + FLUENT + end + + it 'parses all entries correctly' do + with_tmp_file(named: 'test.ftl', content: fluent_content) do |file_path| + entries = described_class.parse_fluent_file(file_path) + + expect(entries.length).to eq(6) + + # Check first entry with comment + expect(entries[0].key).to eq('welcome-message') + expect(entries[0].value).to eq('Welcome to our application!') + expect(entries[0].comment).to eq('Welcome message for new users') + expect(entries[0].line_number).to eq(2) + + # Check entry with different comment + expect(entries[1].key).to eq('error-network') + expect(entries[1].value).to eq('Unable to connect to the network.') + expect(entries[1].comment).to eq('Error messages') + expect(entries[1].line_number).to eq(5) + + # Check entry with variables + expect(entries[3].key).to eq('greeting') + expect(entries[3].value).to eq('Hello, {$name}!') + expect(entries[3].comment).to eq('Dynamic content with variables') + expect(entries[3].line_number).to eq(9) + + # Check entry without comment (no comment preceding it) + expect(entries[5].key).to eq('simple-message') + expect(entries[5].value).to eq('This is a simple message.') + expect(entries[5].comment).to be_nil + expect(entries[5].line_number).to eq(12) + end + end + + it 'handles multiline comments' do + fluent_with_multiline_comment = <<~FLUENT + # This is the first line of a comment + # This is the second line of a comment + # This is the third line of a comment + multiline-comment-key = This has a multiline comment. + FLUENT + + with_tmp_file(named: 'multiline.ftl', content: fluent_with_multiline_comment) do |file_path| + entries = described_class.parse_fluent_file(file_path) + + expect(entries.length).to eq(1) + expect(entries[0].comment).to eq("This is the first line of a comment\nThis is the second line of a comment\nThis is the third line of a comment") + end + end + + it 'resets comments on blank lines' do + fluent_with_separated_comments = <<~FLUENT + # First comment + first-key = First value + + # Second comment + second-key = Second value + FLUENT + + with_tmp_file(named: 'separated.ftl', content: fluent_with_separated_comments) do |file_path| + entries = described_class.parse_fluent_file(file_path) + + expect(entries.length).to eq(2) + expect(entries[0].comment).to eq('First comment') + expect(entries[1].comment).to eq('Second comment') + end + end + + it 'handles entries with equals signs in values' do + fluent_with_equals = <<~FLUENT + equation = 2 + 2 = 4 + url = https://example.com?param=value + FLUENT + + with_tmp_file(named: 'equals.ftl', content: fluent_with_equals) do |file_path| + entries = described_class.parse_fluent_file(file_path) + + expect(entries.length).to eq(2) + expect(entries[0].value).to eq('2 + 2 = 4') + expect(entries[1].value).to eq('https://example.com?param=value') + end + end + end + + context 'with an empty file' do + it 'returns an empty array' do + with_tmp_file(named: 'empty.ftl', content: '') do |file_path| + entries = described_class.parse_fluent_file(file_path) + expect(entries).to be_empty + end + end + end + + context 'with comments only' do + it 'returns an empty array' do + comments_only = <<~FLUENT + # This is a comment + # This is another comment + + # Yet another comment + FLUENT + + with_tmp_file(named: 'comments.ftl', content: comments_only) do |file_path| + entries = described_class.parse_fluent_file(file_path) + expect(entries).to be_empty + end + end + end + end + + describe '.fluent_to_po' do + let(:fluent_entries) do + [ + described_class::FluentEntry.new( + key: 'welcome-message', + value: 'Welcome to our application!', + comment: 'Welcome message for new users', + line_number: 2 + ), + described_class::FluentEntry.new( + key: 'greeting', + value: 'Hello, {$name}!', + comment: 'Dynamic content with variables', + line_number: 8 + ), + described_class::FluentEntry.new( + key: 'simple-message', + value: 'This is a simple message.', + comment: nil, + line_number: 11 + ), + ] + end + + it 'converts Fluent entries to PO format' do + po_content = described_class.fluent_to_po( + fluent_file: 'test.ftl', + fluent_entries: fluent_entries, + locale: 'en-US', + project_name: 'Test Project', + project_version: '1.0.0' + ) + + po_lines = po_content.split("\n").reject(&:empty?) + + # Check header + expect(po_lines[0]).to eq("# Generated by #{Fastlane::Wpmreleasetoolkit::NAME} (#{Fastlane::Wpmreleasetoolkit::VERSION})") + expect(po_lines[1]).to eq('msgid ""') + expect(po_lines[2]).to eq('msgstr ""') + expect(po_lines[3]).to eq('"Project-Id-Version: Test Project 1.0.0\n"') + expect(po_lines[4]).to match(/POT-Creation-Date: \d{4}-\d{2}-\d{2} \d{2}:\d{2}[+-]\d{4}/) + expect(po_lines[5]).to match(/PO-Revision-Date: \d{4}-\d{2}-\d{2} \d{2}:\d{2}[+-]\d{4}/) + expect(po_lines[6]).to eq('"Language: en-US\n"') + expect(po_lines[7]).to eq('"MIME-Version: 1.0\n"') + expect(po_lines[8]).to eq('"Content-Type: text/plain; charset=UTF-8\n"') + expect(po_lines[9]).to eq('"Content-Transfer-Encoding: 8bit\n"') + expect(po_lines[10]).to eq('"Plural-Forms: nplurals=2; plural=(n != 1);\n"') + + # Check first entry + expect(po_lines[11]).to eq('# Welcome message for new users') + expect(po_lines[12]).to eq('#: test.ftl:2') + expect(po_lines[13]).to eq('msgid "welcome-message"') + expect(po_lines[14]).to eq('msgstr "Welcome to our application!"') + + # Check second entry + expect(po_lines[15]).to eq('# Dynamic content with variables') + expect(po_lines[16]).to eq('#: test.ftl:8') + expect(po_lines[17]).to eq('msgid "greeting"') + expect(po_lines[18]).to eq('msgstr "Hello, {$name}!"') + + # Check third entry + expect(po_lines[19]).to eq('#: test.ftl:11') + expect(po_lines[20]).to eq('msgid "simple-message"') + expect(po_lines[21]).to eq('msgstr "This is a simple message."') + end + + it 'handles entries without comments' do + entries_without_comments = [ + described_class::FluentEntry.new( + key: 'no-comment-key', + value: 'Value without comment', + comment: nil, + line_number: 5 + ), + ] + + po_content = described_class.fluent_to_po( + fluent_file: 'test.ftl', + fluent_entries: entries_without_comments, + locale: 'fr', + project_name: 'Test', + project_version: '1.0' + ) + + po_lines = po_content.split("\n").reject(&:empty?) + + # Check entry + expect(po_lines[12]).to eq('#: test.ftl:5') + expect(po_lines[13]).to eq('msgid "no-comment-key"') + expect(po_lines[14]).to eq('msgstr "Value without comment"') + end + end + + describe '.parse_po_file' do + let(:po_content) do + <<~PO + # SOME DESCRIPTIVE TITLE. + # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER + # This file is distributed under the same license as the PACKAGE package. + # FIRST AUTHOR , YEAR. + # + msgid "" + msgstr "" + "Project-Id-Version: Test Project 1.0.0\\n" + "Language: fr\\n" + "MIME-Version: 1.0\\n" + "Content-Type: text/plain; charset=UTF-8\\n" + "Content-Transfer-Encoding: 8bit\\n" + + #. Welcome message for new users + #: test.ftl:2 + msgid "welcome-message" + msgstr "Bienvenue dans notre application!" + + #. Dynamic content with variables + #: test.ftl:8 + msgid "greeting" + msgstr "Bonjour, {$name}!" + + #: test.ftl:11 + msgid "simple-message" + msgstr "Ceci est un message simple." + PO + end + + it 'parses PO file and returns entries' do + with_tmp_file(named: 'test.po', content: po_content) do |file_path| + entries = described_class.parse_po_file(file_path) + + expect(entries.length).to eq(3) + + welcome_entry = entries.find { |e| e.msgid == 'welcome-message' } + expect(welcome_entry.msgstr).to eq('Bienvenue dans notre application!') + expect(welcome_entry.extracted_comment.to_s).to include('Welcome message for new users') + + greeting_entry = entries.find { |e| e.msgid == 'greeting' } + expect(greeting_entry.msgstr).to eq('Bonjour, {$name}!') + + simple_entry = entries.find { |e| e.msgid == 'simple-message' } + expect(simple_entry.msgstr).to eq('Ceci est un message simple.') + end + end + + it 'excludes header entries' do + with_tmp_file(named: 'test.po', content: po_content) do |file_path| + entries = described_class.parse_po_file(file_path) + + # Should not include the header entry (empty msgid) + header_entries = entries.select { |e| e.msgid.to_s.empty? } + expect(header_entries).to be_empty + end + end + end + + describe '.po_to_fluent' do + let(:po_entries) do + # Create mock PO entries using doubles to simulate GetText::POEntry objects + [ + instance_double(GetText::POEntry, + msgid: 'welcome-message', + msgstr: 'Bienvenue dans notre application!', + translator_comment: 'Welcome message for new users'), + instance_double(GetText::POEntry, + msgid: 'greeting', + msgstr: 'Bonjour, {$name}!', + translator_comment: 'Dynamic content with variables'), + instance_double(GetText::POEntry, + msgid: 'simple-message', + msgstr: 'Ceci est un message simple.', + translator_comment: nil), + instance_double(GetText::POEntry, + msgid: 'untranslated-key', + msgstr: '', + translator_comment: 'This should be skipped'), + ] + end + + it 'converts PO entries back to Fluent format' do + fluent_content = described_class.po_to_fluent(po_entries) + + expect(fluent_content).to include('# Welcome message for new users') + expect(fluent_content).to include('welcome-message = Bienvenue dans notre application!') + + expect(fluent_content).to include('# Dynamic content with variables') + expect(fluent_content).to include('greeting = Bonjour, {$name}!') + + expect(fluent_content).to include('simple-message = Ceci est un message simple.') + + # Should not include untranslated entries + expect(fluent_content).not_to include('untranslated-key') + end + + it 'skips entries with empty translations' do + fluent_content = described_class.po_to_fluent(po_entries) + + expect(fluent_content).not_to include('untranslated-key') + end + + it 'handles entries without comments' do + fluent_content = described_class.po_to_fluent(po_entries) + + # Should include the entry without a comment block + lines = fluent_content.split("\n") + simple_message_index = lines.find_index { |line| line.include?('simple-message =') } + expect(simple_message_index).not_to be_nil + + # The line before should not be a comment + previous_line = lines[simple_message_index - 1] + expect(previous_line).not_to start_with('#') + end + + it 'handles multiline comments' do + multiline_entry = instance_double(GetText::POEntry, + msgid: 'multiline-comment-key', + msgstr: 'Translated value', + translator_comment: "First line\nSecond line\nThird line") + + fluent_content = described_class.po_to_fluent([multiline_entry]) + + expect(fluent_content).to include('# First line') + expect(fluent_content).to include('# Second line') + expect(fluent_content).to include('# Third line') + expect(fluent_content).to include('multiline-comment-key = Translated value') + end + end + + describe '.generate_po_header' do + it 'generates a proper PO header' do + header = described_class.generate_po_header( + locale: 'fr-FR', + project_name: 'Test App', + project_version: '2.1.0' + ) + + expect(header).to include('Project-Id-Version: Test App 2.1.0') + expect(header).to include('Language: fr-FR') + expect(header).to include('Content-Type: text/plain; charset=UTF-8') + expect(header).to include('Content-Transfer-Encoding: 8bit') + expect(header).to include('MIME-Version: 1.0') + end + + it 'includes timestamp information' do + header = described_class.generate_po_header( + locale: 'en', + project_name: 'Test', + project_version: '1.0' + ) + + expect(header).to match(/POT-Creation-Date: \d{4}-\d{2}-\d{2} \d{2}:\d{2}[+-]\d{4}/) + expect(header).to match(/PO-Revision-Date: \d{4}-\d{2}-\d{2} \d{2}:\d{2}[+-]\d{4}/) + end + end + + describe '.contains_variables?' do + it 'detects Fluent variables in text' do + expect(described_class.contains_variables?('Hello, {$name}!')).to be true + expect(described_class.contains_variables?('You have {$count} items')).to be true + expect(described_class.contains_variables?('{$var1} and {$var2}')).to be true + end + + it 'returns false for text without variables' do + expect(described_class.contains_variables?('Hello, world!')).to be false + expect(described_class.contains_variables?('Simple message')).to be false + expect(described_class.contains_variables?('Text with {} empty braces')).to be false + expect(described_class.contains_variables?('Text with {no dollar} variable')).to be false + end + + it 'handles edge cases' do + expect(described_class.contains_variables?('')).to be false + expect(described_class.contains_variables?('{$}')).to be false + expect(described_class.contains_variables?('{$var')).to be false + expect(described_class.contains_variables?('$var}')).to be false + end + end + + describe '.get_plural_rule' do + context 'with common languages' do + it 'returns correct plural rule for English' do + rule = described_class.get_plural_rule('en') + expect(rule).to eq('nplurals=2; plural=(n != 1);') + end + + it 'returns correct plural rule for English variants' do + rule = described_class.get_plural_rule('en-US') + expect(rule).to eq('nplurals=2; plural=(n != 1);') + end + + it 'handles Chinese (singular-only language)' do + rule = described_class.get_plural_rule('zh') + expect(rule).to eq('nplurals=1; plural=0;') + end + + it 'handles Chinese variants' do + rule = described_class.get_plural_rule('zh-CN') + expect(rule).to eq('nplurals=1; plural=0;') + end + + it 'returns rule for Slavic languages like Russian' do + rule = described_class.get_plural_rule('ru') + # Russian actually has 4 plural forms in modern CLDR data + expect(rule).to match(/nplurals=[34]; plural=/) + end + + it 'returns six-form rule for Arabic' do + rule = described_class.get_plural_rule('ar') + expect(rule).to eq('nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5);') + end + end + + context 'with unsupported or invalid locales' do + it 'returns fallback rule for unknown languages' do + rule = described_class.get_plural_rule('unknown-locale') + expect(rule).to eq('nplurals=2; plural=(n != 1);') + end + + it 'handles TwitterCldr errors gracefully' do + # Mock TwitterCldr to raise an error + allow(TwitterCldr::Formatters::Plurals::Rules).to receive(:all_for).and_raise(StandardError.new('TwitterCldr error')) + + rule = described_class.get_plural_rule('en') + expect(rule).to eq('nplurals=2; plural=(n != 1);') + end + end + end + + describe 'integration tests' do + it 'performs round-trip conversion: Fluent -> PO -> Fluent' do + original_fluent = <<~FLUENT + # Application title + app-title = My Awesome App + + # User greeting with variable + user-greeting = Hello, {$username}! + + # Error message + error-message = Something went wrong. + FLUENT + + with_tmp_file(named: 'original.ftl', content: original_fluent) do |fluent_path| + # Step 1: Parse original Fluent file + fluent_entries = described_class.parse_fluent_file(fluent_path) + expect(fluent_entries.length).to eq(3) + + # Step 2: Convert to PO + po_content = described_class.fluent_to_po( + fluent_file: 'original.ftl', + fluent_entries: fluent_entries, + locale: 'en', + project_name: 'Test', + project_version: '1.0' + ) + + # Step 3: Write PO file and parse it back + with_tmp_file(named: 'converted.po', content: po_content) do |po_path| + po_entries = described_class.parse_po_file(po_path) + + # Step 4: Convert back to Fluent + regenerated_fluent = described_class.po_to_fluent(po_entries) + + # Step 5: Verify the content matches (allowing for formatting differences) + expect(regenerated_fluent).to include('app-title = My Awesome App') + expect(regenerated_fluent).to include('user-greeting = Hello, {$username}!') + expect(regenerated_fluent).to include('error-message = Something went wrong.') + expect(regenerated_fluent).to include('# Application title') + expect(regenerated_fluent).to include('# User greeting with variable') + expect(regenerated_fluent).to include('# Error message') + end + end + end + end +end diff --git a/spec/fluent_to_po_action_spec.rb b/spec/fluent_to_po_action_spec.rb new file mode 100644 index 000000000..1dc0f3230 --- /dev/null +++ b/spec/fluent_to_po_action_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Fastlane::Actions::FluentToPoAction do + let(:fake_fluent_entries) do + [ + { 'id' => 'welcome-message', 'value' => 'Welcome to our app!', 'comment' => 'Welcome text' }, + { 'id' => 'error-network', 'value' => 'Network error occurred', 'comment' => 'Error messages' }, + ] + end + + let(:fake_po_content) do + <<~PO + # SOME DESCRIPTIVE TITLE. + # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER + # This file is distributed under the same license as the PACKAGE package. + # FIRST AUTHOR , YEAR. + # + msgid "" + msgstr "" + "Project-Id-Version: TestProject 1.0\\n" + "Report-Msgid-Bugs-To: \\n" + "Language: en-US\\n" + "MIME-Version: 1.0\\n" + "Content-Type: text/plain; charset=UTF-8\\n" + "Content-Transfer-Encoding: 8bit\\n" + + #. Welcome text + msgid "welcome-message" + msgstr "Welcome to our app!" + + #. Error messages + msgid "error-network" + msgstr "Network error occurred" + PO + end + + before do + # Mock the FluentLocalizationHelper since it has its own tests + allow(Fastlane::Helper::FluentLocalizationHelper).to receive_messages( + parse_fluent_file: fake_fluent_entries, + fluent_to_po: fake_po_content + ) + end + + describe 'file validation' do + it 'errors when input file does not exist' do + in_tmp_dir do |tmp_dir| + non_existent_file = '/path/to/non/existent/file.ftl' + output_file = File.join(tmp_dir, 'output.po') + + expect(FastlaneCore::UI).to receive(:user_error!) + .with("Input file does not exist: #{non_existent_file}") + + run_described_fastlane_action( + input_file: non_existent_file, + output_file: output_file, + locale: 'en-US' + ) + end + end + end + + describe 'successful conversion' do + it 'converts Fluent file to PO format and writes output' do + with_tmp_file(named: 'input.ftl', content: 'fake fluent content') do |input_path| + in_tmp_dir do |tmp_dir| + output_path = File.join(tmp_dir, 'output.po') + + expect(Fastlane::Helper::FluentLocalizationHelper).to receive(:parse_fluent_file).with(input_path) + expect(Fastlane::Helper::FluentLocalizationHelper).to receive(:fluent_to_po).with( + fluent_file: File.basename(input_path), + fluent_entries: fake_fluent_entries, + locale: 'en-US', + project_name: '', + project_version: '' + ) + + result = run_described_fastlane_action( + input_file: input_path, + output_file: output_path, + locale: 'en-US' + ) + + expect(File.exist?(output_path)).to be true + expect(File.read(output_path, encoding: 'utf-8')).to eq(fake_po_content) + expect(result).to eq(output_path) + end + end + end + + it 'passes optional project parameters to helper' do + with_tmp_file(named: 'input.ftl', content: 'fake fluent content') do |input_path| + in_tmp_dir do |tmp_dir| + output_path = File.join(tmp_dir, 'output.po') + + expect(Fastlane::Helper::FluentLocalizationHelper).to receive(:parse_fluent_file).with(input_path) + expect(Fastlane::Helper::FluentLocalizationHelper).to receive(:fluent_to_po).with( + fluent_file: File.basename(input_path), + fluent_entries: fake_fluent_entries, + locale: 'es-ES', + project_name: 'MyApp', + project_version: '2.0' + ) + + result = run_described_fastlane_action( + input_file: input_path, + output_file: output_path, + locale: 'es-ES', + project_name: 'MyApp', + project_version: '2.0' + ) + + expect(File.exist?(output_path)).to be true + expect(result).to eq(output_path) + end + end + end + + it 'expands relative paths' do + with_tmp_file(named: 'input.ftl', content: 'fake fluent content') do |input_path| + in_tmp_dir do |tmp_dir| + relative_input = 'input.ftl' + relative_output = 'output.po' + FileUtils.cp(input_path, File.join(tmp_dir, relative_input)) + + Dir.chdir(tmp_dir) do + expect(Fastlane::Helper::FluentLocalizationHelper).to receive(:parse_fluent_file) + .with(File.expand_path(relative_input)) + + result = run_described_fastlane_action( + input_file: relative_input, + output_file: relative_output, + locale: 'en-US' + ) + + expect(File.exist?(File.expand_path(relative_output))).to be true + expect(result).to eq(File.expand_path(relative_output)) + end + end + end + end + + it 'displays progress and success messages' do + with_tmp_file(named: 'input.ftl', content: 'fake fluent content') do |input_path| + in_tmp_dir do |tmp_dir| + output_path = File.join(tmp_dir, 'output.po') + + allow(FastlaneCore::UI).to receive(:message) + allow(FastlaneCore::UI).to receive(:success) + + expect(FastlaneCore::UI).to receive(:message).with('Converting Fluent file to PO format...') + expect(FastlaneCore::UI).to receive(:message).with("Found #{fake_fluent_entries.length} entries in Fluent file") + expect(FastlaneCore::UI).to receive(:success).with("Successfully converted Fluent file to PO: #{output_path}") + + run_described_fastlane_action( + input_file: input_path, + output_file: output_path, + locale: 'en-US' + ) + end + end + end + end +end diff --git a/spec/po_to_fluent_action_spec.rb b/spec/po_to_fluent_action_spec.rb new file mode 100644 index 000000000..a17c30613 --- /dev/null +++ b/spec/po_to_fluent_action_spec.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Fastlane::Actions::PoToFluentAction do + let(:fake_po_entries) do + [ + { 'msgid' => 'welcome-message', 'msgstr' => 'Welcome to our app!', 'translator-comments' => 'Welcome text' }, + { 'msgid' => 'error-network', 'msgstr' => 'Network error occurred', 'translator-comments' => 'Error messages' }, + ] + end + + let(:fake_fluent_content) do + <<~FLUENT + # Welcome text + welcome-message = Welcome to our app! + + # Error messages + error-network = Network error occurred + FLUENT + end + + before do + # Mock the FluentLocalizationHelper since it has its own tests + allow(Fastlane::Helper::FluentLocalizationHelper).to receive_messages( + parse_po_file: fake_po_entries, + po_to_fluent: fake_fluent_content + ) + end + + describe 'file validation' do + it 'errors when input file does not exist' do + in_tmp_dir do |tmp_dir| + non_existent_file = '/path/to/non/existent/file.po' + output_file = File.join(tmp_dir, 'output.ftl') + + expect(FastlaneCore::UI).to receive(:user_error!) + .with("Input file does not exist: #{non_existent_file}") + + run_described_fastlane_action( + input_file: non_existent_file, + output_file: output_file + ) + end + end + end + + describe 'successful conversion' do + it 'converts PO file to Fluent format and writes output' do + with_tmp_file(named: 'input.po', content: 'fake po content') do |input_path| + in_tmp_dir do |tmp_dir| + output_path = File.join(tmp_dir, 'output.ftl') + + expect(Fastlane::Helper::FluentLocalizationHelper).to receive(:parse_po_file).with(input_path) + expect(Fastlane::Helper::FluentLocalizationHelper).to receive(:po_to_fluent).with(fake_po_entries) + + result = run_described_fastlane_action( + input_file: input_path, + output_file: output_path + ) + + expect(File.exist?(output_path)).to be true + expect(File.read(output_path, encoding: 'utf-8')).to eq(fake_fluent_content) + expect(result).to eq(output_path) + end + end + end + + it 'expands relative paths' do + with_tmp_file(named: 'input.po', content: 'fake po content') do |input_path| + in_tmp_dir do |tmp_dir| + relative_input = 'input.po' + relative_output = 'output.ftl' + FileUtils.cp(input_path, File.join(tmp_dir, relative_input)) + + Dir.chdir(tmp_dir) do + expect(Fastlane::Helper::FluentLocalizationHelper).to receive(:parse_po_file) + .with(File.expand_path(relative_input)) + + result = run_described_fastlane_action( + input_file: relative_input, + output_file: relative_output + ) + + expect(File.exist?(File.expand_path(relative_output))).to be true + expect(result).to eq(File.expand_path(relative_output)) + end + end + end + end + + it 'displays success message' do + with_tmp_file(named: 'input.po', content: 'fake po content') do |input_path| + in_tmp_dir do |tmp_dir| + output_path = File.join(tmp_dir, 'output.ftl') + + allow(FastlaneCore::UI).to receive(:message) + allow(FastlaneCore::UI).to receive(:success) + + expect(FastlaneCore::UI).to receive(:message).with('Converting PO file to Fluent format...') + expect(FastlaneCore::UI).to receive(:success).with("Successfully converted PO file to Fluent: #{output_path}") + + run_described_fastlane_action( + input_file: input_path, + output_file: output_path + ) + end + end + end + end + + describe 'empty content handling' do + context 'when allow_empty_file is false (default)' do + it 'does not create output file when content is empty' do + empty_fluent_content = '' + allow(Fastlane::Helper::FluentLocalizationHelper).to receive(:po_to_fluent).and_return(empty_fluent_content) + + with_tmp_file(named: 'input.po', content: 'fake po content') do |input_path| + in_tmp_dir do |tmp_dir| + output_path = File.join(tmp_dir, 'output.ftl') + + # Allow the initial message and expect the specific empty file message + allow(FastlaneCore::UI).to receive(:message).with('Converting PO file to Fluent format...') + expect(FastlaneCore::UI).to receive(:message).with('No translated content found in PO file') + + expect(File.exist?(output_path)).to be false + + result = run_described_fastlane_action( + input_file: input_path, + output_file: output_path + ) + + expect(result).to be_nil + end + end + end + + it 'does not create output file when content is whitespace only' do + whitespace_content = " \n\t \n " + allow(Fastlane::Helper::FluentLocalizationHelper).to receive(:po_to_fluent).and_return(whitespace_content) + + with_tmp_file(named: 'input.po', content: 'fake po content') do |input_path| + in_tmp_dir do |tmp_dir| + output_path = File.join(tmp_dir, 'output.ftl') + + # Allow the initial message and expect the specific empty file message + allow(FastlaneCore::UI).to receive(:message).with('Converting PO file to Fluent format...') + expect(FastlaneCore::UI).to receive(:message).with('No translated content found in PO file') + + expect(File.exist?(output_path)).to be false + + result = run_described_fastlane_action( + input_file: input_path, + output_file: output_path, + allow_empty_file: false + ) + + expect(result).to be_nil + end + end + end + end + + context 'when allow_empty_file is true' do + it 'creates output file even when content is empty' do + empty_fluent_content = '' + allow(Fastlane::Helper::FluentLocalizationHelper).to receive(:po_to_fluent).and_return(empty_fluent_content) + + with_tmp_file(named: 'input.po', content: 'fake po content') do |input_path| + in_tmp_dir do |tmp_dir| + output_path = File.join(tmp_dir, 'output.ftl') + + result = run_described_fastlane_action( + input_file: input_path, + output_file: output_path, + allow_empty_file: true + ) + + expect(File.exist?(output_path)).to be true + expect(File.read(output_path, encoding: 'utf-8')).to eq('') + expect(result).to eq(output_path) + end + end + end + + it 'creates output file when content is whitespace only' do + whitespace_content = " \n\t \n " + allow(Fastlane::Helper::FluentLocalizationHelper).to receive(:po_to_fluent).and_return(whitespace_content) + + with_tmp_file(named: 'input.po', content: 'fake po content') do |input_path| + in_tmp_dir do |tmp_dir| + output_path = File.join(tmp_dir, 'output.ftl') + + result = run_described_fastlane_action( + input_file: input_path, + output_file: output_path, + allow_empty_file: true + ) + + expect(File.exist?(output_path)).to be true + expect(File.read(output_path, encoding: 'utf-8')).to eq(whitespace_content) + expect(result).to eq(output_path) + end + end + end + end + end +end