localio 0.1.7 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +28 -0
  3. data/.gitignore +4 -1
  4. data/.rspec +3 -0
  5. data/.ruby-version +1 -0
  6. data/Gemfile.lock +134 -0
  7. data/README.md +36 -34
  8. data/bin/localize +10 -7
  9. data/docs/plans/2026-02-23-modernization-design.md +91 -0
  10. data/docs/plans/2026-02-23-modernization.md +1699 -0
  11. data/docs/plans/2026-02-23-twine-writer-design.md +72 -0
  12. data/docs/plans/2026-02-23-twine-writer.md +267 -0
  13. data/lib/localio/localizable_writer.rb +4 -1
  14. data/lib/localio/processors/csv_processor.rb +1 -1
  15. data/lib/localio/processors/google_drive_processor.rb +19 -45
  16. data/lib/localio/processors/xlsx_processor.rb +1 -1
  17. data/lib/localio/template_handler.rb +3 -1
  18. data/lib/localio/templates/android_localizable.erb +14 -2
  19. data/lib/localio/templates/ios_constant_localizable.erb +16 -2
  20. data/lib/localio/templates/ios_localizable.erb +20 -5
  21. data/lib/localio/templates/java_properties_localizable.erb +16 -2
  22. data/lib/localio/templates/json_localizable.erb +6 -5
  23. data/lib/localio/templates/rails_localizable.erb +15 -3
  24. data/lib/localio/templates/resx_localizable.erb +14 -2
  25. data/lib/localio/templates/swift_constant_localizable.erb +15 -2
  26. data/lib/localio/version.rb +1 -1
  27. data/lib/localio/writers/ios_writer.rb +3 -3
  28. data/lib/localio/writers/swift_writer.rb +3 -3
  29. data/lib/localio/writers/twine_writer.rb +48 -0
  30. data/localio.gemspec +19 -25
  31. data/spec/fixtures/sample.csv +11 -0
  32. data/spec/localio/filter_spec.rb +40 -0
  33. data/spec/localio/formatter_spec.rb +32 -0
  34. data/spec/localio/processors/csv_processor_spec.rb +89 -0
  35. data/spec/localio/processors/google_drive_processor_spec.rb +107 -0
  36. data/spec/localio/processors/xls_processor_spec.rb +65 -0
  37. data/spec/localio/processors/xlsx_processor_spec.rb +59 -0
  38. data/spec/localio/segment_spec.rb +27 -0
  39. data/spec/localio/segments_list_holder_spec.rb +22 -0
  40. data/spec/localio/string_helper_spec.rb +49 -0
  41. data/spec/localio/template_handler_spec.rb +67 -0
  42. data/spec/localio/term_spec.rb +24 -0
  43. data/spec/localio/writers/android_writer_spec.rb +71 -0
  44. data/spec/localio/writers/ios_writer_spec.rb +63 -0
  45. data/spec/localio/writers/java_properties_writer_spec.rb +35 -0
  46. data/spec/localio/writers/json_writer_spec.rb +57 -0
  47. data/spec/localio/writers/rails_writer_spec.rb +47 -0
  48. data/spec/localio/writers/resx_writer_spec.rb +44 -0
  49. data/spec/localio/writers/swift_writer_spec.rb +42 -0
  50. data/spec/localio/writers/twine_writer_spec.rb +68 -0
  51. data/spec/localio_spec.rb +62 -0
  52. data/spec/spec_helper.rb +24 -0
  53. data/spec/support/shared_terms.rb +35 -0
  54. metadata +61 -46
@@ -8,6 +8,19 @@ Created by localio
8
8
 
9
9
  import Foundation
10
10
 
11
- <% @segments.each do |term| %>
12
- let <%= term.key %>: String = { return NSLocalizedString("<%= term.translation %>", comment: "") }()<%
11
+ <%
12
+ node_keys = []
13
+ @segments.each do |term|
14
+ if term.key == '[init-node]' or term.key == '[end-node]'
15
+ node_keys << term.translation if term.key == '[init-node]'
16
+ node_keys.pop if term.key == '[end-node]'
17
+ else
18
+ if node_keys.length() >0
19
+ key_join = node_keys.join("_").capitalize+"_"+term.key.downcase
20
+ else
21
+ key_join = term.key
22
+ end
23
+ %>
24
+ let kLocale<%= key_join %>: String = { return NSLocalizedString("_<%= key_join %>", comment: "") }()<%
25
+ end
13
26
  end %>
@@ -1,3 +1,3 @@
1
1
  module Localio
2
- VERSION = "0.1.7"
2
+ VERSION = "0.2.1"
3
3
  end
@@ -25,7 +25,7 @@ class IosWriter
25
25
 
26
26
  unless term.is_comment?
27
27
  constant_key = ios_constant_formatter term.keyword
28
- constant_value = key
28
+ constant_value = translation
29
29
  constant_segment = Segment.new(constant_key, constant_value, lang)
30
30
  constant_segments.segments << constant_segment
31
31
  end
@@ -45,11 +45,11 @@ class IosWriter
45
45
  private
46
46
 
47
47
  def self.ios_key_formatter(key)
48
- '_'+key.space_to_underscore.strip_tag.capitalize
48
+ key.space_to_underscore.strip_tag.capitalize
49
49
  end
50
50
 
51
51
  def self.ios_constant_formatter(key)
52
- 'kLocale'+key.space_to_underscore.strip_tag.camel_case
52
+ key.space_to_underscore.strip_tag.camel_case
53
53
  end
54
54
 
55
55
  end
@@ -25,7 +25,7 @@ class SwiftWriter
25
25
 
26
26
  unless term.is_comment?
27
27
  constant_key = swift_constant_formatter term.keyword
28
- constant_value = key
28
+ constant_value = translation
29
29
  constant_segment = Segment.new(constant_key, constant_value, lang)
30
30
  constant_segments.segments << constant_segment
31
31
  end
@@ -44,10 +44,10 @@ class SwiftWriter
44
44
  private
45
45
 
46
46
  def self.swift_key_formatter(key)
47
- '_'+key.space_to_underscore.strip_tag.capitalize
47
+ key.space_to_underscore.strip_tag.capitalize
48
48
  end
49
49
 
50
50
  def self.swift_constant_formatter(key)
51
- 'kLocale'+key.space_to_underscore.strip_tag.camel_case
51
+ key.space_to_underscore.strip_tag.camel_case
52
52
  end
53
53
  end
@@ -0,0 +1,48 @@
1
+ require 'fileutils'
2
+ require 'localio/formatter'
3
+
4
+ class TwineWriter
5
+ def self.write(languages, terms, path, formatter, options)
6
+ puts 'Writing Twine translations...'
7
+
8
+ default_language = options[:default_language]
9
+ output_filename = options[:output_file] || 'strings.txt'
10
+
11
+ FileUtils.mkdir_p(path)
12
+
13
+ File.open(File.join(path, output_filename), 'w') do |f|
14
+ pending_comment = nil
15
+
16
+ terms.each do |term|
17
+ if term.is_comment?
18
+ pending_comment = term.values[default_language]
19
+ elsif term.keyword == '[init-node]'
20
+ f.puts "[[#{term.values[default_language]}]]"
21
+ pending_comment = nil
22
+ elsif term.keyword == '[end-node]'
23
+ f.puts ''
24
+ pending_comment = nil
25
+ else
26
+ key = Formatter.format(term.keyword, formatter, method(:twine_key_formatter))
27
+ f.puts "\t[#{key}]"
28
+ languages.keys.each do |lang|
29
+ f.puts "\t\t#{lang} = #{term.values[lang]}"
30
+ end
31
+ if pending_comment
32
+ f.puts "\t\tcomment = #{pending_comment}"
33
+ pending_comment = nil
34
+ end
35
+ f.puts ''
36
+ end
37
+ end
38
+ end
39
+
40
+ puts " > #{output_filename.yellow}"
41
+ end
42
+
43
+ private
44
+
45
+ def self.twine_key_formatter(key)
46
+ key.space_to_underscore.strip_tag.downcase
47
+ end
48
+ end
data/localio.gemspec CHANGED
@@ -1,35 +1,29 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
1
+ lib = File.expand_path('lib', __dir__)
3
2
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
3
  require 'localio/version'
5
4
 
6
5
  Gem::Specification.new do |spec|
7
- spec.name = "localio"
8
- spec.version = Localio::VERSION
9
- spec.authors = ["Nacho Lopez"]
10
- spec.email = ["nacho@nlopez.io"]
11
- spec.description = %q{Automatic Localizable file generation for multiple platforms (Rails YAML, Android, Java Properties, iOS, JSON, .NET ResX)}
12
- spec.summary = %q{Automatic Localizable file generation for multiple type of files, like Android string.xml, Xcode Localizable.strings, JSON files, Rails YAML files, Java properties, etc. reading from Google Drive and Excel spreadsheets as base.}
13
- spec.homepage = "http://github.com/mrmans0n/localio"
14
- spec.license = "MIT"
6
+ spec.name = "localio"
7
+ spec.version = Localio::VERSION
8
+ spec.authors = ["Nacho Lopez"]
9
+ spec.email = ["nacho@nlopez.io"]
10
+ spec.description = %q{Automatic Localizable file generation for multiple platforms}
11
+ spec.summary = %q{Generates Android, iOS, Rails, JSON, Java Properties, and .NET ResX localization files from spreadsheet sources.}
12
+ spec.homepage = "https://github.com/mrmans0n/localio"
13
+ spec.license = "MIT"
15
14
 
16
- spec.files = `git ls-files`.split($/)
17
- spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
- spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
15
+ spec.files = `git ls-files`.split("\n")
16
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
19
17
  spec.require_paths = ["lib"]
20
18
 
21
- spec.executables << "localize"
19
+ spec.required_ruby_version = ">= 3.2"
22
20
 
23
- spec.add_development_dependency "rspec"
21
+ spec.add_development_dependency "rspec", "~> 3.0"
22
+ spec.add_development_dependency "rake", "~> 13.0"
24
23
 
25
- spec.required_ruby_version = ">= 1.9.2"
26
-
27
- spec.add_development_dependency "bundler", "~> 1.3"
28
- spec.add_development_dependency "rake"
29
-
30
- spec.add_dependency "micro-optparse", "~> 1.2"
31
- spec.add_dependency "google_drive", "~> 1.0"
32
- spec.add_dependency "spreadsheet", "~> 1.0"
33
- spec.add_dependency "simple_xlsx_reader", "~> 1.0"
34
- spec.add_dependency "nokogiri", "~> 1.6"
24
+ spec.add_dependency "google_drive", "~> 3.0"
25
+ spec.add_dependency "spreadsheet", "~> 1.3"
26
+ spec.add_dependency "simple_xlsx_reader", "~> 2.0"
27
+ spec.add_dependency "nokogiri", "~> 1.16"
28
+ spec.add_dependency "csv", "~> 3.2"
35
29
  end
@@ -0,0 +1,11 @@
1
+ Title Row,,,
2
+ [key],*en,es,fr
3
+ [comment],Section General,Section General,Section General
4
+ app_name,My App,Mi Aplicación,Mon Application
5
+ greeting,Hello %@ world,Hola %@ mundo,Bonjour %@ monde
6
+ dots_test,Wait...,Espera...,Attendez...
7
+ ampersand_test,Tom & Jerry,Tom & Jerry,Tom & Jerry
8
+ [init-node],module,module,module
9
+ nested_key,Module Key,Clave Módulo,Clé Module
10
+ [end-node],end,end,end
11
+ [end],,,
@@ -0,0 +1,40 @@
1
+ require 'localio/term'
2
+ require 'localio/filter'
3
+
4
+ RSpec.describe Filter do
5
+ let(:terms) do
6
+ ['app_name', 'app_title', 'settings_title', 'settings_back', '[comment]'].map { |kw| Term.new(kw) }
7
+ end
8
+
9
+ describe '.apply_filter' do
10
+ it 'returns all terms when no filters set' do
11
+ expect(Filter.apply_filter(terms, nil, nil)).to eq(terms)
12
+ end
13
+
14
+ context 'with only filter' do
15
+ it 'keeps terms matching the regex' do
16
+ result = Filter.apply_filter(terms, { keys: 'app_' }, nil)
17
+ expect(result.map(&:keyword)).to contain_exactly('app_name', 'app_title')
18
+ end
19
+
20
+ it 'returns empty array when nothing matches' do
21
+ expect(Filter.apply_filter(terms, { keys: 'nonexistent' }, nil)).to be_empty
22
+ end
23
+ end
24
+
25
+ context 'with except filter' do
26
+ it 'excludes terms matching the regex' do
27
+ result = Filter.apply_filter(terms, nil, { keys: 'settings_' })
28
+ expect(result.map(&:keyword)).not_to include('settings_title', 'settings_back')
29
+ expect(result.map(&:keyword)).to include('app_name', 'app_title')
30
+ end
31
+ end
32
+
33
+ context 'with both filters' do
34
+ it 'applies only first then except' do
35
+ result = Filter.apply_filter(terms, { keys: 'app_' }, { keys: 'title' })
36
+ expect(result.map(&:keyword)).to contain_exactly('app_name')
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,32 @@
1
+ require 'localio/string_helper'
2
+ require 'localio/formatter'
3
+
4
+ RSpec.describe Formatter do
5
+ let(:smart_callback) { ->(key) { key.upcase } }
6
+
7
+ describe '.format' do
8
+ it ':smart delegates to callback' do
9
+ expect(Formatter.format('hello', :smart, smart_callback)).to eq('HELLO')
10
+ end
11
+
12
+ it ':none returns key unchanged' do
13
+ expect(Formatter.format('Hello World', :none, smart_callback)).to eq('Hello World')
14
+ end
15
+
16
+ it ':camel_case converts to CamelCase' do
17
+ expect(Formatter.format('hello world', :camel_case, smart_callback)).to eq('HelloWorld')
18
+ end
19
+
20
+ it ':camel_case strips single-letter bracket tags' do
21
+ expect(Formatter.format('[a]hello', :camel_case, smart_callback)).to eq('Hello')
22
+ end
23
+
24
+ it ':snake_case converts spaces to underscores and downcases' do
25
+ expect(Formatter.format('Hello World', :snake_case, smart_callback)).to eq('hello_world')
26
+ end
27
+
28
+ it 'raises ArgumentError for unknown formatter' do
29
+ expect { Formatter.format('key', :unknown, smart_callback) }.to raise_error(ArgumentError)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,89 @@
1
+ require 'localio/term'
2
+ require 'localio/string_helper'
3
+ require 'localio/processors/csv_processor'
4
+
5
+ RSpec.describe CsvProcessor do
6
+ let(:fixture_path) { File.expand_path('../../../fixtures/sample.csv', __FILE__) }
7
+ let(:platform_options) { {} }
8
+ let(:options) { { path: fixture_path } }
9
+
10
+ describe '.load_localizables' do
11
+ subject(:result) { CsvProcessor.load_localizables(platform_options, options) }
12
+
13
+ it 'returns languages en, es, fr' do
14
+ expect(result[:languages].keys).to contain_exactly('en', 'es', 'fr')
15
+ end
16
+
17
+ it 'sets en as default language (marked with *)' do
18
+ expect(result[:default_language]).to eq('en')
19
+ end
20
+
21
+ it 'returns 8 terms between [key] and [end]' do
22
+ expect(result[:segments].count).to eq(8)
23
+ end
24
+
25
+ it 'parses term keywords correctly' do
26
+ keywords = result[:segments].map(&:keyword)
27
+ expect(keywords).to include('app_name', 'greeting', '[comment]', '[init-node]', '[end-node]')
28
+ end
29
+
30
+ it 'parses translations for each language' do
31
+ app_name = result[:segments].find { |t| t.keyword == 'app_name' }
32
+ expect(app_name.values['en']).to eq('My App')
33
+ expect(app_name.values['es']).to eq('Mi Aplicación')
34
+ expect(app_name.values['fr']).to eq('Mon Application')
35
+ end
36
+
37
+ it 'identifies comment rows' do
38
+ comment = result[:segments].find { |t| t.keyword == '[comment]' }
39
+ expect(comment.is_comment?).to be true
40
+ end
41
+
42
+ it 'raises ArgumentError when :path is missing' do
43
+ expect { CsvProcessor.load_localizables({}, {}) }
44
+ .to raise_error(ArgumentError, /:path attribute is missing/)
45
+ end
46
+
47
+ it 'raises IndexError when [key] marker is missing' do
48
+ Dir.mktmpdir do |tmpdir|
49
+ path = File.join(tmpdir, 'bad.csv')
50
+ File.write(path, "no,key,row\ndata,here,\n[end],,,\n")
51
+ expect { CsvProcessor.load_localizables({}, { path: path }) }
52
+ .to raise_error(IndexError, /Could not find any \[key\]/)
53
+ end
54
+ end
55
+
56
+ it 'raises IndexError when [end] marker is missing' do
57
+ Dir.mktmpdir do |tmpdir|
58
+ path = File.join(tmpdir, 'bad.csv')
59
+ File.write(path, "title,,,\n[key],*en,es,\ndata,val,val,\n")
60
+ expect { CsvProcessor.load_localizables({}, { path: path }) }
61
+ .to raise_error(IndexError, /Could not find any \[end\]/)
62
+ end
63
+ end
64
+
65
+ context 'with override_default option' do
66
+ let(:platform_options) { { override_default: 'es' } }
67
+
68
+ it 'uses the overridden default language' do
69
+ expect(result[:default_language]).to eq('es')
70
+ end
71
+ end
72
+
73
+ context 'with avoid_lang_downcase option' do
74
+ let(:tmpdir) { Dir.mktmpdir }
75
+ let(:options) do
76
+ path = File.join(tmpdir, 'upper.csv')
77
+ File.write(path, "Title,,,\n[key],*EN,ES,FR\napp_name,My App,Mi App,Mon App\n[end],,,\n")
78
+ { path: path }
79
+ end
80
+ let(:platform_options) { { avoid_lang_downcase: true } }
81
+
82
+ after { FileUtils.rm_rf(tmpdir) }
83
+
84
+ it 'preserves language case' do
85
+ expect(result[:languages].keys).to contain_exactly('EN', 'ES', 'FR')
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,107 @@
1
+ # google_drive transitively loads nokogiri. Nokogiri is now built for arm64
2
+ # and loads correctly, so we no longer need to stub it out. We only stub
3
+ # google_drive itself (which requires OAuth flow and network access) by:
4
+ # 1. Defining minimal stub modules for GoogleDrive before anything tries to
5
+ # reference them.
6
+ # 2. Pre-populating $LOADED_FEATURES with the google_drive gem paths so that
7
+ # every subsequent `require 'google_drive'` is treated as already loaded.
8
+ # All runtime calls are intercepted by RSpec doubles.
9
+
10
+ module GoogleDrive
11
+ module Session
12
+ def self.from_config(_config, _opts = {}); end
13
+ def self.from_service_account_key(_path, _scope = nil); end
14
+ end
15
+ end
16
+
17
+ begin
18
+ _gd_spec = Gem::Specification.find_by_name('google_drive')
19
+ _gd_base = File.join(_gd_spec.gem_dir, 'lib')
20
+ Dir["#{_gd_base}/**/*.rb"].sort.each do |f|
21
+ $LOADED_FEATURES << f unless $LOADED_FEATURES.include?(f)
22
+ end
23
+ ["#{_gd_base}/google_drive.rb"].each do |f|
24
+ $LOADED_FEATURES << f unless $LOADED_FEATURES.include?(f)
25
+ end
26
+ end
27
+
28
+ require 'localio/term'
29
+ require 'localio/string_helper'
30
+ require 'localio/processors/google_drive_processor'
31
+
32
+ RSpec.describe GoogleDriveProcessor do
33
+ let(:ws_data) do
34
+ {
35
+ [1, 1] => '[key]', [1, 2] => '*en', [1, 3] => 'es',
36
+ [2, 1] => 'app_name', [2, 2] => 'My App', [2, 3] => 'Mi Aplicación',
37
+ [3, 1] => 'greeting', [3, 2] => 'Hello', [3, 3] => 'Hola',
38
+ [4, 1] => '[end]', [4, 2] => '', [4, 3] => '',
39
+ }
40
+ end
41
+
42
+ let(:worksheet) do
43
+ ws = double('worksheet')
44
+ allow(ws).to receive(:[]) { |row, col| ws_data[[row, col]].to_s }
45
+ allow(ws).to receive(:max_rows).and_return(4)
46
+ allow(ws).to receive(:max_cols).and_return(3)
47
+ ws
48
+ end
49
+
50
+ let(:spreadsheet_double) { double('spreadsheet', title: 'My Translations') }
51
+ let(:session_double) { double('session') }
52
+
53
+ before do
54
+ allow(spreadsheet_double).to receive(:worksheets).and_return([worksheet])
55
+ allow(session_double).to receive(:spreadsheets).and_return([spreadsheet_double])
56
+
57
+ # Mock the google_drive 3.x session creation method used by the processor.
58
+ # from_config is used for the OAuth2 client_id/client_secret flow.
59
+ allow(GoogleDrive::Session).to receive(:from_config).and_return(session_double)
60
+
61
+ # Allow File.file? to return false for the :client_token option in OAuth tests
62
+ # so the processor takes the from_config path (not the service-account path).
63
+ allow(File).to receive(:file?).and_call_original
64
+ allow(File).to receive(:file?).with('token').and_return(false)
65
+ end
66
+
67
+ let(:options) do
68
+ { spreadsheet: 'My Translations', client_id: 'id', client_secret: 'secret', client_token: 'token', sheet: 0 }
69
+ end
70
+
71
+ describe '.load_localizables' do
72
+ subject(:result) { GoogleDriveProcessor.load_localizables({}, options) }
73
+
74
+ it 'raises when :spreadsheet is missing' do
75
+ expect { GoogleDriveProcessor.load_localizables({}, { client_id: 'a', client_secret: 'b' }) }
76
+ .to raise_error(ArgumentError, /:spreadsheet required/)
77
+ end
78
+
79
+ it 'raises when :login is provided (deprecated)' do
80
+ expect { GoogleDriveProcessor.load_localizables({}, { spreadsheet: 'x', login: 'u', client_id: 'a', client_secret: 'b' }) }
81
+ .to raise_error(ArgumentError, /:login is deprecated/)
82
+ end
83
+
84
+ it 'raises when :client_id is missing' do
85
+ expect { GoogleDriveProcessor.load_localizables({}, { spreadsheet: 'x', client_secret: 'b' }) }
86
+ .to raise_error(ArgumentError, /:client_id required/)
87
+ end
88
+
89
+ it 'raises when :client_secret is missing' do
90
+ expect { GoogleDriveProcessor.load_localizables({}, { spreadsheet: 'x', client_id: 'a' }) }
91
+ .to raise_error(ArgumentError, /:client_secret required/)
92
+ end
93
+
94
+ it 'returns languages en and es' do
95
+ expect(result[:languages].keys).to contain_exactly('en', 'es')
96
+ end
97
+
98
+ it 'sets en as default language' do
99
+ expect(result[:default_language]).to eq('en')
100
+ end
101
+
102
+ it 'parses translations' do
103
+ app_name = result[:segments].find { |t| t.keyword == 'app_name' }
104
+ expect(app_name.values['en']).to eq('My App')
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,65 @@
1
+ require 'localio/term'
2
+ require 'localio/string_helper'
3
+ require 'localio/processors/xls_processor'
4
+
5
+ RSpec.describe XlsProcessor do
6
+ let(:data) do
7
+ {
8
+ [0, 0] => '[key]', [0, 1] => '*en', [0, 2] => 'es', [0, 3] => 'fr',
9
+ [1, 0] => 'app_name', [1, 1] => 'My App', [1, 2] => 'Mi Aplicación', [1, 3] => 'Mon Application',
10
+ [2, 0] => 'greeting', [2, 1] => 'Hello', [2, 2] => 'Hola', [2, 3] => 'Bonjour',
11
+ [3, 0] => '[end]', [3, 1] => '', [3, 2] => '', [3, 3] => '',
12
+ }
13
+ end
14
+
15
+ let(:worksheet) do
16
+ ws = double('worksheet')
17
+ allow(ws).to receive(:[]) { |row, col| data[[row, col]].to_s }
18
+ allow(ws).to receive(:row_count).and_return(3)
19
+ allow(ws).to receive(:column_count).and_return(3)
20
+ ws
21
+ end
22
+
23
+ let(:book_double) { double('book') }
24
+
25
+ before do
26
+ allow(Spreadsheet).to receive(:client_encoding=)
27
+ allow(Spreadsheet).to receive(:open).and_return(book_double)
28
+ allow(book_double).to receive(:worksheet).with(0).and_return(worksheet)
29
+ end
30
+
31
+ let(:options) { { path: 'fake.xls' } }
32
+ let(:platform_options) { {} }
33
+
34
+ describe '.load_localizables' do
35
+ subject(:result) { XlsProcessor.load_localizables(platform_options, options) }
36
+
37
+ it 'raises ArgumentError when :path is missing' do
38
+ expect { XlsProcessor.load_localizables({}, {}) }
39
+ .to raise_error(ArgumentError, /:path attribute is missing/)
40
+ end
41
+
42
+ it 'returns languages en, es, fr' do
43
+ expect(result[:languages].keys).to contain_exactly('en', 'es', 'fr')
44
+ end
45
+
46
+ it 'sets en as default language' do
47
+ expect(result[:default_language]).to eq('en')
48
+ end
49
+
50
+ it 'returns 2 terms' do
51
+ expect(result[:segments].count).to eq(2)
52
+ end
53
+
54
+ it 'parses translations correctly' do
55
+ app_name = result[:segments].find { |t| t.keyword == 'app_name' }
56
+ expect(app_name.values['en']).to eq('My App')
57
+ end
58
+
59
+ it 'raises when worksheet is nil' do
60
+ allow(book_double).to receive(:worksheet).and_return(nil)
61
+ expect { XlsProcessor.load_localizables({}, { path: 'fake.xls' }) }
62
+ .to raise_error(RuntimeError, /Unable to retrieve/)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,59 @@
1
+ require 'localio/term'
2
+ require 'localio/string_helper'
3
+ require 'localio/processors/xlsx_processor'
4
+
5
+ RSpec.describe XlsxProcessor do
6
+ let(:rows) do
7
+ [
8
+ ['Title', nil, nil, nil],
9
+ ['[key]', '*en', 'es', 'fr'],
10
+ ['[comment]', 'Section General', 'Section General', 'Section General'],
11
+ ['app_name', 'My App', 'Mi Aplicación', 'Mon Application'],
12
+ ['greeting', 'Hello', 'Hola', 'Bonjour'],
13
+ ['[end]', nil, nil, nil],
14
+ ]
15
+ end
16
+
17
+ let(:sheet_double) { double('sheet', rows: rows) }
18
+ let(:book_double) { double('book', sheets: [sheet_double]) }
19
+
20
+ before { allow(SimpleXlsxReader).to receive(:open).and_return(book_double) }
21
+
22
+ let(:options) { { path: 'fake.xlsx', sheet: 0 } }
23
+ let(:platform_options) { {} }
24
+
25
+ describe '.load_localizables' do
26
+ subject(:result) { XlsxProcessor.load_localizables(platform_options, options) }
27
+
28
+ it 'raises ArgumentError when :path is missing' do
29
+ expect { XlsxProcessor.load_localizables({}, {}) }
30
+ .to raise_error(ArgumentError, /:path attribute is missing/)
31
+ end
32
+
33
+ it 'returns languages en, es, fr' do
34
+ expect(result[:languages].keys).to contain_exactly('en', 'es', 'fr')
35
+ end
36
+
37
+ it 'sets en as default language' do
38
+ expect(result[:default_language]).to eq('en')
39
+ end
40
+
41
+ it 'returns 3 terms' do
42
+ expect(result[:segments].count).to eq(3)
43
+ end
44
+
45
+ it 'parses translations correctly' do
46
+ app_name = result[:segments].find { |t| t.keyword == 'app_name' }
47
+ expect(app_name.values['en']).to eq('My App')
48
+ expect(app_name.values['es']).to eq('Mi Aplicación')
49
+ end
50
+
51
+ it 'raises when sheet is nil' do
52
+ named_book = double('book', sheets: [])
53
+ allow(SimpleXlsxReader).to receive(:open).and_return(named_book)
54
+ allow(named_book).to receive(:sheets).and_return([])
55
+ expect { XlsxProcessor.load_localizables({}, { path: 'fake.xlsx', sheet: 'Missing' }) }
56
+ .to raise_error(RuntimeError, /Unable to retrieve/)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,27 @@
1
+ require 'localio/string_helper'
2
+ require 'localio/segment'
3
+
4
+ RSpec.describe Segment do
5
+ subject(:segment) { Segment.new('app_name', 'My App', 'en') }
6
+
7
+ it 'stores key, translation, and language' do
8
+ expect(segment.key).to eq('app_name')
9
+ expect(segment.translation).to eq('My App')
10
+ expect(segment.language).to eq('en')
11
+ end
12
+
13
+ it 'processes translation through replace_escaped' do
14
+ seg = Segment.new('key', 'hello`+world', 'en')
15
+ expect(seg.translation).to eq('hello+world')
16
+ end
17
+
18
+ describe '#is_comment?' do
19
+ it 'returns true when key is nil' do
20
+ segment.key = nil
21
+ expect(segment.is_comment?).to be true
22
+ end
23
+ it 'returns false when key is set' do
24
+ expect(segment.is_comment?).to be false
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ require 'localio/segments_list_holder'
2
+
3
+ RSpec.describe SegmentsListHolder do
4
+ subject(:holder) { SegmentsListHolder.new('en') }
5
+
6
+ it { expect(holder.language).to eq('en') }
7
+ it { expect(holder.segments).to be_empty }
8
+
9
+ describe '#get_binding' do
10
+ it 'returns a Binding' do
11
+ expect(holder.get_binding).to be_a(Binding)
12
+ end
13
+
14
+ it 'exposes @language in the binding' do
15
+ expect(eval('@language', holder.get_binding)).to eq('en')
16
+ end
17
+
18
+ it 'exposes @segments in the binding' do
19
+ expect(eval('@segments', holder.get_binding)).to eq([])
20
+ end
21
+ end
22
+ end