diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 677efef55..6d6b32fc0 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -142,6 +142,8 @@ RSpec/FilePath: - 'spec/ios_merge_translators_strings_spec.rb' - 'spec/release_notes_helper_spec.rb' - 'spec/check_localization_progress_spec.rb' + - 'spec/locale_spec.rb' + - 'spec/locales_spec.rb' # Offense count: 8 # Cop supports --auto-correct. diff --git a/lib/fastlane/plugin/wpmreleasetoolkit.rb b/lib/fastlane/plugin/wpmreleasetoolkit.rb index dad7c7064..a1128823c 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit.rb @@ -4,7 +4,7 @@ module Fastlane module Wpmreleasetoolkit # Return all .rb files inside the "actions" and "helper" directory def self.all_classes - Dir[File.expand_path('**/{actions,helper}/**/*.rb', File.dirname(__FILE__))] + Dir[File.expand_path('**/{actions,helper,models}/**/*.rb', File.dirname(__FILE__))] end end end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/models/locale.rb b/lib/fastlane/plugin/wpmreleasetoolkit/models/locale.rb new file mode 100644 index 000000000..0e37dbc62 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/models/locale.rb @@ -0,0 +1,40 @@ +module Fastlane + # Defines a single Locale with the various locale codes depending on the representation needed. + # + # The various locale codes formats for the various keys can be found as follows: + # + # - glotpress: + # Go to the GP project page (e.g. https://translate.wordpress.org/projects/apps/android/dev/) + # and hover over the link for each locale, locale code is in the URL. + # - android: (`values-*` folder names) + # See https://developer.android.com/guide/topics/resources/providing-resources#AlternativeResources (Scroll to Table 2) + # [ISO639-1 (lowercase)]-r[ISO-3166-alpha-2 (uppercase)], e.g. `zh-rCN` ("Chinese understood in mainland China") + # - google_play: (PlayStore Console, for metadata, release_notes.xml and `fastlane supply`) + # See https://support.google.com/googleplay/android-developer/answer/9844778 (then open "View list of available languages"). + # See also https://github.com/fastlane/fastlane/blob/master/supply/lib/supply/languages.rb + # - ios: (`*.lproj`) + # See https://developer.apple.com/documentation/xcode/choosing-localization-regions-and-scripts#Understand-the-Language-Identifier + # [ISO639-1/ISO639-2 (lowercase)]-[ISO 3166-1 (uppercase region or titlecase script)], e.g. `zh-Hans` ("Simplified Chinese" script) + # - app_store: (AppStoreConnect, for metadata and `fastlane deliver`) + # See https://github.com/fastlane/fastlane/blob/master/deliver/lib/deliver/languages.rb + # + # Links to ISO Standards + # ISO standard portal: https://www.iso.org/obp/ui/#search + # ISO 639-1: https://www.loc.gov/standards/iso639-2/php/code_list.php + # ISO-3166-alpha2: https://www.iso.org/obp/ui/#iso:pub:PUB500001:en + # + # Notes about region vs script codes in ISO-3166-1 + # `zh-CN` is a locale code - Chinese understood in mainland China + # `zh-Hans` is a language+script code - Chinese written in Simplified Chinese (not just understood in mainland China) + # + Locale = Struct.new(:glotpress, :android, :google_play, :ios, :app_store, keyword_init: true) do + # Returns the Locale with the given glotpress locale code from the list of all known locales (`Locales.all`) + # + # @param [String] The glotpress locale code for the locale to fetch + # @return [Locale] The locale found + # @raise [RuntimeException] if the locale with given glotpress code is unknown + def self.[](code) + Locales[code].first + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/models/locales.rb b/lib/fastlane/plugin/wpmreleasetoolkit/models/locales.rb new file mode 100644 index 000000000..0cc6ee762 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/models/locales.rb @@ -0,0 +1,137 @@ +require_relative 'locale' + +module Fastlane + # A class with static methods to manipulate lists of locales. + # + # Exposes various `Array` lists like all known locales, the Mag16, + # and convenience methods to turn list of Strings into list of Locales. + # + class Locales + ################### + ## Constants + ALL_KNOWN_LOCALES = [ + Locale.new(glotpress: 'ar', android: 'ar', google_play: 'ar'), + Locale.new(glotpress: 'de', android: 'de', google_play: 'de-DE'), + Locale.new(glotpress: 'en-gb', android: 'en-rGB', google_play: 'en-US'), + Locale.new(glotpress: 'es', android: 'es', google_play: 'es-ES'), + Locale.new(glotpress: 'fr-ca', android: 'fr-rCA', google_play: 'fr-CA'), + Locale.new(glotpress: 'fr', android: 'fr', google_play: 'fr-FR', ios: 'fr-FR', app_store: 'fr-FR'), + Locale.new(glotpress: 'he', android: 'he', google_play: 'iw-IL'), + Locale.new(glotpress: 'id', android: 'id', google_play: 'id'), + Locale.new(glotpress: 'it', android: 'it', google_play: 'it-IT'), + Locale.new(glotpress: 'ja', android: 'ja', google_play: 'ja-JP'), + Locale.new(glotpress: 'ko', android: 'ko', google_play: 'ko-KR'), + Locale.new(glotpress: 'nl', android: 'nl', google_play: 'nl-NL'), + Locale.new(glotpress: 'pl', android: 'pl', google_play: 'pl-PL'), + Locale.new(glotpress: 'pt-br', android: 'pt-rBR', google_play: 'pt-BR', ios: 'pt-BR', app_store: 'pt-BR'), + Locale.new(glotpress: 'ru', android: 'ru', google_play: 'ru-RU'), + Locale.new(glotpress: 'sr', android: 'sr', google_play: 'sr'), + Locale.new(glotpress: 'sv', android: 'sv', google_play: 'sv-SE'), + Locale.new(glotpress: 'th', android: 'th', google_play: 'th'), + Locale.new(glotpress: 'tr', android: 'tr', google_play: 'tr-TR'), + Locale.new(glotpress: 'vi', android: 'vi', google_play: 'vi'), + Locale.new(glotpress: 'zh-cn', android: 'zh-rCN', google_play: 'zh-CN', ios: 'zh-Hans', app_store: 'zh-Hans'), + Locale.new(glotpress: 'zh-tw', android: 'zh-rTW', google_play: 'zh-TW', ios: 'zh-Hant', app_store: 'zh-Hant'), + Locale.new(glotpress: 'az', android: 'az'), + Locale.new(glotpress: 'el', android: 'el'), + Locale.new(glotpress: 'es-mx', android: 'es-rMX'), + Locale.new(glotpress: 'es-cl', android: 'es-rCL'), + Locale.new(glotpress: 'gd', android: 'gd'), + Locale.new(glotpress: 'hi', android: 'hi'), + Locale.new(glotpress: 'hu', android: 'hu'), + Locale.new(glotpress: 'nb', android: 'nb'), + Locale.new(glotpress: 'pl', android: 'pl'), + Locale.new(glotpress: 'th', android: 'th'), + Locale.new(glotpress: 'uz', android: 'uz'), + Locale.new(glotpress: 'zh-tw', android: 'zh-rHK'), + Locale.new(glotpress: 'eu', android: 'eu'), + Locale.new(glotpress: 'ro', android: 'ro'), + Locale.new(glotpress: 'mk', android: 'mk'), + Locale.new(glotpress: 'en-au', android: 'en-rAU'), + Locale.new(glotpress: 'sr', android: 'sr'), + Locale.new(glotpress: 'sk', android: 'sk'), + Locale.new(glotpress: 'cy', android: 'cy'), + Locale.new(glotpress: 'da', android: 'da'), + Locale.new(glotpress: 'bg', android: 'bg'), + Locale.new(glotpress: 'sq', android: 'sq'), + Locale.new(glotpress: 'hr', android: 'hr'), + Locale.new(glotpress: 'cs', android: 'cs'), + Locale.new(glotpress: 'pt-br', android: 'pt-rBR'), + Locale.new(glotpress: 'en-ca', android: 'en-rCA'), + Locale.new(glotpress: 'ms', android: 'ms'), + Locale.new(glotpress: 'es-ve', android: 'es-rVE'), + Locale.new(glotpress: 'gl', android: 'gl'), + Locale.new(glotpress: 'is', android: 'is'), + Locale.new(glotpress: 'es-co', android: 'es-rCO'), + Locale.new(glotpress: 'kmr', android: 'kmr'), + ].freeze + + MAG16_GP_CODES = %w[ar de es fr he id it ja ko nl pt-br ru sv tr zh-cn zh-tw].freeze + + ################### + ## Static Methods + + class << self + # @return [Array] Array of all the known locales + # + def all + ALL_KNOWN_LOCALES + end + + # Define from_glotpress(code_or_list), from_android(code_or_list) … methods. + # + # Those can be used in the rare cases where you need to find locales via codes other than the glotpress ones, + # like searching by android locale code(s) or google_play locale code(s). + # In most cases, prefer using the `Locales[…]` method instead (with glotpress locale codes). + # + # @param [Array, String] list of locale codes to search for, or single value for single result + # @return [Array, Locale] list of found locales, or single locale if a single value was passed + # @raise [RuntimeException] if at least one of the locale codes was unknown + # + %i[glotpress android google_play ios app_store].each do |key| + define_method("from_#{key}!") { |args| search!(key, args) } + end + + # Return an Array based on glotpress locale codes + # + # @note If you need a single locale instead of an `Array`, you can use Locale[code] instead of Locales[code] + # + # @param [String..., Array] Arbitrary list of strings, either passed as a single array parameter, or as a vararg list of params + # @return [Array] The found locales. + # @raise [RuntimeException] if at least one of the locale codes was unknown + # + def [](*list) + # If we passed a variadic list of Strings, `*list` will make it a single `Array` and we were already good to go. + # But if we passed an Array, `*list` will make it an Array> of one item; taking `list.first` will go back to Array. + list = list.first if list.count == 1 && list.first.is_a?(Array) + from_glotpress!(list) + end + + # Return the subset of the 16 locales most of our apps are localized 100% (the ones we call the "Magnificent 16") + # + # @return [Array] List of the Mag16 locales + def mag16 + from_glotpress!(MAG16_GP_CODES) + end + + ################### + + private + + # Search the known locales for just the ones having the provided locale code, where the codes are expressed using the standard for the given key + def search!(key, code_or_list) + if code_or_list.is_a?(Array) + code_or_list.map { |code| search!(key, code) } + else # String + raise 'The locale code should not contain spaces. Did you accidentally use `%[]` instead of `%w[]` at call site?' if code_or_list.include?(' ') + + ALL_KNOWN_LOCALES.find { |locale| locale.send(key) == code_or_list } || not_found!(code_or_list, key) + end + end + + def not_found!(code, key) + raise "Unknown locale for #{key} code '#{code}'" + end + end + end +end diff --git a/spec/configure_helper_spec.rb b/spec/configure_helper_spec.rb index c45833f1f..00931f77b 100644 --- a/spec/configure_helper_spec.rb +++ b/spec/configure_helper_spec.rb @@ -16,7 +16,7 @@ # reasonable enough assumption to make for the real world usage of this # tool. Still, it would be nice to have proper handling of that # scenario at some point. - `git init --initial-branch main | git init` + `git init --initial-branch main || git init` expect(Fastlane::UI).to receive(:user_error!) diff --git a/spec/git_helper_spec.rb b/spec/git_helper_spec.rb index 4d8edf8a6..1b5ece305 100644 --- a/spec/git_helper_spec.rb +++ b/spec/git_helper_spec.rb @@ -26,12 +26,12 @@ end it 'can detect a valid git repository' do - `git init --initial-branch main | git init` + `git init --initial-branch main || git init` expect(Fastlane::Helper::GitHelper.is_git_repo?).to be true end it 'can detect a valid git repository from a child folder' do - `git init --initial-branch main | git init` + `git init --initial-branch main || git init` `mkdir -p a/b` Dir.chdir('./a/b') expect(Fastlane::Helper::GitHelper.is_git_repo?).to be true @@ -39,14 +39,14 @@ it 'can detect a valid git repository when given a path' do Dir.mktmpdir do |dir| - `git -C #{dir} init --initial-branch main | git -C #{dir} init` + `git -C #{dir} init --initial-branch main || git -C #{dir} init` expect(Fastlane::Helper::GitHelper.is_git_repo?(path: dir)).to be true end end it 'can detect a valid git repository when given a child folder path' do Dir.mktmpdir do |dir| - `git -C #{dir} init --initial-branch main | git -C #{dir} init` + `git -C #{dir} init --initial-branch main || git -C #{dir} init` path = File.join(dir, 'a', 'b') `mkdir -p #{path}` expect(Fastlane::Helper::GitHelper.is_git_repo?(path: path)).to be true @@ -54,13 +54,13 @@ end it 'can detect a repository with Git-lfs enabled' do - `git init --initial-branch main | git init` + `git init --initial-branch main || git init` `git lfs install` expect(Fastlane::Helper::GitHelper.has_git_lfs?).to be true end it 'can detect a repository without Git-lfs enabled' do - `git init --initial-branch main | git init` + `git init --initial-branch main || git init` `git lfs uninstall &>/dev/null` expect(Fastlane::Helper::GitHelper.is_git_repo?).to be true expect(Fastlane::Helper::GitHelper.has_git_lfs?).to be false @@ -186,7 +186,7 @@ end def setup_git_repo(dummy_file_path: nil, add_file_to_gitignore: false, commit_gitignore: false) - `git init --initial-branch main | git init` + `git init --initial-branch main || git init` `touch .gitignore` `git add .gitignore && git commit -m 'Add .gitignore'` diff --git a/spec/locale_spec.rb b/spec/locale_spec.rb new file mode 100644 index 000000000..d22ba63bd --- /dev/null +++ b/spec/locale_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe Fastlane::Locale do + it 'returns a single Locale if one was found' do + locale = described_class['fr'] + expect(locale).to be_instance_of(described_class) + expect(locale.glotpress).to eq('fr') + end + + it 'raises if no locale was found for a given code' do + expect do + described_class['invalidcode'] + end.to raise_error(RuntimeError, "Unknown locale for glotpress code 'invalidcode'") + end + + it 'can convert a Locale to a hash' do + h = described_class['fr'].to_h + expect(h).to eq({ glotpress: 'fr', android: 'fr', google_play: 'fr-FR', ios: 'fr-FR', app_store: 'fr-FR' }) + end +end diff --git a/spec/locales_spec.rb b/spec/locales_spec.rb new file mode 100644 index 000000000..269a1e9b0 --- /dev/null +++ b/spec/locales_spec.rb @@ -0,0 +1,103 @@ +require 'spec_helper' + +describe Fastlane::Locales do + shared_examples 'from_xxx' do |key, fr_code, pt_code| + let(:method_sym) { "from_#{key}!".to_sym } + + it 'can find a locale from a single code' do + fr_locale = described_class.send(method_sym, fr_code) + expect(fr_locale).to be_instance_of(Fastlane::Locale) + expect(fr_locale.glotpress).to eq('fr') + expect(fr_locale.android).to eq('fr') + expect(fr_locale.google_play).to eq('fr-FR') + end + + it 'can find locales from a multiple codes' do + locales = described_class.send(method_sym, [fr_code, pt_code]) + expect(locales).to be_instance_of(Array) + + expect(locales[0]).to be_instance_of(Fastlane::Locale) + expect(locales[0].glotpress).to eq('fr') + + expect(locales[1]).to be_instance_of(Fastlane::Locale) + expect(locales[1].glotpress).to eq('pt-br') + end + + it 'raises if one of the locale codes passed was not found' do + expect do + described_class.send(method_sym, [fr_code, 'invalidcode', pt_code]) + end.to raise_error(RuntimeError, "Unknown locale for #{key} code 'invalidcode'") + end + end + + describe 'from_glotpress!' do + include_examples 'from_xxx', :glotpress, 'fr', 'pt-br' + end + + describe 'from_android!' do + include_examples 'from_xxx', :android, 'fr', 'pt-rBR' + end + + describe 'from_google_play!' do + include_examples 'from_xxx', :google_play, 'fr-FR', 'pt-BR' + end + + describe 'from_ios!' do + include_examples 'from_xxx', :ios, 'fr-FR', 'pt-BR' + end + + describe 'from_app_store!' do + include_examples 'from_xxx', :app_store, 'fr-FR', 'pt-BR' + end + + describe 'subscript [] operator' do + it 'returns an Array even if a single one was passed' do + locales = described_class['fr'] + expect(locales).to be_instance_of(Array) + expect(locales.count).to equal(1) + expect(locales[0].glotpress).to eq('fr') + end + + it 'returns an Array if a list of vararg codes was passed' do + locales = described_class['fr', 'pt-br'] + expect(locales).to be_instance_of(Array) + expect(locales.count).to equal(2) + expect(locales[0]).to be_instance_of(Fastlane::Locale) + expect(locales[0].glotpress).to eq('fr') + expect(locales[1]).to be_instance_of(Fastlane::Locale) + expect(locales[1].glotpress).to eq('pt-br') + end + + it 'returns an Array if an Array of codes was passed' do + list = %w[fr pt-br] + locales = described_class[list] + expect(locales).to be_instance_of(Array) + expect(locales.count).to equal(2) + expect(locales[0]).to be_instance_of(Fastlane::Locale) + expect(locales[0].glotpress).to eq('fr') + expect(locales[1]).to be_instance_of(Fastlane::Locale) + expect(locales[1].glotpress).to eq('pt-br') + end + end + + it 'has only valid codes for known locales' do + described_class.all.each do |locale| + expect(locale.glotpress || 'xx').to match(/^[a-z]{2,3}(-[a-z]{2})?$/) + expect(locale.android || 'xx-rYY').to match(/^[a-z]{2,3}(-r[A-Z]{2})?$/) + expect(locale.google_play || 'xx-YY').to match(/^[a-z]{2,3}(-[A-Z]{2})?$/) + expect(locale.app_store || 'xx-Yy').to match(/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/) + expect(locale.ios || 'xx-Yy').to match(/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/) + end + end + + it 'returns exactly 16 Mag16 locales' do + expect(described_class.mag16.count).to eq(16) + end + + it 'is easy to do Locale subset intersections' do + mag16_except_pt = described_class.mag16 - described_class['pt-br'] + expect(mag16_except_pt.count).to equal(15) + expect(mag16_except_pt.find { |l| l.glotpress == 'pt-br' }).to be_nil + expect(mag16_except_pt.find { |l| l.glotpress == 'fr' }).not_to be_nil + end +end