ad_localize 3.6.0 → 4.0.0

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +34 -11
  4. data/README.md +148 -145
  5. data/ad_localize.gemspec +2 -0
  6. data/bin/console +1 -0
  7. data/exe/ad_localize +1 -1
  8. data/lib/ad_localize.rb +52 -11
  9. data/lib/ad_localize/ad_logger.rb +22 -9
  10. data/lib/ad_localize/cli.rb +10 -0
  11. data/lib/ad_localize/constant.rb +2 -21
  12. data/lib/ad_localize/entities/key.rb +74 -0
  13. data/lib/ad_localize/entities/locale_wording.rb +60 -0
  14. data/lib/ad_localize/entities/translation.rb +20 -0
  15. data/lib/ad_localize/entities/wording.rb +24 -0
  16. data/lib/ad_localize/interactors/execute_export_request.rb +43 -0
  17. data/lib/ad_localize/interactors/export_csv_files.rb +17 -0
  18. data/lib/ad_localize/interactors/export_g_spreadsheet.rb +55 -0
  19. data/lib/ad_localize/interactors/export_wording.rb +27 -0
  20. data/lib/ad_localize/interactors/merge_wordings.rb +43 -0
  21. data/lib/ad_localize/interactors/platforms/export_android_locale_wording.rb +39 -0
  22. data/lib/ad_localize/interactors/platforms/export_ios_locale_wording.rb +62 -0
  23. data/lib/ad_localize/interactors/platforms/export_json_locale_wording.rb +23 -0
  24. data/lib/ad_localize/interactors/platforms/export_platform_factory.rb +44 -0
  25. data/lib/ad_localize/interactors/platforms/export_properties_locale_wording.rb +29 -0
  26. data/lib/ad_localize/interactors/platforms/export_yaml_locale_wording.rb +23 -0
  27. data/lib/ad_localize/mappers/android_translation_mapper.rb +18 -0
  28. data/lib/ad_localize/mappers/csv_path_to_wording.rb +76 -0
  29. data/lib/ad_localize/mappers/ios_translation_mapper.rb +12 -0
  30. data/lib/ad_localize/mappers/locale_wording_to_hash.rb +25 -0
  31. data/lib/ad_localize/mappers/options_to_export_request.rb +28 -0
  32. data/lib/ad_localize/mappers/translation_group_mapper.rb +14 -0
  33. data/lib/ad_localize/mappers/translation_mapper.rb +30 -0
  34. data/lib/ad_localize/mappers/value_range_to_wording.rb +69 -0
  35. data/lib/ad_localize/option_handler.rb +30 -57
  36. data/lib/ad_localize/repositories/file_system_repository.rb +13 -0
  37. data/lib/ad_localize/repositories/g_sheets_repository.rb +44 -0
  38. data/lib/ad_localize/requests/export_request.rb +77 -0
  39. data/lib/ad_localize/requests/g_spreadsheet_options.rb +47 -0
  40. data/lib/ad_localize/requests/merge_policy.rb +28 -0
  41. data/lib/ad_localize/serializers/info_plist_serializer.rb +27 -0
  42. data/lib/ad_localize/serializers/json_serializer.rb +13 -0
  43. data/lib/ad_localize/serializers/localizable_strings_serializer.rb +27 -0
  44. data/lib/ad_localize/serializers/localizable_stringsdict_serializer.rb +35 -0
  45. data/lib/ad_localize/serializers/properties_serializer.rb +25 -0
  46. data/lib/ad_localize/serializers/strings_serializer.rb +33 -0
  47. data/lib/ad_localize/serializers/with_template.rb +19 -0
  48. data/lib/ad_localize/serializers/yaml_serializer.rb +13 -0
  49. data/lib/ad_localize/templates/android/strings.xml.erb +19 -0
  50. data/lib/ad_localize/templates/ios/InfoPlist.strings.erb +3 -0
  51. data/lib/ad_localize/templates/ios/Localizable.strings.erb +3 -0
  52. data/lib/ad_localize/templates/ios/Localizable.stringsdict.erb +41 -0
  53. data/lib/ad_localize/templates/properties/template.properties.erb +3 -0
  54. data/lib/ad_localize/version.rb +1 -1
  55. data/lib/ad_localize/view_models/translation_group_view_model.rb +15 -0
  56. data/lib/ad_localize/view_models/translation_view_model.rb +19 -0
  57. metadata +74 -11
  58. data/Makefile +0 -11
  59. data/lib/ad_localize/csv_file_manager.rb +0 -64
  60. data/lib/ad_localize/csv_parser.rb +0 -165
  61. data/lib/ad_localize/platform/android_formatter.rb +0 -70
  62. data/lib/ad_localize/platform/ios_formatter.rb +0 -143
  63. data/lib/ad_localize/platform/json_formatter.rb +0 -17
  64. data/lib/ad_localize/platform/platform_formatter.rb +0 -109
  65. data/lib/ad_localize/platform/yml_formatter.rb +0 -19
  66. data/lib/ad_localize/runner.rb +0 -55
@@ -0,0 +1,14 @@
1
+ module AdLocalize
2
+ module Mappers
3
+ class TranslationGroupMapper
4
+ def initialize(translation_mapper: TranslationMapper.new)
5
+ @translation_mapper = translation_mapper
6
+ end
7
+
8
+ def map(label:, translations:)
9
+ translation_view_models = translations.map { |translation| @translation_mapper.map(translation: translation) }
10
+ ViewModels::TranslationGroupViewModel.new(label: label, translation_view_models: translation_view_models)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,30 @@
1
+ module AdLocalize
2
+ module Mappers
3
+ class TranslationMapper
4
+ def map(translation:)
5
+ ViewModels::TranslationViewModel.new(
6
+ label: translation.key.label,
7
+ key: key(translation: translation),
8
+ value: sanitize_value(value: translation.value),
9
+ comment: translation.comment
10
+ )
11
+ end
12
+
13
+ protected
14
+
15
+ def sanitize_value(value:)
16
+ value
17
+ end
18
+
19
+ private
20
+
21
+ def key(translation:)
22
+ if translation.key.plural?
23
+ translation.key.plural_key
24
+ elsif translation.key.adaptive?
25
+ translation.key.adaptive_key
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,69 @@
1
+ module AdLocalize
2
+ module Mappers
3
+ class ValueRangeToWording
4
+ def map(value_range:)
5
+ values = value_range.values
6
+ analyze_header(first_row: values.first)
7
+ return unless valid_header?
8
+
9
+ translations = map_rows(values: values)
10
+ locale_wordings = translations.group_by(&:locale).map do |locale, group|
11
+ Entities::LocaleWording.new(locale: locale, translations: group)
12
+ end
13
+ Entities::Wording.new(locale_wordings: locale_wordings, default_locale: @locale_mapping.keys.first)
14
+ end
15
+
16
+ private
17
+
18
+ def map_rows(values:)
19
+ translations = []
20
+ values[1..-1].each do |row|
21
+ row_translations = map_row(row: row)
22
+ next if row_translations.blank?
23
+
24
+ existing_keys = translations.map(&:key)
25
+ new_translations = row_translations.reject do |translation|
26
+ existing_keys.any? do |key|
27
+ existing_plural_key = key.label == translation.key.label && key.plural? && translation.key.singular?
28
+ key.same_as?(key: translation.key) || existing_plural_key
29
+ end
30
+ end
31
+ translations.concat(new_translations)
32
+ end
33
+ translations
34
+ end
35
+
36
+ def analyze_header(first_row:)
37
+ @header = first_row
38
+ @key_index = first_row.index(Constant::CSV_WORDING_KEYS_COLUMN)
39
+ @locale_mapping = {}
40
+ first_row[@key_index.succ..-1].each_index do |relative_index|
41
+ absolute_index = @key_index.succ + relative_index
42
+ next if first_row[absolute_index].blank? || first_row[absolute_index].include?(Constant::COMMENT_KEY_COLUMN_IDENTIFIER)
43
+ @locale_mapping[first_row[absolute_index]] = { key_index: absolute_index }
44
+ comment_column_name = "#{Constant::COMMENT_KEY_COLUMN_IDENTIFIER} #{first_row[absolute_index]}"
45
+ @locale_mapping[first_row[absolute_index]][:comment_index] = first_row.index(comment_column_name)
46
+ end
47
+ end
48
+
49
+ def map_row(row:)
50
+ csv_wording_key = row[@key_index]
51
+ return if csv_wording_key.blank?
52
+ key = Entities::Key.new(label: csv_wording_key)
53
+ @locale_mapping.map do |locale, index_mapping|
54
+ comment = index_mapping[:comment_index].nil? ? nil : row[index_mapping[:comment_index]]
55
+ Entities::Translation.new(
56
+ locale: locale,
57
+ key: key,
58
+ value: row[index_mapping[:key_index]],
59
+ comment: comment
60
+ )
61
+ end
62
+ end
63
+
64
+ def valid_header?
65
+ @key_index.present? || @locale_mapping.keys.size.positive?
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,65 +1,38 @@
1
1
  module AdLocalize
2
2
  class OptionHandler
3
- GOOGLE_DRIVE_DOCUMENT_ID = { length: 32, regexp: /\A[\w-]+\Z/ }
3
+ def self.parse!(options)
4
+ option_parser = OptionParser.new do |parser|
5
+ parser.banner = "Usage: exe/ad_localize [options] file(s)"
4
6
 
5
- class << self
6
- def parse
7
- args = { debug: false, only: nil}
8
-
9
- opt_parser = OptionParser.new do |opts|
10
- opts.banner = "Usage: ruby bin/export [options] file(s)"
11
-
12
-
13
- opts.on("-h", "--help", "Prints help") do
14
- puts opts
15
- exit
16
- end
17
- opts.on("-d", "--debug", "Run in debug mode") do
18
- args[:debug] = true
19
- LOGGER.debug!
20
- end
21
- opts.on("-k", "--drive-key #{GOOGLE_DRIVE_DOCUMENT_ID.dig(:length)}_characters", String, "Use google drive spreadsheets") do |key|
22
- is_valid_drive_key = !!(key =~ GOOGLE_DRIVE_DOCUMENT_ID.dig(:regexp)) && (key.size >= GOOGLE_DRIVE_DOCUMENT_ID.dig(:length))
23
- raise ArgumentError.new("Invalid google drive spreadsheet key \"#{key}\"") unless is_valid_drive_key
24
- args[:drive_key] = key
25
- end
26
- opts.on("-s", "--drive-sheet SHEET_ID", String, "Use a specific sheet id for Google Drive spreadsheets with several sheets") do |value|
27
- args[:sheet_id] = value
28
- end
29
- opts.on("-a", "--use-service-account", "Use a Google Cloud Service Account to access the file. An GCLOUD_CLIENT_SECRET environment variable containting the client_secret.json content is needed.") do
30
- args[:use_service_account] = true
31
- end
32
- opts.on("-o", "--only platform1,platform2", Array, "Only generate localisation files for the specified platforms. Supported platforms : #{Constant::SUPPORTED_PLATFORMS.join(', ')}") do |platforms|
33
- args[:only] = filter_option_args("-o", platforms) { |platform| !!Constant::SUPPORTED_PLATFORMS.index(platform) }
34
- end
35
- opts.on("-t", "--target-dir PATH", String, "Path to the target directory") do |output_path|
36
- pn = Pathname.new(output_path)
37
- if pn.directory? and pn.readable? and pn.writable?
38
- args[:output_path] = output_path
39
- else
40
- raise ArgumentError.new("Invalid target directory. Check the permissions")
41
- end
42
- end
7
+ parser.on("-d", "--debug", TrueClass, "Run in debug mode")
8
+ parser.on("-e", "--export-all-sheets", TrueClass,
9
+ <<~DOC
10
+ Export all sheets from spreadsheet specified by --drive-key option.
11
+ \tBy default, generates one export directory per sheet (see -m|--merge-sheets option to merge them).
12
+ \tAn GCLOUD_CLIENT_SECRET environment variable containing the client_secret.json content is needed.
13
+ DOC
14
+ )
15
+ parser.on("-h", "--help", "Prints help") do
16
+ puts parser
17
+ exit
43
18
  end
44
-
45
- begin
46
- opt_parser.parse!
47
- rescue OptionParser::MissingArgument => e
48
- LOGGER.log(:error, :red, "Missing argument for option #{e.args.join(',')}")
49
- rescue ArgumentError => e
50
- LOGGER.log(:error, :red, e.message)
51
- raise e
52
- end
53
- args
19
+ parser.on("-k", "--drive-key SPREADSHEET_ID", String, "Use google drive spreadsheets")
20
+ parser.on("-m", "--merge-policy POLICY", String,
21
+ <<~DOC
22
+ Merge specified csv (or sheets from --export-all) instead of exporting each csv.
23
+ \treplace: if a key is already defined, replace its value.
24
+ \tkeep: if a key is already defined, keep the previous value.
25
+ DOC
26
+ )
27
+ parser.on("-o", "--only PLATFORMS", Array, "PLATFORMS is a comma separated list. Only generate localisation files for the specified platforms. Supported platforms : #{Requests::ExportRequest::SUPPORTED_PLATFORMS.to_sentence}")
28
+ parser.on("-s", "--sheets SHEET_IDS", Array, "SHEET_IDS is a comma separated list. Use a specific sheet id for Google Drive spreadsheets with several sheets")
29
+ parser.on("-t", "--target-dir PATH", String, "Path to the target directory")
54
30
  end
55
31
 
56
- private
57
- def filter_option_args(option, args)
58
- result = args.select { |arg| yield(arg) }
59
- invalid_args = args - result
60
- LOGGER.log(:debug, :red, "Invalid arg(s) for option #{option}: #{invalid_args.join(', ')}") unless invalid_args.empty?
61
- result
62
- end
32
+ args = {}
33
+ option_parser.parse!(options, into: args)
34
+ args[:csv_paths] = options
35
+ return args
63
36
  end
64
37
  end
65
- end
38
+ end
@@ -0,0 +1,13 @@
1
+ module AdLocalize
2
+ module Repositories
3
+ class FileSystemRepository
4
+ def create_directory(path:)
5
+ path.mkpath unless path.directory?
6
+ end
7
+
8
+ def write(content:, path:)
9
+ path.open("w") { |file| file.write content }
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,44 @@
1
+ module AdLocalize
2
+ module Repositories
3
+ class GSheetsRepository
4
+ SCOPES = [Google::Apis::SheetsV4::AUTH_SPREADSHEETS_READONLY]
5
+ ROWS_MAJOR_DIMENSION = 'ROWS'.freeze
6
+ SPREADSHEET_APPLICATION_NAME = 'ad_localize'.freeze
7
+
8
+ def initialize
9
+ @service = Google::Apis::SheetsV4::SheetsService.new
10
+ @service.client_options.application_name = SPREADSHEET_APPLICATION_NAME
11
+ end
12
+
13
+ def get_sheets_values(g_spreadsheet_options:)
14
+ configure(json_configuration: g_spreadsheet_options.service_account_config) if @service.authorization.nil?
15
+ spreadsheet = @service.get_spreadsheet(g_spreadsheet_options.spreadsheet_id)
16
+ if g_spreadsheet_options.export_all
17
+ spreadsheet.sheets.map { |sheet| get_sheet_values(spreadsheet_id: g_spreadsheet_options.spreadsheet_id, sheet: sheet) }
18
+ elsif g_spreadsheet_options.sheet_ids.nil? || g_spreadsheet_options.sheet_ids.size.zero?
19
+ spreadsheet.sheets[0..0].map { |sheet| get_sheet_values(spreadsheet_id: g_spreadsheet_options.spreadsheet_id, sheet: sheet) }
20
+ else
21
+ spreadsheet.sheets.select do |sheet|
22
+ g_spreadsheet_options.sheet_ids.include?(sheet.properties.sheet_id)
23
+ end.map do |sheet|
24
+ get_sheet_values(spreadsheet_id: g_spreadsheet_options.spreadsheet_id, sheet: sheet)
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def configure(json_configuration:)
32
+ raise ArgumentError.new('No service account configuration') if json_configuration.blank?
33
+ json_key_io = StringIO.new(json_configuration)
34
+ authorizer = Google::Auth::ServiceAccountCredentials.make_creds(json_key_io: json_key_io, scope: SCOPES)
35
+ @service.authorization = authorizer
36
+ end
37
+
38
+ def get_sheet_values(spreadsheet_id:, sheet:)
39
+ range = sheet.properties.title
40
+ @service.get_spreadsheet_values(spreadsheet_id, range, major_dimension: ROWS_MAJOR_DIMENSION)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,77 @@
1
+ module AdLocalize
2
+ module Requests
3
+ class ExportRequest
4
+ SUPPORTED_PLATFORMS = %w(ios android yml json properties).freeze
5
+ DEFAULT_EXPORT_FOLDER = 'exports'.freeze
6
+ CSV_CONTENT_TYPES = %w(text/csv text/plain).freeze
7
+
8
+ def initialize(**args)
9
+ @locales = Array(args[:locales].presence)
10
+ @platforms = args[:platforms].blank? ? SUPPORTED_PLATFORMS : Array(args[:platforms])
11
+ @csv_paths = Array(args[:csv_paths])
12
+ @g_spreadsheet_options = args[:g_spreadsheet_options]
13
+ @verbose = args[:verbose].presence || false
14
+ @output_path = Pathname.new(args[:output_path].presence || DEFAULT_EXPORT_FOLDER)
15
+ if @csv_paths.size > 1 || @g_spreadsheet_options&.has_multiple_sheets?
16
+ @merge_policy = MergePolicy.new(policy: args[:merge_policy].presence || MergePolicy::DEFAULT_POLICY)
17
+ else
18
+ @merge_policy = nil
19
+ end
20
+ end
21
+
22
+ attr_reader(
23
+ :locales,
24
+ :platforms,
25
+ :g_spreadsheet_options,
26
+ :output_path,
27
+ :verbose,
28
+ :merge_policy
29
+ )
30
+
31
+ attr_accessor(:csv_paths)
32
+
33
+ def has_csv_files?
34
+ !@csv_paths.blank? && @csv_paths.all? { |csv_path| File.exist?(csv_path) && is_csv?(path: csv_path) }
35
+ end
36
+
37
+ def has_g_spreadsheet_options?
38
+ @g_spreadsheet_options.present?
39
+ end
40
+
41
+ def multiple_platforms?
42
+ @platforms.size > 1
43
+ end
44
+
45
+ def valid?
46
+ valid_platforms? && (valid_csv_options? || valid_g_spreadsheet_options?)
47
+ end
48
+
49
+ def verbose?
50
+ verbose
51
+ end
52
+
53
+ private
54
+
55
+ def valid_csv_options?
56
+ has_csv_files? && (@csv_paths.size == 1 || (@csv_paths.size > 1 && @merge_policy&.valid?))
57
+ end
58
+
59
+ def valid_platforms?
60
+ @platforms.size.positive? && (@platforms & SUPPORTED_PLATFORMS).size == @platforms.size
61
+ end
62
+
63
+ def is_csv?(path:)
64
+ (MIME::Types.of(path).map(&:content_type) & CSV_CONTENT_TYPES).present?
65
+ end
66
+
67
+ def valid_g_spreadsheet_options?
68
+ return false if @g_spreadsheet_options.blank?
69
+ if @g_spreadsheet_options.has_multiple_sheets?
70
+ @g_spreadsheet_options.valid? && @merge_policy&.valid?
71
+ else
72
+ @g_spreadsheet_options.valid?
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,47 @@
1
+ module AdLocalize
2
+ module Requests
3
+ class GSpreadsheetOptions
4
+ attr_accessor(
5
+ :spreadsheet_id,
6
+ :sheet_ids,
7
+ :export_all,
8
+ :service_account_config
9
+ )
10
+
11
+ def initialize(**args)
12
+ @spreadsheet_id = args[:spreadsheet_id]
13
+ @sheet_ids = Array(args[:sheet_ids])
14
+ @export_all = args[:export_all] || false
15
+ @service_account_config = args[:service_account_config].presence
16
+ end
17
+
18
+ def valid?
19
+ (spreadsheet_id && !export_all) || (spreadsheet_id && service_account_config.present?)
20
+ end
21
+
22
+ def public_download_urls
23
+ return [] if @service_account_config
24
+ if @sheet_ids.size.zero?
25
+ [public_download_url(sheet_id: nil)]
26
+ else
27
+ @sheet_ids.map { |sheet_id| public_download_url(sheet_id: sheet_id) }
28
+ end
29
+ end
30
+
31
+ def has_multiple_sheets?
32
+ export_all || @sheet_ids.size > 1
33
+ end
34
+
35
+ private
36
+
37
+ def public_download_url(sheet_id:)
38
+ query_id = sheet_id.blank? ? "id=#{@spreadsheet_id}" : "gid=#{sheet_id}"
39
+ "https://docs.google.com/spreadsheets/d/#{@spreadsheet_id}/export?format=csv&#{query_id}"
40
+ end
41
+
42
+ def valid_export_all_config
43
+ export_all && service_account_config.present?
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,28 @@
1
+ module AdLocalize
2
+ module Requests
3
+ class MergePolicy
4
+ REPLACE_POLICY = 'replace'.freeze
5
+ KEEP_POLICY = 'keep'.freeze
6
+ MERGE_POLICIES = [KEEP_POLICY, REPLACE_POLICY]
7
+ DEFAULT_POLICY = KEEP_POLICY
8
+
9
+ attr_reader(:policy)
10
+
11
+ def initialize(policy:)
12
+ @policy = policy
13
+ end
14
+
15
+ def keep?
16
+ @policy == KEEP_POLICY
17
+ end
18
+
19
+ def replace?
20
+ @policy == REPLACE_POLICY
21
+ end
22
+
23
+ def valid?
24
+ @policy.present? && MERGE_POLICIES.include?(@policy)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ module AdLocalize
2
+ module Serializers
3
+ class InfoPlistSerializer
4
+ include WithTemplate
5
+
6
+ INFO_PLIST_FILENAME = "InfoPlist.strings".freeze
7
+
8
+ def initialize
9
+ @translation_mapper = Mappers::IOSTranslationMapper.new
10
+ end
11
+
12
+ private
13
+
14
+ def template_path
15
+ TEMPLATES_DIRECTORY + "/ios/#{INFO_PLIST_FILENAME}.erb"
16
+ end
17
+
18
+ def hash_binding(locale_wording:)
19
+ { translations: map_translations(translations: locale_wording.info_plists) }
20
+ end
21
+
22
+ def map_translations(translations:)
23
+ translations.map { |translation| @translation_mapper.map(translation: translation) }
24
+ end
25
+ end
26
+ end
27
+ end