22require 'optparse'
33require 'csv'
44require 'set'
5+ require 'yaml'
56
67module 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
287439end
0 commit comments