From d386cb8e0dc9cc2fb1111c0942af8b829a94069e Mon Sep 17 00:00:00 2001 From: Bruno Coelho <4brunu@gmail.com> Date: Thu, 28 Aug 2025 21:56:59 +0100 Subject: [PATCH 1/2] Add support for Xcode String Catalog --- README.md | 3 + lib/babelish.rb | 2 + lib/babelish/commandline.rb | 2 + lib/babelish/csv2base.rb | 1 + lib/babelish/csv2xcstrings.rb | 127 +++++++++++++ lib/babelish/xcstrings2csv.rb | 172 ++++++++++++++++++ .../commands/test_command_csv2xcstrings.rb | 112 ++++++++++++ .../commands/test_command_xcstrings2csv.rb | 122 +++++++++++++ test/babelish/test_csv2xcstrings.rb | 148 +++++++++++++++ test/babelish/test_xcstrings2csv.rb | 140 ++++++++++++++ test/data/test_sample.xcstrings | 71 ++++++++ 11 files changed, 900 insertions(+) create mode 100644 lib/babelish/csv2xcstrings.rb create mode 100644 lib/babelish/xcstrings2csv.rb create mode 100644 test/babelish/commands/test_command_csv2xcstrings.rb create mode 100644 test/babelish/commands/test_command_xcstrings2csv.rb create mode 100644 test/babelish/test_csv2xcstrings.rb create mode 100644 test/babelish/test_xcstrings2csv.rb create mode 100644 test/data/test_sample.xcstrings diff --git a/README.md b/README.md index 46f38a4..771f09e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Originally created to deal with localizedStrings files (aka *CSV-to-iOS-Localizable.strings-converter*), this command tool now converts a csv file of translations into the below file formats and vice-versa: * .strings (iOS) +* .xcstrings (iOS String Catalog - Xcode 15+) * .xml (Android) * .json * .php @@ -34,6 +35,7 @@ Commands: babelish csv2json # Convert CSV file to .json babelish csv2php # Convert CSV file to .php babelish csv2strings # Convert CSV file to .strings + babelish csv2xcstrings # Convert CSV file to .xcstrings (iOS String Catalog) babelish csv_download # Download Google Spreadsheet containing translations babelish help [COMMAND] # Describe available commands or one specific command babelish init # Create a configuration file from template @@ -42,6 +44,7 @@ Commands: babelish php2csv # Convert .php files to CSV file babelish strings2csv # Convert .strings files to CSV file babelish version # Display current version + babelish xcstrings2csv # Convert .xcstrings files to CSV file Options: [--verbose], [--no-verbose] diff --git a/lib/babelish.rb b/lib/babelish.rb index b5004fa..201c2e3 100644 --- a/lib/babelish.rb +++ b/lib/babelish.rb @@ -14,6 +14,7 @@ def to_utf8 require "babelish/csv2android" require "babelish/csv2php" require "babelish/csv2json" +require "babelish/csv2xcstrings" # To CSV require "babelish/base2csv" @@ -21,6 +22,7 @@ def to_utf8 require "babelish/android2csv" require "babelish/php2csv" require "babelish/json2csv" +require "babelish/xcstrings2csv" # General require "babelish/language" diff --git a/lib/babelish/commandline.rb b/lib/babelish/commandline.rb index 29ce582..8c41a90 100644 --- a/lib/babelish/commandline.rb +++ b/lib/babelish/commandline.rb @@ -8,6 +8,7 @@ class Commandline < Thor CSVCLASSES = [ {:name => "CSV2Strings", :ext => ".strings"}, + {:name => "CSV2XCStrings", :ext => ".xcstrings"}, {:name => "CSV2Android", :ext => ".xml"}, {:name => "CSV2JSON", :ext => ".json"}, {:name => "CSV2Php", :ext => ".php"}, @@ -41,6 +42,7 @@ class Commandline < Thor BASECLASSES = [ {:name => "Strings2CSV", :ext => ".strings"}, + {:name => "XCStrings2CSV", :ext => ".xcstrings"}, {:name => "Android2CSV", :ext => ".xml"}, {:name => "JSON2CSV", :ext => ".json"}, {:name => "Php2CSV", :ext => ".php"}, diff --git a/lib/babelish/csv2base.rb b/lib/babelish/csv2base.rb index 35df277..06b6557 100644 --- a/lib/babelish/csv2base.rb +++ b/lib/babelish/csv2base.rb @@ -1,4 +1,5 @@ require 'pathname' +require 'thor' module Babelish class Csv2Base attr_accessor :output_dir, :output_basename diff --git a/lib/babelish/csv2xcstrings.rb b/lib/babelish/csv2xcstrings.rb new file mode 100644 index 0000000..c27cc8f --- /dev/null +++ b/lib/babelish/csv2xcstrings.rb @@ -0,0 +1,127 @@ +require 'json' +require_relative 'csv2base' + +module Babelish + class CSV2XCStrings < Csv2Base + attr_accessor :languages + + def language_filepaths(language) + require 'pathname' + filepaths = [] + if language.regions.empty? + filepaths << Pathname.new(@output_dir) + "#{output_basename}.#{extension}" + else + language.regions.each do |region| + filepaths << Pathname.new(@output_dir) + "#{output_basename}.#{extension}" + end + end + filepaths + end + + def extension + "xcstrings" + end + + def output_basename + @output_basename || 'Localizable' + end + + def write_content + info = "List of created files:\n" + count = 0 + + file_path = @ignore_lang_path ? default_filepath : Pathname.new(@output_dir) + "#{output_basename}.#{extension}" + file = create_file_from_path(file_path) + + xcstrings_data = { + "sourceLanguage" => determine_source_language, + "strings" => {}, + "version" => "1.0" + } + + keys.each do |key| + next if key.nil? || key.empty? + + string_entry = { + "extractionState" => "manual" + } + + if @comments[key] && !@comments[key].empty? + string_entry["comment"] = @comments[key] + end + + localizations = {} + @languages.each do |language| + next if language.nil? || language.content.nil? + + value = language.content[key] + next if value.nil? || value.empty? + + if language.regions && !language.regions.empty? + language.regions.each do |region| + lang_code = "#{language.code}-#{region}" + next if lang_code.nil? + + localizations[lang_code] = { + "stringUnit" => { + "state" => "translated", + "value" => value + } + } + end + else + lang_code = language.code + next if lang_code.nil? + + localizations[lang_code] = { + "stringUnit" => { + "state" => "translated", + "value" => value + } + } + end + end + + string_entry["localizations"] = localizations unless localizations.empty? + xcstrings_data["strings"][key] = string_entry unless localizations.empty? + end + + file.write(JSON.pretty_generate(xcstrings_data)) + info += "- #{File.absolute_path(file)}\n" + count += 1 + file.close + + info = "Created #{count} files.\n" + info + return info + end + + private + + def determine_source_language + source_lang = 'en' + + @languages.each do |language| + next if language.nil? + + if language.regions.any? { |code| code == 'en' || code.start_with?('en-') } + source_lang = 'en' + break + elsif language.code == 'en' || language.code&.start_with?('en-') + source_lang = 'en' + break + end + end + + if source_lang == 'en' && !@languages.any? { |lang| lang&.code == 'en' || lang&.regions&.include?('en') } + first_lang = @languages.find { |lang| !lang.nil? && !lang.regions.empty? } + source_lang = first_lang&.regions&.first || first_lang&.code || 'en' + end + + source_lang + end + + def hash_to_output(content = {}) + "" + end + end +end \ No newline at end of file diff --git a/lib/babelish/xcstrings2csv.rb b/lib/babelish/xcstrings2csv.rb new file mode 100644 index 0000000..66c7ef1 --- /dev/null +++ b/lib/babelish/xcstrings2csv.rb @@ -0,0 +1,172 @@ +require 'json' +require 'set' +require_relative 'base2csv' + +module Babelish + class XCStrings2CSV < Base2Csv + attr_accessor :csv_filename, :headers, :filenames, :default_lang + + def initialize(args = {:filenames => []}) + super(args) + end + + # Load all strings of a given file + def load_strings(xcstrings_filename) + strings = {} + comments = {} + + begin + file_content = File.read(xcstrings_filename) + xcstrings_data = JSON.parse(file_content) + + lang_code = extract_language_from_filename(xcstrings_filename, xcstrings_data) + + xcstrings_data["strings"]&.each do |key, entry| + comments[key] = entry["comment"] if entry["comment"] + + localizations = entry["localizations"] || {} + + translation = find_translation_for_language(localizations, lang_code, xcstrings_data["sourceLanguage"]) + + if translation + strings[key] = translation + end + end + + rescue JSON::ParserError => e + puts "Error parsing xcstrings file #{xcstrings_filename}: #{e.message}" + return [{}, {}] + rescue => e + puts "Error reading xcstrings file #{xcstrings_filename}: #{e.message}" + return [{}, {}] + end + + [strings, comments] + end + + private + + def extract_language_from_filename(filename, xcstrings_data) + source_language = xcstrings_data["sourceLanguage"] || "en" + + basename = File.basename(filename, ".xcstrings") + if basename.include?("-") + potential_lang = basename.split("-").last + return potential_lang if potential_lang.length == 2 || potential_lang.include?("_") + end + + source_language + end + + def find_translation_for_language(localizations, target_lang, source_lang) + if localizations[target_lang]&.dig("stringUnit", "value") + return localizations[target_lang]["stringUnit"]["value"] + end + + base_lang = target_lang.split("-").first + if localizations[base_lang]&.dig("stringUnit", "value") + return localizations[base_lang]["stringUnit"]["value"] + end + + localizations.each do |lang_code, localization| + if lang_code.start_with?(base_lang + "-") && localization.dig("stringUnit", "value") + return localization["stringUnit"]["value"] + end + end + + if localizations[source_lang]&.dig("stringUnit", "value") + return localizations[source_lang]["stringUnit"]["value"] + end + + localizations.each do |lang_code, localization| + value = localization.dig("stringUnit", "value") + return value if value + end + + nil + end + + public + def convert(write_to_file = true) + all_strings = {} + all_keys = Set.new + all_comments = {} + all_languages = Set.new + + @filenames.each do |fname| + begin + file_content = File.read(fname) + xcstrings_data = JSON.parse(file_content) + + xcstrings_data["strings"]&.each do |key, entry| + all_keys.add(key) + all_comments[key] = entry["comment"] if entry["comment"] + + entry["localizations"]&.each do |lang_code, localization| + all_languages.add(lang_code) + end + end + rescue => e + puts "Error processing #{fname}: #{e.message}" + next + end + end + + all_languages.each do |lang_code| + lang_strings = {} + + @filenames.each do |fname| + begin + file_content = File.read(fname) + xcstrings_data = JSON.parse(file_content) + + xcstrings_data["strings"]&.each do |key, entry| + localizations = entry["localizations"] || {} + translation = find_translation_for_language(localizations, lang_code, xcstrings_data["sourceLanguage"]) + lang_strings[key] = translation if translation + end + rescue => e + next + end + end + + all_strings[lang_code] = lang_strings + end + + if write_to_file + puts "Creating #{@csv_filename}" + create_csv_file_for_xcstrings(all_keys.to_a, all_strings, all_comments, all_languages.to_a) + else + return all_keys.to_a, all_strings + end + end + + def create_csv_file_for_xcstrings(keys, strings, comments, languages) + require 'csv' + + raise "csv_filename must not be nil" unless @csv_filename + + headers = ["Variables"] + languages.sort.each { |lang| headers << lang } + headers << "Comments" if !comments.nil? && !comments.empty? + + CSV.open(@csv_filename, "wb") do |csv| + csv << headers + + keys.each do |key| + line = [key] + + languages.sort.each do |lang| + value = strings[lang] ? strings[lang][key] : "" + line << (value || "") + end + + line << comments[key] if comments && comments[key] + csv << line + end + + puts "Done" + end + end + end +end \ No newline at end of file diff --git a/test/babelish/commands/test_command_csv2xcstrings.rb b/test/babelish/commands/test_command_csv2xcstrings.rb new file mode 100644 index 0000000..53d94dc --- /dev/null +++ b/test/babelish/commands/test_command_csv2xcstrings.rb @@ -0,0 +1,112 @@ +require 'test_helper' + +class TestCommandCSV2XCStrings < Test::Unit::TestCase + def test_csv2xcstrings_command_help + assert_nothing_raised do + require "babelish/commandline" + Commandline.start(["help", "csv2xcstrings"]) + end + end + + def test_csv2xcstrings_command_basic_conversion + output = nil + assert_nothing_raised do + Dir.chdir(File.dirname(__FILE__) + "/../../..") do + # Capture STDOUT to check command output + original_stdout = $stdout + $stdout = StringIO.new + + require "babelish/commandline" + + # Run the command + Commandline.start([ + "csv2xcstrings", + "-i", "test/data/test_data.csv", + "-L", "English:en" + ]) + + output = $stdout.string + $stdout = original_stdout + end + end + + assert output.include?("Created"), "Should show creation message" + assert output.include?(".xcstrings"), "Should mention xcstrings file" + + # Clean up + system("rm -f Localizable.xcstrings") + end + + def test_csv2xcstrings_command_with_multiple_languages + output = nil + assert_nothing_raised do + Dir.chdir(File.dirname(__FILE__) + "/../../..") do + original_stdout = $stdout + $stdout = StringIO.new + + require "babelish/commandline" + + Commandline.start([ + "csv2xcstrings", + "-i", "test/data/test_data_multiple_langs.csv", + "-L", "English:en", "French:fr", "German:de", "Spanish:es", + "-o", "TestApp" + ]) + + output = $stdout.string + $stdout = original_stdout + end + end + + assert output.include?("Created"), "Should show creation message" + assert File.exist?("TestApp.xcstrings"), "Should create TestApp.xcstrings" + + # Verify content + content = JSON.parse(File.read("TestApp.xcstrings")) + assert_equal "1.0", content["version"] + assert_equal "en", content["sourceLanguage"] + assert content["strings"].is_a?(Hash) + + # Clean up + system("rm -f TestApp.xcstrings") + end + + def test_csv2xcstrings_command_with_comments + output = nil + assert_nothing_raised do + Dir.chdir(File.dirname(__FILE__) + "/../../..") do + original_stdout = $stdout + $stdout = StringIO.new + + require "babelish/commandline" + + Commandline.start([ + "csv2xcstrings", + "-i", "test/data/test_data_with_comments.csv", + "-L", "English:en", "French:fr", + "-C", "5" # Comments in column 5 + ]) + + output = $stdout.string + $stdout = original_stdout + end + end + + assert output.include?("Created"), "Should show creation message" + assert File.exist?("Localizable.xcstrings"), "Should create Localizable.xcstrings" + + # Verify comments are included + content = JSON.parse(File.read("Localizable.xcstrings")) + has_comment = false + content["strings"].each do |key, entry| + if entry["comment"] && !entry["comment"].empty? + has_comment = true + break + end + end + assert has_comment, "Should include comments from CSV" + + # Clean up + system("rm -f Localizable.xcstrings") + end +end \ No newline at end of file diff --git a/test/babelish/commands/test_command_xcstrings2csv.rb b/test/babelish/commands/test_command_xcstrings2csv.rb new file mode 100644 index 0000000..c19ffd4 --- /dev/null +++ b/test/babelish/commands/test_command_xcstrings2csv.rb @@ -0,0 +1,122 @@ +require 'test_helper' + +class TestCommandXCStrings2CSV < Test::Unit::TestCase + def test_xcstrings2csv_command_help + assert_nothing_raised do + require "babelish/commandline" + Commandline.start(["help", "xcstrings2csv"]) + end + end + + def test_xcstrings2csv_command_basic_conversion + output = nil + assert_nothing_raised do + Dir.chdir(File.dirname(__FILE__) + "/../../..") do + original_stdout = $stdout + $stdout = StringIO.new + + require "babelish/commandline" + + Commandline.start([ + "xcstrings2csv", + "-i", "test/data/test_sample.xcstrings", + "-o", "test_command_output.csv" + ]) + + output = $stdout.string + $stdout = original_stdout + end + end + + assert output.include?("Creating"), "Should show creation message" + assert output.include?("Done"), "Should show completion message" + assert File.exist?("test_command_output.csv"), "Should create CSV file" + + # Verify CSV content + csv_content = File.read("test_command_output.csv") + assert csv_content.include?("Variables"), "Should have Variables header" + assert csv_content.include?("hello_world"), "Should contain hello_world key" + assert csv_content.include?("Hello, World!"), "Should contain English translation" + + # Clean up + system("rm -f test_command_output.csv") + end + + def test_xcstrings2csv_command_with_custom_filename + output = nil + assert_nothing_raised do + Dir.chdir(File.dirname(__FILE__) + "/../../..") do + original_stdout = $stdout + $stdout = StringIO.new + + require "babelish/commandline" + + Commandline.start([ + "xcstrings2csv", + "-i", "test/data/test_sample.xcstrings", + "-o", "custom_translations.csv" + ]) + + output = $stdout.string + $stdout = original_stdout + end + end + + assert output.include?("Creating custom_translations.csv"), "Should show custom filename" + assert File.exist?("custom_translations.csv"), "Should create custom CSV file" + + # Clean up + system("rm -f custom_translations.csv") + end + + def test_xcstrings2csv_command_dry_run + # Note: The dry-run feature would need to be implemented in the commandline interface + # Currently it's only available in the class interface + converter = Babelish::XCStrings2CSV.new( + :filenames => ["test/data/test_sample.xcstrings"] + ) + + keys, strings = converter.convert(false) # dry run + + assert_instance_of Array, keys + assert_instance_of Hash, strings + assert keys.include?("hello_world") + assert strings.key?("en") + end + + def test_xcstrings2csv_preserves_all_languages + output = nil + assert_nothing_raised do + Dir.chdir(File.dirname(__FILE__) + "/../../..") do + original_stdout = $stdout + $stdout = StringIO.new + + require "babelish/commandline" + + Commandline.start([ + "xcstrings2csv", + "-i", "test/data/test_sample.xcstrings", + "-o", "multilang_test.csv" + ]) + + output = $stdout.string + $stdout = original_stdout + end + end + + assert File.exist?("multilang_test.csv"), "Should create CSV file" + + # Verify all languages are preserved + csv_content = File.read("multilang_test.csv") + lines = csv_content.split("\n") + header = lines.first + + # Should have columns for all languages in the xcstrings file + assert header.include?("en"), "Should have English column" + assert header.include?("fr"), "Should have French column" + assert header.include?("es"), "Should have Spanish column" + + # Clean up + system("rm -f multilang_test.csv") + end +end \ No newline at end of file diff --git a/test/babelish/test_csv2xcstrings.rb b/test/babelish/test_csv2xcstrings.rb new file mode 100644 index 0000000..172134b --- /dev/null +++ b/test/babelish/test_csv2xcstrings.rb @@ -0,0 +1,148 @@ +require 'test_helper' +class TestCSV2XCStrings < Test::Unit::TestCase + + def test_converting_csv_to_xcstrings + csv_file = "test/data/test_data.csv" + converter = Babelish::CSV2XCStrings.new(csv_file, 'English' => [:en]) + converter.convert + assert File.exist?("Localizable.xcstrings"), "the output file does not exist" + + # Verify the JSON structure + content = File.read("Localizable.xcstrings") + json_data = JSON.parse(content) + + assert_equal "1.0", json_data["version"] + assert_equal "en", json_data["sourceLanguage"] + assert json_data["strings"].is_a?(Hash), "strings should be a hash" + + # Clean up + system("rm -f Localizable.xcstrings") + end + + def test_converting_csv_to_xcstrings_multiple_languages + csv_file = "test/data/test_data_multiple_langs.csv" + converter = Babelish::CSV2XCStrings.new(csv_file, + {'English' => [:en], "French" => "fr", "German" => "de", "Spanish" => "es"}) + converter.convert + + assert File.exist?("Localizable.xcstrings"), "the output file does not exist" + + # Verify the JSON structure and content + content = File.read("Localizable.xcstrings") + json_data = JSON.parse(content) + + assert_equal "1.0", json_data["version"] + assert_equal "en", json_data["sourceLanguage"] + + # Check that all languages are included + first_string_key = json_data["strings"].keys.first + localizations = json_data["strings"][first_string_key]["localizations"] + + assert localizations.key?("en"), "Should have English localization" + assert localizations.key?("fr"), "Should have French localization" + assert localizations.key?("de"), "Should have German localization" + assert localizations.key?("es"), "Should have Spanish localization" + + # Verify structure of localization entries + assert_equal "translated", localizations["en"]["stringUnit"]["state"] + assert localizations["en"]["stringUnit"]["value"].is_a?(String) + + # Clean up + system("rm -f Localizable.xcstrings") + end + + def test_converting_csv_with_comments_to_xcstrings + csv_file = "test/data/test_data_with_comments.csv" + converter = Babelish::CSV2XCStrings.new(csv_file, + {"English" => "en", "French" => "fr"}, + :comments_column => 5) + converter.convert + + assert File.exist?("Localizable.xcstrings"), "the output file does not exist" + + # Verify comments are included + content = File.read("Localizable.xcstrings") + json_data = JSON.parse(content) + + # Check for comments in the strings + strings = json_data["strings"] + has_comment = false + strings.each do |key, entry| + if entry["comment"] && !entry["comment"].empty? + has_comment = true + break + end + end + assert has_comment, "Should include comments from CSV" + + # Clean up + system("rm -f Localizable.xcstrings") + end + + def test_converting_csv_to_xcstrings_with_output_basename + csv_file = "test/data/test_data.csv" + converter = Babelish::CSV2XCStrings.new(csv_file, + {'English' => [:en]}, + :output_basename => 'MyApp') + converter.convert + + assert File.exist?("MyApp.xcstrings"), "the output file with custom basename does not exist" + + # Clean up + system("rm -f MyApp.xcstrings") + end + + def test_converting_csv_to_xcstrings_with_ignore_lang_path + csv_file = "test/data/test_data.csv" + converter = Babelish::CSV2XCStrings.new(csv_file, + {'English' => [:en]}, + :ignore_lang_path => true) + converter.convert + + assert File.exist?("Localizable.xcstrings"), "the output file does not exist" + + # Clean up + system("rm -f Localizable.xcstrings") + end + + def test_language_filepaths + converter = Babelish::CSV2XCStrings.new("dummy.csv", {'English' => [:en]}) + language = double_language('en', ['en']) + + filepaths = converter.language_filepaths(language) + assert filepaths.length > 0 + assert filepaths.first.to_s.end_with?("Localizable.xcstrings") + end + + def test_extension + converter = Babelish::CSV2XCStrings.new("dummy.csv", {'English' => [:en]}) + assert_equal "xcstrings", converter.extension + end + + def test_output_basename_default + converter = Babelish::CSV2XCStrings.new("dummy.csv", {'English' => [:en]}) + assert_equal "Localizable", converter.output_basename + end + + def test_output_basename_custom + converter = Babelish::CSV2XCStrings.new("dummy.csv", {'English' => [:en]}, :output_basename => 'MyApp') + assert_equal "MyApp", converter.output_basename + end + + def test_hash_to_output_not_used + converter = Babelish::CSV2XCStrings.new("dummy.csv", {'English' => [:en]}) + result = converter.send(:hash_to_output, {"key" => "value"}) + assert_equal "", result, "hash_to_output should return empty string for xcstrings" + end + + private + + # Helper method to create a mock language object + def double_language(code, regions) + language = Object.new + language.define_singleton_method(:code) { code } + language.define_singleton_method(:regions) { regions } + language.define_singleton_method(:content) { {} } + language + end +end \ No newline at end of file diff --git a/test/babelish/test_xcstrings2csv.rb b/test/babelish/test_xcstrings2csv.rb new file mode 100644 index 0000000..8bfb1c3 --- /dev/null +++ b/test/babelish/test_xcstrings2csv.rb @@ -0,0 +1,140 @@ +require 'test_helper' +class TestXCStrings2CSV < Test::Unit::TestCase + + def test_load_strings_with_valid_xcstrings_file + expected_strings = { + "hello_world" => "Hello, World!", + "welcome_message" => "Welcome to our app", + "goodbye" => "Goodbye" + } + expected_comments = { + "hello_world" => "A greeting message", + "welcome_message" => "Welcome message for users" + } + + strings, comments = Babelish::XCStrings2CSV.new.load_strings("test/data/test_sample.xcstrings") + + # Test that we get the expected strings for English (source language) + assert_equal expected_strings["hello_world"], strings["hello_world"] + assert_equal expected_strings["welcome_message"], strings["welcome_message"] + assert_equal expected_strings["goodbye"], strings["goodbye"] + + # Test that comments are loaded correctly + assert_equal expected_comments["hello_world"], comments["hello_world"] + assert_equal expected_comments["welcome_message"], comments["welcome_message"] + end + + def test_load_strings_with_nonexistent_file + strings, comments = Babelish::XCStrings2CSV.new.load_strings("nonexistent_file.xcstrings") + assert_equal({}, strings, "Should return empty hash for nonexistent file") + assert_equal({}, comments, "Should return empty hash for nonexistent file") + end + + def test_load_strings_with_invalid_json + # Create a temporary file with invalid JSON + invalid_json_file = "test_invalid.xcstrings" + File.write(invalid_json_file, "{ invalid json }") + + begin + strings, comments = Babelish::XCStrings2CSV.new.load_strings(invalid_json_file) + assert_equal({}, strings, "Should return empty hash for invalid JSON") + assert_equal({}, comments, "Should return empty hash for invalid JSON") + ensure + File.delete(invalid_json_file) if File.exist?(invalid_json_file) + end + end + + def test_extract_language_from_filename + converter = Babelish::XCStrings2CSV.new + xcstrings_data = {"sourceLanguage" => "en"} + + # Test with basic filename + lang = converter.send(:extract_language_from_filename, "Localizable.xcstrings", xcstrings_data) + assert_equal "en", lang + + # Test with language-specific filename + lang = converter.send(:extract_language_from_filename, "Localizable-fr.xcstrings", xcstrings_data) + assert_equal "fr", lang + end + + def test_find_translation_for_language + converter = Babelish::XCStrings2CSV.new + localizations = { + "en" => {"stringUnit" => {"value" => "Hello"}}, + "fr" => {"stringUnit" => {"value" => "Bonjour"}}, + "es" => {"stringUnit" => {"value" => "Hola"}} + } + + # Test exact match + result = converter.send(:find_translation_for_language, localizations, "fr", "en") + assert_equal "Bonjour", result + + # Test fallback to source language + result = converter.send(:find_translation_for_language, localizations, "de", "en") + assert_equal "Hello", result + + # Test with no matching language + empty_localizations = {} + result = converter.send(:find_translation_for_language, empty_localizations, "fr", "en") + assert_nil result + end + + def test_convert_xcstrings_to_csv + converter = Babelish::XCStrings2CSV.new( + :filenames => ["test/data/test_sample.xcstrings"], + :csv_filename => "test_xcstrings_output.csv" + ) + + converter.convert + + # Check that the CSV file was created + assert File.exist?("test_xcstrings_output.csv"), "CSV file should be created" + + # Verify CSV content + csv_content = File.read("test_xcstrings_output.csv") + assert csv_content.include?("Variables"), "Should have Variables header" + assert csv_content.include?("hello_world"), "Should contain hello_world key" + assert csv_content.include?("Hello, World!"), "Should contain English translation" + assert csv_content.include?("Bonjour, le monde!"), "Should contain French translation" + assert csv_content.include?("A greeting message"), "Should contain comments" + + # Clean up + File.delete("test_xcstrings_output.csv") if File.exist?("test_xcstrings_output.csv") + end + + def test_convert_without_writing_file + converter = Babelish::XCStrings2CSV.new( + :filenames => ["test/data/test_sample.xcstrings"] + ) + + keys, strings = converter.convert(false) + + # Should return arrays/hashes instead of writing file + assert_instance_of Array, keys + assert_instance_of Hash, strings + assert keys.include?("hello_world") + assert keys.include?("welcome_message") + end + + def test_initialize + csv_filename = "test.csv" + filenames = ["test1.xcstrings", "test2.xcstrings"] + headers = ["Variables", "English", "French"] + + converter = Babelish::XCStrings2CSV.new( + :csv_filename => csv_filename, + :headers => headers, + :filenames => filenames + ) + + assert_equal csv_filename, converter.csv_filename + assert_equal headers, converter.headers + assert_equal filenames, converter.filenames + end + + def test_initialize_with_default_values + converter = Babelish::XCStrings2CSV.new + assert_not_nil converter.csv_filename + assert_equal "translations.csv", converter.csv_filename + end +end \ No newline at end of file diff --git a/test/data/test_sample.xcstrings b/test/data/test_sample.xcstrings new file mode 100644 index 0000000..81f4933 --- /dev/null +++ b/test/data/test_sample.xcstrings @@ -0,0 +1,71 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "hello_world" : { + "comment" : "A greeting message", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hello, World!" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bonjour, le monde!" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Hola, Mundo!" + } + } + } + }, + "welcome_message" : { + "comment" : "Welcome message for users", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Welcome to our app" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bienvenue dans notre application" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bienvenido a nuestra aplicación" + } + } + } + }, + "goodbye" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Goodbye" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Au revoir" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file From 780d716dd9e40effc21fbcc7fbf34f4a4c914ae8 Mon Sep 17 00:00:00 2001 From: Bruno Coelho <4brunu@gmail.com> Date: Sat, 30 Aug 2025 22:06:58 +0100 Subject: [PATCH 2/2] Try to fix style issues --- lib/babelish.rb | 1 - lib/babelish/commandline.rb | 20 ++++++++++---------- lib/babelish/csv2base.rb | 4 ++-- lib/babelish/csv2xcstrings.rb | 32 ++++++++++++++++---------------- 4 files changed, 28 insertions(+), 29 deletions(-) diff --git a/lib/babelish.rb b/lib/babelish.rb index 201c2e3..b5abde9 100644 --- a/lib/babelish.rb +++ b/lib/babelish.rb @@ -26,7 +26,6 @@ def to_utf8 # General require "babelish/language" -require "babelish/keys" require "babelish/google_doc" # iOS specific diff --git a/lib/babelish/commandline.rb b/lib/babelish/commandline.rb index 8c41a90..f1991d4 100644 --- a/lib/babelish/commandline.rb +++ b/lib/babelish/commandline.rb @@ -7,11 +7,11 @@ class Commandline < Thor map "-v" => :version CSVCLASSES = [ - {:name => "CSV2Strings", :ext => ".strings"}, - {:name => "CSV2XCStrings", :ext => ".xcstrings"}, - {:name => "CSV2Android", :ext => ".xml"}, - {:name => "CSV2JSON", :ext => ".json"}, - {:name => "CSV2Php", :ext => ".php"}, + { name: "CSV2Strings", ext: ".strings" }, + { name: "CSV2XCStrings", ext: ".xcstrings" }, + { name: "CSV2Android", ext: ".xml" }, + { name: "CSV2JSON", ext: ".json" }, + { name: "CSV2Php", ext: ".php" } ] CSVCLASSES.each do |klass| @@ -41,11 +41,11 @@ class Commandline < Thor end BASECLASSES = [ - {:name => "Strings2CSV", :ext => ".strings"}, - {:name => "XCStrings2CSV", :ext => ".xcstrings"}, - {:name => "Android2CSV", :ext => ".xml"}, - {:name => "JSON2CSV", :ext => ".json"}, - {:name => "Php2CSV", :ext => ".php"}, + { name: "Strings2CSV", ext: ".strings" }, + { name: "XCStrings2CSV", ext: ".xcstrings" }, + { name: "Android2CSV", ext: ".xml" }, + { name: "JSON2CSV", ext: ".json" }, + { name: "Php2CSV", ext: ".php" } ] BASECLASSES.each do |klass| diff --git a/lib/babelish/csv2base.rb b/lib/babelish/csv2base.rb index 06b6557..c7f702a 100644 --- a/lib/babelish/csv2base.rb +++ b/lib/babelish/csv2base.rb @@ -1,5 +1,5 @@ -require 'pathname' -require 'thor' +require "pathname" +require "thor" module Babelish class Csv2Base attr_accessor :output_dir, :output_basename diff --git a/lib/babelish/csv2xcstrings.rb b/lib/babelish/csv2xcstrings.rb index c27cc8f..3e33d43 100644 --- a/lib/babelish/csv2xcstrings.rb +++ b/lib/babelish/csv2xcstrings.rb @@ -1,5 +1,5 @@ -require 'json' -require_relative 'csv2base' +require "json" +require_relative "csv2base" module Babelish class CSV2XCStrings < Csv2Base @@ -98,30 +98,30 @@ def write_content private def determine_source_language - source_lang = 'en' - + source_lang = "en" + @languages.each do |language| next if language.nil? - - if language.regions.any? { |code| code == 'en' || code.start_with?('en-') } - source_lang = 'en' + + if language.regions.any? { |code| code == "en" || code.start_with?("en-") } + source_lang = "en" break - elsif language.code == 'en' || language.code&.start_with?('en-') - source_lang = 'en' + elsif language.code == "en" || language.code&.start_with?("en-") + source_lang = "en" break end end - - if source_lang == 'en' && !@languages.any? { |lang| lang&.code == 'en' || lang&.regions&.include?('en') } - first_lang = @languages.find { |lang| !lang.nil? && !lang.regions.empty? } - source_lang = first_lang&.regions&.first || first_lang&.code || 'en' + + if source_lang == "en" && @languages.none? { |lang| lang&.code == "en" || lang&.regions&.include?("en") } + first_lang = @languages.detect { |lang| !lang.nil? && !lang.regions.empty? } + source_lang = first_lang&.regions&.first || first_lang&.code || "en" end - + source_lang end - def hash_to_output(content = {}) + def hash_to_output(*) "" end end -end \ No newline at end of file +end