diff --git a/README.md b/README.md index a085a64..1622022 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ Command line application for [POEditor](https://poeditor.com). $ [sudo] gem install poeditor-cli ``` +Install from given repo and branch: + +```console +$ [sudo] gem specific_install https://github.com/splendo/poeditor-cli -b kotlin-plurals +``` + ## Usage A single command will do almost everything for you. @@ -121,6 +127,30 @@ path_replace: en: myapp/src/main/res/values/strings.xml ``` +* Multiplatform project + +For Kotlin and [Kaluga](https://github.com/splendo/kaluga) + + ```yaml + api_key: $POEDITOR_API_KEY + project_id: $POEDITOR_PROJECT_ID + type: kotlin_strings + languages: [en] + + header: "package my.project.models" + path: 'shared/src/commonMain/kotlin/my/project/models/Strings.kt' + path_plural: 'shared/src/commonMain/kotlin/my/project/models/Plurals.kt' + ``` + +Where header is your package for generated objects. + +Usage: + + ```kotlin + val myString = Strings.myAwesomeString + val myPlural = Plurals.myAwesomePlural(someIntValue) + ``` + * Projects using gettext ```yaml diff --git a/lib/poeditor/commands/pull_command.rb b/lib/poeditor/commands/pull_command.rb index 8a2af67..4971688 100644 --- a/lib/poeditor/commands/pull_command.rb +++ b/lib/poeditor/commands/pull_command.rb @@ -1,9 +1,7 @@ module POEditor class PullCommand def run(argv) - UI.puts "Reading configuration" configuration = get_configuration(argv) - UI.puts configuration client = POEditor::Core.new(configuration) client.pull() end @@ -43,10 +41,15 @@ def get_configuration(argv) type: get_or_raise(yaml, "type"), tags: yaml["tags"], filters: yaml["filters"], + header: yaml["header"], languages: get_or_raise(yaml, "languages"), language_alias: yaml["language_alias"], path: get_or_raise(yaml, "path"), + path_plural: yaml["path_plural"], path_replace: yaml["path_replace"], + context_path: yaml["context_path"], + context_path_plural: yaml["context_path_plural"], + context_path_replace: yaml["context_path_replace"] ) end diff --git a/lib/poeditor/configuration.rb b/lib/poeditor/configuration.rb index de65e81..c0d97a3 100644 --- a/lib/poeditor/configuration.rb +++ b/lib/poeditor/configuration.rb @@ -15,6 +15,9 @@ class Configuration # @return [Array] Filters by 'translated', 'untranslated', 'fuzzy', 'not_fuzzy', 'automatic', 'not_automatic', 'proofread', 'not_proofread' (optional) attr_accessor :filters + + # @return [Hash{Sting => String}] Header (optional) + attr_accessor :header # @return [Array] The languages codes attr_accessor :languages @@ -25,23 +28,42 @@ class Configuration # @return [String] The path template attr_accessor :path + # @return [String] The plural path template + attr_accessor :path_plural + # @return [Hash{Sting => String}] The path replacements attr_accessor :path_replace - def initialize(api_key:, project_id:, type:, tags:nil, - filters:nil, languages:, language_alias:nil, - path:, path_replace:nil) + # @return [String] The context path template + attr_accessor :context_path + + # @return [String] The plural context path template + attr_accessor :context_path_plural + + # @return [Hash{Sting => String}] The context path replacements + attr_accessor :context_path_replace + + def initialize(api_key:, project_id:, type:, tags:nil, filters:nil, + header:nil, languages:, language_alias:nil, + path:, path_plural: nil, path_replace:nil, + context_path:nil, context_path_plural:nil, context_path_replace:nil) @api_key = from_env(api_key) @project_id = from_env(project_id.to_s) @type = type @tags = tags || [] @filters = filters || [] + @header = header @languages = languages @language_alias = language_alias || {} @path = path + @path_plural = path_plural || {} @path_replace = path_replace || {} + + @context_path = context_path + @context_path_plural = context_path_plural || {} + @context_path_replace = context_path_replace || {} end def from_env(value) @@ -58,10 +80,15 @@ def to_s "type" => self.type, "tags" => self.tags, "filters" => self.filters, + "header" => self.header, "languages" => self.languages, "language_alias" => self.language_alias, "path" => self.path, + "path_plural" => self.path_plaural, "path_replace" => self.path_replace, + "context_path" => self.context_path, + "context_path_plural" => self.context_path_plural, + "context_path_replace" => self.context_path_replace, } YAML.dump(values)[4..-2] .each_line diff --git a/lib/poeditor/core.rb b/lib/poeditor/core.rb index 26e252c..aa6b4d0 100644 --- a/lib/poeditor/core.rb +++ b/lib/poeditor/core.rb @@ -40,14 +40,8 @@ def pull() :language => language, :type => @configuration.type, :tags => @configuration.tags, - :filters => @configuration.filters) - write(language, content) - - for alias_to, alias_from in @configuration.language_alias - if language == alias_from - write(alias_to, content) - end - end + :filters => @configuration.filters, + :header => @configuration.header) end end @@ -59,13 +53,14 @@ def pull() # @param type [String] # @param tags [Array] # @param filters [Array] + # @param header [String] # # @return Downloaded translation content - def export(api_key:, project_id:, language:, type:, tags:nil, filters:nil) + def export(api_key:, project_id:, language:, type:, tags:nil, filters:nil, header:nil) options = { "id" => project_id, "language" => convert_to_poeditor_language(language), - "type" => type, + "type" => "json", "tags" => (tags || []).join(","), "filters" => (filters || []).join(","), } @@ -83,16 +78,228 @@ def export(api_key:, project_id:, language:, type:, tags:nil, filters:nil) case type when "apple_strings" content.gsub!(/(%(\d+\$)?)s/, '\1@') # %s -> %@ - when "android_strings" + when "android_strings", "kotlin_strings" content.gsub!(/(%(\d+\$)?)@/, '\1s') # %@ -> %s end - unless content.end_with? "\n" - content += "\n" + json = JSON.parse content + groups = json.group_by { |json| json['context'] } + placeholderItems = [] + groups.each do |context, json| + if context == "" + json.each { |item| + definition = item["definition"] + if definition =~ /\$([a-z_]{3,})/ + placeholderItems << item + end + } + end end + groups.each do |context, json| + if context != "" + if @configuration.context_path == nil + next # if context path is not defined, skip saving context strings + end + copyPlaceholderItems(placeholderItems, context, json) + end + + case type + when "apple_strings" + singularContent = singularAppleStrings(json) + write(context, language, singularContent, :singular) + + if @configuration.path_plural != {} + pluralContent = pluralAppleStrings(json) + write(context, language, pluralContent, :plural) + end + when "android_strings" + content = androidStrings(json) + path = path_for_context_language(context, language) + write(context, language, content, :singular) + when "kotlin_strings" + content = kotlinStrings(json, header) + path = path_for_context_language(context, language) + write(context, language, content, :singular) + if @configuration.path_plural != {} + pluralContent = pluralKotlinStrings(json, header) + write(context, language, pluralContent, :plural) + end + end + end + end + + # Copy items with replaced placeholders to context json + # + # @param items [JSON] + # @param context String + # @param contextJson JSON + # + def copyPlaceholderItems(items, context, contextJson) + items.each { |item| + term = item["term"] + definition = item["definition"].gsub(/\$([a-z_]{3,})/) { |placeholder| + definitionForPlaceholder(placeholder, contextJson) + } + + if !contextJson.find { |e| e["term"] == term } + newItem = { + "term" => term, + "definition" => definition, + "context" => context + } + contextJson << newItem + end + } + end + + def definitionForPlaceholder(placeholder, contextJson) + term = placeholder.delete_prefix("$") + contextJson.each { |item| + if item["term"] == term + return item["definition"] + end + } + return placeholder + end + + def singularAppleStrings(json) + content = "" + json.each { |item| + term = item["term"] + definition = item["definition"] + if definition.instance_of? String + value = definition.gsub("\"", "\\\"") + content << "\"#{term}\" = \"#{value}\";\n" + end + } + return content + end + + def pluralAppleStrings(json) + content = " + + +" + json.each { |item| + term = item["term"] + definition = item["definition"] + if definition.instance_of? Hash + content << " + #{term} + + NSStringLocalizedFormatKey + %\#@VARIABLE@ + VARIABLE + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d" + ["zero", "one", "two", "few", "many", "other"].each { |form| + if definition[form] != nil + value = definition[form].gsub("\"", "\\\"") + content << " + #{form} + #{value}" + else + content << " + #{form} + " + end + } + content << " + + " + end + } + content << " + +" return content end + def androidStrings(json) + content = "\n\n" + json.each { |item| + definition = item["definition"] + if definition != nil + if definition.instance_of? String + value = definition.gsub("\"", "\\\"").gsub("&", "&") + content << " \"#{value}\"\n" + else + content << " \n" + ["zero", "one", "two", "few", "many", "other"].each { |form| + pluralItem = androidPluralItem(definition, form) + if pluralItem != nil + content << pluralItem + end + } + content << " \n" + end + end + } + content << "\n" + return content + end + + def kotlinStrings(json, header) + content = "" + if header != nil + content << "#{header}\n\n" + end + content << "import com.splendo.kaluga.resources.localized +import kotlin.native.concurrent.ThreadLocal + +@ThreadLocal +object Strings { +" + json.each { |item| + term = item["term"] + definition = item["definition"] + if definition.instance_of? String + content << " val #{snakeCaseToCamelCase(term)} by lazy { \"#{term}\".localized() }\n" + end + } + content << "}\n" + return content + end + + def pluralKotlinStrings(json, header) + content = "" + if header != nil + content << "#{header}\n\n" + end + content << "import com.splendo.kaluga.resources.quantity +import kotlin.native.concurrent.ThreadLocal + +@ThreadLocal +object Plurals { +" + json.each { |item| + term = item["term"] + definition = item["definition"] + if definition.instance_of? Hash + content << " fun #{snakeCaseToCamelCase(term)}(value: Int): String { return \"#{term}\".quantity(value) }\n" + end + } + content << "}\n" + return content + end + + def snakeCaseToCamelCase(text) + words = text.split('_') + return words[0] + words[1..-1].collect(&:capitalize).join + end + + def androidPluralItem(definition, form) + if definition[form] != nil + value = definition[form].gsub("\"", "\\\"").gsub("&", "&") + return " \"#{value}\"\n" + else + return nil + end + end + def convert_to_poeditor_language(language) if language.downcase.match(/zh.+(hans|cn)/) 'zh-CN' @@ -103,23 +310,64 @@ def convert_to_poeditor_language(language) end end + def write(context, language, content, plurality) + write_content_to_path(context, language, content, plurality) + for alias_to, alias_from in @configuration.language_alias + if language == alias_from + write_content_to_path(context, alias_to, content, plurality) + end + end + end + # Write translation file - def write(language, content) - path = path_for_language(language) - unless File.exist?(path) - raise POEditor::Exception.new "#{path} doesn't exist" + def write_content_to_path(context, language, content, plurality) + case plurality + when :singular + path = path_for_context_language(context, language) + when :plural + path = path_plural_for_context_language(context, language) + end + + unless path != nil + raise POEditor::Exception.new "Undefined context path" + end + + if File.exist?(path) + File.write(path, content) + UI.puts " #{"\xe2\x9c\x93".green} Saved at '#{path}'" + else + UI.puts " #{"\xe2\x9c\x97".red} File not found '#{path}'" end - File.write(path, content) - UI.puts " #{"\xe2\x9c\x93".green} Saved at '#{path}'" end - def path_for_language(language) - if @configuration.path_replace[language] - @configuration.path_replace[language] + def path_for_context_language(context, language) + if context == nil || context == "" + if @configuration.path_replace[language] + path = @configuration.path_replace[language] + else + path = @configuration.path.gsub("{LANGUAGE}", language) + end else - @configuration.path.gsub("{LANGUAGE}", language) + if @configuration.context_path_replace[language] + path = @configuration.context_path_replace[language].gsub("{CONTEXT}", context) + elsif @configuration.context_path + path = @configuration.context_path.gsub("{LANGUAGE}", language).gsub("{CONTEXT}", context) + else + return nil + end end end + def path_plural_for_context_language(context, language) + if context == nil || context == "" + path = @configuration.path_plural.gsub("{LANGUAGE}", language) + else + if @configuration.context_path_plural + path = @configuration.context_path_plural.gsub("{LANGUAGE}", language).gsub("{CONTEXT}", context) + else + return nil + end + end + end end end diff --git a/test/test.rb b/test/test.rb index 089c401..b63e360 100644 --- a/test/test.rb +++ b/test/test.rb @@ -1,7 +1,7 @@ require "minitest/autorun" require "webmock/minitest" -require "helper" +require_relative "helper" require_relative "../lib/poeditor" class Test < Minitest::Test diff --git a/test/test_core.rb b/test/test_core.rb index 0795eaf..0bef02e 100644 --- a/test/test_core.rb +++ b/test/test_core.rb @@ -8,24 +8,63 @@ def clean def setup clean() - for language in ["en", "ko", "ja", "zh", "zh-Hans", "zh-Hant"] + + contexts = ["context1", "context2"] + ios_languages = ["en", "ja", "ko", "nl", "zh", "zh-Hans", "zh-Hant"] + android_languages = ["en", "ja", "ko", "nl", "zh", "zh-rCN", "zh-rTW"] + base_language = "en" + + # iOS + for language in ios_languages FileUtils.mkdir_p("TestProj/#{language}.lproj") File.write("TestProj/#{language}.lproj/Localizable.strings", "") + File.write("TestProj/#{language}.lproj/Localizable.stringsdict", "") + for context in contexts + File.write("TestProj/#{language}.lproj/#{context}.strings", "") + File.write("TestProj/#{language}.lproj/#{context}.stringsdict", "") + end end - for language in ["en", "ko", "ja", "zh", "zh-rCN", "zh-rTW"] - if language == "en" - dirname = "TestProj/values" - else - dirname = "TestProj/values-#{language}" + + # Android + for language in android_languages + if language == base_language + FileUtils.mkdir_p("TestProj/values") + File.write("TestProj/values/strings.xml", "") + else + FileUtils.mkdir_p("TestProj/values-#{language}") + File.write("TestProj/values-#{language}/strings.xml", "") + end + for context in contexts + if language == base_language + FileUtils.mkdir_p("TestProj/#{context}/values") + File.write("TestProj/#{context}/values/strings.xml", "") + else + FileUtils.mkdir_p("TestProj/#{context}/values-#{language}") + File.write("TestProj/#{context}/values-#{language}/strings.xml", "") + end end - FileUtils.mkdir_p(dirname) - File.write("#{dirname}/strings.xml", "") end - stub_api_export "en", %{"greeting" = "Hi, %s!";} - stub_api_export "ko", %{"greeting" = "%s님 안녕하세요!";} - stub_api_export "zh-CN", %{"greeting" = "Simplified 你好, %s!";} - stub_api_export "zh-TW", %{"greeting" = "Traditional 你好, %s!";} + stub_api_export "en", %{[ + {"term": "greeting", "definition": "Hi, %s!", "context": ""}, + {"term": "welcome", "definition": "Welcome!", "context": ""}, + {"term": "welcome", "definition": "Welcome to App 1!", "context": "context1"}, + {"term": "welcome", "definition": "Welcome to App 2!", "context": "context2"}, + {"term": "thank_you", "definition": "Thank you for downloading $app_name.", "context": ""}, + {"term": "app_name", "definition": "App 1 in 🇬🇧", "context": "context1"}, + {"term": "app_name", "definition": "App 2 in 🇬🇧", "context": "context2"} + ]} + stub_api_export "nl", %{[ + {"term": "welcome", "definition": "Welkom!", "context": ""}, + {"term": "welcome", "definition": "Welkom bij App 1!", "context": "context1"}, + {"term": "welcome", "definition": "Welkom bij App 2!", "context": "context2"}, + {"term": "thank_you", "definition": "Bedankt voor het downloaden van $app_name.", "context": ""}, + {"term": "app_name", "definition": "App 1 in 🇳🇱", "context": "context1"}, + {"term": "app_name", "definition": "App 2 in 🇳🇱", "context": "context2"} + ]} + stub_api_export "ko", %{[{"term": "greeting", "definition": "%s님 안녕하세요!", "context": ""}]} + stub_api_export "zh-CN", %{[{"term": "greeting", "definition": "Simplified 你好, %s!", "context": ""}]} + stub_api_export "zh-TW", %{[{"term": "greeting", "definition": "Traditional 你好, %s!", "context": ""}]} end def teardown @@ -33,9 +72,9 @@ def teardown clean() end - def get_client(type:, - languages:, language_alias:nil, - path:, path_replace:nil) + def get_client(type:, languages:, language_alias:nil, + path:, path_plural:nil, path_replace:nil, + context_path:nil, context_path_plural:nil, context_path_replace:nil) configuration = POEditor::Configuration.new( :api_key => "TEST", :project_id => 12345, @@ -44,8 +83,12 @@ def get_client(type:, :filters => nil, :languages => languages, :language_alias => language_alias, - :path_replace => path_replace, :path => path, + :path_plural => path_plural, + :path_replace => path_replace, + :context_path => context_path, + :context_path_plural => context_path_plural, + :context_path_replace => context_path_replace ) POEditor::Core.new(configuration) end @@ -114,4 +157,49 @@ def test_pull_path_replace File.read("TestProj/values/strings.xml") end + def test_context + client = get_client( + :type => "android_strings", + :languages => ["en", "nl"], + :path => "TestProj/values-{LANGUAGE}/strings.xml", + :path_replace => {"en" => "TestProj/values/strings.xml"}, + :context_path => "TestProj/{CONTEXT}/values-{LANGUAGE}/strings.xml", + :context_path_replace => {"en" => "TestProj/{CONTEXT}/values/strings.xml"} + ) + client.pull() + + assert_match /Welcome!/, File.read("TestProj/values/strings.xml") + assert_match /Welcome to App 1!/, File.read("TestProj/context1/values/strings.xml") + assert_match /Welcome to App 2!/, File.read("TestProj/context2/values/strings.xml") + + assert_match /Welkom!/, File.read("TestProj/values-nl/strings.xml") + assert_match /Welkom bij App 1!/, File.read("TestProj/context1/values-nl/strings.xml") + assert_match /Welkom bij App 2!/, File.read("TestProj/context2/values-nl/strings.xml") + + assert(!/Welcome!/.match(File.read("TestProj/values-nl/strings.xml"))) + assert(!/Welcome!/.match(File.read("TestProj/context1/values/strings.xml"))) + assert(!/Welcome!/.match(File.read("TestProj/context1/values-nl/strings.xml"))) + + assert(!/Welkom!/.match(File.read("TestProj/values/strings.xml"))) + assert(!/Welkom!/.match(File.read("TestProj/context1/values/strings.xml"))) + assert(!/Welkom!/.match(File.read("TestProj/context1/values-nl/strings.xml"))) + end + + def test_placeholders + client = get_client( + :type => "android_strings", + :languages => ["en", "nl"], + :path => "TestProj/values-{LANGUAGE}/strings.xml", + :path_replace => {"en" => "TestProj/values/strings.xml"}, + :context_path => "TestProj/{CONTEXT}/values-{LANGUAGE}/strings.xml", + :context_path_replace => {"en" => "TestProj/{CONTEXT}/values/strings.xml"} + ) + client.pull() + + assert_match /Thank you for downloading App 1 in 🇬🇧./, File.read("TestProj/context1/values/strings.xml") + assert_match /Thank you for downloading App 2 in 🇬🇧./, File.read("TestProj/context2/values/strings.xml") + assert_match /Bedankt voor het downloaden van App 1 in 🇳🇱./, File.read("TestProj/context1/values-nl/strings.xml") + assert_match /Bedankt voor het downloaden van App 2 in 🇳🇱./, File.read("TestProj/context2/values-nl/strings.xml") + end + end