Skip to content

Commit 43ea2e6

Browse files
Copilotmarkets
andcommitted
Implement file-based import/export strategy for better workflow coordination
Co-authored-by: markets <[email protected]>
1 parent 21af32c commit 43ea2e6

File tree

2 files changed

+252
-33
lines changed

2 files changed

+252
-33
lines changed

lib/mini_i18n/cli.rb

Lines changed: 179 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
require 'optparse'
33
require 'csv'
44
require 'set'
5+
require 'yaml'
56

67
module MiniI18n
78
class CLI
@@ -75,33 +76,53 @@ def missing_command
7576
def import_command
7677
options = parse_import_options
7778

78-
unless options[:file]
79-
puts "Error: --file option is required"
80-
puts "Usage: mi18n import --file=translations.csv"
81-
exit 1
82-
end
83-
84-
unless File.exist?(options[:file])
85-
puts "Error: File '#{options[:file]}' not found"
86-
exit 1
79+
if options[:file]
80+
# Import from a specific CSV file
81+
unless File.exist?(options[:file])
82+
puts "Error: File '#{options[:file]}' not found"
83+
exit 1
84+
end
85+
import_from_csv_file(options[:file])
86+
else
87+
# Import from all CSV files in current directory
88+
csv_files = Dir.glob('*.csv')
89+
if csv_files.empty?
90+
puts "Error: No CSV files found in current directory"
91+
puts "Usage: mi18n import --file=translations.csv OR place CSV files in current directory"
92+
exit 1
93+
end
94+
95+
csv_files.each { |file| import_from_csv_file(file) }
96+
puts "Imported translations from #{csv_files.count} CSV files: #{csv_files.join(', ')}"
8797
end
88-
89-
# Load existing translations first
90-
load_translations_for_cli
91-
92-
import_from_csv(options[:file])
93-
puts "Translations imported successfully from #{options[:file]}"
94-
puts "Note: Imported translations are merged with existing ones in memory."
95-
puts "To persist changes, use 'mi18n export' to save to files."
9698
end
9799

98100
def export_command
99101
options = parse_export_options
100-
load_translations_for_cli
101102

102-
output_file = options[:file] || 'translations.csv'
103-
export_to_csv(output_file)
104-
puts "Translations exported successfully to #{output_file}"
103+
if options[:file]
104+
# Export to a specific CSV file (legacy single-file mode)
105+
load_translations_for_cli
106+
export_to_csv(options[:file])
107+
puts "Translations exported successfully to #{options[:file]}"
108+
else
109+
# Export using file-based strategy (new default)
110+
translation_files = find_translation_files
111+
if translation_files.empty?
112+
puts "Error: No translation files found"
113+
exit 1
114+
end
115+
116+
exported_files = []
117+
translation_files.each do |yaml_file|
118+
csv_file = File.basename(yaml_file, File.extname(yaml_file)) + '.csv'
119+
export_yaml_to_csv(yaml_file, csv_file)
120+
exported_files << csv_file
121+
end
122+
123+
puts "Exported translations to #{exported_files.count} CSV files:"
124+
exported_files.each { |file| puts " #{file}" }
125+
end
105126
end
106127

107128
def version_command
@@ -115,16 +136,23 @@ def help_command
115136
Commands:
116137
stats Show translation statistics
117138
missing [--locale=LOCALE] Show missing translation keys
118-
import --file=FILE Import translations from CSV file
119-
export [--file=FILE] Export translations to CSV file (default: translations.csv)
139+
import [--file=FILE] Import translations from CSV file(s)
140+
export [--file=FILE] Export translations to CSV file(s)
120141
version Show version
121142
help Show this help message
122143
144+
Export/Import Workflow:
145+
1. Run 'mi18n export' to create CSV files from your YAML translation files
146+
2. Send CSV files to translators for translation/review
147+
3. Run 'mi18n import' to update YAML files with translated CSV content
148+
123149
Examples:
124150
mi18n stats
125151
mi18n missing --locale=es
126-
mi18n import --file=translations.csv
127-
mi18n export --file=my_translations.csv
152+
mi18n export # Creates CSV files for each YAML file
153+
mi18n export --file=all.csv # Creates single CSV with all translations
154+
mi18n import # Updates YAML files from CSV files
155+
mi18n import --file=all.csv # Imports from specific CSV file
128156
HELP
129157
end
130158

@@ -141,7 +169,7 @@ def parse_missing_options
141169
def parse_import_options
142170
options = {}
143171
OptionParser.new do |opts|
144-
opts.on('--file=FILE', 'CSV file to import from') do |file|
172+
opts.on('--file=FILE', 'CSV file to import from (optional - will import all CSV files if not specified)') do |file|
145173
options[:file] = file
146174
end
147175
end.parse!(@args[1..-1])
@@ -151,7 +179,7 @@ def parse_import_options
151179
def parse_export_options
152180
options = {}
153181
OptionParser.new do |opts|
154-
opts.on('--file=FILE', 'CSV file to export to') do |file|
182+
opts.on('--file=FILE', 'CSV file to export to (optional - will create multiple CSV files if not specified)') do |file|
155183
options[:file] = file
156184
end
157185
end.parse!(@args[1..-1])
@@ -283,5 +311,129 @@ def set_nested_key(hash, key_path, value)
283311

284312
current[keys.last] = value
285313
end
314+
315+
def find_translation_files
316+
possible_patterns = [
317+
'config/locales/*.yml',
318+
'config/locales/*.yaml',
319+
'locales/*.yml',
320+
'locales/*.yaml',
321+
'translations/*.yml',
322+
'translations/*.yaml'
323+
]
324+
325+
all_files = []
326+
possible_patterns.each do |pattern|
327+
all_files.concat(Dir.glob(pattern))
328+
end
329+
330+
all_files.uniq
331+
end
332+
333+
def export_yaml_to_csv(yaml_file, csv_file)
334+
# Load the specific YAML file
335+
yaml_content = YAML.load_file(yaml_file)
336+
337+
# Extract all locales from this file
338+
locales = yaml_content.keys
339+
340+
# Collect all keys from all locales in this file
341+
all_keys = Set.new
342+
yaml_content.each do |locale, translations|
343+
collect_keys_recursive(translations).each { |key| all_keys << key }
344+
end
345+
346+
# Write CSV
347+
CSV.open(csv_file, 'w') do |csv|
348+
csv << ['key'] + locales
349+
350+
all_keys.to_a.sort.each do |key|
351+
row = [key]
352+
locales.each do |locale|
353+
value = get_nested_value(yaml_content[locale], key) || ''
354+
row << value
355+
end
356+
csv << row
357+
end
358+
end
359+
end
360+
361+
def import_from_csv_file(csv_file)
362+
# Determine corresponding YAML file
363+
base_name = File.basename(csv_file, '.csv')
364+
yaml_file = find_corresponding_yaml_file(base_name)
365+
366+
if yaml_file
367+
# File-based import: update specific YAML file
368+
import_to_yaml_file(csv_file, yaml_file)
369+
else
370+
# Legacy import: merge into memory (for backward compatibility)
371+
puts "Warning: Could not find corresponding YAML file for #{csv_file}."
372+
puts "Importing into memory. Changes will not be persisted to files."
373+
load_translations_for_cli
374+
import_from_csv(csv_file)
375+
end
376+
end
377+
378+
def import_to_yaml_file(csv_file, yaml_file)
379+
# Load existing YAML content or create new structure
380+
yaml_content = File.exist?(yaml_file) ? YAML.load_file(yaml_file) : {}
381+
382+
# Read CSV and update YAML content
383+
CSV.foreach(csv_file, headers: true) do |row|
384+
key = row['key']
385+
next if key.nil? || key.strip.empty?
386+
387+
row.headers.each do |header|
388+
next if header == 'key'
389+
390+
locale = header.to_s
391+
value = row[header]
392+
393+
# Skip nil values, but allow empty strings (they might be intentional)
394+
next if value.nil?
395+
396+
yaml_content[locale] ||= {}
397+
set_nested_key(yaml_content[locale], key, value)
398+
end
399+
end
400+
401+
# Write back to YAML file
402+
File.write(yaml_file, yaml_content.to_yaml)
403+
puts "Updated #{yaml_file} from #{csv_file}"
404+
end
405+
406+
def find_corresponding_yaml_file(base_name)
407+
possible_extensions = ['.yml', '.yaml']
408+
possible_patterns = [
409+
'config/locales/',
410+
'locales/',
411+
'translations/',
412+
'' # current directory
413+
]
414+
415+
possible_patterns.each do |path|
416+
possible_extensions.each do |ext|
417+
candidate = "#{path}#{base_name}#{ext}"
418+
return candidate if File.exist?(candidate)
419+
end
420+
end
421+
422+
nil
423+
end
424+
425+
def get_nested_value(hash, key_path)
426+
return nil unless hash.is_a?(Hash)
427+
428+
keys = key_path.split('.')
429+
current = hash
430+
431+
keys.each do |key|
432+
return nil unless current.is_a?(Hash) && current.key?(key)
433+
current = current[key]
434+
end
435+
436+
current
437+
end
286438
end
287439
end

spec/cli_spec.rb

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,41 @@
121121
end
122122
end
123123

124-
context 'with export command' do
124+
context 'with export command (file-based strategy)' do
125+
let(:args) { ['export'] }
126+
127+
it 'exports translations to multiple CSV files based on YAML files' do
128+
output = capture_stdout { cli.run }
129+
130+
expect(output).to include('Exported translations to 2 CSV files:')
131+
expect(output).to include('en.csv')
132+
expect(output).to include('es.csv')
133+
134+
# Check that CSV files were created
135+
expect(File.exist?('en.csv')).to be true
136+
expect(File.exist?('es.csv')).to be true
137+
138+
# Check en.csv content
139+
en_content = CSV.read('en.csv', headers: true)
140+
expect(en_content.headers).to eq(['key', 'en'])
141+
expect(en_content.map(&:to_h)).to include(
142+
{ 'key' => 'hello', 'en' => 'Hello' }
143+
)
144+
145+
# Check es.csv content
146+
es_content = CSV.read('es.csv', headers: true)
147+
expect(es_content.headers).to eq(['key', 'es'])
148+
expect(es_content.map(&:to_h)).to include(
149+
{ 'key' => 'hello', 'es' => 'Hola' }
150+
)
151+
end
152+
end
153+
154+
context 'with export command (legacy single-file strategy)' do
125155
let(:temp_csv) { File.join(temp_dir, 'test_export.csv') }
126156
let(:args) { ['export', "--file=#{temp_csv}"] }
127157

128-
it 'exports translations to CSV' do
158+
it 'exports translations to single CSV file' do
129159
output = capture_stdout { cli.run }
130160

131161
expect(output).to include("Translations exported successfully to #{temp_csv}")
@@ -139,23 +169,60 @@
139169
end
140170
end
141171

142-
context 'with import command' do
172+
context 'with import command (file-based strategy)' do
173+
let(:args) { ['import'] }
174+
175+
before do
176+
# Create CSV files for import
177+
CSV.open('en.csv', 'w') do |csv|
178+
csv << ['key', 'en']
179+
csv << ['hello', 'Hello Updated']
180+
csv << ['new_key', 'New Value']
181+
end
182+
183+
CSV.open('es.csv', 'w') do |csv|
184+
csv << ['key', 'es']
185+
csv << ['hello', 'Hola Actualizado']
186+
csv << ['nested.greeting', 'Buenos días actualizados']
187+
end
188+
end
189+
190+
it 'imports translations from CSV files and updates YAML files' do
191+
output = capture_stdout { cli.run }
192+
193+
expect(output).to include('Updated locales/en.yml from en.csv')
194+
expect(output).to include('Updated locales/es.yml from es.csv')
195+
expect(output).to include('Imported translations from 2 CSV files')
196+
197+
# Check that YAML files were updated
198+
en_content = YAML.load_file('locales/en.yml')
199+
expect(en_content['en']['hello']).to eq('Hello Updated')
200+
expect(en_content['en']['new_key']).to eq('New Value')
201+
202+
es_content = YAML.load_file('locales/es.yml')
203+
expect(es_content['es']['hello']).to eq('Hola Actualizado')
204+
expect(es_content['es']['nested']['greeting']).to eq('Buenos días actualizados')
205+
end
206+
end
207+
208+
context 'with import command (legacy single-file strategy)' do
143209
let(:temp_csv) { File.join(temp_dir, 'test_import.csv') }
144210
let(:args) { ['import', "--file=#{temp_csv}"] }
145211

146212
before do
147-
# Create a CSV file to import
213+
# Create a CSV file to import that doesn't correspond to existing YAML files
148214
CSV.open(temp_csv, 'w') do |csv|
149215
csv << ['key', 'en', 'es']
150216
csv << ['hello', 'Hello', 'Hola']
151217
csv << ['goodbye', 'Goodbye', 'Adiós']
152218
end
153219
end
154220

155-
it 'imports translations from CSV' do
221+
it 'imports translations from single CSV file (legacy mode)' do
156222
output = capture_stdout { cli.run }
157223

158-
expect(output).to include("Translations imported successfully from #{temp_csv}")
224+
expect(output).to include("Warning: Could not find corresponding YAML file")
225+
expect(output).to include("Importing into memory")
159226
end
160227
end
161228
end

0 commit comments

Comments
 (0)