i18n-processes 0.1.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 (132) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile.lock +102 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +46 -0
  5. data/Rakefile +12 -0
  6. data/bin/i18n-processes +28 -0
  7. data/bin/i18n-processes.cmd +2 -0
  8. data/config/locales/en.yml +2 -0
  9. data/config/locales/zh-CN.yml +2 -0
  10. data/i18n-processes.gemspec +64 -0
  11. data/lib/i18n/processes/base_process.rb +47 -0
  12. data/lib/i18n/processes/cli.rb +208 -0
  13. data/lib/i18n/processes/command/collection.rb +21 -0
  14. data/lib/i18n/processes/command/commander.rb +43 -0
  15. data/lib/i18n/processes/command/commands/data.rb +107 -0
  16. data/lib/i18n/processes/command/commands/eq_base.rb +21 -0
  17. data/lib/i18n/processes/command/commands/health.rb +26 -0
  18. data/lib/i18n/processes/command/commands/meta.rb +38 -0
  19. data/lib/i18n/processes/command/commands/missing.rb +86 -0
  20. data/lib/i18n/processes/command/commands/preprocessing.rb +90 -0
  21. data/lib/i18n/processes/command/commands/tree.rb +119 -0
  22. data/lib/i18n/processes/command/commands/usages.rb +69 -0
  23. data/lib/i18n/processes/command/commands/xlsx.rb +29 -0
  24. data/lib/i18n/processes/command/dsl.rb +56 -0
  25. data/lib/i18n/processes/command/option_parsers/enum.rb +55 -0
  26. data/lib/i18n/processes/command/option_parsers/locale.rb +60 -0
  27. data/lib/i18n/processes/command/options/common.rb +41 -0
  28. data/lib/i18n/processes/command/options/data.rb +95 -0
  29. data/lib/i18n/processes/command/options/locales.rb +36 -0
  30. data/lib/i18n/processes/command_error.rb +13 -0
  31. data/lib/i18n/processes/commands.rb +31 -0
  32. data/lib/i18n/processes/configuration.rb +132 -0
  33. data/lib/i18n/processes/console_context.rb +76 -0
  34. data/lib/i18n/processes/data/adapter/json_adapter.rb +29 -0
  35. data/lib/i18n/processes/data/adapter/yaml_adapter.rb +27 -0
  36. data/lib/i18n/processes/data/file_formats.rb +111 -0
  37. data/lib/i18n/processes/data/file_system.rb +14 -0
  38. data/lib/i18n/processes/data/file_system_base.rb +205 -0
  39. data/lib/i18n/processes/data/router/conservative_router.rb +66 -0
  40. data/lib/i18n/processes/data/router/pattern_router.rb +60 -0
  41. data/lib/i18n/processes/data/tree/node.rb +204 -0
  42. data/lib/i18n/processes/data/tree/nodes.rb +97 -0
  43. data/lib/i18n/processes/data/tree/siblings.rb +333 -0
  44. data/lib/i18n/processes/data/tree/traversal.rb +190 -0
  45. data/lib/i18n/processes/data.rb +87 -0
  46. data/lib/i18n/processes/google_translation.rb +125 -0
  47. data/lib/i18n/processes/html_keys.rb +16 -0
  48. data/lib/i18n/processes/ignore_keys.rb +30 -0
  49. data/lib/i18n/processes/key_pattern_matching.rb +37 -0
  50. data/lib/i18n/processes/locale_list.rb +19 -0
  51. data/lib/i18n/processes/locale_pathname.rb +17 -0
  52. data/lib/i18n/processes/logging.rb +37 -0
  53. data/lib/i18n/processes/missing_keys.rb +122 -0
  54. data/lib/i18n/processes/path.rb +42 -0
  55. data/lib/i18n/processes/plural_keys.rb +41 -0
  56. data/lib/i18n/processes/rainbow_utils.rb +13 -0
  57. data/lib/i18n/processes/references.rb +101 -0
  58. data/lib/i18n/processes/reports/base.rb +71 -0
  59. data/lib/i18n/processes/reports/spreadsheet.rb +72 -0
  60. data/lib/i18n/processes/reports/terminal.rb +252 -0
  61. data/lib/i18n/processes/scanners/file_scanner.rb +65 -0
  62. data/lib/i18n/processes/scanners/files/caching_file_finder.rb +34 -0
  63. data/lib/i18n/processes/scanners/files/caching_file_finder_provider.rb +33 -0
  64. data/lib/i18n/processes/scanners/files/caching_file_reader.rb +28 -0
  65. data/lib/i18n/processes/scanners/files/file_finder.rb +60 -0
  66. data/lib/i18n/processes/scanners/files/file_reader.rb +19 -0
  67. data/lib/i18n/processes/scanners/occurrence_from_position.rb +27 -0
  68. data/lib/i18n/processes/scanners/pattern_mapper.rb +60 -0
  69. data/lib/i18n/processes/scanners/pattern_scanner.rb +103 -0
  70. data/lib/i18n/processes/scanners/pattern_with_scope_scanner.rb +98 -0
  71. data/lib/i18n/processes/scanners/relative_keys.rb +53 -0
  72. data/lib/i18n/processes/scanners/results/key_occurrences.rb +54 -0
  73. data/lib/i18n/processes/scanners/results/occurrence.rb +69 -0
  74. data/lib/i18n/processes/scanners/ruby_ast_call_finder.rb +62 -0
  75. data/lib/i18n/processes/scanners/ruby_ast_scanner.rb +206 -0
  76. data/lib/i18n/processes/scanners/ruby_key_literals.rb +30 -0
  77. data/lib/i18n/processes/scanners/scanner.rb +17 -0
  78. data/lib/i18n/processes/scanners/scanner_multiplexer.rb +41 -0
  79. data/lib/i18n/processes/split_key.rb +68 -0
  80. data/lib/i18n/processes/stats.rb +24 -0
  81. data/lib/i18n/processes/string_interpolation.rb +16 -0
  82. data/lib/i18n/processes/unused_keys.rb +23 -0
  83. data/lib/i18n/processes/used_keys.rb +177 -0
  84. data/lib/i18n/processes/version.rb +7 -0
  85. data/lib/i18n/processes.rb +69 -0
  86. data/source/p1/_messages/zh/article.properties +9 -0
  87. data/source/p1/_messages/zh/company.properties +62 -0
  88. data/source/p1/_messages/zh/devices.properties +40 -0
  89. data/source/p1/_messages/zh/meeting-rooms.properties +99 -0
  90. data/source/p1/_messages/zh/meetingBooking.properties +18 -0
  91. data/source/p1/_messages/zh/office-areas.properties +64 -0
  92. data/source/p1/_messages/zh/orders.properties +25 -0
  93. data/source/p1/_messages/zh/schedulings.properties +7 -0
  94. data/source/p1/_messages/zh/tag.properties +2 -0
  95. data/source/p1/_messages/zh/ticket.properties +9 -0
  96. data/source/p1/_messages/zh/visitor.properties +5 -0
  97. data/source/p1/messages +586 -0
  98. data/source/p2/orders.properties +25 -0
  99. data/source/p2/schedulings.properties +7 -0
  100. data/source/p2/tag.properties +2 -0
  101. data/source/p2/ticket.properties +9 -0
  102. data/source/p2/visitor.properties +5 -0
  103. data/source/zh.messages.ts +30 -0
  104. data/translated/en/p1/_messages/zh/article.properties +9 -0
  105. data/translated/en/p1/_messages/zh/company.properties +62 -0
  106. data/translated/en/p1/_messages/zh/devices.properties +40 -0
  107. data/translated/en/p1/_messages/zh/meeting-rooms.properties +99 -0
  108. data/translated/en/p1/_messages/zh/meetingBooking.properties +18 -0
  109. data/translated/en/p1/_messages/zh/office-areas.properties +64 -0
  110. data/translated/en/p1/_messages/zh/orders.properties +25 -0
  111. data/translated/en/p1/_messages/zh/schedulings.properties +7 -0
  112. data/translated/en/p1/_messages/zh/tag.properties +2 -0
  113. data/translated/en/p1/_messages/zh/ticket.properties +9 -0
  114. data/translated/en/p1/_messages/zh/visitor.properties +5 -0
  115. data/translated/en/p1/messages +586 -0
  116. data/translated/en/p2/orders.properties +25 -0
  117. data/translated/en/p2/schedulings.properties +7 -0
  118. data/translated/en/p2/tag.properties +2 -0
  119. data/translated/en/p2/ticket.properties +9 -0
  120. data/translated/en/p2/visitor.properties +5 -0
  121. data/translated/en/zh.messages.ts +30 -0
  122. data/translation/en/article.properties +9 -0
  123. data/translation/en/company.properties +56 -0
  124. data/translation/en/meeting-rooms.properties +87 -0
  125. data/translation/en/meetingBooking.properties +14 -0
  126. data/translation/en/messages.en +164 -0
  127. data/translation/en/office-areas.properties +51 -0
  128. data/translation/en/orders.properties +26 -0
  129. data/translation/en/tag.properties +2 -0
  130. data/translation/en/translated +1263 -0
  131. data/translation/en/visitor.properties +4 -0
  132. metadata +408 -0
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'yaml'
5
+ module I18n
6
+ module Processes
7
+ module Data
8
+ module FileFormats
9
+ def self.included(base)
10
+ base.extend ClassMethods
11
+ end
12
+
13
+ def adapter_dump(tree, format)
14
+ adapter_op :dump, format, tree, write_config(format)
15
+ end
16
+
17
+ # @return [Hash]
18
+ def adapter_parse(tree, format)
19
+ adapter_op :parse, format, tree, read_config(format)
20
+ end
21
+
22
+ def adapter_op(op, format, tree, config)
23
+ self.class.adapter_by_name(format).send(op, tree, config)
24
+ rescue Exception => e # rubocop:disable Lint/RescueException
25
+ raise CommandError, "#{format} #{op} error: #{e.message}"
26
+ end
27
+
28
+
29
+
30
+ protected
31
+
32
+ def write_config(format)
33
+ (config[format] || {})[:write]
34
+ end
35
+
36
+ def read_config(format)
37
+ (config[format] || {})[:read]
38
+ end
39
+
40
+
41
+
42
+ # def source_config(format)
43
+ # (config[format] || {})[:source]
44
+ # end
45
+ #
46
+ # def translation_config(format)
47
+ # (config[format] || {})[:translation]
48
+ # end
49
+ #
50
+ # def translated_config(format)
51
+ # (config[format] || {})[:translated]
52
+ # end
53
+
54
+ # @return [Hash]
55
+ def load_file(path)
56
+ adapter_parse read_file(path), self.class.adapter_name_for_path(path)
57
+ end
58
+
59
+ # @return [String]
60
+ def read_file(path)
61
+ ::File.read(path, encoding: 'UTF-8')
62
+ end
63
+
64
+ def write_tree(path, tree)
65
+ hash = tree.to_hash(true)
66
+ adapter = self.class.adapter_name_for_path(path)
67
+ content = adapter_dump(hash, adapter)
68
+ # Ignore unchanged data
69
+ return if File.file?(path) && content == read_file(path)
70
+ ::FileUtils.mkpath(File.dirname(path))
71
+ ::File.open(path, 'w') { |f| f.write content }
72
+ end
73
+
74
+ def normalized?(path, tree)
75
+ return false unless File.file?(path)
76
+ read_file(path) == adapter_dump(tree.to_hash(true), self.class.adapter_name_for_path(path))
77
+ end
78
+
79
+ module ClassMethods
80
+ # @param pattern [String] File.fnmatch pattern
81
+ # @param adapter [responds to parse(string)->hash and dump(hash)->string]
82
+ def register_adapter(name, pattern, adapter)
83
+ (@fn_patterns ||= []) << [name, pattern, adapter]
84
+ end
85
+
86
+ def adapter_name_for_path(path)
87
+ @fn_patterns.detect do |(_name, pattern, _adapter)|
88
+ ::File.fnmatch(pattern, path)
89
+ end.try(:first) || fail(
90
+ CommandError, "Adapter not found for #{path}. Registered adapters: #{@fn_patterns.inspect}"
91
+ )
92
+ end
93
+
94
+ def adapter_names
95
+ @fn_patterns.map(&:first)
96
+ end
97
+
98
+ def adapter_by_name(name)
99
+ name = name.to_s
100
+ @fn_patterns.detect do |(adapter_name, _pattern, _adapter)|
101
+ adapter_name.to_s == name
102
+ end.try(:last) || fail(
103
+ CommandError,
104
+ "Adapter with name #{name.inspect} not found. Registered adapters: #{@fn_patterns.inspect}"
105
+ )
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/processes/data/file_system_base'
4
+ require 'i18n/processes/data/adapter/json_adapter'
5
+ require 'i18n/processes/data/adapter/yaml_adapter'
6
+
7
+ module I18n::Processes
8
+ module Data
9
+ class FileSystem < FileSystemBase
10
+ register_adapter :yaml, '*.yml', Adapter::YamlAdapter
11
+ register_adapter :json, '*.json', Adapter::JsonAdapter
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/processes/data/tree/node'
4
+ require 'i18n/processes/data/router/pattern_router'
5
+ require 'i18n/processes/data/router/conservative_router'
6
+ require 'i18n/processes/data/file_formats'
7
+ require 'i18n/processes/key_pattern_matching'
8
+
9
+ module I18n::Processes
10
+ module Data
11
+ class FileSystemBase # rubocop:disable Metrics/ClassLength
12
+ include I18n::Processes::Data::FileFormats
13
+ include I18n::Processes::Logging
14
+
15
+ attr_reader :config, :base_locale, :locales
16
+ attr_writer :locales
17
+
18
+ DEFAULTS = {
19
+ read: ['config/locales/%{locale}.yml'],
20
+ write: ['config/locales/%{locale}.yml']
21
+ }.freeze
22
+
23
+ def initialize(config = {})
24
+ self.config = config.except(:base_locale, :locales)
25
+ @base_locale = config[:base_locale]
26
+ locales = config[:locales].presence
27
+ @locales = LocaleList.normalize_locale_list(locales || available_locales, base_locale, true)
28
+ if locales.present?
29
+ log_verbose "locales read from config #{@locales * ', '}"
30
+ else
31
+ log_verbose "locales inferred from data: #{@locales * ', '}"
32
+ end
33
+ end
34
+
35
+ # @param [String, Symbol] locale
36
+ # @return [::I18n::Processes::Data::Siblings]
37
+ def get(locale)
38
+ locale = locale.to_s
39
+ @trees ||= {}
40
+ @trees[locale] ||= read_locale(locale)
41
+ end
42
+
43
+ alias [] get
44
+
45
+ # @param [String, Symbol] locale
46
+ # @return [::I18n::Processes::Data::Siblings]
47
+ def external(locale)
48
+ locale = locale.to_s
49
+ @external ||= {}
50
+ @external[locale] ||= read_locale(locale, paths: config[:external])
51
+ end
52
+
53
+ # set locale tree
54
+ # @param [String, Symbol] locale
55
+ # @param [::I18n::Processes::Data::Siblings] tree
56
+ def set(locale, tree)
57
+ locale = locale.to_s
58
+ @trees.delete(locale) if @trees
59
+ paths_before = Set.new(get(locale)[locale].leaves.map { |node| node.data[:path] })
60
+ paths_after = Set.new([])
61
+ # $stderr.puts Rainbow("locale: #{locale}").green
62
+ # $stderr.puts Rainbow("tree: #{tree.class}").green
63
+ router.route locale, tree do |path, tree_slice|
64
+ paths_after << path
65
+ write_tree path, tree_slice
66
+ end
67
+ (paths_before - paths_after).each do |path|
68
+ FileUtils.remove_file(path) if File.exist?(path)
69
+ end
70
+ @trees.delete(locale) if @trees
71
+ @available_locales = nil
72
+ end
73
+
74
+ alias []= set
75
+
76
+ # @param [String] locale
77
+ # @return [Array<String>] paths to files that are not normalized
78
+ def non_normalized_paths(locale)
79
+ router.route(locale, get(locale))
80
+ .reject { |path, tree_slice| normalized?(path, tree_slice) }
81
+ .map(&:first)
82
+ end
83
+
84
+ def write(forest)
85
+ forest.each { |root| set(root.key, root.to_siblings) }
86
+ end
87
+
88
+ def merge!(forest)
89
+ ## forest is a sibling
90
+ forest.inject(Tree::Siblings.new) do |result, root|
91
+ # root is a node, merge is a sibling
92
+ locale = root.key
93
+ merged = get(locale).merge(root)
94
+ $stderr.puts Rainbow("root: #{root}").green
95
+ $stderr.puts Rainbow("locale: #{locale}").green
96
+ $stderr.puts Rainbow("merged: #{merged}").green
97
+ set locale, merged
98
+ result.merge! merged
99
+ end
100
+ end
101
+
102
+ def remove_by_key!(forest)
103
+ forest.inject(Tree::Siblings.new) do |removed, root|
104
+ locale = root.key
105
+ locale_data = get(locale)
106
+ subtracted = locale_data.subtract_by_key(forest)
107
+ set locale, subtracted
108
+ removed.merge! locale_data.subtract_by_key(subtracted)
109
+ end
110
+ end
111
+
112
+ # @return self
113
+ def reload
114
+ @trees = nil
115
+ @available_locales = nil
116
+ self
117
+ end
118
+
119
+ # Get available locales from the list of file names to read
120
+ def available_locales
121
+ @available_locales ||= begin
122
+ locales = Set.new
123
+ Array(config[:read]).map do |pattern|
124
+ [pattern, Dir.glob(format(pattern, locale: '*'))] if pattern.include?('%{locale}')
125
+ end.compact.each do |pattern, paths|
126
+ p = pattern.gsub('\\', '\\\\').gsub('/', '\/').gsub('.', '\.')
127
+ p = p.gsub(/(\*+)/) { Regexp.last_match(1) == '**' ? '.*' : '[^/]*?' }.gsub('%{locale}', '([^/.]+)')
128
+ re = /\A#{p}\z/
129
+ paths.each do |path|
130
+ locales << Regexp.last_match(1) if re =~ path
131
+ end
132
+ end
133
+ locales
134
+ end
135
+ end
136
+
137
+ def t(key, locale)
138
+ tree = self[locale.to_s]
139
+ return unless tree
140
+ tree[locale][key].try(:value_or_children_hash)
141
+ end
142
+
143
+ def config=(config)
144
+ @config = DEFAULTS.deep_merge((config || {}).reject { |_k, v| v.nil? })
145
+ reload
146
+ end
147
+
148
+ def with_router(router)
149
+ router_was = self.router
150
+ self.router = router
151
+ yield
152
+ ensure
153
+ self.router = router_was
154
+ end
155
+
156
+ ROUTER_NAME_ALIASES = {
157
+ 'conservative_router' => 'I18n::Processes::Data::Router::ConservativeRouter',
158
+ 'pattern_router' => 'I18n::Processes::Data::Router::PatternRouter'
159
+ }.freeze
160
+ def router
161
+ @router ||= begin
162
+ name = @config[:router].presence || 'conservative_router'
163
+ name = ROUTER_NAME_ALIASES[name] || name
164
+ router_class = ActiveSupport::Inflector.constantize(name)
165
+ router_class.new(self, @config.merge(base_locale: base_locale, locales: locales))
166
+ end
167
+ end
168
+ attr_writer :router
169
+
170
+ protected
171
+
172
+ def read_locale(locale, paths: config[:read])
173
+ Array(paths).flat_map do |path|
174
+ Dir.glob format(path, locale: locale)
175
+ end.map do |path|
176
+ [path.freeze, load_file(path) || {}]
177
+ end.map do |path, data|
178
+ filter_nil_keys! path, data
179
+ Data::Tree::Siblings.from_nested_hash(data).tap do |s|
180
+ s.leaves { |x| x.data.update(path: path, locale: locale) }
181
+ end
182
+ end.reduce(Tree::Siblings[locale => {}], :merge!)
183
+ end
184
+
185
+ def filter_nil_keys!(path, data, suffix = [])
186
+ data.each do |key, value|
187
+ if key.nil?
188
+ data.delete(key)
189
+ log_warn <<-TEXT
190
+ Skipping a nil key found in #{path.inspect}:
191
+ key: #{suffix.join('.')}.`nil`
192
+ value: #{value.inspect}
193
+ Nil keys are not supported by i18n.
194
+ The following unquoted YAML keys result in a nil key:
195
+ #{%w[null Null NULL ~].join(', ')}
196
+ See http://yaml.org/type/null.html
197
+ TEXT
198
+ elsif value.is_a?(Hash)
199
+ filter_nil_keys! path, value, suffix + [key]
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/processes/data/router/pattern_router'
4
+
5
+ module I18n::Processes
6
+ module Data::Router
7
+ # Keep the path, or infer from base locale
8
+ class ConservativeRouter < PatternRouter
9
+ def initialize(adapter, config)
10
+ @adapter = adapter
11
+ @base_locale = config[:base_locale]
12
+ @locales = config[:locales]
13
+ super
14
+ end
15
+
16
+ def route(locale, forest, &block)
17
+ return to_enum(:route, locale, forest) unless block
18
+ out = Hash.new { |hash, key| hash[key] = Set.new }
19
+ not_found = Set.new
20
+ forest.keys do |key, _node|
21
+ path = key_path(locale, key)
22
+ $stderr.puts Rainbow("path in route: #{path}").green
23
+ # infer from another locale
24
+ unless path
25
+ inferred_from = (locales - [locale]).detect { |loc| path = key_path(loc, key) }
26
+ path = LocalePathname.replace_locale(path, inferred_from, locale) if inferred_from
27
+ end
28
+ key_with_locale = "#{locale}.#{key}"
29
+ $stderr.puts Rainbow("key_with_locale in route: #{key_with_locale}").green
30
+ if path
31
+ out[path] << key_with_locale
32
+ else
33
+ not_found << key_with_locale
34
+ end
35
+ end
36
+
37
+
38
+ if not_found.present?
39
+ # fall back to pattern router
40
+ not_found_tree = forest.select_keys(root: true) { |key, _| not_found.include?(key) }
41
+ super(locale, not_found_tree) do |path, tree|
42
+ out[path] += tree.key_names(root: true)
43
+ end
44
+ end
45
+
46
+ out.each do |dest, keys|
47
+ # $stderr.puts Rainbow("dest in route: #{dest}").green
48
+ # $stderr.puts Rainbow("dest in route: #{dest}").green
49
+ block.yield dest, forest.select_keys(root: true) { |key, _| keys.include?(key) }
50
+ end
51
+ end
52
+
53
+ protected
54
+
55
+ def base_tree
56
+ adapter[base_locale]
57
+ end
58
+
59
+ def key_path(locale, key)
60
+ adapter[locale]["#{locale}.#{key}"].try(:data).try(:[], :path)
61
+ end
62
+
63
+ attr_reader :adapter, :base_locale, :locales
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/processes/key_pattern_matching'
4
+ require 'i18n/processes/data/tree/node'
5
+
6
+ module I18n::Processes
7
+ module Data::Router
8
+ # Route based on key name
9
+ class PatternRouter
10
+ include ::I18n::Processes::KeyPatternMatching
11
+
12
+ attr_reader :routes
13
+ # @option data_config write [Array] of routes
14
+ # @example
15
+ # {write:
16
+ # # keys matched top to bottom
17
+ # [['devise.*', 'config/locales/devise.%{locale}.yml'],
18
+ # # default catch-all (same as ['*', 'config/locales/%{locale}.yml'])
19
+ # 'config/locales/%{locale}.yml']}
20
+ def initialize(_adapter, data_config)
21
+ @routes_config = data_config[:write]
22
+ @routes = compile_routes @routes_config
23
+ end
24
+
25
+ # Route keys to destinations
26
+ # @param forest [I18n::Processes::Data::Tree::Siblings] forest roots are locales.
27
+ # @yieldparam [String] dest_path
28
+ # @yieldparam [I18n::Processes::Data::Tree::Siblings] tree_slice
29
+ # @return [Hash] mapping of destination => [ [key, value], ... ]
30
+ def route(locale, forest, &block)
31
+ return to_enum(:route, locale, forest) unless block
32
+ locale = locale.to_s
33
+ out = {}
34
+ forest.keys do |key, _node|
35
+ pattern, path = routes.detect { |route| route[0] =~ key }
36
+ if pattern
37
+ key_match = $~
38
+ path = format(path, locale: locale)
39
+ path.gsub!(/\\\d+/) { |m| key_match[m[1..-1].to_i] }
40
+ (out[path] ||= Set.new) << "#{locale}.#{key}"
41
+ else
42
+ fail CommandError, "Cannot route key #{key}. Routes are #{@routes_config.inspect}"
43
+ end
44
+ end
45
+ out.each do |dest, keys|
46
+ block.yield dest,
47
+ forest.select_keys(root: true) { |key, _| keys.include?(key) }
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def compile_routes(routes)
54
+ routes.map { |x| x.is_a?(String) ? ['*', x] : x }.map do |x|
55
+ [compile_key_pattern(x[0]), x[1]]
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/processes/data/tree/traversal'
4
+ require 'i18n/processes/data/tree/siblings'
5
+ require 'i18n/processes/rainbow_utils'
6
+ module I18n::Processes::Data::Tree
7
+ class Node # rubocop:disable Metrics/ClassLength
8
+ include Enumerable
9
+ include Traversal
10
+
11
+ attr_accessor :value
12
+ attr_reader :key, :children, :parent
13
+
14
+ # rubocop:disable Metrics/ParameterLists
15
+ def initialize(key:, value: nil, data: nil, parent: nil, children: nil, warn_about_add_children_to_leaf: true)
16
+ @key = key
17
+ @key = @key.to_s.freeze if @key
18
+ @value = value
19
+ @data = data
20
+ @parent = parent
21
+ @warn_about_add_children_to_leaf = warn_about_add_children_to_leaf
22
+ self.children = (children if children)
23
+ end
24
+ # rubocop:enable Metrics/ParameterLists
25
+
26
+ def attributes
27
+ { key: @key, value: @value, data: @data.try(:clone), parent: @parent, children: @children }
28
+ end
29
+
30
+ def derive(new_attr = {})
31
+ self.class.new(attributes.merge(new_attr))
32
+ end
33
+
34
+ def children=(children)
35
+ @children = case children
36
+ when Siblings
37
+ children.parent == self ? children : children.derive(parent: self)
38
+ when NilClass
39
+ nil
40
+ else
41
+ Siblings.new(
42
+ nodes: children,
43
+ parent: self,
44
+ warn_about_add_children_to_leaf: @warn_about_add_children_to_leaf
45
+ )
46
+ end
47
+ dirty!
48
+ end
49
+
50
+ def each(&block)
51
+ return to_enum(:each) { 1 } unless block
52
+ block.yield(self)
53
+ self
54
+ end
55
+
56
+ def value_or_children_hash
57
+ leaf? ? value : children.try(:to_hash)
58
+ end
59
+
60
+ def leaf?
61
+ !children
62
+ end
63
+
64
+ # a node with key nil is considered Empty. this is to allow for using these nodes instead of nils
65
+ def root?
66
+ !parent?
67
+ end
68
+
69
+ def parent?
70
+ !parent.nil?
71
+ end
72
+
73
+ def children?
74
+ children && !children.empty?
75
+ end
76
+
77
+ def data
78
+ @data ||= {}
79
+ end
80
+
81
+ def data?
82
+ @data.present?
83
+ end
84
+
85
+ def reference?
86
+ value.is_a?(Symbol)
87
+ end
88
+
89
+ def get(key)
90
+ children.get(key)
91
+ end
92
+
93
+ alias [] get
94
+
95
+ # append and reparent nodes
96
+ def append!(nodes)
97
+ if @children
98
+ @children.merge!(nodes)
99
+ else
100
+ @children = Siblings.new(nodes: nodes, parent: self)
101
+ end
102
+ self
103
+ end
104
+
105
+ def append(nodes)
106
+ derive.append!(nodes)
107
+ end
108
+
109
+ def full_key(root: true)
110
+ @full_key ||= {}
111
+ @full_key[root] ||= "#{"#{parent.full_key(root: root)}." if parent? && (root || parent.parent?)}#{key}"
112
+ end
113
+
114
+ def walk_to_root(&visitor)
115
+ return to_enum(:walk_to_root) unless visitor
116
+ visitor.yield self
117
+ parent.walk_to_root(&visitor) if parent?
118
+ end
119
+
120
+ def root
121
+ p = nil
122
+ walk_to_root { |node| p = node }
123
+ p
124
+ end
125
+
126
+ def walk_from_root(&visitor)
127
+ return to_enum(:walk_from_root) unless visitor
128
+ walk_to_root.reverse_each do |node|
129
+ visitor.yield node
130
+ end
131
+ end
132
+
133
+ def set(full_key, node)
134
+ (@children ||= Siblings.new(parent: self)).set(full_key, node)
135
+ dirty!
136
+ node
137
+ end
138
+
139
+ alias []= set
140
+
141
+ def to_nodes
142
+ Nodes.new([self])
143
+ end
144
+
145
+ def to_siblings
146
+ parent && parent.children || Siblings.new(nodes: [self])
147
+ end
148
+
149
+ def to_hash(sort = false)
150
+ (@hash ||= {})[sort] ||= begin
151
+ children_hash = children ? children.to_hash(sort) : {}
152
+ if key.nil?
153
+ children_hash
154
+ elsif leaf?
155
+ { key => value }
156
+ else
157
+ { key => children_hash }
158
+ end
159
+ end
160
+ end
161
+
162
+ delegate :to_json, to: :to_hash
163
+ delegate :to_yaml, to: :to_hash
164
+
165
+ def inspect(level = 0)
166
+ label = if key.nil?
167
+ I18n::Processes::RainbowUtils.faint_color('∅')
168
+ else
169
+ [Rainbow(key).color(1 + level % 15),
170
+ (": #{format_value_for_inspect(value)}" if leaf?),
171
+ (" #{data}" if data?)].compact.join
172
+ end
173
+ [' ' * level, label, ("\n" + children.map { |c| c.inspect(level + 1) }.join("\n") if children?)].compact.join
174
+ end
175
+
176
+ def format_value_for_inspect(value)
177
+ if value.is_a?(Symbol)
178
+ "#{Rainbow('⮕ ').bright.yellow}#{Rainbow(value).yellow}"
179
+ else
180
+ Rainbow(value).cyan
181
+ end
182
+ end
183
+
184
+ protected
185
+
186
+ def dirty!
187
+ @hash = nil
188
+ @full_key = nil
189
+ end
190
+
191
+ class << self
192
+ # value can be a nested hash
193
+ def from_key_value(key, value)
194
+ Node.new(key: key.try(:to_s)).tap do |node|
195
+ if value.is_a?(Hash)
196
+ node.children = Siblings.from_nested_hash(value)
197
+ else
198
+ node.value = value
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end