Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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]
Expand Down
3 changes: 2 additions & 1 deletion lib/babelish.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,18 @@ def to_utf8
require "babelish/csv2android"
require "babelish/csv2php"
require "babelish/csv2json"
require "babelish/csv2xcstrings"

# To CSV
require "babelish/base2csv"
require "babelish/strings2csv"
require "babelish/android2csv"
require "babelish/php2csv"
require "babelish/json2csv"
require "babelish/xcstrings2csv"

# General
require "babelish/language"
require "babelish/keys"
require "babelish/google_doc"

# iOS specific
Expand Down
18 changes: 10 additions & 8 deletions lib/babelish/commandline.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ class Commandline < Thor
map "-v" => :version

CSVCLASSES = [
{:name => "CSV2Strings", :ext => ".strings"},
{: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|
Expand Down Expand Up @@ -40,10 +41,11 @@ class Commandline < Thor
end

BASECLASSES = [
{:name => "Strings2CSV", :ext => ".strings"},
{: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|
Expand Down
3 changes: 2 additions & 1 deletion lib/babelish/csv2base.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'pathname'
require "pathname"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Style/FrozenStringLiteralComment: Missing magic comment # frozen_string_literal: true.

require "thor"
module Babelish
class Csv2Base
attr_accessor :output_dir, :output_basename
Expand Down
127 changes: 127 additions & 0 deletions lib/babelish/csv2xcstrings.rb
Original file line number Diff line number Diff line change
@@ -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?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Layout/TrailingWhitespace: Trailing whitespace detected.

if language.regions && !language.regions.empty?
language.regions.each do |region|
lang_code = "#{language.code}-#{region}"
next if lang_code.nil?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Layout/TrailingWhitespace: Trailing whitespace detected.

localizations[lang_code] = {
"stringUnit" => {
"state" => "translated",
"value" => value
}
}
end
else
lang_code = language.code
next if lang_code.nil?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Layout/TrailingWhitespace: Trailing whitespace detected.

localizations[lang_code] = {
"stringUnit" => {
"state" => "translated",
Copy link
Collaborator

Choose a reason for hiding this comment

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

Layout/TrailingWhitespace: Trailing whitespace detected.

"value" => value
}
}
end
end

Copy link
Collaborator

Choose a reason for hiding this comment

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

Layout/TrailingWhitespace: Trailing whitespace detected.

string_entry["localizations"] = localizations unless localizations.empty?
Copy link
Collaborator

Choose a reason for hiding this comment

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

Metrics/LineLength: Line is too long. [81/80]

xcstrings_data["strings"][key] = string_entry unless localizations.empty?
Copy link
Collaborator

Choose a reason for hiding this comment

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

Metrics/LineLength: Line is too long. [81/80]

end

Copy link
Collaborator

Choose a reason for hiding this comment

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

Layout/TrailingWhitespace: Trailing whitespace detected.

file.write(JSON.pretty_generate(xcstrings_data))
info += "- #{File.absolute_path(file)}\n"
count += 1
file.close

Copy link
Collaborator

Choose a reason for hiding this comment

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

Layout/TrailingWhitespace: Trailing whitespace detected.

info = "Created #{count} files.\n" + info
return info
Copy link
Collaborator

Choose a reason for hiding this comment

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

Style/RedundantReturn: Redundant return detected.

end

Copy link
Collaborator

Choose a reason for hiding this comment

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

Layout/TrailingWhitespace: Trailing whitespace detected.

private

Copy link
Collaborator

Choose a reason for hiding this comment

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

Layout/TrailingWhitespace: Trailing whitespace detected.

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-") }
Copy link
Collaborator

Choose a reason for hiding this comment

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

Metrics/LineLength: Line is too long. [83/80]

source_lang = "en"
break
elsif language.code == "en" || language.code&.start_with?("en-")
source_lang = "en"
break
end
end

if source_lang == "en" && @languages.none? { |lang| lang&.code == "en" || lang&.regions&.include?("en") }
Copy link
Collaborator

Choose a reason for hiding this comment

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

Metrics/LineLength: Line is too long. [111/80]

first_lang = @languages.detect { |lang| !lang.nil? && !lang.regions.empty? }
Copy link
Collaborator

Choose a reason for hiding this comment

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

Metrics/LineLength: Line is too long. [84/80]

source_lang = first_lang&.regions&.first || first_lang&.code || "en"
end

source_lang
end

Copy link
Collaborator

Choose a reason for hiding this comment

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

Layout/TrailingWhitespace: Trailing whitespace detected.

def hash_to_output(*)
""
end
end
end
172 changes: 172 additions & 0 deletions lib/babelish/xcstrings2csv.rb
Original file line number Diff line number Diff line change
@@ -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
Loading