From bb179c8879caad6e3a2ac2304f6d904db135397f Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Mon, 12 Jan 2026 11:42:18 +0100 Subject: [PATCH 01/20] Add gettext --- Gemfile.lock | 15 +++++++++++++++ fastlane-plugin-wpmreleasetoolkit.gemspec | 1 + 2 files changed, 16 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index 63da3e27c..647d6d557 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) @@ -163,6 +164,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) @@ -243,8 +245,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) @@ -304,6 +313,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) @@ -336,6 +346,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) @@ -413,10 +426,12 @@ 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) diff --git a/fastlane-plugin-wpmreleasetoolkit.gemspec b/fastlane-plugin-wpmreleasetoolkit.gemspec index 045b252f4..2dfd5ffd3 100644 --- a/fastlane-plugin-wpmreleasetoolkit.gemspec +++ b/fastlane-plugin-wpmreleasetoolkit.gemspec @@ -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' From ad6f962455434fcb62a4502b6dc1eb546787054c Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Mon, 12 Jan 2026 11:43:31 +0100 Subject: [PATCH 02/20] Add PO Generator --- .../helper/metadata/po_file_generator.rb | 168 ++++++++++ spec/po_file_generator_spec.rb | 312 ++++++++++++++++++ 2 files changed, 480 insertions(+) create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb create mode 100644 spec/po_file_generator_spec.rb diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb new file mode 100644 index 000000000..fd8441a0c --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'gettext/po' +require 'gettext/po_entry' +require 'gettext/po_parser' + +module Fastlane + module Helper + # Generates PO/POT files from source text files. + # + # This class generates gettext PO files from a hash of source files, + # handling special cases like versioned release notes and what's new entries. + class PoFileGenerator + # @param release_version [String] The release version (e.g., "1.23") + # @param source_files [Hash] A hash mapping keys to file paths + # @param existing_po_path [String, nil] Optional path to existing PO file (needed for release_note to preserve n-1) + def initialize(release_version:, source_files:, existing_po_path: nil) + @release_version = release_version + @source_files = source_files + @existing_po_path = existing_po_path + end + + # Generates the PO file content as a string + # @return [String] The generated PO file content + def generate + po = GetText::PO.new + + @source_files.each do |key, file_path| + content = File.read(file_path) + add_entries_for_key(po, key.to_sym, content) + end + + # GetText::PO#to_s doesn't add a trailing newline, but our tests expect one + "#{po}\n" + end + + # Writes the generated PO content to a file + # @param output_path [String] The path to write to + def write(output_path) + File.write(output_path, generate) + end + + private + + def add_entries_for_key(po, key, content) + case key + when :whats_new + add_whats_new_entry(po, content) + when :release_note + add_release_note_entries(po, content) + when :release_note_short + add_release_note_short_entries(po, content) + else + add_standard_entry(po, key.to_s, content) + end + end + + def add_standard_entry(po, msgctxt, content) + entry = create_entry(msgctxt, content.rstrip) + po[entry.msgctxt, entry.msgid] = entry + end + + def add_whats_new_entry(po, content) + msgctxt = "v#{@release_version}-whats-new" + # Ensure content ends with newline for multiline formatting + msgid = content.end_with?("\n") ? content : "#{content}\n" + entry = create_entry(msgctxt, msgid) + po[entry.msgctxt, entry.msgid] = entry + end + + def add_release_note_entries(po, content) + # Generate new entry for current version + new_key = release_note_key_for_version(@release_version) + msgid = "#{@release_version}:\n#{content}" + msgid = "#{msgid}\n" unless msgid.end_with?("\n") + entry = create_entry(new_key, msgid) + po[entry.msgctxt, entry.msgid] = entry + + # Preserve the n-1 entry from existing file if available + preserve_previous_release_note(po, :release_note) + end + + def add_release_note_short_entries(po, content) + return if content.strip.empty? + + # Generate new entry for current version + new_key = release_note_short_key_for_version(@release_version) + msgid = "#{@release_version}:\n#{content}" + msgid = "#{msgid}\n" unless msgid.end_with?("\n") + entry = create_entry(new_key, msgid) + po[entry.msgctxt, entry.msgid] = entry + + # Preserve the n-1 entry from existing file if available + preserve_previous_release_note(po, :release_note_short) + end + + def preserve_previous_release_note(po, type) + return unless @existing_po_path && File.exist?(@existing_po_path) + + keep_key = case type + when :release_note + release_note_key_for_previous_version(@release_version) + when :release_note_short + release_note_short_key_for_previous_version(@release_version) + end + + existing_entry = find_entry_in_existing_po(keep_key) + return unless existing_entry + + po[existing_entry.msgctxt, existing_entry.msgid] = existing_entry + end + + def find_entry_in_existing_po(target_msgctxt) + existing_po = GetText::PO.new + parser = GetText::POParser.new + parser.parse_file(@existing_po_path, existing_po) + + existing_po.find { |entry| entry.msgctxt == target_msgctxt } + rescue StandardError + nil + end + + def create_entry(msgctxt, msgid) + entry = GetText::POEntry.new(:msgctxt) + entry.msgctxt = msgctxt + entry.msgid = msgid + entry.msgstr = '' + entry + end + + def release_note_key_for_version(version) + major, minor = parse_version(version) + "release_note_#{major.to_s.rjust(2, '0')}#{minor}" + end + + def release_note_key_for_previous_version(version) + major, minor = previous_version(version) + "release_note_#{major.to_s.rjust(2, '0')}#{minor}" + end + + def release_note_short_key_for_version(version) + major, minor = parse_version(version) + "release_note_short_#{major.to_s.rjust(2, '0')}#{minor}" + end + + def release_note_short_key_for_previous_version(version) + major, minor = previous_version(version) + "release_note_short_#{major.to_s.rjust(2, '0')}#{minor}" + end + + def parse_version(version) + parts = version.split('.') + [Integer(parts[0]), Integer(parts[1])] + end + + def previous_version(version) + major, minor = parse_version(version) + if minor.zero? + major -= 1 + minor = 9 + else + minor -= 1 + end + [major, minor] + end + end + end +end diff --git a/spec/po_file_generator_spec.rb b/spec/po_file_generator_spec.rb new file mode 100644 index 000000000..5da41160c --- /dev/null +++ b/spec/po_file_generator_spec.rb @@ -0,0 +1,312 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +describe Fastlane::Helper::PoFileGenerator do + describe '#generate' do + context 'with standard entries' do + it 'generates a single-line entry for simple content' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'name.txt') + File.write(source_path, 'My App Name') + + generator = described_class.new( + release_version: '1.0', + source_files: { app_name: source_path } + ) + + result = generator.generate + + expect(result).to include('msgctxt "app_name"') + expect(result).to include('msgid "My App Name"') + expect(result).to include('msgstr ""') + end + end + + it 'generates a multiline entry for content with newlines' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'desc.txt') + File.write(source_path, "Line 1\nLine 2\nLine 3") + + generator = described_class.new( + release_version: '1.0', + source_files: { description: source_path } + ) + + result = generator.generate + + expect(result).to include('msgctxt "description"') + expect(result).to include('msgid ""') + expect(result).to include('"Line 1\n"') + expect(result).to include('"Line 2\n"') + expect(result).to include('"Line 3"') + end + end + + it 'strips trailing whitespace from single-line content' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'name.txt') + File.write(source_path, "App Name \n") + + generator = described_class.new( + release_version: '1.0', + source_files: { app_name: source_path } + ) + + result = generator.generate + + expect(result).to include('msgid "App Name"') + end + end + + it 'handles multiple source files' do + in_tmp_dir do |dir| + name_path = File.join(dir, 'name.txt') + File.write(name_path, 'My App') + + keywords_path = File.join(dir, 'keywords.txt') + File.write(keywords_path, 'app,mobile,tool') + + generator = described_class.new( + release_version: '1.0', + source_files: { + app_name: name_path, + keywords: keywords_path + } + ) + + result = generator.generate + + expect(result).to include('msgctxt "app_name"') + expect(result).to include('msgctxt "keywords"') + end + end + + it 'accepts string keys in source_files hash' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'screenshot.txt') + File.write(source_path, 'Screenshot caption') + + generator = described_class.new( + release_version: '1.0', + source_files: { 'app_store_screenshot-1' => source_path } + ) + + result = generator.generate + + expect(result).to include('msgctxt "app_store_screenshot-1"') + end + end + end + + context 'with whats_new entries' do + it 'generates versioned whats_new entry' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'whats_new.txt') + File.write(source_path, "- New feature\n- Bug fix") + + generator = described_class.new( + release_version: '1.23', + source_files: { whats_new: source_path } + ) + + result = generator.generate + + expect(result).to include('msgctxt "v1.23-whats-new"') + expect(result).to include('"- New feature\n"') + expect(result).to include('"- Bug fix\n"') + end + end + end + + context 'with release_note entries' do + it 'generates versioned release_note entry with version header' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'release_notes.txt') + File.write(source_path, "- Feature 1\n- Feature 2") + + generator = described_class.new( + release_version: '1.23', + source_files: { release_note: source_path } + ) + + result = generator.generate + + expect(result).to include('msgctxt "release_note_0123"') + expect(result).to include('"1.23:\n"') + expect(result).to include('"- Feature 1\n"') + expect(result).to include('"- Feature 2\n"') + end + end + + it 'preserves the n-1 release note from existing file' do + in_tmp_dir do |dir| + # Create existing PO file with previous release note + existing_po_path = File.join(dir, 'existing.po') + existing_content = <<~PO + msgctxt "release_note_0122" + msgid "Previous release notes" + msgstr "" + + msgctxt "release_note_0121" + msgid "Older release notes" + msgstr "" + PO + File.write(existing_po_path, existing_content) + + # Create new release notes + source_path = File.join(dir, 'release_notes.txt') + File.write(source_path, 'New release notes') + + generator = described_class.new( + release_version: '1.23', + source_files: { release_note: source_path }, + existing_po_path: existing_po_path + ) + + result = generator.generate + + # Should have new entry + expect(result).to include('msgctxt "release_note_0123"') + # Should preserve n-1 entry + expect(result).to include('msgctxt "release_note_0122"') + expect(result).to include('msgid "Previous release notes"') + # Should NOT include older entries + expect(result).not_to include('release_note_0121') + end + end + + it 'handles version 1.0 (wraps to previous major version)' do + in_tmp_dir do |dir| + # For version 1.0, the n-1 version is 0.9, so the key is release_note_009 + existing_po_path = File.join(dir, 'existing.po') + existing_content = <<~PO + msgctxt "release_note_009" + msgid "Previous major version notes" + msgstr "" + PO + File.write(existing_po_path, existing_content) + + source_path = File.join(dir, 'release_notes.txt') + File.write(source_path, 'First release of v1') + + generator = described_class.new( + release_version: '1.0', + source_files: { release_note: source_path }, + existing_po_path: existing_po_path + ) + + result = generator.generate + + expect(result).to include('msgctxt "release_note_010"') + expect(result).to include('msgctxt "release_note_009"') + end + end + end + + context 'with release_note_short entries' do + it 'generates versioned release_note_short entry' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'release_notes_short.txt') + File.write(source_path, 'Bug fixes and improvements') + + generator = described_class.new( + release_version: '2.5', + source_files: { release_note_short: source_path } + ) + + result = generator.generate + + expect(result).to include('msgctxt "release_note_short_025"') + expect(result).to include('"2.5:\n"') + end + end + + it 'skips empty release_note_short content' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'release_notes_short.txt') + File.write(source_path, ' ') + + generator = described_class.new( + release_version: '2.5', + source_files: { release_note_short: source_path } + ) + + result = generator.generate + + expect(result).not_to include('release_note_short') + end + end + end + + context 'output format' do + it 'ends with a trailing newline' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'name.txt') + File.write(source_path, 'App') + + generator = described_class.new( + release_version: '1.0', + source_files: { name: source_path } + ) + + result = generator.generate + + expect(result).to end_with("\n") + end + end + + it 'generates valid PO format that can be parsed' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'content.txt') + File.write(source_path, "Multi\nLine\nContent") + + generator = described_class.new( + release_version: '1.0', + source_files: { content: source_path } + ) + + output_path = File.join(dir, 'output.po') + generator.write(output_path) + + # Verify the output can be parsed by gettext + require 'gettext/po' + require 'gettext/po_parser' + + po = GetText::PO.new + parser = GetText::POParser.new + expect { parser.parse_file(output_path, po) }.not_to raise_error + + entry = po.find { |e| e.msgctxt == 'content' } + expect(entry).not_to be_nil + # Standard entries strip trailing content but preserve internal newlines + expect(entry.msgid).to include("Multi\n") + expect(entry.msgid).to include("Line\n") + expect(entry.msgid).to include('Content') + end + end + end + end + + describe '#write' do + it 'writes the generated content to a file' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'name.txt') + File.write(source_path, 'Test App') + + output_path = File.join(dir, 'output.po') + + generator = described_class.new( + release_version: '1.0', + source_files: { name: source_path } + ) + + generator.write(output_path) + + expect(File.exist?(output_path)).to be true + expect(File.read(output_path)).to include('msgctxt "name"') + end + end + end +end From fb3d21949a99604cf448f4684b86166d6c2c2453 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Mon, 12 Jan 2026 11:45:52 +0100 Subject: [PATCH 03/20] Delete MetadataBlock classes now replaced by PO Generator --- .../helper/metadata/metadata_block.rb | 22 ------ .../metadata/release_note_metadata_block.rb | 74 ------------------- .../release_note_short_metadata_block.rb | 25 ------- .../metadata/standard_metadata_block.rb | 49 ------------ .../helper/metadata/unknown_metadata_block.rb | 15 ---- .../metadata/whats_new_metadata_block.rb | 55 -------------- spec/standard_metadata_block_spec.rb | 59 --------------- 7 files changed, 299 deletions(-) delete mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/metadata_block.rb delete mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/release_note_metadata_block.rb delete mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/release_note_short_metadata_block.rb delete mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/standard_metadata_block.rb delete mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/unknown_metadata_block.rb delete mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/whats_new_metadata_block.rb delete mode 100644 spec/standard_metadata_block_spec.rb diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/metadata_block.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/metadata_block.rb deleted file mode 100644 index a04e6402e..000000000 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/metadata_block.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Fastlane - module Helper - # Basic line handler - class MetadataBlock - attr_reader :block_key - - def initialize(block_key) - @block_key = block_key - end - - def handle_line(file, line) - file.puts(line) # Standard line handling: just copy - end - - def is_handler_for(key) - true - end - end - end -end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/release_note_metadata_block.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/release_note_metadata_block.rb deleted file mode 100644 index 9bee6e67f..000000000 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/release_note_metadata_block.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -require_relative 'metadata_block' -require_relative 'standard_metadata_block' - -module Fastlane - module Helper - class ReleaseNoteMetadataBlock < StandardMetadataBlock - attr_reader :new_key, :keep_key, :rel_note_key, :release_version - - def initialize(block_key, content_file_path, release_version) - super(block_key, content_file_path) - @rel_note_key = 'release_note' - @release_version = release_version - generate_keys(release_version) - end - - def generate_keys(release_version) - values = release_version.split('.') - version_major = Integer(values[0]) - version_minor = Integer(values[1]) - @new_key = "#{@rel_note_key}_#{version_major.to_s.rjust(2, '0')}#{version_minor}" - - version_major -= 1 if version_minor.zero? - version_minor = version_minor.zero? ? 9 : version_minor - 1 - - @keep_key = "#{@rel_note_key}_#{version_major.to_s.rjust(2, '0')}#{version_minor}" - end - - def is_handler_for(key) - values = key.split('_') - key.start_with?(@rel_note_key) && values.length == 3 && is_int?(values[2].sub(/^0*/, '')) - end - - def handle_line(file, line) - # put content on block start or if copying the latest one - # and skip all the other content - if line.start_with?('msgctxt') - key = extract_key(line) - @is_copying = (key == @keep_key) - generate_block(file) if @is_copying - end - - file.puts(line) if @is_copying - end - - def generate_block(file) - # init - file.puts("msgctxt \"#{@new_key}\"") - file.puts('msgid ""') - file.puts("\"#{@release_version}:\\n\"") - - # insert content - File.open(@content_file_path, 'r').each do |line| - file.puts("\"#{line.strip}\\n\"") - end - - # close - file.puts('msgstr ""') - file.puts('') - end - - def extract_key(line) - line.split[1].tr('\"', '') - end - - def is_int?(value) - true if Integer(value) - rescue StandardError - false - end - end - end -end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/release_note_short_metadata_block.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/release_note_short_metadata_block.rb deleted file mode 100644 index 0e5d31f17..000000000 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/release_note_short_metadata_block.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -require_relative 'release_note_metadata_block' - -module Fastlane - module Helper - class ReleaseNoteShortMetadataBlock < ReleaseNoteMetadataBlock - def initialize(block_key, content_file_path, release_version) - super - @rel_note_key = 'release_note_short' - @release_version = release_version - generate_keys(release_version) - end - - def is_handler_for(key) - values = key.split('_') - key.start_with?(@rel_note_key) && values.length == 4 && is_int?(values[3].sub(/^0*/, '')) - end - - def generate_block(file) - super unless File.empty?(@content_file_path) - end - end - end -end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/standard_metadata_block.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/standard_metadata_block.rb deleted file mode 100644 index 59f9da598..000000000 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/standard_metadata_block.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require_relative 'metadata_block' - -module Fastlane - module Helper - class StandardMetadataBlock < MetadataBlock - attr_reader :content_file_path - - def initialize(block_key, content_file_path) - super(block_key) - @content_file_path = content_file_path - end - - def is_handler_for(key) - key == @block_key.to_s - end - - def handle_line(file, line) - # put the new content on block start - # and skip all the other content - generate_block(file) if line.start_with?('msgctxt') - end - - def generate_block(file) - # init - file.puts("msgctxt \"#{@block_key}\"") - line_count = File.foreach(@content_file_path).inject(0) { |c, _line| c + 1 } - - if line_count <= 1 - # Single line output - file.puts("msgid \"#{File.read(@content_file_path).rstrip}\"") - else - # Multiple line output - file.puts('msgid ""') - - # insert content - File.open(@content_file_path, 'r').each do |line| - file.puts("\"#{line.strip}\\n\"") - end - end - - # close - file.puts('msgstr ""') - file.puts('') - end - end - end -end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/unknown_metadata_block.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/unknown_metadata_block.rb deleted file mode 100644 index 827bb9130..000000000 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/unknown_metadata_block.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require_relative 'metadata_block' - -module Fastlane - module Helper - class UnknownMetadataBlock < MetadataBlock - attr_reader :content_file_path - - def initialize - super(nil) - end - end - end -end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/whats_new_metadata_block.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/whats_new_metadata_block.rb deleted file mode 100644 index 879f40a17..000000000 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/whats_new_metadata_block.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require_relative 'standard_metadata_block' - -module Fastlane - module Helper - class WhatsNewMetadataBlock < StandardMetadataBlock - attr_reader :new_key, :old_key, :rel_note_key, :release_version - - def initialize(block_key, content_file_path, release_version) - super(block_key, content_file_path) - @rel_note_key = 'whats_new' - @release_version = release_version - generate_keys(release_version) - end - - def generate_keys(release_version) - values = release_version.split('.') - version_major = Integer(values[0]) - version_minor = Integer(values[1]) - @new_key = "v#{release_version}-whats-new" - - version_major -= 1 if version_minor.zero? - version_minor = version_minor.zero? ? 9 : version_minor - 1 - - @old_key = "v#{version_major}.#{version_minor}-whats-new" - end - - def is_handler_for(key) - key.start_with?('v') && key.end_with?('-whats-new') - end - - def handle_line(file, line) - # put content on block start or if copying the latest one - # and skip all the other content - generate_block(file) if line.start_with?('msgctxt') - end - - def generate_block(file) - # init - file.puts("msgctxt \"#{@new_key}\"") - file.puts('msgid ""') - - # insert content - File.open(@content_file_path, 'r').each do |line| - file.puts("\"#{line.strip}\\n\"") - end - - # close - file.puts('msgstr ""') - file.puts('') - end - end - end -end diff --git a/spec/standard_metadata_block_spec.rb b/spec/standard_metadata_block_spec.rb deleted file mode 100644 index 459feef99..000000000 --- a/spec/standard_metadata_block_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -require 'tmpdir' -require_relative 'spec_helper' - -describe Fastlane::Helper::StandardMetadataBlock do - it 'strips any trailing newline when generating the block for a single-line input' do - Dir.mktmpdir do |dir| - input = "Single line message with new line\n" - - # Generate the input file to convert to .pot block - input_path = File.join(dir, 'input') - File.write(input_path, input) - # Ensure the input has only one line - expect(File.read(input_path).lines.count).to eq 1 - - # Write the .pot block in a StringIO to bypass the filesystem and have - # faster tests - output_io = StringIO.new - described_class.new('any-key', input_path).generate_block(output_io) - - # Ensure the output matches the expectation: the trailing new line has been stripped. - # - # Note that the final new line is intentional. It's part of the formatting at the time of writing. - expect(output_io.string).to eq <<~EXP - msgctxt "any-key" - msgid "Single line message with new line" - msgstr "" - - EXP - end - end - - it 'does not strip a trailing new line when generating the block for a multi-line input' do - Dir.mktmpdir do |dir| - input = "Multi-line\nmessage\nwith\ntrailing new line\n" - - # Generate the input file to convert to .pot block - input_path = File.join(dir, 'input') - File.write(input_path, input) - - # Write the .pot block in a StringIO to bypass the filesystem and have faster tests - output_io = StringIO.new - described_class.new('any-key', input_path).generate_block(output_io) - - # Note that the new line after `msgstr` is intentional. It's part of the formatting at the time of writing. - expect(output_io.string).to eq <<~'EXP' - msgctxt "any-key" - msgid "" - "Multi-line\n" - "message\n" - "with\n" - "trailing new line\n" - msgstr "" - - EXP - end - end -end From 38bde9ce60ab1dabb2f016e110b119fa273462fb Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Mon, 12 Jan 2026 12:19:27 +0100 Subject: [PATCH 04/20] Update PO metadata update actions actions --- .../an_update_metadata_source_action.rb | 129 +++--------------- .../common/gp_update_metadata_source.rb | 129 +++--------------- spec/an_update_metadata_source_spec.rb | 3 +- spec/gp_update_metadata_source_spec.rb | 2 +- spec/ios_update_metadata_source_spec.rb | 2 +- ...mples_for_update_metadata_source_action.rb | 52 ++++++- 6 files changed, 89 insertions(+), 228 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_update_metadata_source_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_update_metadata_source_action.rb index d14170419..8a0dda573 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_update_metadata_source_action.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_update_metadata_source_action.rb @@ -1,157 +1,67 @@ # frozen_string_literal: true -require 'fastlane/action' -require_relative '../../helper/metadata/release_note_metadata_block' -require_relative '../../helper/metadata/release_note_short_metadata_block' -require_relative '../../helper/metadata/whats_new_metadata_block' +require_relative '../../helper/metadata/po_file_generator' module Fastlane module Actions class AnUpdateMetadataSourceAction < Action def self.run(params) - # fastlane will take care of reading in the parameter and fetching the environment variable: - UI.message "Parameter .po file path: #{params[:po_file_path]}" + UI.message "PO file path: #{params[:po_file_path]}" UI.message "Release version: #{params[:release_version]}" - # Init - create_block_parsers(params[:release_version], params[:source_files]) + validate_source_files(params[:source_files]) - # Do - check_source_files(params[:source_files]) - temp_po_name = create_temp_po(params) - swap_po(params[:po_file_path], temp_po_name) + generator = Fastlane::Helper::PoFileGenerator.new( + release_version: params[:release_version], + source_files: params[:source_files], + existing_po_path: params[:po_file_path] + ) + + generator.write(params[:po_file_path]) UI.message "File #{params[:po_file_path]} updated!" end - # Verifies that all the source files are available - # to this action - def self.check_source_files(source_files) + def self.validate_source_files(source_files) source_files.each_value do |file_path| UI.user_error!("Couldn't find file at path '#{file_path}'") unless File.exist?(file_path) end end - # Creates a temp po file merging - # new data for known tags - # and the data already in the original - # .po fo the others. - def self.create_temp_po(params) - orig = params[:po_file_path] - target = create_target_file_path(orig) - - # Clear if older exists - FileUtils.rm_f(target) - - # Create the new one - begin - File.open(target, 'a') do |fw| - File.open(orig, 'r').each do |fr| - write_target_block(fw, fr) - end - end - rescue StandardError - FileUtils.rm_f(target) - raise - end - - target - end - - # Deletes the old po and moves the temp one - # to the final location - def self.swap_po(orig_file_path, temp_file_path) - FileUtils.rm_f(orig_file_path) - File.rename(temp_file_path, orig_file_path) - end - - # Generates the temp file path - def self.create_target_file_path(orig_file_path) - "#{File.dirname(orig_file_path)}/#{File.basename(orig_file_path, '.*')}.tmp" - end - - # Creates the block instances - def self.create_block_parsers(release_version, block_files) - @blocks = [] - - # Inits default handler - @blocks.push Fastlane::Helper::UnknownMetadataBlock.new - - # Init special handlers - block_files.each do |key, file_path| - case key - when :release_note - @blocks.push Fastlane::Helper::ReleaseNoteMetadataBlock.new(key, file_path, release_version) - when :release_note_short - @blocks.push Fastlane::Helper::ReleaseNoteShortMetadataBlock.new(key, file_path, release_version) - else - @blocks.push Fastlane::Helper::StandardMetadataBlock.new(key, file_path) - end - end - - # Sets the default - @current_block = @blocks[0] - end - - # Manages tags depending on the type - def self.write_target_block(fw, line) - if is_block_id(line) - key = line.split[1].tr('\"', '') - @blocks.each do |block| - @current_block = block if block.is_handler_for(key) - end - end - - @current_block = @blocks.first if is_comment(line) - - @current_block.handle_line(fw, line) - end - - def self.is_block_id(line) - line.start_with?('msgctxt') - end - - def self.is_comment(line) - line.start_with?('#') - end - ##################################################### # @!group Documentation ##################################################### def self.description - 'Updates a .po file with new data from .txt files' + 'Generates a .po file from source .txt files' end def self.details - 'You can use this action to update the .po file that contains the string to load to GlotPress for localization.' + 'Generates a .po file from source .txt files for localization via GlotPress.' end def self.available_options - # Define all options your action supports. - - # Below a few examples [ FastlaneCore::ConfigItem.new(key: :po_file_path, env_name: 'FL_UPDATE_METADATA_SOURCE_PO_FILE_PATH', - description: 'The path of the .po file to update', + description: 'The path of the .po file to generate', type: String, verify_block: proc do |value| - UI.user_error!("No .po file path for UpdateMetadataSourceAction given, pass using `po_file_path: 'file path'`") unless value && !value.empty? + UI.user_error!("No .po file path given, pass using `po_file_path: 'file path'`") unless value && !value.empty? UI.user_error!("Couldn't find file at path '#{value}'") unless File.exist?(value) end), FastlaneCore::ConfigItem.new(key: :release_version, env_name: 'FL_UPDATE_METADATA_SOURCE_RELEASE_VERSION', - description: 'The release version of the app (to use to mark the release notes)', + description: 'The release version of the app (used for release notes)', verify_block: proc do |value| - UI.user_error!("No relase version for UpdateMetadataSourceAction given, pass using `release_version: 'version'`") unless value && !value.empty? + UI.user_error!("No release version given, pass using `release_version: 'version'`") unless value && !value.empty? end), FastlaneCore::ConfigItem.new(key: :source_files, env_name: 'FL_UPDATE_METADATA_SOURCE_SOURCE_FILES', - description: 'The hash with the path to the source files and the key to use to include their content', + description: 'Hash mapping keys to source file paths', type: Hash, verify_block: proc do |value| - UI.user_error!("No source file hash for UpdateMetadataSourceAction given, pass using `source_files: 'source file hash'`") unless value && !value.empty? + UI.user_error!("No source files given, pass using `source_files: { key: 'path' }`") unless value && !value.empty? end), ] end @@ -160,7 +70,6 @@ def self.output end def self.return_value - # If your method provides a return value, you can describe here what it does end def self.authors diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb index 86f117711..fee8814b8 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb @@ -1,156 +1,66 @@ # frozen_string_literal: true -require_relative '../../helper/metadata/release_note_metadata_block' -require_relative '../../helper/metadata/release_note_short_metadata_block' -require_relative '../../helper/metadata/whats_new_metadata_block' +require_relative '../../helper/metadata/po_file_generator' module Fastlane module Actions class GpUpdateMetadataSourceAction < Action def self.run(params) - # fastlane will take care of reading in the parameter and fetching the environment variable: - UI.message "Parameter .po file path: #{params[:po_file_path]}" + UI.message "PO file path: #{params[:po_file_path]}" UI.message "Release version: #{params[:release_version]}" - # Init - create_block_parsers(params[:release_version], params[:source_files]) + validate_source_files(params[:source_files]) - # Do - check_source_files(params[:source_files]) - temp_po_name = create_temp_po(params) - swap_po(params[:po_file_path], temp_po_name) + generator = Fastlane::Helper::PoFileGenerator.new( + release_version: params[:release_version], + source_files: params[:source_files], + existing_po_path: params[:po_file_path] + ) + + generator.write(params[:po_file_path]) UI.message "File #{params[:po_file_path]} updated!" end - # Verifies that all the source files are available - # to this action - def self.check_source_files(source_files) + def self.validate_source_files(source_files) source_files.each_value do |file_path| UI.user_error!("Couldn't find file at path '#{file_path}'") unless File.exist?(file_path) end end - # Creates a temp po file merging - # new data for known tags - # and the data already in the original - # .po fo the others. - def self.create_temp_po(params) - orig = params[:po_file_path] - target = create_target_file_path(orig) - - # Clear if older exists - FileUtils.rm_f(target) - - # Create the new one - begin - File.open(target, 'a') do |fw| - File.open(orig, 'r').each do |fr| - write_target_block(fw, fr) - end - end - rescue StandardError - FileUtils.rm_f(target) - raise - end - - target - end - - # Deletes the old po and moves the temp one - # to the final location - def self.swap_po(orig_file_path, temp_file_path) - FileUtils.rm_f(orig_file_path) - File.rename(temp_file_path, orig_file_path) - end - - # Generates the temp file path - def self.create_target_file_path(orig_file_path) - "#{File.dirname(orig_file_path)}/#{File.basename(orig_file_path, '.*')}.tmp" - end - - # Creates the block instances - def self.create_block_parsers(release_version, block_files) - @blocks = [] - - # Inits default handler - @blocks.push Fastlane::Helper::UnknownMetadataBlock.new - - # Init special handlers - block_files.each do |key, file_path| - case key - when :release_note - @blocks.push Fastlane::Helper::ReleaseNoteMetadataBlock.new(key, file_path, release_version) - when :whats_new - @blocks.push Fastlane::Helper::WhatsNewMetadataBlock.new(key, file_path, release_version) - else - @blocks.push Fastlane::Helper::StandardMetadataBlock.new(key, file_path) - end - end - - # Sets the default - @current_block = @blocks[0] - end - - # Manages tags depending on the type - def self.write_target_block(fw, line) - if is_block_id(line) - key = line.split[1].tr('\"', '') - @blocks.each do |block| - @current_block = block if block.is_handler_for(key) - end - end - - @current_block = @blocks.first if is_comment(line) - - @current_block.handle_line(fw, line) - end - - def self.is_block_id(line) - line.start_with?('msgctxt') - end - - def self.is_comment(line) - line.start_with?('#') - end - ##################################################### # @!group Documentation ##################################################### def self.description - 'Updates a .po file with new data from .txt files' + 'Generates a .po file from source .txt files' end def self.details - 'You can use this action to update the .po file that contains the string to load to GlotPress for localization.' + 'Generates a .po file from source .txt files for localization via GlotPress.' end def self.available_options - # Define all options your action supports. - - # Below a few examples [ FastlaneCore::ConfigItem.new(key: :po_file_path, env_name: 'FL_UPDATE_METADATA_SOURCE_PO_FILE_PATH', - description: 'The path of the .po file to update', + description: 'The path of the .po file to generate', type: String, verify_block: proc do |value| - UI.user_error!("No .po file path for UpdateMetadataSourceAction given, pass using `po_file_path: 'file path'`") unless value && !value.empty? - UI.user_error!("Couldn't find file at path '#{value}'") unless File.exist?(value) + UI.user_error!("No .po file path given, pass using `po_file_path: 'file path'`") unless value && !value.empty? end), FastlaneCore::ConfigItem.new(key: :release_version, env_name: 'FL_UPDATE_METADATA_SOURCE_RELEASE_VERSION', - description: 'The release version of the app (to use to mark the release notes)', + description: 'The release version of the app (used for release notes)', verify_block: proc do |value| - UI.user_error!("No relase version for UpdateMetadataSourceAction given, pass using `release_version: 'version'`") unless value && !value.empty? + UI.user_error!("No release version given, pass using `release_version: 'version'`") unless value && !value.empty? end), FastlaneCore::ConfigItem.new(key: :source_files, env_name: 'FL_UPDATE_METADATA_SOURCE_SOURCE_FILES', - description: 'The hash with the path to the source files and the key to use to include their content', + description: 'Hash mapping keys to source file paths', type: Hash, verify_block: proc do |value| - UI.user_error!("No source file hash for UpdateMetadataSourceAction given, pass using `source_files: 'source file hash'`") unless value && !value.empty? + UI.user_error!("No source files given, pass using `source_files: { key: 'path' }`") unless value && !value.empty? end), ] end @@ -159,7 +69,6 @@ def self.output end def self.return_value - # If your method provides a return value, you can describe here what it does end def self.authors diff --git a/spec/an_update_metadata_source_spec.rb b/spec/an_update_metadata_source_spec.rb index 91403c0ff..44b947dbc 100644 --- a/spec/an_update_metadata_source_spec.rb +++ b/spec/an_update_metadata_source_spec.rb @@ -4,7 +4,7 @@ require 'shared_examples_for_update_metadata_source_action' describe Fastlane::Actions::AnUpdateMetadataSourceAction do - include_examples 'update_metadata_source_action', whats_new_fails: true + include_examples 'update_metadata_source_action' it 'combines the given `release_version` and `release_notes` in a new block, keeps the n-1 ones, and deletes the others' do in_tmp_dir do |dir| @@ -44,6 +44,7 @@ msgctxt "release_note_0122" msgid "previous version notes required to have current one added" msgstr "" + PO expect(File.read(output_path).inspect).to eq(expected.inspect) end diff --git a/spec/gp_update_metadata_source_spec.rb b/spec/gp_update_metadata_source_spec.rb index 9b2d0caef..f9907c113 100644 --- a/spec/gp_update_metadata_source_spec.rb +++ b/spec/gp_update_metadata_source_spec.rb @@ -4,5 +4,5 @@ require 'shared_examples_for_update_metadata_source_action' describe Fastlane::Actions::GpUpdateMetadataSourceAction do - include_examples 'update_metadata_source_action', whats_new_fails: false + include_examples 'update_metadata_source_action' end diff --git a/spec/ios_update_metadata_source_spec.rb b/spec/ios_update_metadata_source_spec.rb index 2bb7ffd4b..9a6d134c9 100644 --- a/spec/ios_update_metadata_source_spec.rb +++ b/spec/ios_update_metadata_source_spec.rb @@ -16,5 +16,5 @@ allow(Fastlane::Actions::EnsureGitStatusCleanAction).to receive(:run) end - include_examples 'update_metadata_source_action', whats_new_fails: false + include_examples 'update_metadata_source_action' end diff --git a/spec/shared_examples_for_update_metadata_source_action.rb b/spec/shared_examples_for_update_metadata_source_action.rb index 201408143..1759d8043 100644 --- a/spec/shared_examples_for_update_metadata_source_action.rb +++ b/spec/shared_examples_for_update_metadata_source_action.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.shared_examples 'update_metadata_source_action' do |options| +RSpec.shared_examples 'update_metadata_source_action' do it 'updates any block in a given .po file with the values from the given sources' do in_tmp_dir do |dir| output_path = File.join(dir, 'output.po') @@ -45,8 +45,6 @@ end it 'combines the given `release_version` and `whats_new` parameter into a new block' do - pending 'this currently fails; in the long run, we might consolidate `whats_new` with `release_notes`' if options[:whats_new_fails] - in_tmp_dir do |dir| output_path = File.join(dir, 'output.po') dummy_text = <<~PO @@ -80,8 +78,6 @@ end it 'adds entries passed as input even if not part of the original `.po` file' do - pending 'this currently fails and will be addressed as part of the upcoming refactor/rewrite of the functionality' - in_tmp_dir do |dir| output_path = File.join(dir, 'output.po') dummy_text = <<~PO @@ -119,4 +115,50 @@ expect(File.read(output_path)).to eq(expected) end end + + it 'removes entries from the `.po` file that are not in the source_files input' do + in_tmp_dir do |dir| + output_path = File.join(dir, 'output.po') + dummy_text = <<~PO + msgctxt "key1" + msgid "this value should change" + msgstr "" + + msgctxt "stale_key" + msgid "this entry should be removed" + msgstr "" + + msgctxt "key2" + msgid "this value should also change" + msgstr "" + PO + File.write(output_path, dummy_text) + + file_1_path = File.join(dir, '1.txt') + File.write(file_1_path, 'value 1') + file_2_path = File.join(dir, '2.txt') + File.write(file_2_path, 'value 2') + + run_described_fastlane_action( + po_file_path: output_path, + release_version: '1.0', + source_files: { + key1: file_1_path, + key2: file_2_path + } + ) + + expected = <<~PO + msgctxt "key1" + msgid "value 1" + msgstr "" + + msgctxt "key2" + msgid "value 2" + msgstr "" + + PO + expect(File.read(output_path)).to eq(expected) + end + end end From d03ff9ae729afcf776716b95326f4615c55c01c3 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Mon, 12 Jan 2026 12:23:29 +0100 Subject: [PATCH 05/20] Consolidate PO metadata update on gp_update_metadata_source --- .../an_update_metadata_source_action.rb | 27 +++++------------ .../common/gp_update_metadata_source.rb | 22 ++++++++++++++ .../actions/ios/ios_update_metadata_source.rb | 30 +++++++------------ 3 files changed, 41 insertions(+), 38 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_update_metadata_source_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_update_metadata_source_action.rb index 8a0dda573..9195fa23d 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_update_metadata_source_action.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_update_metadata_source_action.rb @@ -1,31 +1,16 @@ # frozen_string_literal: true -require_relative '../../helper/metadata/po_file_generator' - module Fastlane module Actions class AnUpdateMetadataSourceAction < Action def self.run(params) - UI.message "PO file path: #{params[:po_file_path]}" - UI.message "Release version: #{params[:release_version]}" - - validate_source_files(params[:source_files]) + UI.deprecated('`an_update_metadata_source` is deprecated. Please use `gp_update_metadata_source` instead.') - generator = Fastlane::Helper::PoFileGenerator.new( - release_version: params[:release_version], + other_action.gp_update_metadata_source( + po_file_path: params[:po_file_path], source_files: params[:source_files], - existing_po_path: params[:po_file_path] + release_version: params[:release_version] ) - - generator.write(params[:po_file_path]) - - UI.message "File #{params[:po_file_path]} updated!" - end - - def self.validate_source_files(source_files) - source_files.each_value do |file_path| - UI.user_error!("Couldn't find file at path '#{file_path}'") unless File.exist?(file_path) - end end ##################################################### @@ -79,6 +64,10 @@ def self.authors def self.is_supported?(platform) [:android].include?(platform) end + + def self.deprecated? + true + end end end end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb index fee8814b8..0fe88a989 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb @@ -9,6 +9,9 @@ def self.run(params) UI.message "PO file path: #{params[:po_file_path]}" UI.message "Release version: #{params[:release_version]}" + # Check local repo status if we're going to commit changes + other_action.ensure_git_status_clean if params[:commit_changes] + validate_source_files(params[:source_files]) generator = Fastlane::Helper::PoFileGenerator.new( @@ -20,6 +23,8 @@ def self.run(params) generator.write(params[:po_file_path]) UI.message "File #{params[:po_file_path]} updated!" + + commit_changes(params) if params[:commit_changes] end def self.validate_source_files(source_files) @@ -28,6 +33,19 @@ def self.validate_source_files(source_files) end end + def self.commit_changes(params) + Action.sh("git add #{params[:po_file_path]}") + params[:source_files].each_value do |file| + Action.sh("git add #{file}") + end + + repo_status = Actions.sh('git status --porcelain') + repo_clean = repo_status.empty? + return if repo_clean + + Action.sh('git commit -m "Update metadata strings"') + end + ##################################################### # @!group Documentation ##################################################### @@ -62,6 +80,10 @@ def self.available_options verify_block: proc do |value| UI.user_error!("No source files given, pass using `source_files: { key: 'path' }`") unless value && !value.empty? end), + FastlaneCore::ConfigItem.new(key: :commit_changes, + description: 'If true, checks git status is clean, then adds and commits the changes', + type: Boolean, + default_value: false), ] end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_update_metadata_source.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_update_metadata_source.rb index 820d00261..106ddaa87 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_update_metadata_source.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_update_metadata_source.rb @@ -4,23 +4,14 @@ module Fastlane module Actions class IosUpdateMetadataSourceAction < Action def self.run(params) - # Check local repo status - other_action.ensure_git_status_clean + UI.deprecated('`ios_update_metadata_source` is deprecated. Please use `gp_update_metadata_source` with `commit_changes: true` instead.') - other_action.gp_update_metadata_source(po_file_path: params[:po_file_path], - source_files: params[:source_files], - release_version: params[:release_version]) - - Action.sh("git add #{params[:po_file_path]}") - params[:source_files].each_value do |file| - Action.sh("git add #{file}") - end - - repo_status = Actions.sh('git status --porcelain') - repo_clean = repo_status.empty? - return if repo_clean - - Action.sh('git commit -m "Update metadata strings"') + other_action.gp_update_metadata_source( + po_file_path: params[:po_file_path], + source_files: params[:source_files], + release_version: params[:release_version], + commit_changes: true + ) end ##################################################### @@ -36,9 +27,6 @@ def self.details end def self.available_options - # Define all options your action supports. - - # Below a few examples [ FastlaneCore::ConfigItem.new(key: :po_file_path, env_name: 'FL_IOS_UPDATE_METADATA_SOURCE_PO_FILE_PATH', @@ -77,6 +65,10 @@ def self.authors def self.is_supported?(platform) %i[ios mac].include?(platform) end + + def self.deprecated? + true + end end end end From 2b7207f9578d3b02264b71b206eca16eaf13a84a Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Mon, 12 Jan 2026 13:40:31 +0100 Subject: [PATCH 06/20] Update CHANGELOG for PO generation refactor Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b96b9b51f..4804c9440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -_None_ +- Add `commit_changes` option to `gp_update_metadata_source` to optionally commit changes after updating the PO file. [#684] ### Bug Fixes @@ -18,7 +18,13 @@ _None_ ### Internal Changes -_None_ +- Consolidate PO file update logic on `gp_update_metadata_source` action. [#684] +- Remove legacy `MetadataBlock` classes replaced by the new `PoFileGenerator`. [#684] + +### Deprecated + +- `an_update_metadata_source` action is deprecated; use `gp_update_metadata_source` instead. [#684] +- `ios_update_metadata_source` action is deprecated; use `gp_update_metadata_source` with `commit_changes: true` instead. [#684] ## 13.8.1 From 84b59fa2ef2e9d54d72df05c192d7284de222305 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Mon, 12 Jan 2026 13:45:40 +0100 Subject: [PATCH 07/20] Fix RuboCop linting issues in PO generator - Rename `po` parameter to `po_data` (min 3 chars) - Fix context description wording in spec Co-Authored-By: Claude Opus 4.5 --- .../helper/metadata/po_file_generator.rb | 34 +++++++++---------- spec/po_file_generator_spec.rb | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb index fd8441a0c..795c12a84 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb @@ -42,45 +42,45 @@ def write(output_path) private - def add_entries_for_key(po, key, content) + def add_entries_for_key(po_data, key, content) case key when :whats_new - add_whats_new_entry(po, content) + add_whats_new_entry(po_data, content) when :release_note - add_release_note_entries(po, content) + add_release_note_entries(po_data, content) when :release_note_short - add_release_note_short_entries(po, content) + add_release_note_short_entries(po_data, content) else - add_standard_entry(po, key.to_s, content) + add_standard_entry(po_data, key.to_s, content) end end - def add_standard_entry(po, msgctxt, content) + def add_standard_entry(po_data, msgctxt, content) entry = create_entry(msgctxt, content.rstrip) - po[entry.msgctxt, entry.msgid] = entry + po_data[entry.msgctxt, entry.msgid] = entry end - def add_whats_new_entry(po, content) + def add_whats_new_entry(po_data, content) msgctxt = "v#{@release_version}-whats-new" # Ensure content ends with newline for multiline formatting msgid = content.end_with?("\n") ? content : "#{content}\n" entry = create_entry(msgctxt, msgid) - po[entry.msgctxt, entry.msgid] = entry + po_data[entry.msgctxt, entry.msgid] = entry end - def add_release_note_entries(po, content) + def add_release_note_entries(po_data, content) # Generate new entry for current version new_key = release_note_key_for_version(@release_version) msgid = "#{@release_version}:\n#{content}" msgid = "#{msgid}\n" unless msgid.end_with?("\n") entry = create_entry(new_key, msgid) - po[entry.msgctxt, entry.msgid] = entry + po_data[entry.msgctxt, entry.msgid] = entry # Preserve the n-1 entry from existing file if available - preserve_previous_release_note(po, :release_note) + preserve_previous_release_note(po_data, :release_note) end - def add_release_note_short_entries(po, content) + def add_release_note_short_entries(po_data, content) return if content.strip.empty? # Generate new entry for current version @@ -88,13 +88,13 @@ def add_release_note_short_entries(po, content) msgid = "#{@release_version}:\n#{content}" msgid = "#{msgid}\n" unless msgid.end_with?("\n") entry = create_entry(new_key, msgid) - po[entry.msgctxt, entry.msgid] = entry + po_data[entry.msgctxt, entry.msgid] = entry # Preserve the n-1 entry from existing file if available - preserve_previous_release_note(po, :release_note_short) + preserve_previous_release_note(po_data, :release_note_short) end - def preserve_previous_release_note(po, type) + def preserve_previous_release_note(po_data, type) return unless @existing_po_path && File.exist?(@existing_po_path) keep_key = case type @@ -107,7 +107,7 @@ def preserve_previous_release_note(po, type) existing_entry = find_entry_in_existing_po(keep_key) return unless existing_entry - po[existing_entry.msgctxt, existing_entry.msgid] = existing_entry + po_data[existing_entry.msgctxt, existing_entry.msgid] = existing_entry end def find_entry_in_existing_po(target_msgctxt) diff --git a/spec/po_file_generator_spec.rb b/spec/po_file_generator_spec.rb index 5da41160c..fa2937f6b 100644 --- a/spec/po_file_generator_spec.rb +++ b/spec/po_file_generator_spec.rb @@ -240,7 +240,7 @@ end end - context 'output format' do + context 'with generated output' do it 'ends with a trailing newline' do in_tmp_dir do |dir| source_path = File.join(dir, 'name.txt') From fc65181bdbc3bfab33d3a110bee11b267bcb0ba5 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Mon, 12 Jan 2026 16:04:16 +0100 Subject: [PATCH 08/20] Preserve header from existing PO file if available --- .../helper/metadata/po_file_generator.rb | 36 ++++++ spec/po_file_generator_spec.rb | 115 ++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb index 795c12a84..1575bb4c7 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb @@ -25,6 +25,9 @@ def initialize(release_version:, source_files:, existing_po_path: nil) def generate po = GetText::PO.new + # Preserve header from existing PO file if available + add_header(po) + @source_files.each do |key, file_path| content = File.read(file_path) add_entries_for_key(po, key.to_sym, content) @@ -42,6 +45,39 @@ def write(output_path) private + def add_header(po_data) + return unless @existing_po_path && File.exist?(@existing_po_path) + + existing_po = GetText::PO.new + parser = GetText::POParser.new + parser.parse_file(@existing_po_path, existing_po) + + # Get the header entry (empty msgid) + header = existing_po[''] + return unless header + + # Update PO-Revision-Date to current time + updated_msgstr = update_revision_date(header.msgstr) + + # Create new header entry preserving comments + new_header = GetText::POEntry.new(:normal) + new_header.msgid = '' + new_header.msgstr = updated_msgstr + new_header.translator_comment = header.translator_comment if header.translator_comment + + po_data[new_header.msgctxt, new_header.msgid] = new_header + rescue StandardError + # If header parsing fails, continue without header + nil + end + + def update_revision_date(msgstr) + return msgstr unless msgstr + + current_time = Time.now.strftime('%Y-%m-%d %H:%M%z') + msgstr.gsub(/PO-Revision-Date:.*\n/, "PO-Revision-Date: #{current_time}\n") + end + def add_entries_for_key(po_data, key, content) case key when :whats_new diff --git a/spec/po_file_generator_spec.rb b/spec/po_file_generator_spec.rb index fa2937f6b..7313a393a 100644 --- a/spec/po_file_generator_spec.rb +++ b/spec/po_file_generator_spec.rb @@ -240,6 +240,121 @@ end end + context 'with header preservation' do + it 'preserves header from existing PO file' do + in_tmp_dir do |dir| + existing_po_path = File.join(dir, 'existing.po') + existing_content = <<~PO + # Test comment line 1 + # Test comment line 2 + msgid "" + msgstr "" + "PO-Revision-Date: 2019-01-03 17:30-0000\\n" + "MIME-Version: 1.0\\n" + "Content-Type: text/plain; charset=UTF-8\\n" + "Project-Id-Version: Test Project\\n" + + msgctxt "old_entry" + msgid "Old content" + msgstr "" + PO + File.write(existing_po_path, existing_content) + + source_path = File.join(dir, 'name.txt') + File.write(source_path, 'Test App') + + generator = described_class.new( + release_version: '1.0', + source_files: { name: source_path }, + existing_po_path: existing_po_path + ) + + result = generator.generate + + expect(result).to include('# Test comment line 1') + expect(result).to include('# Test comment line 2') + expect(result).to include('Project-Id-Version: Test Project') + expect(result).to include('MIME-Version: 1.0') + end + end + + it 'updates PO-Revision-Date to current time' do + in_tmp_dir do |dir| + existing_po_path = File.join(dir, 'existing.po') + existing_content = <<~PO + msgid "" + msgstr "" + "PO-Revision-Date: 2019-01-03 17:30-0000\\n" + "MIME-Version: 1.0\\n" + PO + File.write(existing_po_path, existing_content) + + source_path = File.join(dir, 'name.txt') + File.write(source_path, 'Test App') + + generator = described_class.new( + release_version: '1.0', + source_files: { name: source_path }, + existing_po_path: existing_po_path + ) + + result = generator.generate + + # Should not contain the old date + expect(result).not_to include('2019-01-03') + # Should contain a recent date (current year) + expect(result).to match(/PO-Revision-Date: \d{4}-\d{2}-\d{2}/) + end + end + + it 'generates output without header when no existing file provided' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'name.txt') + File.write(source_path, 'Test App') + + generator = described_class.new( + release_version: '1.0', + source_files: { name: source_path } + ) + + result = generator.generate + + # Should not have a header entry + expect(result).not_to include('PO-Revision-Date') + # But should have the content entry + expect(result).to include('msgctxt "name"') + end + end + + it 'generates output without header when existing file has no header' do + in_tmp_dir do |dir| + existing_po_path = File.join(dir, 'existing.po') + existing_content = <<~PO + msgctxt "old_entry" + msgid "Old content" + msgstr "" + PO + File.write(existing_po_path, existing_content) + + source_path = File.join(dir, 'name.txt') + File.write(source_path, 'Test App') + + generator = described_class.new( + release_version: '1.0', + source_files: { name: source_path }, + existing_po_path: existing_po_path + ) + + result = generator.generate + + # Should not have a header entry + expect(result).not_to include('PO-Revision-Date') + # But should have the content entry + expect(result).to include('msgctxt "name"') + end + end + end + context 'with generated output' do it 'ends with a trailing newline' do in_tmp_dir do |dir| From d7f49256e20c1e38b003a455e01e14b3aea79f00 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Mon, 12 Jan 2026 16:17:13 +0100 Subject: [PATCH 09/20] Make sure generated PO order is the same --- .../helper/metadata/po_file_generator.rb | 64 +++++++++++-------- spec/po_file_generator_spec.rb | 63 ++++++++++++++++++ 2 files changed, 100 insertions(+), 27 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb index 1575bb4c7..cfce469f8 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb @@ -28,9 +28,16 @@ def generate # Preserve header from existing PO file if available add_header(po) + # Collect all entries first, then sort by msgctxt for deterministic output + entries = [] @source_files.each do |key, file_path| content = File.read(file_path) - add_entries_for_key(po, key.to_sym, content) + entries.concat(create_entries_for_key(key.to_sym, content)) + end + + # Sort entries alphabetically by msgctxt and add to PO + entries.sort_by(&:msgctxt).each do |entry| + po[entry.msgctxt, entry.msgid] = entry end # GetText::PO#to_s doesn't add a trailing newline, but our tests expect one @@ -78,60 +85,66 @@ def update_revision_date(msgstr) msgstr.gsub(/PO-Revision-Date:.*\n/, "PO-Revision-Date: #{current_time}\n") end - def add_entries_for_key(po_data, key, content) + def create_entries_for_key(key, content) case key when :whats_new - add_whats_new_entry(po_data, content) + [create_whats_new_entry(content)] when :release_note - add_release_note_entries(po_data, content) + create_release_note_entries(content) when :release_note_short - add_release_note_short_entries(po_data, content) + create_release_note_short_entries(content) else - add_standard_entry(po_data, key.to_s, content) + [create_standard_entry(key.to_s, content)] end end - def add_standard_entry(po_data, msgctxt, content) - entry = create_entry(msgctxt, content.rstrip) - po_data[entry.msgctxt, entry.msgid] = entry + def create_standard_entry(msgctxt, content) + create_entry(msgctxt, content.rstrip) end - def add_whats_new_entry(po_data, content) + def create_whats_new_entry(content) msgctxt = "v#{@release_version}-whats-new" # Ensure content ends with newline for multiline formatting msgid = content.end_with?("\n") ? content : "#{content}\n" - entry = create_entry(msgctxt, msgid) - po_data[entry.msgctxt, entry.msgid] = entry + create_entry(msgctxt, msgid) end - def add_release_note_entries(po_data, content) + def create_release_note_entries(content) + entries = [] + # Generate new entry for current version new_key = release_note_key_for_version(@release_version) msgid = "#{@release_version}:\n#{content}" msgid = "#{msgid}\n" unless msgid.end_with?("\n") - entry = create_entry(new_key, msgid) - po_data[entry.msgctxt, entry.msgid] = entry + entries << create_entry(new_key, msgid) # Preserve the n-1 entry from existing file if available - preserve_previous_release_note(po_data, :release_note) + previous_entry = find_previous_release_note(:release_note) + entries << previous_entry if previous_entry + + entries end - def add_release_note_short_entries(po_data, content) - return if content.strip.empty? + def create_release_note_short_entries(content) + return [] if content.strip.empty? + + entries = [] # Generate new entry for current version new_key = release_note_short_key_for_version(@release_version) msgid = "#{@release_version}:\n#{content}" msgid = "#{msgid}\n" unless msgid.end_with?("\n") - entry = create_entry(new_key, msgid) - po_data[entry.msgctxt, entry.msgid] = entry + entries << create_entry(new_key, msgid) # Preserve the n-1 entry from existing file if available - preserve_previous_release_note(po_data, :release_note_short) + previous_entry = find_previous_release_note(:release_note_short) + entries << previous_entry if previous_entry + + entries end - def preserve_previous_release_note(po_data, type) - return unless @existing_po_path && File.exist?(@existing_po_path) + def find_previous_release_note(type) + return nil unless @existing_po_path && File.exist?(@existing_po_path) keep_key = case type when :release_note @@ -140,10 +153,7 @@ def preserve_previous_release_note(po_data, type) release_note_short_key_for_previous_version(@release_version) end - existing_entry = find_entry_in_existing_po(keep_key) - return unless existing_entry - - po_data[existing_entry.msgctxt, existing_entry.msgid] = existing_entry + find_entry_in_existing_po(keep_key) end def find_entry_in_existing_po(target_msgctxt) diff --git a/spec/po_file_generator_spec.rb b/spec/po_file_generator_spec.rb index 7313a393a..782325724 100644 --- a/spec/po_file_generator_spec.rb +++ b/spec/po_file_generator_spec.rb @@ -355,6 +355,69 @@ end end + context 'with entry ordering' do + it 'sorts entries alphabetically by msgctxt' do + in_tmp_dir do |dir| + # Create source files in non-alphabetical order + zebra_path = File.join(dir, 'zebra.txt') + File.write(zebra_path, 'Zebra content') + + apple_path = File.join(dir, 'apple.txt') + File.write(apple_path, 'Apple content') + + mango_path = File.join(dir, 'mango.txt') + File.write(mango_path, 'Mango content') + + generator = described_class.new( + release_version: '1.0', + source_files: { + zebra: zebra_path, + apple: apple_path, + mango: mango_path + } + ) + + result = generator.generate + msgctxts = result.scan(/msgctxt "([^"]+)"/).flatten + + expect(msgctxts).to eq(%w[apple mango zebra]) + end + end + + it 'sorts release_note entries with other entries' do + in_tmp_dir do |dir| + existing_po_path = File.join(dir, 'existing.po') + existing_content = <<~PO + msgctxt "release_note_0122" + msgid "Previous notes" + msgstr "" + PO + File.write(existing_po_path, existing_content) + + release_path = File.join(dir, 'release.txt') + File.write(release_path, 'New notes') + + zebra_path = File.join(dir, 'zebra.txt') + File.write(zebra_path, 'Zebra content') + + generator = described_class.new( + release_version: '1.23', + source_files: { + zebra: zebra_path, + release_note: release_path + }, + existing_po_path: existing_po_path + ) + + result = generator.generate + msgctxts = result.scan(/msgctxt "([^"]+)"/).flatten + + # release_note entries should be sorted with other entries + expect(msgctxts).to eq(%w[release_note_0122 release_note_0123 zebra]) + end + end + end + context 'with generated output' do it 'ends with a trailing newline' do in_tmp_dir do |dir| From ac341c9a2cd108381700dd20a878e04e1b2c95ec Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Mon, 12 Jan 2026 16:31:08 +0100 Subject: [PATCH 10/20] Allow metadata PO comments --- .../common/gp_update_metadata_source.rb | 29 ++++- .../helper/metadata/po_file_generator.rb | 73 +++++++++--- spec/po_file_generator_spec.rb | 109 ++++++++++++++++++ 3 files changed, 188 insertions(+), 23 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb index 0fe88a989..93ff6cbaf 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb @@ -28,15 +28,17 @@ def self.run(params) end def self.validate_source_files(source_files) - source_files.each_value do |file_path| + source_files.each_value do |value| + file_path = value.is_a?(Hash) ? value[:path] : value UI.user_error!("Couldn't find file at path '#{file_path}'") unless File.exist?(file_path) end end def self.commit_changes(params) Action.sh("git add #{params[:po_file_path]}") - params[:source_files].each_value do |file| - Action.sh("git add #{file}") + params[:source_files].each_value do |value| + file_path = value.is_a?(Hash) ? value[:path] : value + Action.sh("git add #{file_path}") end repo_status = Actions.sh('git status --porcelain') @@ -55,7 +57,24 @@ def self.description end def self.details - 'Generates a .po file from source .txt files for localization via GlotPress.' + <<~DETAILS + Generates a .po file from source .txt files for localization via GlotPress. + + The `source_files` parameter accepts either simple file paths or hashes with path and comment: + + ```ruby + source_files: { + # Simple path (no translator comment) + app_name: 'path/to/name.txt', + + # Hash with path and translator comment + app_store_subtitle: { + path: 'path/to/subtitle.txt', + comment: 'translators: Limit to 30 characters!' + } + } + ``` + DETAILS end def self.available_options @@ -75,7 +94,7 @@ def self.available_options end), FastlaneCore::ConfigItem.new(key: :source_files, env_name: 'FL_UPDATE_METADATA_SOURCE_SOURCE_FILES', - description: 'Hash mapping keys to source file paths', + description: 'Hash mapping keys to file paths (String) or hashes with :path and optional :comment', type: Hash, verify_block: proc do |value| UI.user_error!("No source files given, pass using `source_files: { key: 'path' }`") unless value && !value.empty? diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb index cfce469f8..c089cb4fc 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb @@ -10,9 +10,30 @@ module Helper # # This class generates gettext PO files from a hash of source files, # handling special cases like versioned release notes and what's new entries. + # + # @example Basic usage with file paths + # generator = PoFileGenerator.new( + # release_version: '1.0', + # source_files: { + # app_name: '/path/to/name.txt', + # description: '/path/to/desc.txt' + # } + # ) + # + # @example With translator comments + # generator = PoFileGenerator.new( + # release_version: '1.0', + # source_files: { + # app_store_subtitle: { + # path: '/path/to/subtitle.txt', + # comment: 'translators: Limit to 30 characters!' + # }, + # description: '/path/to/desc.txt' # no comment + # } + # ) class PoFileGenerator # @param release_version [String] The release version (e.g., "1.23") - # @param source_files [Hash] A hash mapping keys to file paths + # @param source_files [Hash] A hash mapping keys to file paths (String) or hashes with :path and :comment keys # @param existing_po_path [String, nil] Optional path to existing PO file (needed for release_note to preserve n-1) def initialize(release_version:, source_files:, existing_po_path: nil) @release_version = release_version @@ -30,9 +51,10 @@ def generate # Collect all entries first, then sort by msgctxt for deterministic output entries = [] - @source_files.each do |key, file_path| - content = File.read(file_path) - entries.concat(create_entries_for_key(key.to_sym, content)) + @source_files.each do |key, value| + path, comment = extract_path_and_comment(value) + content = File.read(path) + entries.concat(create_entries_for_key(key.to_sym, content, comment)) end # Sort entries alphabetically by msgctxt and add to PO @@ -85,38 +107,52 @@ def update_revision_date(msgstr) msgstr.gsub(/PO-Revision-Date:.*\n/, "PO-Revision-Date: #{current_time}\n") end - def create_entries_for_key(key, content) + # Extracts path and comment from a source_files value + # @param value [String, Hash] Either a file path string or a hash with :path and optional :comment + # @return [Array] A tuple of [path, comment] + def extract_path_and_comment(value) + case value + when String + [value, nil] + when Hash + [value[:path], value[:comment]] + else + raise ArgumentError, "Invalid source_files value: expected String or Hash, got #{value.class}" + end + end + + def create_entries_for_key(key, content, comment = nil) case key when :whats_new - [create_whats_new_entry(content)] + [create_whats_new_entry(content, comment)] when :release_note - create_release_note_entries(content) + create_release_note_entries(content, comment) when :release_note_short - create_release_note_short_entries(content) + create_release_note_short_entries(content, comment) else - [create_standard_entry(key.to_s, content)] + [create_standard_entry(key.to_s, content, comment)] end end - def create_standard_entry(msgctxt, content) - create_entry(msgctxt, content.rstrip) + def create_standard_entry(msgctxt, content, comment = nil) + create_entry(msgctxt, content.rstrip, comment) end - def create_whats_new_entry(content) + def create_whats_new_entry(content, comment = nil) msgctxt = "v#{@release_version}-whats-new" # Ensure content ends with newline for multiline formatting msgid = content.end_with?("\n") ? content : "#{content}\n" - create_entry(msgctxt, msgid) + create_entry(msgctxt, msgid, comment) end - def create_release_note_entries(content) + def create_release_note_entries(content, comment = nil) entries = [] # Generate new entry for current version new_key = release_note_key_for_version(@release_version) msgid = "#{@release_version}:\n#{content}" msgid = "#{msgid}\n" unless msgid.end_with?("\n") - entries << create_entry(new_key, msgid) + entries << create_entry(new_key, msgid, comment) # Preserve the n-1 entry from existing file if available previous_entry = find_previous_release_note(:release_note) @@ -125,7 +161,7 @@ def create_release_note_entries(content) entries end - def create_release_note_short_entries(content) + def create_release_note_short_entries(content, comment = nil) return [] if content.strip.empty? entries = [] @@ -134,7 +170,7 @@ def create_release_note_short_entries(content) new_key = release_note_short_key_for_version(@release_version) msgid = "#{@release_version}:\n#{content}" msgid = "#{msgid}\n" unless msgid.end_with?("\n") - entries << create_entry(new_key, msgid) + entries << create_entry(new_key, msgid, comment) # Preserve the n-1 entry from existing file if available previous_entry = find_previous_release_note(:release_note_short) @@ -166,11 +202,12 @@ def find_entry_in_existing_po(target_msgctxt) nil end - def create_entry(msgctxt, msgid) + def create_entry(msgctxt, msgid, comment = nil) entry = GetText::POEntry.new(:msgctxt) entry.msgctxt = msgctxt entry.msgid = msgid entry.msgstr = '' + entry.extracted_comment = comment if comment entry end diff --git a/spec/po_file_generator_spec.rb b/spec/po_file_generator_spec.rb index 782325724..ec53d13f9 100644 --- a/spec/po_file_generator_spec.rb +++ b/spec/po_file_generator_spec.rb @@ -355,6 +355,115 @@ end end + context 'with translator comments' do + it 'adds extracted comment when source_files value is a hash with comment' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'subtitle.txt') + File.write(source_path, 'Your store in your pocket') + + generator = described_class.new( + release_version: '1.0', + source_files: { + app_store_subtitle: { + path: source_path, + comment: 'translators: Limit to 30 characters!' + } + } + ) + + result = generator.generate + + expect(result).to include('#. translators: Limit to 30 characters!') + expect(result).to include('msgctxt "app_store_subtitle"') + end + end + + it 'handles multiline comments' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'screenshot.txt') + File.write(source_path, 'Screenshot text') + + generator = described_class.new( + release_version: '1.0', + source_files: { + 'app_store_screenshot-1' => { + path: source_path, + comment: "translators: Line one.\nLine two." + } + } + ) + + result = generator.generate + + expect(result).to include('#. translators: Line one.') + expect(result).to include('#. Line two.') + end + end + + it 'works with string values for backward compatibility' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'name.txt') + File.write(source_path, 'My App') + + generator = described_class.new( + release_version: '1.0', + source_files: { app_name: source_path } + ) + + result = generator.generate + + expect(result).to include('msgctxt "app_name"') + expect(result).not_to include('#.') + end + end + + it 'mixes string and hash values in source_files' do + in_tmp_dir do |dir| + name_path = File.join(dir, 'name.txt') + File.write(name_path, 'My App') + + subtitle_path = File.join(dir, 'subtitle.txt') + File.write(subtitle_path, 'Great app') + + generator = described_class.new( + release_version: '1.0', + source_files: { + app_name: name_path, + app_subtitle: { + path: subtitle_path, + comment: 'translators: Keep it short' + } + } + ) + + result = generator.generate + + expect(result).to include('msgctxt "app_name"') + expect(result).to include('msgctxt "app_subtitle"') + expect(result).to include('#. translators: Keep it short') + end + end + + it 'handles hash without comment key' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'name.txt') + File.write(source_path, 'My App') + + generator = described_class.new( + release_version: '1.0', + source_files: { + app_name: { path: source_path } + } + ) + + result = generator.generate + + expect(result).to include('msgctxt "app_name"') + expect(result).not_to include('#.') + end + end + end + context 'with entry ordering' do it 'sorts entries alphabetically by msgctxt' do in_tmp_dir do |dir| From bf541c15a167b4b6a7aecd7f175ed2fbb7168171 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Mon, 12 Jan 2026 16:54:11 +0100 Subject: [PATCH 11/20] Fix ordering --- .../wpmreleasetoolkit/helper/metadata/po_file_generator.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb index c089cb4fc..2e0a15cb6 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb @@ -45,6 +45,8 @@ def initialize(release_version:, source_files:, existing_po_path: nil) # @return [String] The generated PO file content def generate po = GetText::PO.new + # Disable GetText's internal sorting so we control entry order via our own sort_by(:msgctxt) + po.order = :none # Preserve header from existing PO file if available add_header(po) From 536b615595a89e7e1432b4e51dc5e47a5383fd46 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Mon, 12 Jan 2026 17:33:49 +0100 Subject: [PATCH 12/20] Add migration instructions and update changelog --- CHANGELOG.md | 8 ++++--- MIGRATION.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4804c9440..6d6d04743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,13 @@ ### Breaking Changes -_None_ +- Generated PO files now have entries sorted **alphabetically by `msgctxt`** for deterministic output. This may affect tests or tooling that depend on a specific entry order. [#684] +- Existing translator comments (`#.` lines) in PO files will be **lost** when regenerating unless explicitly added to the `source_files` parameter. See `MIGRATION.md` for instructions on preserving comments. [#684] ### New Features - Add `commit_changes` option to `gp_update_metadata_source` to optionally commit changes after updating the PO file. [#684] +- Add support for translator comments in `gp_update_metadata_source` via a new hash format for `source_files` entries: `{ path: 'file.txt', comment: 'translators: ...' }`. Simple string paths are still supported for entries without comments. [#684] ### Bug Fixes @@ -23,8 +25,8 @@ _None_ ### Deprecated -- `an_update_metadata_source` action is deprecated; use `gp_update_metadata_source` instead. [#684] -- `ios_update_metadata_source` action is deprecated; use `gp_update_metadata_source` with `commit_changes: true` instead. [#684] +- `an_update_metadata_source` action is deprecated; use `gp_update_metadata_source` instead. The API is unchanged, but generated PO files will have different ordering and comments will be lost unless migrated to use the new comment format. [#684] +- `ios_update_metadata_source` action is deprecated; use `gp_update_metadata_source` with `commit_changes: true` instead. The API is unchanged, but generated PO files will have different ordering and comments will be lost unless migrated to use the new comment format. [#684] ## 13.8.1 diff --git a/MIGRATION.md b/MIGRATION.md index 690bcdcdf..78ec06f49 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,72 @@ # Migration Instructions for Major Releases +## From 13.x to 14.0.0 + +### Metadata Source Actions + +The `ios_update_metadata_source` and `an_update_metadata_source` actions are now deprecated. Use `gp_update_metadata_source` instead: + +```ruby +# Before (iOS) +ios_update_metadata_source( + po_file_path: 'path/to/AppStoreStrings.pot', + source_files: { app_name: 'path/to/name.txt' }, + release_version: '1.0' +) + +# Before (Android) +an_update_metadata_source( + po_file_path: 'path/to/PlayStoreStrings.po', + source_files: { app_name: 'path/to/name.txt' }, + release_version: '1.0' +) + +# After (both platforms) +gp_update_metadata_source( + po_file_path: 'path/to/AppStoreStrings.pot', + source_files: { app_name: 'path/to/name.txt' }, + release_version: '1.0', + commit_changes: true # Set to true if you want auto-commit (like ios_update_metadata_source did) +) +``` + +### Translator Comments in PO Files + +**Important:** The new `gp_update_metadata_source` action regenerates PO files from scratch. Any existing translator comments (lines starting with `#.`) in your PO files will be **lost** unless you explicitly add them to your `source_files` hash. + +To preserve translator comments, update your `source_files` to use the new hash format with `:path` and `:comment` keys: + +```ruby +# Before (comments in PO file will be lost) +source_files: { + app_store_subtitle: 'path/to/subtitle.txt', + app_store_keywords: 'path/to/keywords.txt' +} + +# After (comments are preserved in generated PO) +source_files: { + app_store_subtitle: { + path: 'path/to/subtitle.txt', + comment: 'translators: Limit to 30 characters!' + }, + app_store_keywords: { + path: 'path/to/keywords.txt', + comment: "translators: Delimit with commas.\nLimit to 100 characters." + }, + # Simple paths still work for entries without comments + app_name: 'path/to/name.txt' +} +``` + +**Migration steps:** +1. Check your existing `.po`/`.pot` files for any `#.` comment lines +2. Extract those comments and add them to the `source_files` hash in your Fastfile +3. Replace `ios_update_metadata_source`/`an_update_metadata_source` with `gp_update_metadata_source` + +### PO File Entry Ordering + +Generated PO files now have entries sorted **alphabetically by `msgctxt`**. This ensures deterministic output across runs. If you have tests or tooling that depend on a specific entry order, they may need to be updated. + ## From 12.x to 13.0.0 - The `prototype_build_details_comment` action have been updated to work with Firebase App Distribution instead of App Center [#630]. From e4ea3da59e2c7d5cdfe78e507a22735cce1501ad Mon Sep 17 00:00:00 2001 From: "Ian G. Maia" Date: Mon, 12 Jan 2026 17:37:35 +0100 Subject: [PATCH 13/20] gettext minimum to 3.5 --- fastlane-plugin-wpmreleasetoolkit.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastlane-plugin-wpmreleasetoolkit.gemspec b/fastlane-plugin-wpmreleasetoolkit.gemspec index 2dfd5ffd3..d1a6d3828 100644 --- a/fastlane-plugin-wpmreleasetoolkit.gemspec +++ b/fastlane-plugin-wpmreleasetoolkit.gemspec @@ -31,7 +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 'gettext', '~> 3.5' spec.add_dependency 'git', '~> 1.3' spec.add_dependency 'java-properties', '~> 0.3.0' spec.add_dependency 'nokogiri', '~> 1.11' From 1de77edf9f908bd04ad59ee87d338e552ac1b4b4 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Mon, 12 Jan 2026 17:58:48 +0100 Subject: [PATCH 14/20] Fix test considering the order is now alphabetical --- spec/an_update_metadata_source_spec.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/spec/an_update_metadata_source_spec.rb b/spec/an_update_metadata_source_spec.rb index 44b947dbc..4664f90f2 100644 --- a/spec/an_update_metadata_source_spec.rb +++ b/spec/an_update_metadata_source_spec.rb @@ -33,7 +33,12 @@ } ) + # Entries are sorted alphabetically by msgctxt expected = <<~'PO' + msgctxt "release_note_0122" + msgid "previous version notes required to have current one added" + msgstr "" + msgctxt "release_note_0123" msgid "" "1.23:\n" @@ -41,10 +46,6 @@ "- more release notes\n" msgstr "" - msgctxt "release_note_0122" - msgid "previous version notes required to have current one added" - msgstr "" - PO expect(File.read(output_path).inspect).to eq(expected.inspect) end From 4b92f8f908cbdde11df07112fc867a2748c38e4e Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 13 Jan 2026 12:12:35 +0100 Subject: [PATCH 15/20] Use Fastlane actions instead of using `sh` directly --- .../actions/common/gp_update_metadata_source.rb | 15 ++++++++------- .../helper/metadata/po_file_generator.rb | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb index 93ff6cbaf..3045d315a 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb @@ -35,17 +35,18 @@ def self.validate_source_files(source_files) end def self.commit_changes(params) - Action.sh("git add #{params[:po_file_path]}") + files_to_add = [params[:po_file_path]] params[:source_files].each_value do |value| file_path = value.is_a?(Hash) ? value[:path] : value - Action.sh("git add #{file_path}") + files_to_add << file_path end - repo_status = Actions.sh('git status --porcelain') - repo_clean = repo_status.empty? - return if repo_clean - - Action.sh('git commit -m "Update metadata strings"') + other_action.git_add(path: files_to_add) + other_action.git_commit( + path: files_to_add, + message: 'Update metadata strings', + allow_nothing_to_commit: true + ) end ##################################################### diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb index 2e0a15cb6..e47e18d1c 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb @@ -64,7 +64,7 @@ def generate po[entry.msgctxt, entry.msgid] = entry end - # GetText::PO#to_s doesn't add a trailing newline, but our tests expect one + # GetText::PO#to_s doesn't add a trailing newline "#{po}\n" end From d82623d18c6254748bb576b887ca37c6c01590d9 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 13 Jan 2026 12:48:11 +0100 Subject: [PATCH 16/20] Update Gemfile.lock --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 647d6d557..95432d52d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,7 +7,7 @@ PATH chroma (= 0.2.0) diffy (~> 3.3) fastlane (~> 2.213) - gettext (~> 3.4) + gettext (~> 3.5) git (~> 1.3) google-cloud-storage (~> 1.31) java-properties (~> 0.3.0) From cede46f62f635578b9e7a47f35669c0e287e80f8 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 13 Jan 2026 14:14:42 +0100 Subject: [PATCH 17/20] Remove using any existing PO file for generating a new one h/t @AliSoftware https://github.com/wordpress-mobile/release-toolkit/pull/684#discussion_r2684778663 --- .../an_update_metadata_source_action.rb | 1 - .../common/gp_update_metadata_source.rb | 3 +- .../actions/ios/ios_update_metadata_source.rb | 3 +- .../helper/metadata/po_file_generator.rb | 120 +++---------- spec/an_update_metadata_source_spec.rb | 34 +--- spec/po_file_generator_spec.rb | 163 ++---------------- ...mples_for_update_metadata_source_action.rb | 63 +++---- 7 files changed, 65 insertions(+), 322 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_update_metadata_source_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_update_metadata_source_action.rb index 9195fa23d..593d89815 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_update_metadata_source_action.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_update_metadata_source_action.rb @@ -33,7 +33,6 @@ def self.available_options type: String, verify_block: proc do |value| UI.user_error!("No .po file path given, pass using `po_file_path: 'file path'`") unless value && !value.empty? - UI.user_error!("Couldn't find file at path '#{value}'") unless File.exist?(value) end), FastlaneCore::ConfigItem.new(key: :release_version, env_name: 'FL_UPDATE_METADATA_SOURCE_RELEASE_VERSION', diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb index 3045d315a..78c1d7c6d 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb @@ -16,8 +16,7 @@ def self.run(params) generator = Fastlane::Helper::PoFileGenerator.new( release_version: params[:release_version], - source_files: params[:source_files], - existing_po_path: params[:po_file_path] + source_files: params[:source_files] ) generator.write(params[:po_file_path]) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_update_metadata_source.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_update_metadata_source.rb index 106ddaa87..575855cda 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_update_metadata_source.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_update_metadata_source.rb @@ -30,11 +30,10 @@ def self.available_options [ FastlaneCore::ConfigItem.new(key: :po_file_path, env_name: 'FL_IOS_UPDATE_METADATA_SOURCE_PO_FILE_PATH', - description: 'The path of the .po file to update', + description: 'The path of the .po file to generate', type: String, verify_block: proc do |value| UI.user_error!("No .po file path for UpdateMetadataSourceAction given, pass using `po_file_path: 'file path'`") unless value && !value.empty? - UI.user_error!("Couldn't find file at path '#{value}'") unless File.exist?(value) end), FastlaneCore::ConfigItem.new(key: :release_version, env_name: 'FL_IOS_UPDATE_METADATA_SOURCE_RELEASE_VERSION', diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb index e47e18d1c..6e813ad35 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb @@ -2,7 +2,7 @@ require 'gettext/po' require 'gettext/po_entry' -require 'gettext/po_parser' +require_relative '../../version' module Fastlane module Helper @@ -34,11 +34,9 @@ module Helper class PoFileGenerator # @param release_version [String] The release version (e.g., "1.23") # @param source_files [Hash] A hash mapping keys to file paths (String) or hashes with :path and :comment keys - # @param existing_po_path [String, nil] Optional path to existing PO file (needed for release_note to preserve n-1) - def initialize(release_version:, source_files:, existing_po_path: nil) + def initialize(release_version:, source_files:) @release_version = release_version @source_files = source_files - @existing_po_path = existing_po_path end # Generates the PO file content as a string @@ -48,7 +46,7 @@ def generate # Disable GetText's internal sorting so we control entry order via our own sort_by(:msgctxt) po.order = :none - # Preserve header from existing PO file if available + # Add standard PO header add_header(po) # Collect all entries first, then sort by msgctxt for deterministic output @@ -77,36 +75,22 @@ def write(output_path) private def add_header(po_data) - return unless @existing_po_path && File.exist?(@existing_po_path) + revision_date = Time.now.strftime('%Y-%m-%d %H:%M%z') + generator = "fastlane-plugin-wpmreleasetoolkit #{Fastlane::Wpmreleasetoolkit::VERSION}" - existing_po = GetText::PO.new - parser = GetText::POParser.new - parser.parse_file(@existing_po_path, existing_po) + header_content = <<~HEADER.chomp + PO-Revision-Date: #{revision_date} + MIME-Version: 1.0 + Content-Type: text/plain; charset=UTF-8 + Content-Transfer-Encoding: 8bit + Plural-Forms: nplurals=2; plural=n != 1; + X-Generator: #{generator} + HEADER - # Get the header entry (empty msgid) - header = existing_po[''] - return unless header - - # Update PO-Revision-Date to current time - updated_msgstr = update_revision_date(header.msgstr) - - # Create new header entry preserving comments - new_header = GetText::POEntry.new(:normal) - new_header.msgid = '' - new_header.msgstr = updated_msgstr - new_header.translator_comment = header.translator_comment if header.translator_comment - - po_data[new_header.msgctxt, new_header.msgid] = new_header - rescue StandardError - # If header parsing fails, continue without header - nil - end - - def update_revision_date(msgstr) - return msgstr unless msgstr - - current_time = Time.now.strftime('%Y-%m-%d %H:%M%z') - msgstr.gsub(/PO-Revision-Date:.*\n/, "PO-Revision-Date: #{current_time}\n") + header = GetText::POEntry.new(:normal) + header.msgid = '' + header.msgstr = header_content + po_data[header.msgctxt, header.msgid] = header end # Extracts path and comment from a source_files value @@ -148,60 +132,19 @@ def create_whats_new_entry(content, comment = nil) end def create_release_note_entries(content, comment = nil) - entries = [] - - # Generate new entry for current version - new_key = release_note_key_for_version(@release_version) + key = release_note_key_for_version(@release_version) msgid = "#{@release_version}:\n#{content}" msgid = "#{msgid}\n" unless msgid.end_with?("\n") - entries << create_entry(new_key, msgid, comment) - - # Preserve the n-1 entry from existing file if available - previous_entry = find_previous_release_note(:release_note) - entries << previous_entry if previous_entry - - entries + [create_entry(key, msgid, comment)] end def create_release_note_short_entries(content, comment = nil) return [] if content.strip.empty? - entries = [] - - # Generate new entry for current version - new_key = release_note_short_key_for_version(@release_version) + key = release_note_short_key_for_version(@release_version) msgid = "#{@release_version}:\n#{content}" msgid = "#{msgid}\n" unless msgid.end_with?("\n") - entries << create_entry(new_key, msgid, comment) - - # Preserve the n-1 entry from existing file if available - previous_entry = find_previous_release_note(:release_note_short) - entries << previous_entry if previous_entry - - entries - end - - def find_previous_release_note(type) - return nil unless @existing_po_path && File.exist?(@existing_po_path) - - keep_key = case type - when :release_note - release_note_key_for_previous_version(@release_version) - when :release_note_short - release_note_short_key_for_previous_version(@release_version) - end - - find_entry_in_existing_po(keep_key) - end - - def find_entry_in_existing_po(target_msgctxt) - existing_po = GetText::PO.new - parser = GetText::POParser.new - parser.parse_file(@existing_po_path, existing_po) - - existing_po.find { |entry| entry.msgctxt == target_msgctxt } - rescue StandardError - nil + [create_entry(key, msgid, comment)] end def create_entry(msgctxt, msgid, comment = nil) @@ -218,36 +161,15 @@ def release_note_key_for_version(version) "release_note_#{major.to_s.rjust(2, '0')}#{minor}" end - def release_note_key_for_previous_version(version) - major, minor = previous_version(version) - "release_note_#{major.to_s.rjust(2, '0')}#{minor}" - end - def release_note_short_key_for_version(version) major, minor = parse_version(version) "release_note_short_#{major.to_s.rjust(2, '0')}#{minor}" end - def release_note_short_key_for_previous_version(version) - major, minor = previous_version(version) - "release_note_short_#{major.to_s.rjust(2, '0')}#{minor}" - end - def parse_version(version) parts = version.split('.') [Integer(parts[0]), Integer(parts[1])] end - - def previous_version(version) - major, minor = parse_version(version) - if minor.zero? - major -= 1 - minor = 9 - else - minor -= 1 - end - [major, minor] - end end end end diff --git a/spec/an_update_metadata_source_spec.rb b/spec/an_update_metadata_source_spec.rb index 4664f90f2..c421772bf 100644 --- a/spec/an_update_metadata_source_spec.rb +++ b/spec/an_update_metadata_source_spec.rb @@ -6,21 +6,9 @@ describe Fastlane::Actions::AnUpdateMetadataSourceAction do include_examples 'update_metadata_source_action' - it 'combines the given `release_version` and `release_notes` in a new block, keeps the n-1 ones, and deletes the others' do + it 'generates a versioned release_note entry from the given source file' do in_tmp_dir do |dir| output_path = File.join(dir, 'output.po') - dummy_text = <<~PO - msgctxt "release_note_0122" - msgid "previous version notes required to have current one added" - msgstr "" - msgctxt "release_note_0121" - msgid "this older release notes block should be removed" - msgstr "" - msgctxt "release_note_0120" - msgid "this older release notes block should be removed" - msgstr "" - PO - File.write(output_path, dummy_text) release_notes_path = File.join(dir, 'release_notes.txt') File.write(release_notes_path, "- release notes\n- more release notes") @@ -33,21 +21,11 @@ } ) - # Entries are sorted alphabetically by msgctxt - expected = <<~'PO' - msgctxt "release_note_0122" - msgid "previous version notes required to have current one added" - msgstr "" - - msgctxt "release_note_0123" - msgid "" - "1.23:\n" - "- release notes\n" - "- more release notes\n" - msgstr "" - - PO - expect(File.read(output_path).inspect).to eq(expected.inspect) + result = File.read(output_path) + expect(result).to include('msgctxt "release_note_0123"') + expect(result).to include('"1.23:\n"') + expect(result).to include('"- release notes\n"') + expect(result).to include('"- more release notes\n"') end end end diff --git a/spec/po_file_generator_spec.rb b/spec/po_file_generator_spec.rb index ec53d13f9..a45d6bf23 100644 --- a/spec/po_file_generator_spec.rb +++ b/spec/po_file_generator_spec.rb @@ -139,70 +139,6 @@ expect(result).to include('"- Feature 2\n"') end end - - it 'preserves the n-1 release note from existing file' do - in_tmp_dir do |dir| - # Create existing PO file with previous release note - existing_po_path = File.join(dir, 'existing.po') - existing_content = <<~PO - msgctxt "release_note_0122" - msgid "Previous release notes" - msgstr "" - - msgctxt "release_note_0121" - msgid "Older release notes" - msgstr "" - PO - File.write(existing_po_path, existing_content) - - # Create new release notes - source_path = File.join(dir, 'release_notes.txt') - File.write(source_path, 'New release notes') - - generator = described_class.new( - release_version: '1.23', - source_files: { release_note: source_path }, - existing_po_path: existing_po_path - ) - - result = generator.generate - - # Should have new entry - expect(result).to include('msgctxt "release_note_0123"') - # Should preserve n-1 entry - expect(result).to include('msgctxt "release_note_0122"') - expect(result).to include('msgid "Previous release notes"') - # Should NOT include older entries - expect(result).not_to include('release_note_0121') - end - end - - it 'handles version 1.0 (wraps to previous major version)' do - in_tmp_dir do |dir| - # For version 1.0, the n-1 version is 0.9, so the key is release_note_009 - existing_po_path = File.join(dir, 'existing.po') - existing_content = <<~PO - msgctxt "release_note_009" - msgid "Previous major version notes" - msgstr "" - PO - File.write(existing_po_path, existing_content) - - source_path = File.join(dir, 'release_notes.txt') - File.write(source_path, 'First release of v1') - - generator = described_class.new( - release_version: '1.0', - source_files: { release_note: source_path }, - existing_po_path: existing_po_path - ) - - result = generator.generate - - expect(result).to include('msgctxt "release_note_010"') - expect(result).to include('msgctxt "release_note_009"') - end - end end context 'with release_note_short entries' do @@ -240,74 +176,28 @@ end end - context 'with header preservation' do - it 'preserves header from existing PO file' do + context 'with header generation' do + it 'generates a header with standard PO fields' do in_tmp_dir do |dir| - existing_po_path = File.join(dir, 'existing.po') - existing_content = <<~PO - # Test comment line 1 - # Test comment line 2 - msgid "" - msgstr "" - "PO-Revision-Date: 2019-01-03 17:30-0000\\n" - "MIME-Version: 1.0\\n" - "Content-Type: text/plain; charset=UTF-8\\n" - "Project-Id-Version: Test Project\\n" - - msgctxt "old_entry" - msgid "Old content" - msgstr "" - PO - File.write(existing_po_path, existing_content) - source_path = File.join(dir, 'name.txt') File.write(source_path, 'Test App') generator = described_class.new( release_version: '1.0', - source_files: { name: source_path }, - existing_po_path: existing_po_path + source_files: { name: source_path } ) result = generator.generate - expect(result).to include('# Test comment line 1') - expect(result).to include('# Test comment line 2') - expect(result).to include('Project-Id-Version: Test Project') expect(result).to include('MIME-Version: 1.0') + expect(result).to include('Content-Type: text/plain; charset=UTF-8') + expect(result).to include('Content-Transfer-Encoding: 8bit') + expect(result).to include('Plural-Forms: nplurals=2; plural=n != 1;') + expect(result).to include('X-Generator: fastlane-plugin-wpmreleasetoolkit') end end - it 'updates PO-Revision-Date to current time' do - in_tmp_dir do |dir| - existing_po_path = File.join(dir, 'existing.po') - existing_content = <<~PO - msgid "" - msgstr "" - "PO-Revision-Date: 2019-01-03 17:30-0000\\n" - "MIME-Version: 1.0\\n" - PO - File.write(existing_po_path, existing_content) - - source_path = File.join(dir, 'name.txt') - File.write(source_path, 'Test App') - - generator = described_class.new( - release_version: '1.0', - source_files: { name: source_path }, - existing_po_path: existing_po_path - ) - - result = generator.generate - - # Should not contain the old date - expect(result).not_to include('2019-01-03') - # Should contain a recent date (current year) - expect(result).to match(/PO-Revision-Date: \d{4}-\d{2}-\d{2}/) - end - end - - it 'generates output without header when no existing file provided' do + it 'includes PO-Revision-Date with current timestamp' do in_tmp_dir do |dir| source_path = File.join(dir, 'name.txt') File.write(source_path, 'Test App') @@ -319,38 +209,24 @@ result = generator.generate - # Should not have a header entry - expect(result).not_to include('PO-Revision-Date') - # But should have the content entry - expect(result).to include('msgctxt "name"') + # Should contain a date in YYYY-MM-DD format + expect(result).to match(/PO-Revision-Date: \d{4}-\d{2}-\d{2} \d{2}:\d{2}[+-]\d{4}/) end end - it 'generates output without header when existing file has no header' do + it 'includes gem version in X-Generator field' do in_tmp_dir do |dir| - existing_po_path = File.join(dir, 'existing.po') - existing_content = <<~PO - msgctxt "old_entry" - msgid "Old content" - msgstr "" - PO - File.write(existing_po_path, existing_content) - source_path = File.join(dir, 'name.txt') File.write(source_path, 'Test App') generator = described_class.new( release_version: '1.0', - source_files: { name: source_path }, - existing_po_path: existing_po_path + source_files: { name: source_path } ) result = generator.generate - # Should not have a header entry - expect(result).not_to include('PO-Revision-Date') - # But should have the content entry - expect(result).to include('msgctxt "name"') + expect(result).to include("X-Generator: fastlane-plugin-wpmreleasetoolkit #{Fastlane::Wpmreleasetoolkit::VERSION}") end end end @@ -495,14 +371,6 @@ it 'sorts release_note entries with other entries' do in_tmp_dir do |dir| - existing_po_path = File.join(dir, 'existing.po') - existing_content = <<~PO - msgctxt "release_note_0122" - msgid "Previous notes" - msgstr "" - PO - File.write(existing_po_path, existing_content) - release_path = File.join(dir, 'release.txt') File.write(release_path, 'New notes') @@ -514,15 +382,14 @@ source_files: { zebra: zebra_path, release_note: release_path - }, - existing_po_path: existing_po_path + } ) result = generator.generate msgctxts = result.scan(/msgctxt "([^"]+)"/).flatten # release_note entries should be sorted with other entries - expect(msgctxts).to eq(%w[release_note_0122 release_note_0123 zebra]) + expect(msgctxts).to eq(%w[release_note_0123 zebra]) end end end diff --git a/spec/shared_examples_for_update_metadata_source_action.rb b/spec/shared_examples_for_update_metadata_source_action.rb index 1759d8043..84282b9b6 100644 --- a/spec/shared_examples_for_update_metadata_source_action.rb +++ b/spec/shared_examples_for_update_metadata_source_action.rb @@ -30,17 +30,14 @@ } ) - expected = <<~PO - msgctxt "key1" - msgid "value 1" - msgstr "" - - msgctxt "key2" - msgid "value 2" - msgstr "" - - PO - expect(File.read(output_path)).to eq(expected) + result = File.read(output_path) + # Should include the header + expect(result).to include('MIME-Version: 1.0') + # Should include the updated entries + expect(result).to include('msgctxt "key1"') + expect(result).to include('msgid "value 1"') + expect(result).to include('msgctxt "key2"') + expect(result).to include('msgid "value 2"') end end @@ -65,15 +62,10 @@ } ) - expected = <<~'PO' - msgctxt "v1.23-whats-new" - msgid "" - "- something new\n" - "- something else new\n" - msgstr "" - - PO - expect(File.read(output_path)).to eq(expected) + result = File.read(output_path) + expect(result).to include('msgctxt "v1.23-whats-new"') + expect(result).to include('"- something new\n"') + expect(result).to include('"- something else new\n"') end end @@ -102,17 +94,11 @@ } ) - expected = <<~PO - msgctxt "key1" - msgid "value 1" - msgstr "" - - msgctxt "key2" - msgid "value 2" - msgstr "" - - PO - expect(File.read(output_path)).to eq(expected) + result = File.read(output_path) + expect(result).to include('msgctxt "key1"') + expect(result).to include('msgid "value 1"') + expect(result).to include('msgctxt "key2"') + expect(result).to include('msgid "value 2"') end end @@ -148,17 +134,10 @@ } ) - expected = <<~PO - msgctxt "key1" - msgid "value 1" - msgstr "" - - msgctxt "key2" - msgid "value 2" - msgstr "" - - PO - expect(File.read(output_path)).to eq(expected) + result = File.read(output_path) + expect(result).to include('msgctxt "key1"') + expect(result).to include('msgctxt "key2"') + expect(result).not_to include('stale_key') end end end From b7999ec94e26c822a1da1b08d66ddc3b9d45d3c0 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 13 Jan 2026 14:17:54 +0100 Subject: [PATCH 18/20] Allow trailing line break on PO header --- .../wpmreleasetoolkit/helper/metadata/po_file_generator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb index 6e813ad35..23119793c 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb @@ -78,7 +78,7 @@ def add_header(po_data) revision_date = Time.now.strftime('%Y-%m-%d %H:%M%z') generator = "fastlane-plugin-wpmreleasetoolkit #{Fastlane::Wpmreleasetoolkit::VERSION}" - header_content = <<~HEADER.chomp + header_content = <<~HEADER PO-Revision-Date: #{revision_date} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 From 1127b62866f51c0e85cc94fe5cbbc02a09eb6df2 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 13 Jan 2026 14:21:15 +0100 Subject: [PATCH 19/20] Add constant for gem name --- fastlane-plugin-wpmreleasetoolkit.gemspec | 4 ++-- .../wpmreleasetoolkit/helper/metadata/po_file_generator.rb | 2 +- lib/fastlane/plugin/wpmreleasetoolkit/version.rb | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/fastlane-plugin-wpmreleasetoolkit.gemspec b/fastlane-plugin-wpmreleasetoolkit.gemspec index d1a6d3828..a61d257f3 100644 --- a/fastlane-plugin-wpmreleasetoolkit.gemspec +++ b/fastlane-plugin-wpmreleasetoolkit.gemspec @@ -5,12 +5,12 @@ $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' - spec.summary = 'GitHub helper functions' + spec.summary = 'Fastlane plugin for release automation' spec.homepage = 'https://github.com/wordpress-mobile/release-toolkit' spec.license = 'MIT' diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb index 23119793c..f155b79d6 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb @@ -76,7 +76,7 @@ def write(output_path) def add_header(po_data) revision_date = Time.now.strftime('%Y-%m-%d %H:%M%z') - generator = "fastlane-plugin-wpmreleasetoolkit #{Fastlane::Wpmreleasetoolkit::VERSION}" + generator = "#{Fastlane::Wpmreleasetoolkit::NAME} #{Fastlane::Wpmreleasetoolkit::VERSION}" header_content = <<~HEADER PO-Revision-Date: #{revision_date} diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/version.rb b/lib/fastlane/plugin/wpmreleasetoolkit/version.rb index 69d6eb739..872de78e0 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.8.1' end end From 073d704a9525c403dba05abe08a5e57c00931982 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Thu, 15 Jan 2026 18:09:33 +0100 Subject: [PATCH 20/20] Implement feedback from code review 1. Removed `ensure_git_status_clean` from `gp_update_metadata_source.rb` 2. Added `:path` key validation in `extract_path_and_comment` 3. Added proper error handling to `parse_version` 4. Empty `whats_new` content handling 5. DRYed up versioned key methods 6. Added a few new tests. --- .../common/gp_update_metadata_source.rb | 5 +- .../helper/metadata/po_file_generator.rb | 26 +++++--- spec/po_file_generator_spec.rb | 61 +++++++++++++++++++ 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb index 78c1d7c6d..72f3870d9 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb @@ -9,9 +9,6 @@ def self.run(params) UI.message "PO file path: #{params[:po_file_path]}" UI.message "Release version: #{params[:release_version]}" - # Check local repo status if we're going to commit changes - other_action.ensure_git_status_clean if params[:commit_changes] - validate_source_files(params[:source_files]) generator = Fastlane::Helper::PoFileGenerator.new( @@ -100,7 +97,7 @@ def self.available_options UI.user_error!("No source files given, pass using `source_files: { key: 'path' }`") unless value && !value.empty? end), FastlaneCore::ConfigItem.new(key: :commit_changes, - description: 'If true, checks git status is clean, then adds and commits the changes', + description: 'If true, adds and commits the changes', type: Boolean, default_value: false), ] diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb index f155b79d6..40159314b 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb @@ -101,6 +101,7 @@ def extract_path_and_comment(value) when String [value, nil] when Hash + UI.user_error!("Hash must contain :path key, got: #{value.keys}") unless value.key?(:path) [value[:path], value[:comment]] else raise ArgumentError, "Invalid source_files value: expected String or Hash, got #{value.class}" @@ -110,7 +111,7 @@ def extract_path_and_comment(value) def create_entries_for_key(key, content, comment = nil) case key when :whats_new - [create_whats_new_entry(content, comment)] + create_whats_new_entries(content, comment) when :release_note create_release_note_entries(content, comment) when :release_note_short @@ -124,11 +125,13 @@ def create_standard_entry(msgctxt, content, comment = nil) create_entry(msgctxt, content.rstrip, comment) end - def create_whats_new_entry(content, comment = nil) + def create_whats_new_entries(content, comment = nil) + return [] if content.strip.empty? + msgctxt = "v#{@release_version}-whats-new" # Ensure content ends with newline for multiline formatting msgid = content.end_with?("\n") ? content : "#{content}\n" - create_entry(msgctxt, msgid, comment) + [create_entry(msgctxt, msgid, comment)] end def create_release_note_entries(content, comment = nil) @@ -157,18 +160,27 @@ def create_entry(msgctxt, msgid, comment = nil) end def release_note_key_for_version(version) - major, minor = parse_version(version) - "release_note_#{major.to_s.rjust(2, '0')}#{minor}" + versioned_key('release_note', version) end def release_note_short_key_for_version(version) + versioned_key('release_note_short', version) + end + + def versioned_key(prefix, version) major, minor = parse_version(version) - "release_note_short_#{major.to_s.rjust(2, '0')}#{minor}" + "#{prefix}_#{major.to_s.rjust(2, '0')}#{minor}" end def parse_version(version) parts = version.split('.') - [Integer(parts[0]), Integer(parts[1])] + UI.user_error!("Invalid version format '#{version}': expected 'major.minor' (e.g., '1.2')") if parts.length < 2 + + begin + [Integer(parts[0]), Integer(parts[1])] + rescue ArgumentError + UI.user_error!("Invalid version format '#{version}': major and minor must be integers") + end end end end diff --git a/spec/po_file_generator_spec.rb b/spec/po_file_generator_spec.rb index a45d6bf23..b458794e8 100644 --- a/spec/po_file_generator_spec.rb +++ b/spec/po_file_generator_spec.rb @@ -118,6 +118,22 @@ expect(result).to include('"- Bug fix\n"') end end + + it 'skips empty whats_new content' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'whats_new.txt') + File.write(source_path, ' ') + + generator = described_class.new( + release_version: '1.23', + source_files: { whats_new: source_path } + ) + + result = generator.generate + + expect(result).not_to include('whats-new') + end + end end context 'with release_note entries' do @@ -463,4 +479,49 @@ end end end + + describe 'error handling' do + context 'with invalid version format' do + it 'raises user error for version without minor component' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'release.txt') + File.write(source_path, 'Notes') + + generator = described_class.new( + release_version: '1', + source_files: { release_note: source_path } + ) + + expect { generator.generate }.to raise_error(FastlaneCore::Interface::FastlaneError, /Invalid version format '1'/) + end + end + + it 'raises user error for version with non-integer components' do + in_tmp_dir do |dir| + source_path = File.join(dir, 'release.txt') + File.write(source_path, 'Notes') + + generator = described_class.new( + release_version: 'foo.bar', + source_files: { release_note: source_path } + ) + + expect { generator.generate }.to raise_error(FastlaneCore::Interface::FastlaneError, /major and minor must be integers/) + end + end + end + + context 'with invalid source_files hash' do + it 'raises user error when hash is missing :path key' do + generator = described_class.new( + release_version: '1.0', + source_files: { + app_name: { comment: 'A comment but no path' } + } + ) + + expect { generator.generate }.to raise_error(FastlaneCore::Interface::FastlaneError, /Hash must contain :path key/) + end + end + end end