diff --git a/CHANGELOG.md b/CHANGELOG.md index b96b9b51f..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 -_None_ +- 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 @@ -18,7 +20,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. 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/Gemfile.lock b/Gemfile.lock index 63da3e27c..95432d52d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,6 +7,7 @@ PATH chroma (= 0.2.0) diffy (~> 3.3) fastlane (~> 2.213) + gettext (~> 3.5) 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/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]. diff --git a/fastlane-plugin-wpmreleasetoolkit.gemspec b/fastlane-plugin-wpmreleasetoolkit.gemspec index 045b252f4..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' @@ -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.5' spec.add_dependency 'git', '~> 1.3' spec.add_dependency 'java-properties', '~> 0.3.0' spec.add_dependency 'nokogiri', '~> 1.11' 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..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 @@ -1,118 +1,16 @@ # 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' - 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 "Release version: #{params[:release_version]}" - - # Init - create_block_parsers(params[:release_version], 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) - - 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) - 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) + UI.deprecated('`an_update_metadata_source` is deprecated. Please use `gp_update_metadata_source` instead.') - @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?('#') + other_action.gp_update_metadata_source( + po_file_path: params[:po_file_path], + source_files: params[:source_files], + release_version: params[:release_version] + ) end ##################################################### @@ -120,38 +18,34 @@ def self.is_comment(line) ##################################################### 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 @@ -160,7 +54,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 @@ -170,6 +63,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 86f117711..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 @@ -1,117 +1,48 @@ # 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] + ) - 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) - 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 + generator.write(params[:po_file_path]) - # 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 + UI.message "File #{params[:po_file_path]} updated!" - # 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" + commit_changes(params) if params[:commit_changes] 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 + def self.validate_source_files(source_files) + 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 - - # 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 + def self.commit_changes(params) + files_to_add = [params[:po_file_path]] + params[:source_files].each_value do |value| + file_path = value.is_a?(Hash) ? value[:path] : value + files_to_add << file_path 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?('#') + 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 ##################################################### @@ -119,39 +50,56 @@ def self.is_comment(line) ##################################################### 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.' + <<~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 - # 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 file paths (String) or hashes with :path and optional :comment', 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), + FastlaneCore::ConfigItem.new(key: :commit_changes, + description: 'If true, adds and commits the changes', + type: Boolean, + default_value: false), ] end @@ -159,7 +107,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/ios/ios_update_metadata_source.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_update_metadata_source.rb index 820d00261..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 @@ -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,17 +27,13 @@ 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', - 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', @@ -77,6 +64,10 @@ def self.authors def self.is_supported?(platform) %i[ios mac].include?(platform) end + + def self.deprecated? + true + end end end end 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/po_file_generator.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb new file mode 100644 index 000000000..40159314b --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata/po_file_generator.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require 'gettext/po' +require 'gettext/po_entry' +require_relative '../../version' + +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. + # + # @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 (String) or hashes with :path and :comment keys + def initialize(release_version:, source_files:) + @release_version = release_version + @source_files = source_files + end + + # Generates the PO file content as a string + # @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 + + # Add standard PO header + add_header(po) + + # Collect all entries first, then sort by msgctxt for deterministic output + entries = [] + @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 + entries.sort_by(&:msgctxt).each do |entry| + po[entry.msgctxt, entry.msgid] = entry + end + + # GetText::PO#to_s doesn't add a trailing newline + "#{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_header(po_data) + revision_date = Time.now.strftime('%Y-%m-%d %H:%M%z') + generator = "#{Fastlane::Wpmreleasetoolkit::NAME} #{Fastlane::Wpmreleasetoolkit::VERSION}" + + header_content = <<~HEADER + 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 + + 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 + # @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 + 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}" + end + end + + def create_entries_for_key(key, content, comment = nil) + case key + when :whats_new + create_whats_new_entries(content, comment) + when :release_note + create_release_note_entries(content, comment) + when :release_note_short + create_release_note_short_entries(content, comment) + else + [create_standard_entry(key.to_s, content, comment)] + end + end + + def create_standard_entry(msgctxt, content, comment = nil) + create_entry(msgctxt, content.rstrip, comment) + end + + 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)] + end + + def create_release_note_entries(content, comment = nil) + key = release_note_key_for_version(@release_version) + msgid = "#{@release_version}:\n#{content}" + msgid = "#{msgid}\n" unless msgid.end_with?("\n") + [create_entry(key, msgid, comment)] + end + + def create_release_note_short_entries(content, comment = nil) + return [] if content.strip.empty? + + key = release_note_short_key_for_version(@release_version) + msgid = "#{@release_version}:\n#{content}" + msgid = "#{msgid}\n" unless msgid.end_with?("\n") + [create_entry(key, msgid, comment)] + end + + 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 + + def release_note_key_for_version(version) + 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) + "#{prefix}_#{major.to_s.rjust(2, '0')}#{minor}" + end + + def parse_version(version) + parts = version.split('.') + 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 +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/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 diff --git a/spec/an_update_metadata_source_spec.rb b/spec/an_update_metadata_source_spec.rb index 91403c0ff..c421772bf 100644 --- a/spec/an_update_metadata_source_spec.rb +++ b/spec/an_update_metadata_source_spec.rb @@ -4,23 +4,11 @@ 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 + 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,19 +21,11 @@ } ) - expected = <<~'PO' - msgctxt "release_note_0123" - msgid "" - "1.23:\n" - "- release notes\n" - "- 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) + 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/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/po_file_generator_spec.rb b/spec/po_file_generator_spec.rb new file mode 100644 index 000000000..b458794e8 --- /dev/null +++ b/spec/po_file_generator_spec.rb @@ -0,0 +1,527 @@ +# 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 + + 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 + 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 + 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 'with header generation' do + it 'generates a header with standard PO fields' 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 + + 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 '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') + + generator = described_class.new( + release_version: '1.0', + source_files: { name: source_path } + ) + + result = generator.generate + + # 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 'includes gem version in X-Generator field' 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 + + expect(result).to include("X-Generator: fastlane-plugin-wpmreleasetoolkit #{Fastlane::Wpmreleasetoolkit::VERSION}") + end + 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| + # 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| + 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 + } + ) + + result = generator.generate + msgctxts = result.scan(/msgctxt "([^"]+)"/).flatten + + # release_note entries should be sorted with other entries + expect(msgctxts).to eq(%w[release_note_0123 zebra]) + end + end + end + + context 'with generated output' 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 + + 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 diff --git a/spec/shared_examples_for_update_metadata_source_action.rb b/spec/shared_examples_for_update_metadata_source_action.rb index 201408143..84282b9b6 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') @@ -30,23 +30,18 @@ } ) - 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 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 @@ -67,21 +62,14 @@ } ) - 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 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 @@ -106,17 +94,50 @@ } ) - expected = <<~PO + 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 + + 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 "value 1" + msgid "this value should change" msgstr "" - msgctxt "key2" - msgid "value 2" + msgctxt "stale_key" + msgid "this entry should be removed" msgstr "" + msgctxt "key2" + msgid "this value should also change" + msgstr "" PO - expect(File.read(output_path)).to eq(expected) + 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 + } + ) + + 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 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