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.
- checksums.yaml +7 -0
- data/Gemfile.lock +102 -0
- data/LICENSE.txt +21 -0
- data/README.md +46 -0
- data/Rakefile +12 -0
- data/bin/i18n-processes +28 -0
- data/bin/i18n-processes.cmd +2 -0
- data/config/locales/en.yml +2 -0
- data/config/locales/zh-CN.yml +2 -0
- data/i18n-processes.gemspec +64 -0
- data/lib/i18n/processes/base_process.rb +47 -0
- data/lib/i18n/processes/cli.rb +208 -0
- data/lib/i18n/processes/command/collection.rb +21 -0
- data/lib/i18n/processes/command/commander.rb +43 -0
- data/lib/i18n/processes/command/commands/data.rb +107 -0
- data/lib/i18n/processes/command/commands/eq_base.rb +21 -0
- data/lib/i18n/processes/command/commands/health.rb +26 -0
- data/lib/i18n/processes/command/commands/meta.rb +38 -0
- data/lib/i18n/processes/command/commands/missing.rb +86 -0
- data/lib/i18n/processes/command/commands/preprocessing.rb +90 -0
- data/lib/i18n/processes/command/commands/tree.rb +119 -0
- data/lib/i18n/processes/command/commands/usages.rb +69 -0
- data/lib/i18n/processes/command/commands/xlsx.rb +29 -0
- data/lib/i18n/processes/command/dsl.rb +56 -0
- data/lib/i18n/processes/command/option_parsers/enum.rb +55 -0
- data/lib/i18n/processes/command/option_parsers/locale.rb +60 -0
- data/lib/i18n/processes/command/options/common.rb +41 -0
- data/lib/i18n/processes/command/options/data.rb +95 -0
- data/lib/i18n/processes/command/options/locales.rb +36 -0
- data/lib/i18n/processes/command_error.rb +13 -0
- data/lib/i18n/processes/commands.rb +31 -0
- data/lib/i18n/processes/configuration.rb +132 -0
- data/lib/i18n/processes/console_context.rb +76 -0
- data/lib/i18n/processes/data/adapter/json_adapter.rb +29 -0
- data/lib/i18n/processes/data/adapter/yaml_adapter.rb +27 -0
- data/lib/i18n/processes/data/file_formats.rb +111 -0
- data/lib/i18n/processes/data/file_system.rb +14 -0
- data/lib/i18n/processes/data/file_system_base.rb +205 -0
- data/lib/i18n/processes/data/router/conservative_router.rb +66 -0
- data/lib/i18n/processes/data/router/pattern_router.rb +60 -0
- data/lib/i18n/processes/data/tree/node.rb +204 -0
- data/lib/i18n/processes/data/tree/nodes.rb +97 -0
- data/lib/i18n/processes/data/tree/siblings.rb +333 -0
- data/lib/i18n/processes/data/tree/traversal.rb +190 -0
- data/lib/i18n/processes/data.rb +87 -0
- data/lib/i18n/processes/google_translation.rb +125 -0
- data/lib/i18n/processes/html_keys.rb +16 -0
- data/lib/i18n/processes/ignore_keys.rb +30 -0
- data/lib/i18n/processes/key_pattern_matching.rb +37 -0
- data/lib/i18n/processes/locale_list.rb +19 -0
- data/lib/i18n/processes/locale_pathname.rb +17 -0
- data/lib/i18n/processes/logging.rb +37 -0
- data/lib/i18n/processes/missing_keys.rb +122 -0
- data/lib/i18n/processes/path.rb +42 -0
- data/lib/i18n/processes/plural_keys.rb +41 -0
- data/lib/i18n/processes/rainbow_utils.rb +13 -0
- data/lib/i18n/processes/references.rb +101 -0
- data/lib/i18n/processes/reports/base.rb +71 -0
- data/lib/i18n/processes/reports/spreadsheet.rb +72 -0
- data/lib/i18n/processes/reports/terminal.rb +252 -0
- data/lib/i18n/processes/scanners/file_scanner.rb +65 -0
- data/lib/i18n/processes/scanners/files/caching_file_finder.rb +34 -0
- data/lib/i18n/processes/scanners/files/caching_file_finder_provider.rb +33 -0
- data/lib/i18n/processes/scanners/files/caching_file_reader.rb +28 -0
- data/lib/i18n/processes/scanners/files/file_finder.rb +60 -0
- data/lib/i18n/processes/scanners/files/file_reader.rb +19 -0
- data/lib/i18n/processes/scanners/occurrence_from_position.rb +27 -0
- data/lib/i18n/processes/scanners/pattern_mapper.rb +60 -0
- data/lib/i18n/processes/scanners/pattern_scanner.rb +103 -0
- data/lib/i18n/processes/scanners/pattern_with_scope_scanner.rb +98 -0
- data/lib/i18n/processes/scanners/relative_keys.rb +53 -0
- data/lib/i18n/processes/scanners/results/key_occurrences.rb +54 -0
- data/lib/i18n/processes/scanners/results/occurrence.rb +69 -0
- data/lib/i18n/processes/scanners/ruby_ast_call_finder.rb +62 -0
- data/lib/i18n/processes/scanners/ruby_ast_scanner.rb +206 -0
- data/lib/i18n/processes/scanners/ruby_key_literals.rb +30 -0
- data/lib/i18n/processes/scanners/scanner.rb +17 -0
- data/lib/i18n/processes/scanners/scanner_multiplexer.rb +41 -0
- data/lib/i18n/processes/split_key.rb +68 -0
- data/lib/i18n/processes/stats.rb +24 -0
- data/lib/i18n/processes/string_interpolation.rb +16 -0
- data/lib/i18n/processes/unused_keys.rb +23 -0
- data/lib/i18n/processes/used_keys.rb +177 -0
- data/lib/i18n/processes/version.rb +7 -0
- data/lib/i18n/processes.rb +69 -0
- data/source/p1/_messages/zh/article.properties +9 -0
- data/source/p1/_messages/zh/company.properties +62 -0
- data/source/p1/_messages/zh/devices.properties +40 -0
- data/source/p1/_messages/zh/meeting-rooms.properties +99 -0
- data/source/p1/_messages/zh/meetingBooking.properties +18 -0
- data/source/p1/_messages/zh/office-areas.properties +64 -0
- data/source/p1/_messages/zh/orders.properties +25 -0
- data/source/p1/_messages/zh/schedulings.properties +7 -0
- data/source/p1/_messages/zh/tag.properties +2 -0
- data/source/p1/_messages/zh/ticket.properties +9 -0
- data/source/p1/_messages/zh/visitor.properties +5 -0
- data/source/p1/messages +586 -0
- data/source/p2/orders.properties +25 -0
- data/source/p2/schedulings.properties +7 -0
- data/source/p2/tag.properties +2 -0
- data/source/p2/ticket.properties +9 -0
- data/source/p2/visitor.properties +5 -0
- data/source/zh.messages.ts +30 -0
- data/translated/en/p1/_messages/zh/article.properties +9 -0
- data/translated/en/p1/_messages/zh/company.properties +62 -0
- data/translated/en/p1/_messages/zh/devices.properties +40 -0
- data/translated/en/p1/_messages/zh/meeting-rooms.properties +99 -0
- data/translated/en/p1/_messages/zh/meetingBooking.properties +18 -0
- data/translated/en/p1/_messages/zh/office-areas.properties +64 -0
- data/translated/en/p1/_messages/zh/orders.properties +25 -0
- data/translated/en/p1/_messages/zh/schedulings.properties +7 -0
- data/translated/en/p1/_messages/zh/tag.properties +2 -0
- data/translated/en/p1/_messages/zh/ticket.properties +9 -0
- data/translated/en/p1/_messages/zh/visitor.properties +5 -0
- data/translated/en/p1/messages +586 -0
- data/translated/en/p2/orders.properties +25 -0
- data/translated/en/p2/schedulings.properties +7 -0
- data/translated/en/p2/tag.properties +2 -0
- data/translated/en/p2/ticket.properties +9 -0
- data/translated/en/p2/visitor.properties +5 -0
- data/translated/en/zh.messages.ts +30 -0
- data/translation/en/article.properties +9 -0
- data/translation/en/company.properties +56 -0
- data/translation/en/meeting-rooms.properties +87 -0
- data/translation/en/meetingBooking.properties +14 -0
- data/translation/en/messages.en +164 -0
- data/translation/en/office-areas.properties +51 -0
- data/translation/en/orders.properties +26 -0
- data/translation/en/tag.properties +2 -0
- data/translation/en/translated +1263 -0
- data/translation/en/visitor.properties +4 -0
- metadata +408 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'i18n/processes/reports/base'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'i18n/processes/path'
|
|
6
|
+
|
|
7
|
+
module I18n::Processes::Reports
|
|
8
|
+
class Spreadsheet < Base
|
|
9
|
+
include I18n::Processes::Path
|
|
10
|
+
|
|
11
|
+
def missing_report(locale)
|
|
12
|
+
path = 'tmp/missing_keys/'
|
|
13
|
+
FileUtils::mkdir_p path unless Dir.exist?path
|
|
14
|
+
file = "#{path}missing_keys_#{locale}"
|
|
15
|
+
report = File.new(file, 'w')
|
|
16
|
+
report.write("# 说明:以#开头的行,表示key对应的中文翻译\n# 下一行'='左边为key,'='右边需要填上对应的#{locale}翻译: \n")
|
|
17
|
+
report.write("\n\n# ======================= missing keys list =============================\n\n")
|
|
18
|
+
find_missing(locale).map do |k,v|
|
|
19
|
+
report.write("# #{v}")
|
|
20
|
+
report.write("#{k}=#{k}\n\n")
|
|
21
|
+
end
|
|
22
|
+
report.close
|
|
23
|
+
$stderr.puts Rainbow("missing report saved to #{file}\n").green
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def translated_files(locale)
|
|
27
|
+
path = translated_path.first
|
|
28
|
+
dic = get_dic("./tmp/#{locale}")
|
|
29
|
+
FileUtils.rm_f Dir.glob("./#{path}**/**") unless Dir["./#{path}**/**"].size.zero?
|
|
30
|
+
origin_files = origin_files(base_locale).flatten
|
|
31
|
+
# $stderr.puts Rainbow origin_files
|
|
32
|
+
origin_files.each do |origin_file|
|
|
33
|
+
translated_file(origin_file,"#{path}#{locale}/", dic)
|
|
34
|
+
end
|
|
35
|
+
$stderr.puts Rainbow("translated files saved to #{path}\n").green
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def find_missing(locale = nil)
|
|
39
|
+
path = './tmp/'
|
|
40
|
+
comp_dic = get_dic(path + locale)
|
|
41
|
+
base_dic = get_dic(path + base_locale)
|
|
42
|
+
base_dic.select { |k,v| (base_dic.keys - comp_dic.keys).include?(k)}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def translated_file(file, path, dic)
|
|
48
|
+
translated_file = new_file(file, path)
|
|
49
|
+
File.open(file).read.each_line do |line|
|
|
50
|
+
if line =~ /^#/ || line == "\n" || !line.include?('.')
|
|
51
|
+
translated_file.write line
|
|
52
|
+
elsif line.include?('\':')
|
|
53
|
+
line.gsub!(/'|,/, '')
|
|
54
|
+
key = line.split(': ').first.delete(' ')
|
|
55
|
+
translated_file.write(" '#{key}': '#{dic[key].chomp}',\n") if dic.key?(key)
|
|
56
|
+
else
|
|
57
|
+
key = line.split('=').first.delete(' ')
|
|
58
|
+
translated_file.write("#{key}=#{dic[key]}") if dic.key?(key)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
translated_file.close
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def new_file(file, path)
|
|
65
|
+
sourced = source_path.first
|
|
66
|
+
new_file = file.sub(sourced, path) if file.include?(sourced)
|
|
67
|
+
FileUtils::mkdir_p File.dirname(new_file) unless Dir.exist?File.dirname(new_file)
|
|
68
|
+
File.new(new_file, 'w')
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'i18n/processes/reports/base'
|
|
4
|
+
require 'i18n/processes/rainbow_utils'
|
|
5
|
+
require 'terminal-table'
|
|
6
|
+
module I18n
|
|
7
|
+
module Processes
|
|
8
|
+
module Reports
|
|
9
|
+
class Terminal < Base # rubocop:disable Metrics/ClassLength
|
|
10
|
+
def missing_keys(forest = task.missing_keys)
|
|
11
|
+
forest = collapse_missing_tree! forest
|
|
12
|
+
if forest.present?
|
|
13
|
+
print_title missing_title(forest)
|
|
14
|
+
print_table headings: [Rainbow('Locale').cyan.bright,
|
|
15
|
+
Rainbow('Key').cyan.bright,
|
|
16
|
+
'Value in other locales or source'] do |t|
|
|
17
|
+
t.rows = sort_by_attr!(forest_to_attr(forest)).map do |a|
|
|
18
|
+
[{ value: Rainbow(format_locale(a[:locale])).cyan, alignment: :center },
|
|
19
|
+
format_key(a[:key], a[:data]),
|
|
20
|
+
missing_key_info(a)]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
else
|
|
24
|
+
print_success 'No translations are missing.'
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def changed_keys(diff = nil)
|
|
29
|
+
if diff
|
|
30
|
+
print_title "#{diff.count} keys' value changed"
|
|
31
|
+
print_table headings: [Rainbow('key').cyan.bright,
|
|
32
|
+
Rainbow('Current').cyan.bright,
|
|
33
|
+
'Previous'] do |t|
|
|
34
|
+
t.rows = diff.map do |key, value|
|
|
35
|
+
[key, value[:current], value[:previous]]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
else
|
|
39
|
+
print_success 'No key have been changed.'
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def icon(type)
|
|
44
|
+
glyph = missing_type_info(type)[:glyph]
|
|
45
|
+
{ missing_used: Rainbow(glyph).red, missing_diff: Rainbow(glyph).yellow }[type]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def used_keys(used_tree = task.used_tree)
|
|
49
|
+
# For the used tree we may have usage nodes that are not leaves as references.
|
|
50
|
+
keys_nodes = used_tree.nodes.select { |node| node.data[:occurrences].present? }.map do |node|
|
|
51
|
+
[node.full_key(root: false), node]
|
|
52
|
+
end
|
|
53
|
+
print_title used_title(keys_nodes, used_tree.first.root.data[:key_filter])
|
|
54
|
+
# Group multiple nodes
|
|
55
|
+
if keys_nodes.present?
|
|
56
|
+
keys_nodes.sort! { |a, b| a[0] <=> b[0] }.each do |key, node|
|
|
57
|
+
print_occurrences node, key
|
|
58
|
+
end
|
|
59
|
+
else
|
|
60
|
+
print_error 'No key usages found.'
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def unused_keys(tree = task.unused_keys)
|
|
65
|
+
keys = tree.root_key_value_data(true)
|
|
66
|
+
if keys.present?
|
|
67
|
+
print_title unused_title(keys)
|
|
68
|
+
print_locale_key_value_data_table keys
|
|
69
|
+
else
|
|
70
|
+
print_success 'Every translation is in use.'
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def eq_base_keys(tree = task.eq_base_keys)
|
|
75
|
+
keys = tree.root_key_value_data(true)
|
|
76
|
+
if keys.present?
|
|
77
|
+
print_title eq_base_title(keys)
|
|
78
|
+
print_locale_key_value_data_table keys
|
|
79
|
+
else
|
|
80
|
+
print_info Rainbow('No translations are the same as base value').cyan
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def show_tree(tree)
|
|
85
|
+
print_locale_key_value_data_table tree.root_key_value_data(true)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def forest_stats(forest, stats = task.forest_stats(forest))
|
|
89
|
+
text = if stats[:locale_count] == 1
|
|
90
|
+
"has #{stats[:key_count]} keys in total. On average, values are #{stats[:value_chars_avg]} characters long, keys have #{stats[:key_segments_avg]} segments."
|
|
91
|
+
else
|
|
92
|
+
"has #{stats[:key_count]} keys across #{stats[:locale_count]} locales. On average, values are #{stats[:value_chars_avg]} characters long, keys have #{stats[:key_segments_avg]} segments, a locale has #{stats[:per_locale_avg]} keys."
|
|
93
|
+
end
|
|
94
|
+
title = Rainbow("Forest (#{stats.slice(:locales)})").bright
|
|
95
|
+
print_info "#{Rainbow(title).cyan} #{Rainbow(text).cyan}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def mv_results(results)
|
|
99
|
+
results.each do |(from, to)|
|
|
100
|
+
if to
|
|
101
|
+
print_info "#{Rainbow(from).cyan} #{Rainbow('⮕').yellow.bright} #{Rainbow(to).cyan}"
|
|
102
|
+
else
|
|
103
|
+
print_info "#{Rainbow(from).red}#{Rainbow(' 🗑').red.bright}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def check_normalized_results(non_normalized)
|
|
109
|
+
if non_normalized.empty?
|
|
110
|
+
print_success 'All data is normalized'
|
|
111
|
+
return
|
|
112
|
+
end
|
|
113
|
+
log_stderr Rainbow('The following data requires normalization:').yellow
|
|
114
|
+
puts non_normalized
|
|
115
|
+
log_stderr Rainbow('Run `i18n-processes normalize` to fix').yellow
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def missing_key_info(leaf)
|
|
121
|
+
if leaf[:type] == :missing_used
|
|
122
|
+
first_occurrence leaf
|
|
123
|
+
else
|
|
124
|
+
"#{Rainbow(leaf[:data][:missing_diff_locale]).cyan} "\
|
|
125
|
+
"#{format_value(leaf[:value].is_a?(String) ? leaf[:value].strip : leaf[:value])}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def format_key(key, data)
|
|
130
|
+
if data[:ref_info]
|
|
131
|
+
from, to = data[:ref_info]
|
|
132
|
+
resolved = key[0...to.length]
|
|
133
|
+
after = key[to.length..-1]
|
|
134
|
+
" #{Rainbow(from).yellow}#{Rainbow(after).cyan}\n" \
|
|
135
|
+
"#{Rainbow('⮕').yellow.bright} #{Rainbow(resolved).yellow.bright}"
|
|
136
|
+
else
|
|
137
|
+
Rainbow(key).cyan
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def format_value(val)
|
|
142
|
+
val.is_a?(Symbol) ? "#{Rainbow('⮕ ').yellow.bright}#{Rainbow(val).yellow}" : val.to_s.strip
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def format_reference_desc(node_data)
|
|
146
|
+
return nil unless node_data
|
|
147
|
+
case node_data[:ref_type]
|
|
148
|
+
when :reference_usage
|
|
149
|
+
Rainbow('(ref)').yellow.bright
|
|
150
|
+
when :reference_usage_resolved
|
|
151
|
+
Rainbow('(resolved ref)').yellow.bright
|
|
152
|
+
when :reference_usage_key
|
|
153
|
+
Rainbow('(ref key)').yellow.bright
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def print_occurrences(node, full_key = node.full_key)
|
|
158
|
+
occurrences = node.data[:occurrences]
|
|
159
|
+
puts [Rainbow(full_key).bright,
|
|
160
|
+
format_reference_desc(node.data),
|
|
161
|
+
(Rainbow(occurrences.size).green if occurrences.size > 1)].compact.join ' '
|
|
162
|
+
occurrences.each do |occurrence|
|
|
163
|
+
puts " #{key_occurrence full_key, occurrence}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def print_locale_key_value_data_table(locale_key_value_datas)
|
|
168
|
+
if locale_key_value_datas.present?
|
|
169
|
+
print_table headings: [Rainbow("Locale").cyan.bright,
|
|
170
|
+
Rainbow("Key").cyan.bright,
|
|
171
|
+
"Value"] do |t|
|
|
172
|
+
t.rows = locale_key_value_datas.map { |(locale, k, v, data)|
|
|
173
|
+
[{ value: Rainbow(locale).cyan, alignment: :center }, format_key(k, data), format_value(v)]
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
else
|
|
177
|
+
puts 'ø'
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def print_title(title)
|
|
182
|
+
log_stderr "#{Rainbow(title.strip).bright} #{I18n::Processes::RainbowUtils.faint_color('|')} " \
|
|
183
|
+
"#{"i18n-tasks v#{I18n::Processes::VERSION}"}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def print_success(message)
|
|
187
|
+
log_stderr Rainbow("✓ #{["Good job!", "Well done!", "Perfect!"].sample} #{message}").green.bright
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def print_error(message)
|
|
191
|
+
log_stderr(Rainbow(message).red.bright)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def print_info(message)
|
|
195
|
+
log_stderr message
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def indent(txt, n = 2)
|
|
199
|
+
txt.gsub(/^/, ' ' * n)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def print_table(opts, &block)
|
|
203
|
+
puts ::Terminal::Table.new(opts, &block)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def key_occurrence(full_key, occurrence)
|
|
207
|
+
location = Rainbow("#{occurrence.path}:#{occurrence.line_num}").green
|
|
208
|
+
source = highlight_key(occurrence.raw_key || full_key, occurrence.line, occurrence.line_pos..-1).strip
|
|
209
|
+
"#{location} #{source}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def first_occurrence(leaf)
|
|
213
|
+
# @type [I18n::Processes::Scanners::KeyOccurrences]
|
|
214
|
+
occurrences = leaf[:data][:occurrences]
|
|
215
|
+
# @type [I18n::Processes::Scanners::Occurrence]
|
|
216
|
+
first = occurrences.first
|
|
217
|
+
[
|
|
218
|
+
Rainbow("#{first.path}:#{first.line_num}").green,
|
|
219
|
+
("(#{occurrences.length - 1} more)" if occurrences.length > 1)
|
|
220
|
+
|
|
221
|
+
].compact.join(' ')
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def highlight_key(full_key, line, range = (0..-1))
|
|
225
|
+
line.dup.tap do |s|
|
|
226
|
+
s[range] = s[range].sub(full_key) do |m|
|
|
227
|
+
highlight_string m
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
module HighlightUnderline
|
|
233
|
+
def highlight_string(s)
|
|
234
|
+
Rainbow(s).underline
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
module HighlightOther
|
|
239
|
+
def highlight_string(s)
|
|
240
|
+
Rainbow(s).yellow
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
if Gem.win_platform?
|
|
245
|
+
include HighlightOther
|
|
246
|
+
else
|
|
247
|
+
include HighlightUnderline
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'i18n/processes/scanners/scanner'
|
|
4
|
+
|
|
5
|
+
module I18n::Processes::Scanners
|
|
6
|
+
# A base class for a scanner that analyses files.
|
|
7
|
+
#
|
|
8
|
+
# @abstract The child must implement {#scan_file}.
|
|
9
|
+
# @since 0.9.0
|
|
10
|
+
class FileScanner < Scanner
|
|
11
|
+
attr_reader :config
|
|
12
|
+
|
|
13
|
+
def initialize(
|
|
14
|
+
config: {},
|
|
15
|
+
file_finder_provider: Files::CachingFileFinderProvider.new,
|
|
16
|
+
file_reader: Files::CachingFileReader.new
|
|
17
|
+
)
|
|
18
|
+
@config = config
|
|
19
|
+
@file_reader = file_reader
|
|
20
|
+
@file_finder = file_finder_provider.get(**config.slice(:paths, :only, :exclude))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return (see Scanner#keys)
|
|
24
|
+
def keys
|
|
25
|
+
(traverse_files do |path|
|
|
26
|
+
scan_file(path)
|
|
27
|
+
end.reduce(:+) || []).group_by(&:first).map do |key, keys_occurrences|
|
|
28
|
+
Results::KeyOccurrences.new(key: key, occurrences: keys_occurrences.map(&:second))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
protected
|
|
33
|
+
|
|
34
|
+
# Extract all occurrences of translate calls from the file at the given path.
|
|
35
|
+
#
|
|
36
|
+
# @return [Array<[key, Results::KeyOccurrence]>] each occurrence found in the file
|
|
37
|
+
def scan_file(_path)
|
|
38
|
+
fail 'Unimplemented'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Read a file. Reads of the same path are cached.
|
|
42
|
+
#
|
|
43
|
+
# @param path [String]
|
|
44
|
+
# @return [String] file contents
|
|
45
|
+
def read_file(path)
|
|
46
|
+
@file_reader.read_file(path)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Traverse the paths and yield the matching ones.
|
|
50
|
+
#
|
|
51
|
+
# @note This method is cached, it will only access the filesystem on the first invocation.
|
|
52
|
+
# @param (see FileFinder#traverse_files)
|
|
53
|
+
# @yieldparam (see FileFinder#traverse_files)
|
|
54
|
+
# @return (see FileFinder#traverse_files)
|
|
55
|
+
def traverse_files(&block)
|
|
56
|
+
@file_finder.traverse_files(&block)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @note This method is cached, it will only access the filesystem on the first invocation.
|
|
60
|
+
# @return (see FileFinder#find_files)
|
|
61
|
+
def find_files
|
|
62
|
+
@file_finder.find_files
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'i18n/processes/scanners/files/file_finder'
|
|
4
|
+
module I18n::Processes::Scanners::Files
|
|
5
|
+
# Finds the files in the specified search paths with support for exclusion / inclusion patterns.
|
|
6
|
+
# Wraps a {FileFinder} and caches the results.
|
|
7
|
+
#
|
|
8
|
+
# @note This class is thread-safe. All methods are cached.
|
|
9
|
+
# @since 0.9.0
|
|
10
|
+
class CachingFileFinder < FileFinder
|
|
11
|
+
# @param (see FileFinder#initialize)
|
|
12
|
+
def initialize(**args)
|
|
13
|
+
super
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@cached_paths = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Traverse the paths and yield the matching ones.
|
|
19
|
+
#
|
|
20
|
+
# @note This method is cached, it will only access the filesystem on the first invocation.
|
|
21
|
+
# @param (see FileFinder#traverse_files)
|
|
22
|
+
# @yieldparam (see FileFinder#traverse_files)
|
|
23
|
+
# @return (see FileFinder#traverse_files)
|
|
24
|
+
def traverse_files
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @note This method is cached, it will only access the filesystem on the first invocation.
|
|
29
|
+
# @return (see FileFinder#find_files)
|
|
30
|
+
def find_files
|
|
31
|
+
@cached_paths || @mutex.synchronize { @cached_paths ||= super }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'i18n/processes/scanners/files/caching_file_finder'
|
|
4
|
+
|
|
5
|
+
module I18n::Processes::Scanners::Files
|
|
6
|
+
# Finds the files and provides their contents.
|
|
7
|
+
#
|
|
8
|
+
# @note This class is thread-safe. All methods are cached.
|
|
9
|
+
# @since 0.9.0
|
|
10
|
+
class CachingFileFinderProvider
|
|
11
|
+
# @param exclude [Array<String>]
|
|
12
|
+
def initialize(exclude: [])
|
|
13
|
+
@cache = {}
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@defaults = { exclude: exclude }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Initialize a {CachingFileFinder} or get one from cache based on the constructor arguments.
|
|
19
|
+
#
|
|
20
|
+
# @param (see FileFinder#initialize)
|
|
21
|
+
# @return [CachingFileFinder]
|
|
22
|
+
def get(**file_finder_args)
|
|
23
|
+
@cache[file_finder_args] || @mutex.synchronize do
|
|
24
|
+
@cache[file_finder_args] ||= begin
|
|
25
|
+
args = file_finder_args.dup
|
|
26
|
+
args[:exclude] = @defaults[:exclude] + (args[:exclude] || [])
|
|
27
|
+
args[:exclude].uniq!
|
|
28
|
+
CachingFileFinder.new(**args)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'i18n/processes/scanners/files/file_reader'
|
|
4
|
+
module I18n::Processes::Scanners::Files
|
|
5
|
+
# Reads the files in 'rb' mode and UTF-8 encoding.
|
|
6
|
+
# Wraps a {FileReader} and caches the results.
|
|
7
|
+
#
|
|
8
|
+
# @note This class is thread-safe. All methods are cached.
|
|
9
|
+
# @since 0.9.0
|
|
10
|
+
class CachingFileReader < FileReader
|
|
11
|
+
def initialize
|
|
12
|
+
super
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@cache = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Return the contents of the file at the given path.
|
|
18
|
+
# The file is read in the 'rb' mode and UTF-8 encoding.
|
|
19
|
+
#
|
|
20
|
+
# @param (see FileReader#read_file)
|
|
21
|
+
# @return (see FileReader#read_file)
|
|
22
|
+
# @note This method is cached, it will only access the filesystem on the first invocation.
|
|
23
|
+
def read_file(path)
|
|
24
|
+
absolute_path = File.expand_path(path)
|
|
25
|
+
@cache[absolute_path] || @mutex.synchronize { @cache[absolute_path] ||= super }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18n::Processes::Scanners::Files
|
|
4
|
+
# Finds the files in the specified search paths with support for exclusion / inclusion patterns.
|
|
5
|
+
#
|
|
6
|
+
# @since 0.9.0
|
|
7
|
+
class FileFinder
|
|
8
|
+
include I18n::Processes::Logging
|
|
9
|
+
|
|
10
|
+
# @param paths [Array<String>] {Find.find}-compatible paths to traverse,
|
|
11
|
+
# absolute or relative to the working directory.
|
|
12
|
+
# @param only [Array<String>, nil] {File.fnmatch}-compatible patterns files to include.
|
|
13
|
+
# Files not matching any of the inclusion patterns will be excluded.
|
|
14
|
+
# @param exclude [Arry<String>] {File.fnmatch}-compatible patterns of files to exclude.
|
|
15
|
+
# Files matching any of the exclusion patterns will be excluded even if they match an inclusion pattern.
|
|
16
|
+
def initialize(paths: ['.'], only: nil, exclude: [])
|
|
17
|
+
fail 'paths argument is required' if paths.nil?
|
|
18
|
+
@paths = paths
|
|
19
|
+
@include = only
|
|
20
|
+
@exclude = exclude || []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Traverse the paths and yield the matching ones.
|
|
24
|
+
#
|
|
25
|
+
# @yield [path]
|
|
26
|
+
# @yieldparam path [String] the path of the found file.
|
|
27
|
+
# @return [Array<of block results>]
|
|
28
|
+
def traverse_files
|
|
29
|
+
find_files.map { |path| yield path }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Array<String>] found files
|
|
33
|
+
def find_files # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
34
|
+
results = []
|
|
35
|
+
paths = @paths.select { |p| File.exist?(p) }
|
|
36
|
+
log_warn "None of the search.paths exist #{@paths.inspect}" if paths.empty?
|
|
37
|
+
Find.find(*paths) do |path|
|
|
38
|
+
is_dir = File.directory?(path)
|
|
39
|
+
hidden = File.basename(path).start_with?('.') && !%w[. ./].include?(path)
|
|
40
|
+
not_incl = @include && !path_fnmatch_any?(path, @include)
|
|
41
|
+
excl = path_fnmatch_any?(path, @exclude)
|
|
42
|
+
if is_dir || hidden || not_incl || excl
|
|
43
|
+
Find.prune if is_dir && (hidden || excl)
|
|
44
|
+
else
|
|
45
|
+
results << path
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
results
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# @param path [String]
|
|
54
|
+
# @param globs [Array<String>]
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def path_fnmatch_any?(path, globs)
|
|
57
|
+
globs.any? { |glob| File.fnmatch(glob, path) }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18n::Processes::Scanners::Files
|
|
4
|
+
# Reads the files in 'rb' mode and UTF-8 encoding.
|
|
5
|
+
#
|
|
6
|
+
# @since 0.9.0
|
|
7
|
+
class FileReader
|
|
8
|
+
# Return the contents of the file at the given path.
|
|
9
|
+
# The file is read in the 'rb' mode and UTF-8 encoding.
|
|
10
|
+
#
|
|
11
|
+
# @param path [String] Path to the file, absolute or relative to the working directory.
|
|
12
|
+
# @return [String] file contents
|
|
13
|
+
def read_file(path)
|
|
14
|
+
result = nil
|
|
15
|
+
File.open(path, 'rb', encoding: 'UTF-8') { |f| result = f.read }
|
|
16
|
+
result
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18n::Processes
|
|
4
|
+
module Scanners
|
|
5
|
+
module OccurrenceFromPosition
|
|
6
|
+
# Given a path to a file, its contents and a position in the file,
|
|
7
|
+
# return a {Results::Occurrence} at the position until the end of the line.
|
|
8
|
+
#
|
|
9
|
+
# @param path [String]
|
|
10
|
+
# @param contents [String] contents of the file at the path.
|
|
11
|
+
# @param position [Integer] position just before the beginning of the match.
|
|
12
|
+
# @return [Results::Occurrence]
|
|
13
|
+
def occurrence_from_position(path, contents, position, raw_key: nil)
|
|
14
|
+
line_begin = contents.rindex(/^/, position - 1)
|
|
15
|
+
line_end = contents.index(/.(?=\r?\n|$)/, position)
|
|
16
|
+
Results::Occurrence.new(
|
|
17
|
+
path: path,
|
|
18
|
+
pos: position,
|
|
19
|
+
line_num: contents[0..position].count("\n") + 1,
|
|
20
|
+
line_pos: position - line_begin + 1,
|
|
21
|
+
line: contents[line_begin..line_end],
|
|
22
|
+
raw_key: raw_key
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'i18n/processes/scanners/file_scanner'
|
|
4
|
+
require 'i18n/processes/scanners/relative_keys'
|
|
5
|
+
require 'i18n/processes/scanners/occurrence_from_position'
|
|
6
|
+
require 'i18n/processes/scanners/ruby_key_literals'
|
|
7
|
+
|
|
8
|
+
module I18n::Processes::Scanners
|
|
9
|
+
# Maps the provided patterns to keys.
|
|
10
|
+
class PatternMapper < FileScanner
|
|
11
|
+
include I18n::Processes::Scanners::RelativeKeys
|
|
12
|
+
include I18n::Processes::Scanners::OccurrenceFromPosition
|
|
13
|
+
include I18n::Processes::Scanners::RubyKeyLiterals
|
|
14
|
+
|
|
15
|
+
# @param patterns [Array<[String, String]> the list of pattern-key pairs
|
|
16
|
+
# the patterns follow the regular expression syntax, with a syntax addition for matching
|
|
17
|
+
# string/symbol literals: you can include %{key} in the pattern, and it will be converted to
|
|
18
|
+
# a named capture group, capturing ruby strings and symbols, that can then be used in the key:
|
|
19
|
+
#
|
|
20
|
+
# patterns: [['Spree\.t[( ]\s*%{key}', 'spree.%{key}']]
|
|
21
|
+
#
|
|
22
|
+
# All of the named capture groups are interpolated into the key with %{group_name} interpolations.
|
|
23
|
+
#
|
|
24
|
+
def initialize(config:, **args)
|
|
25
|
+
super
|
|
26
|
+
@patterns = configure_patterns(config[:patterns] || [])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
protected
|
|
30
|
+
|
|
31
|
+
# @return [Array<[absolute key, Results::Occurrence]>]
|
|
32
|
+
def scan_file(path)
|
|
33
|
+
text = read_file(path)
|
|
34
|
+
@patterns.flat_map do |pattern, key|
|
|
35
|
+
result = []
|
|
36
|
+
text.scan(pattern) do |_|
|
|
37
|
+
match = Regexp.last_match
|
|
38
|
+
matches = Hash[match.names.map(&:to_sym).zip(match.captures)]
|
|
39
|
+
if matches.key?(:key)
|
|
40
|
+
matches[:key] = strip_literal(matches[:key])
|
|
41
|
+
next unless valid_key?(matches[:key])
|
|
42
|
+
end
|
|
43
|
+
result << [absolute_key(format(key, matches), path),
|
|
44
|
+
occurrence_from_position(path, text, match.offset(0).first)]
|
|
45
|
+
end
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
KEY_GROUP = "(?<key>#{LITERAL_RE})"
|
|
53
|
+
|
|
54
|
+
def configure_patterns(patterns)
|
|
55
|
+
patterns.map do |(pattern, key)|
|
|
56
|
+
[pattern.is_a?(Regexp) ? pattern : Regexp.new(format(pattern, key: KEY_GROUP)), key]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|