Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce Locale helper models #296

Draft
wants to merge 10 commits into
base: trunk
Choose a base branch
from
2 changes: 2 additions & 0 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion lib/fastlane/plugin/wpmreleasetoolkit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions lib/fastlane/plugin/wpmreleasetoolkit/models/locale.rb
Original file line number Diff line number Diff line change
@@ -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
137 changes: 137 additions & 0 deletions lib/fastlane/plugin/wpmreleasetoolkit/models/locales.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
require_relative 'locale'

module Fastlane
# A class with static methods to manipulate lists of locales.
#
# Exposes various `Array<Locale>` 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 = [
Copy link
Contributor Author

@AliSoftware AliSoftware Aug 10, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WIP: To be completed with ios: and app_store: values for each locale; we'll also need to double-check all the tuples to ensure we defined the correct ones.

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<Locale>] 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>, String] list of locale codes to search for, or single value for single result
# @return [Array<Locale>, 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<Locale> based on glotpress locale codes
#
# @note If you need a single locale instead of an `Array<Locale>`, you can use Locale[code] instead of Locales[code]
#
# @param [String..., Array<String>] Arbitrary list of strings, either passed as a single array parameter, or as a vararg list of params
# @return [Array<Locale>] 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<String>` and we were already good to go.
# But if we passed an Array, `*list` will make it an Array<Array<String>> of one item; taking `list.first` will go back to Array<String>.
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<Locale>] 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
2 changes: 1 addition & 1 deletion spec/configure_helper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!)

Expand Down
14 changes: 7 additions & 7 deletions spec/git_helper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,41 +26,41 @@
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
end

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
end
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
Expand Down Expand Up @@ -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'`

Expand Down
20 changes: 20 additions & 0 deletions spec/locale_spec.rb
Original file line number Diff line number Diff line change
@@ -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
103 changes: 103 additions & 0 deletions spec/locales_spec.rb
Original file line number Diff line number Diff line change
@@ -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<Locale> 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<Locale> 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<Locale> if an Array<String> 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