i18n-context-generator 0.4.0 → 0.5.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 +4 -4
- data/README.md +163 -26
- data/lib/i18n_context_generator/android_resource.rb +209 -0
- data/lib/i18n_context_generator/apple_string_literal.rb +28 -0
- data/lib/i18n_context_generator/cache.rb +40 -8
- data/lib/i18n_context_generator/changed_location.rb +55 -0
- data/lib/i18n_context_generator/cli.rb +163 -76
- data/lib/i18n_context_generator/config/cli_values.rb +33 -0
- data/lib/i18n_context_generator/config/defaults.rb +35 -0
- data/lib/i18n_context_generator/config/schema.rb +225 -0
- data/lib/i18n_context_generator/config/serialization.rb +64 -0
- data/lib/i18n_context_generator/config/validation.rb +249 -0
- data/lib/i18n_context_generator/config.rb +293 -102
- data/lib/i18n_context_generator/context_extractor/cache_identity.rb +65 -0
- data/lib/i18n_context_generator/context_extractor/extraction_result.rb +46 -0
- data/lib/i18n_context_generator/context_extractor/run_logging.rb +45 -6
- data/lib/i18n_context_generator/context_extractor/source_entries.rb +103 -0
- data/lib/i18n_context_generator/context_extractor/source_filters.rb +51 -5
- data/lib/i18n_context_generator/context_extractor/translation_filters.rb +91 -0
- data/lib/i18n_context_generator/context_extractor/workflow.rb +118 -0
- data/lib/i18n_context_generator/context_extractor.rb +207 -216
- data/lib/i18n_context_generator/file_classifier.rb +72 -0
- data/lib/i18n_context_generator/generated_comment.rb +32 -0
- data/lib/i18n_context_generator/git_diff/xml_changes.rb +235 -0
- data/lib/i18n_context_generator/git_diff.rb +224 -95
- data/lib/i18n_context_generator/llm/anthropic.rb +65 -38
- data/lib/i18n_context_generator/llm/client.rb +294 -92
- data/lib/i18n_context_generator/llm/openai.rb +92 -51
- data/lib/i18n_context_generator/llm/openai_compatible.rb +20 -0
- data/lib/i18n_context_generator/llm/prompt_evidence.rb +47 -0
- data/lib/i18n_context_generator/llm/request_policy.rb +147 -0
- data/lib/i18n_context_generator/localization_syntax.rb +246 -0
- data/lib/i18n_context_generator/parsers/android_xml_parser.rb +47 -8
- data/lib/i18n_context_generator/parsers/base.rb +7 -4
- data/lib/i18n_context_generator/parsers/json_parser.rb +4 -0
- data/lib/i18n_context_generator/parsers/xcstrings_parser.rb +79 -0
- data/lib/i18n_context_generator/parsers/yaml_parser.rb +20 -7
- data/lib/i18n_context_generator/path_policy.rb +107 -0
- data/lib/i18n_context_generator/platform_validator.rb +24 -50
- data/lib/i18n_context_generator/run_metrics.rb +47 -0
- data/lib/i18n_context_generator/searcher/comment_masking.rb +115 -0
- data/lib/i18n_context_generator/searcher/match_filtering.rb +52 -0
- data/lib/i18n_context_generator/searcher/source_discovery.rb +109 -64
- data/lib/i18n_context_generator/searcher.rb +61 -205
- data/lib/i18n_context_generator/supplemental_context.rb +77 -0
- data/lib/i18n_context_generator/translation_comment_index.rb +121 -0
- data/lib/i18n_context_generator/version.rb +1 -1
- data/lib/i18n_context_generator/writers/android_xml_writer.rb +48 -18
- data/lib/i18n_context_generator/writers/atomic_file.rb +37 -0
- data/lib/i18n_context_generator/writers/csv_writer.rb +43 -18
- data/lib/i18n_context_generator/writers/helpers.rb +7 -29
- data/lib/i18n_context_generator/writers/json_writer.rb +31 -6
- data/lib/i18n_context_generator/writers/preview.rb +80 -0
- data/lib/i18n_context_generator/writers/result_serialization.rb +30 -0
- data/lib/i18n_context_generator/writers/strings_writer.rb +23 -12
- data/lib/i18n_context_generator/writers/swift_writer.rb +16 -54
- data/lib/i18n_context_generator/writers/xcstrings_writer.rb +57 -0
- data/lib/i18n_context_generator/xcstrings_document.rb +218 -0
- data/lib/i18n_context_generator/xml_scanner.rb +38 -0
- data/lib/i18n_context_generator.rb +20 -4
- metadata +59 -12
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nContextGenerator
|
|
4
|
+
# Immutable, named evidence supplied in addition to translations and source usages.
|
|
5
|
+
ContextSource = Data.define(:kind, :name, :content) do
|
|
6
|
+
def initialize(kind:, name:, content:)
|
|
7
|
+
super(
|
|
8
|
+
kind: kind.to_sym,
|
|
9
|
+
name: name.dup.freeze,
|
|
10
|
+
content: content.dup.freeze
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Converts configured text files and caller-provided values into context records.
|
|
16
|
+
class SupplementalContext
|
|
17
|
+
class << self
|
|
18
|
+
def load(files:, runtime:)
|
|
19
|
+
validate_files!(files)
|
|
20
|
+
validate_runtime!(runtime)
|
|
21
|
+
|
|
22
|
+
file_sources = files.map { |path| load_file(path) }
|
|
23
|
+
runtime_sources = runtime.map do |name, content|
|
|
24
|
+
ContextSource.new(
|
|
25
|
+
kind: :runtime,
|
|
26
|
+
name: scrub_text(name),
|
|
27
|
+
content: scrub_text(content)
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
(file_sources + runtime_sources).freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def validate_files!(files)
|
|
37
|
+
return if files.is_a?(Array) && files.all? { |path| path.is_a?(String) && !path.strip.empty? }
|
|
38
|
+
|
|
39
|
+
raise Error, 'context_files must be an array of non-empty strings'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def validate_runtime!(runtime)
|
|
43
|
+
raise Error, 'supplemental_context must be a mapping' unless runtime.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
runtime.each do |name, content|
|
|
46
|
+
raise Error, 'supplemental context name must be a non-empty string' unless
|
|
47
|
+
name.is_a?(String) && !name.strip.empty?
|
|
48
|
+
next if content.is_a?(String) && !content.strip.empty?
|
|
49
|
+
|
|
50
|
+
raise Error, "supplemental context value for #{name} must be a non-empty string"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def load_file(path)
|
|
55
|
+
bytes = File.binread(path)
|
|
56
|
+
raise Error, "context file contains NUL bytes; convert it to UTF-8 text: #{path}" if bytes.include?("\0")
|
|
57
|
+
|
|
58
|
+
content = scrub_text(bytes)
|
|
59
|
+
raise Error, "context file is empty: #{path}" if content.strip.empty?
|
|
60
|
+
|
|
61
|
+
ContextSource.new(
|
|
62
|
+
kind: :file,
|
|
63
|
+
name: scrub_text(File.basename(path)),
|
|
64
|
+
content: content
|
|
65
|
+
)
|
|
66
|
+
rescue Error
|
|
67
|
+
raise
|
|
68
|
+
rescue SystemCallError => e
|
|
69
|
+
raise Error, "Unable to read context file #{path}: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def scrub_text(value)
|
|
73
|
+
value.dup.force_encoding(Encoding::UTF_8).scrub
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'xml_scanner'
|
|
4
|
+
require_relative 'apple_string_literal'
|
|
5
|
+
|
|
6
|
+
module I18nContextGenerator
|
|
7
|
+
# Associates translator-comment lines with the translation entry that follows.
|
|
8
|
+
# Diff consumers use this to treat comment-only edits as entry changes while
|
|
9
|
+
# retaining the exact changed comment line for review placement.
|
|
10
|
+
class TranslationCommentIndex
|
|
11
|
+
def initialize(file_path = nil, format:, content: nil)
|
|
12
|
+
@file_path = file_path
|
|
13
|
+
@format = format
|
|
14
|
+
@content = content || File.binread(file_path)
|
|
15
|
+
@key_lines = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def key_at(line_number)
|
|
19
|
+
entries[line_number]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def line_for_key(key)
|
|
23
|
+
entries
|
|
24
|
+
@key_lines[key]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def entries
|
|
30
|
+
@entries ||= case @format
|
|
31
|
+
when :strings then strings_entries
|
|
32
|
+
when :xml then xml_entries
|
|
33
|
+
else {}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def strings_entries
|
|
38
|
+
entries = {}
|
|
39
|
+
pending_lines = []
|
|
40
|
+
inside_comment = false
|
|
41
|
+
|
|
42
|
+
@content.each_line.with_index(1) do |line, line_number|
|
|
43
|
+
content = line
|
|
44
|
+
|
|
45
|
+
if inside_comment
|
|
46
|
+
pending_lines << line_number
|
|
47
|
+
comment_end = content.index('*/')
|
|
48
|
+
next unless comment_end
|
|
49
|
+
|
|
50
|
+
inside_comment = false
|
|
51
|
+
content = content[(comment_end + 2)..].to_s
|
|
52
|
+
elsif (comment_start = content.index(%r{\A\s*/\*}))
|
|
53
|
+
pending_lines << line_number
|
|
54
|
+
comment_end = content.index('*/', comment_start + 2)
|
|
55
|
+
unless comment_end
|
|
56
|
+
inside_comment = true
|
|
57
|
+
next
|
|
58
|
+
end
|
|
59
|
+
content = content[(comment_end + 2)..].to_s
|
|
60
|
+
elsif content.lstrip.start_with?('//')
|
|
61
|
+
pending_lines << line_number
|
|
62
|
+
next
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if (key = strings_key_from(content))
|
|
66
|
+
@key_lines[key] ||= line_number
|
|
67
|
+
pending_lines.each { |comment_line| entries[comment_line] = key }
|
|
68
|
+
pending_lines.clear
|
|
69
|
+
elsif !content.strip.empty?
|
|
70
|
+
pending_lines.clear
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
entries
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def strings_key_from(content)
|
|
78
|
+
AppleStringLiteral.assignment_key(content)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def xml_entries
|
|
82
|
+
entries = {}
|
|
83
|
+
pending_lines = []
|
|
84
|
+
pending_tag = nil
|
|
85
|
+
pending_tag_line = nil
|
|
86
|
+
comment_state = {}
|
|
87
|
+
|
|
88
|
+
@content.each_line.with_index(1) do |line, line_number|
|
|
89
|
+
visible, contained_comment = XmlScanner.without_comments(line, comment_state)
|
|
90
|
+
pending_lines << line_number if contained_comment
|
|
91
|
+
|
|
92
|
+
if pending_tag
|
|
93
|
+
pending_tag << visible
|
|
94
|
+
elsif (tag_start = visible.index(/<(?:string-array|plurals|string)\b/))
|
|
95
|
+
pending_tag = visible[tag_start..]
|
|
96
|
+
pending_tag_line = line_number
|
|
97
|
+
elsif !visible.strip.empty?
|
|
98
|
+
pending_lines.clear
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
next unless pending_tag&.include?('>')
|
|
102
|
+
|
|
103
|
+
if (key = resource_name_from(pending_tag))
|
|
104
|
+
@key_lines[key] ||= pending_tag_line
|
|
105
|
+
pending_lines.each { |comment_line| entries[comment_line] = key }
|
|
106
|
+
end
|
|
107
|
+
pending_lines.clear
|
|
108
|
+
pending_tag = nil
|
|
109
|
+
pending_tag_line = nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
entries
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def resource_name_from(tag)
|
|
116
|
+
return unless tag.match?(/<(?:string-array|plurals|string)\b/)
|
|
117
|
+
|
|
118
|
+
tag[/\bname\s*=\s*(["'])(.*?)\1/m, 2]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../android_resource'
|
|
4
|
+
require_relative '../generated_comment'
|
|
5
|
+
|
|
3
6
|
module I18nContextGenerator
|
|
4
7
|
module Writers
|
|
5
8
|
# Writer that updates Android strings.xml files with context comments
|
|
@@ -15,8 +18,10 @@ module I18nContextGenerator
|
|
|
15
18
|
def write(results, source_path)
|
|
16
19
|
return unless File.exist?(source_path)
|
|
17
20
|
|
|
21
|
+
original_content = File.binread(source_path)
|
|
18
22
|
lines = File.readlines(source_path, encoding: 'UTF-8')
|
|
19
23
|
results_by_key = build_results_lookup(results, source_path)
|
|
24
|
+
return false unless results_by_key.values.any? { |result| writable_result?(result) }
|
|
20
25
|
|
|
21
26
|
output_lines = []
|
|
22
27
|
i = 0
|
|
@@ -27,11 +32,11 @@ module I18nContextGenerator
|
|
|
27
32
|
# Only write comments on <string> elements, not <plurals> or <string-array>.
|
|
28
33
|
# Plural/array parent comments would use a single child's description which
|
|
29
34
|
# is misleading for the resource as a whole.
|
|
30
|
-
# Match the opening
|
|
31
|
-
#
|
|
32
|
-
if (
|
|
33
|
-
indent =
|
|
34
|
-
key =
|
|
35
|
+
# Match the complete opening tag so attribute order, quote style, and
|
|
36
|
+
# multiline attributes do not affect write-back.
|
|
37
|
+
if (element = string_element_at(lines, i))
|
|
38
|
+
indent = element[:indent]
|
|
39
|
+
key = element[:key]
|
|
35
40
|
result = results_by_key[key]
|
|
36
41
|
|
|
37
42
|
insert_context_comment(output_lines, indent, result.description) if writable_result?(result)
|
|
@@ -41,11 +46,34 @@ module I18nContextGenerator
|
|
|
41
46
|
i += 1
|
|
42
47
|
end
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
rendered = output_lines.join
|
|
50
|
+
return false if rendered == original_content
|
|
51
|
+
|
|
52
|
+
AtomicFile.replace(source_path, rendered) do |candidate_path|
|
|
53
|
+
REXML::Document.new(File.read(candidate_path, encoding: 'UTF-8'))
|
|
54
|
+
end
|
|
55
|
+
true
|
|
45
56
|
end
|
|
46
57
|
|
|
47
58
|
private
|
|
48
59
|
|
|
60
|
+
def string_element_at(lines, index)
|
|
61
|
+
start_match = lines[index].match(/^(\s*)<string(?=\s|>)/)
|
|
62
|
+
return unless start_match
|
|
63
|
+
|
|
64
|
+
opening_tag_lines = []
|
|
65
|
+
lines[index..].each do |line|
|
|
66
|
+
opening_tag_lines << line
|
|
67
|
+
break if line.include?('>')
|
|
68
|
+
end
|
|
69
|
+
opening_tag = opening_tag_lines.join
|
|
70
|
+
opening_tag = opening_tag.split('>', 2).first
|
|
71
|
+
name_match = opening_tag.match(/\bname\s*=\s*(["'])(.*?)\1/m)
|
|
72
|
+
return unless name_match
|
|
73
|
+
|
|
74
|
+
{ indent: start_match[1], key: name_match[2] }
|
|
75
|
+
end
|
|
76
|
+
|
|
49
77
|
# Build a lookup that maps base resource names to results.
|
|
50
78
|
# For plural keys like "post_likes_count:one", maps "post_likes_count" to a result.
|
|
51
79
|
# For array keys like "days_of_week[0]", maps "days_of_week" to a result.
|
|
@@ -57,7 +85,7 @@ module I18nContextGenerator
|
|
|
57
85
|
results.sort_by(&:key).each do |r|
|
|
58
86
|
next if source_path && !result_matches_source_path?(r, source_path)
|
|
59
87
|
|
|
60
|
-
base = r.key
|
|
88
|
+
base = AndroidResource.base_key(r.key)
|
|
61
89
|
lookup[base] ||= r
|
|
62
90
|
lookup[r.key] ||= r
|
|
63
91
|
end
|
|
@@ -65,7 +93,7 @@ module I18nContextGenerator
|
|
|
65
93
|
end
|
|
66
94
|
|
|
67
95
|
def insert_context_comment(output_lines, indent, description)
|
|
68
|
-
context_text = "#{@context_prefix}#{
|
|
96
|
+
context_text = escape_comment("#{@context_prefix}#{description}")
|
|
69
97
|
|
|
70
98
|
if output_lines.any? && output_lines.last.match?(/^\s*<!--.*-->\s*$/)
|
|
71
99
|
existing_match = output_lines.last.match(/^\s*<!--\s*(.*?)\s*-->\s*$/)
|
|
@@ -90,19 +118,17 @@ module I18nContextGenerator
|
|
|
90
118
|
# so we always treat the preceding comment as replaceable — the user accepted
|
|
91
119
|
# this trade-off by choosing an empty prefix.
|
|
92
120
|
def managed_comment?(comment)
|
|
93
|
-
|
|
121
|
+
GeneratedComment.managed?(comment, prefix: escaped_context_prefix)
|
|
94
122
|
end
|
|
95
123
|
|
|
96
124
|
def build_comment(existing_comment, context_text)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
"#{existing_comment} #{context_text}"
|
|
105
|
-
end
|
|
125
|
+
GeneratedComment.merge(
|
|
126
|
+
existing: existing_comment,
|
|
127
|
+
generated: context_text,
|
|
128
|
+
prefix: escaped_context_prefix,
|
|
129
|
+
mode: @context_mode,
|
|
130
|
+
separator: ' '
|
|
131
|
+
)
|
|
106
132
|
end
|
|
107
133
|
|
|
108
134
|
def escape_comment(text)
|
|
@@ -112,6 +138,10 @@ module I18nContextGenerator
|
|
|
112
138
|
.gsub("\n", ' ')
|
|
113
139
|
.strip
|
|
114
140
|
end
|
|
141
|
+
|
|
142
|
+
def escaped_context_prefix
|
|
143
|
+
escape_comment(@context_prefix)
|
|
144
|
+
end
|
|
115
145
|
end
|
|
116
146
|
end
|
|
117
147
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module I18nContextGenerator
|
|
7
|
+
module Writers
|
|
8
|
+
# Safely replaces an existing file after optionally validating the candidate.
|
|
9
|
+
class AtomicFile
|
|
10
|
+
class << self
|
|
11
|
+
def replace(path, content)
|
|
12
|
+
original_mode = File.stat(path).mode & 0o7777
|
|
13
|
+
directory = File.dirname(File.expand_path(path))
|
|
14
|
+
basename = File.basename(path)
|
|
15
|
+
temporary_file = Tempfile.new([".#{basename}.", '.tmp'], directory)
|
|
16
|
+
temporary_path = temporary_file.path
|
|
17
|
+
|
|
18
|
+
begin
|
|
19
|
+
temporary_file.binmode
|
|
20
|
+
temporary_file.write(content)
|
|
21
|
+
temporary_file.flush
|
|
22
|
+
temporary_file.fsync
|
|
23
|
+
temporary_file.chmod(original_mode)
|
|
24
|
+
temporary_file.close
|
|
25
|
+
|
|
26
|
+
yield temporary_path if block_given?
|
|
27
|
+
|
|
28
|
+
File.rename(temporary_path, path)
|
|
29
|
+
ensure
|
|
30
|
+
temporary_file.close unless temporary_file.closed?
|
|
31
|
+
FileUtils.rm_f(temporary_path)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -4,30 +4,55 @@ module I18nContextGenerator
|
|
|
4
4
|
module Writers
|
|
5
5
|
# Writes extraction results to a CSV file.
|
|
6
6
|
class CsvWriter
|
|
7
|
-
|
|
7
|
+
include ResultSerialization
|
|
8
|
+
|
|
9
|
+
HEADERS = %w[
|
|
10
|
+
schema_version key source_file translation_key text description ui_element
|
|
11
|
+
tone max_length confidence ambiguity_reason locations changed_locations
|
|
12
|
+
changed_location_groups changed_translation_locations status cache_hit
|
|
13
|
+
request_count input_tokens output_tokens retries error
|
|
14
|
+
].freeze
|
|
8
15
|
DANGEROUS_CSV_PREFIX = /\A[ \t\r\n]*[=+\-@]/
|
|
9
16
|
|
|
10
|
-
def write(results, path)
|
|
11
|
-
CSV.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
results.sort_by(&:key).each do |result|
|
|
15
|
-
csv << [
|
|
16
|
-
sanitize_cell(result.key),
|
|
17
|
-
sanitize_cell(result.text),
|
|
18
|
-
sanitize_cell(result.description),
|
|
19
|
-
sanitize_cell(result.ui_element),
|
|
20
|
-
sanitize_cell(result.tone),
|
|
21
|
-
result.max_length,
|
|
22
|
-
sanitize_cell(result.locations.join(';')),
|
|
23
|
-
sanitize_cell(result.error)
|
|
24
|
-
]
|
|
25
|
-
end
|
|
26
|
-
end
|
|
17
|
+
def write(results, path, output: $stdout, **_options)
|
|
18
|
+
return write_rows(CSV.new(output), results) if path == '-'
|
|
19
|
+
|
|
20
|
+
CSV.open(path, 'w') { |csv| write_rows(csv, results) }
|
|
27
21
|
end
|
|
28
22
|
|
|
29
23
|
private
|
|
30
24
|
|
|
25
|
+
def write_rows(csv, results)
|
|
26
|
+
csv << HEADERS
|
|
27
|
+
|
|
28
|
+
results.sort_by { |result| result_sort_key(result) }.each do |result|
|
|
29
|
+
csv << [
|
|
30
|
+
OUTPUT_SCHEMA_VERSION,
|
|
31
|
+
sanitize_cell(result.key),
|
|
32
|
+
sanitize_cell(result.source_file),
|
|
33
|
+
sanitize_cell(result.translation_key),
|
|
34
|
+
sanitize_cell(result.text),
|
|
35
|
+
sanitize_cell(result.description),
|
|
36
|
+
sanitize_cell(result.ui_element),
|
|
37
|
+
sanitize_cell(result.tone),
|
|
38
|
+
result.max_length,
|
|
39
|
+
sanitize_cell(result.confidence),
|
|
40
|
+
sanitize_cell(result.ambiguity_reason),
|
|
41
|
+
sanitize_cell(Oj.dump(serialize_locations(result.locations), mode: :compat)),
|
|
42
|
+
sanitize_cell(Oj.dump(serialize_locations(result.changed_locations), mode: :compat)),
|
|
43
|
+
sanitize_cell(Oj.dump(serialize_location_groups(result.changed_location_groups), mode: :compat)),
|
|
44
|
+
sanitize_cell(Oj.dump(serialize_locations(result.changed_translation_locations), mode: :compat)),
|
|
45
|
+
result.status,
|
|
46
|
+
result.cache_hit,
|
|
47
|
+
result.request_count,
|
|
48
|
+
result.input_tokens,
|
|
49
|
+
result.output_tokens,
|
|
50
|
+
result.retries,
|
|
51
|
+
sanitize_cell(result.error)
|
|
52
|
+
]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
31
56
|
def sanitize_cell(value)
|
|
32
57
|
return value unless value.is_a?(String)
|
|
33
58
|
return value unless value.match?(DANGEROUS_CSV_PREFIX)
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../path_policy'
|
|
4
|
+
require_relative '../file_classifier'
|
|
5
|
+
|
|
3
6
|
module I18nContextGenerator
|
|
4
7
|
module Writers
|
|
5
8
|
# Shared utilities for writer classes (description filtering, file discovery).
|
|
@@ -10,6 +13,7 @@ module I18nContextGenerator
|
|
|
10
13
|
|
|
11
14
|
def writable_result?(result)
|
|
12
15
|
return false unless result&.description
|
|
16
|
+
return false if result.respond_to?(:actionable?) && !result.actionable?
|
|
13
17
|
return false unless result.error.nil?
|
|
14
18
|
return false if result.description.strip.empty?
|
|
15
19
|
|
|
@@ -23,35 +27,9 @@ module I18nContextGenerator
|
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
def find_swift_files(path, ignore_patterns: [])
|
|
26
|
-
|
|
27
|
-
[path]
|
|
28
|
-
|
|
29
|
-
Dir.glob(File.join(path, '**', '*.swift'))
|
|
30
|
-
else
|
|
31
|
-
[]
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
filter_ignored_paths(files, ignore_patterns)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
private
|
|
38
|
-
|
|
39
|
-
def filter_ignored_paths(paths, ignore_patterns)
|
|
40
|
-
compiled_patterns = ignore_patterns.map { |pattern| glob_to_regex(pattern) }
|
|
41
|
-
paths.reject { |path| ignored_path?(path, compiled_patterns) }.sort
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def ignored_path?(path, compiled_patterns)
|
|
45
|
-
compiled_patterns.any? { |pattern| pattern.match?(path) }
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def glob_to_regex(glob_pattern)
|
|
49
|
-
regex_str = Regexp.escape(glob_pattern)
|
|
50
|
-
.gsub('\*\*/', '(.*/)?')
|
|
51
|
-
.gsub('\*\*', '.*')
|
|
52
|
-
.gsub('\*', '[^/]*')
|
|
53
|
-
.gsub('\?', '.')
|
|
54
|
-
Regexp.new("(?:^|/)#{regex_str}(?:$|/)")
|
|
30
|
+
PathPolicy.new(ignore_patterns: ignore_patterns, roots: [path])
|
|
31
|
+
.files([path]) { |file| FileClassifier.swift_source?(file) }
|
|
32
|
+
.sort
|
|
55
33
|
end
|
|
56
34
|
end
|
|
57
35
|
end
|
|
@@ -6,28 +6,53 @@ module I18nContextGenerator
|
|
|
6
6
|
module Writers
|
|
7
7
|
# Writes extraction results to a JSON file.
|
|
8
8
|
class JsonWriter
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
include ResultSerialization
|
|
10
|
+
|
|
11
|
+
def write(results, path, metrics: nil, output: $stdout)
|
|
12
|
+
document = {
|
|
13
|
+
schema_version: OUTPUT_SCHEMA_VERSION,
|
|
11
14
|
generated_at: Time.now.iso8601,
|
|
12
15
|
version: I18nContextGenerator::VERSION,
|
|
13
16
|
total: results.size,
|
|
14
|
-
|
|
17
|
+
metrics: metrics&.to_h,
|
|
18
|
+
entries: results.sort_by { |result| result_sort_key(result) }.map do |result|
|
|
15
19
|
{
|
|
16
20
|
key: result.key,
|
|
21
|
+
source_file: result.source_file,
|
|
22
|
+
translation_key: result.translation_key,
|
|
17
23
|
text: result.text,
|
|
18
24
|
context: {
|
|
19
25
|
description: result.description,
|
|
20
26
|
ui_element: result.ui_element,
|
|
21
27
|
tone: result.tone,
|
|
22
|
-
max_length: result.max_length
|
|
28
|
+
max_length: result.max_length,
|
|
29
|
+
confidence: result.confidence,
|
|
30
|
+
ambiguity_reason: result.ambiguity_reason
|
|
31
|
+
},
|
|
32
|
+
locations: serialize_locations(result.locations),
|
|
33
|
+
changed_locations: serialize_locations(result.changed_locations),
|
|
34
|
+
changed_location_groups: serialize_location_groups(result.changed_location_groups),
|
|
35
|
+
changed_translation_locations: serialize_locations(result.changed_translation_locations),
|
|
36
|
+
status: result.status,
|
|
37
|
+
telemetry: {
|
|
38
|
+
cache_hit: result.cache_hit,
|
|
39
|
+
request_count: result.request_count,
|
|
40
|
+
input_tokens: result.input_tokens,
|
|
41
|
+
output_tokens: result.output_tokens,
|
|
42
|
+
retries: result.retries
|
|
23
43
|
},
|
|
24
|
-
locations: result.locations,
|
|
25
44
|
error: result.error
|
|
26
45
|
}
|
|
27
46
|
end
|
|
28
47
|
}
|
|
29
48
|
|
|
30
|
-
|
|
49
|
+
rendered = Oj.dump(document, indent: 2, mode: :compat)
|
|
50
|
+
if path == '-'
|
|
51
|
+
output.write(rendered)
|
|
52
|
+
output.write("\n") unless rendered.end_with?("\n")
|
|
53
|
+
else
|
|
54
|
+
File.write(path, rendered)
|
|
55
|
+
end
|
|
31
56
|
end
|
|
32
57
|
end
|
|
33
58
|
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require 'tempfile'
|
|
6
|
+
|
|
7
|
+
module I18nContextGenerator
|
|
8
|
+
module Writers
|
|
9
|
+
# Renders a writer against a temporary copy and returns a Git-style patch
|
|
10
|
+
# without mutating the original file.
|
|
11
|
+
class Preview
|
|
12
|
+
class << self
|
|
13
|
+
def render(path)
|
|
14
|
+
extension = File.extname(path)
|
|
15
|
+
candidate = Tempfile.new(['i18n-context-preview-', extension])
|
|
16
|
+
candidate.binmode
|
|
17
|
+
candidate.write(File.binread(path))
|
|
18
|
+
candidate.close
|
|
19
|
+
|
|
20
|
+
yield candidate.path
|
|
21
|
+
return nil if File.binread(candidate.path) == File.binread(path)
|
|
22
|
+
|
|
23
|
+
unified_diff(path, candidate.path)
|
|
24
|
+
ensure
|
|
25
|
+
candidate&.close!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def unified_diff(original_path, candidate_path)
|
|
31
|
+
stdout, stderr, status = Open3.capture3(
|
|
32
|
+
'git', 'diff', '--no-index', '--no-prefix', '--', original_path, candidate_path
|
|
33
|
+
)
|
|
34
|
+
unless [0, 1].include?(status.exitstatus)
|
|
35
|
+
detail = stderr.strip
|
|
36
|
+
detail = 'git diff --no-index failed' if detail.empty?
|
|
37
|
+
raise Error, "Unable to render preview for #{original_path}: #{detail}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sanitize_headers(stdout, original_path)
|
|
41
|
+
rescue SystemCallError => e
|
|
42
|
+
raise Error, "Unable to render preview for #{original_path}: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def sanitize_headers(diff, original_path)
|
|
46
|
+
display_path = display_path_for(original_path)
|
|
47
|
+
in_hunk = false
|
|
48
|
+
diff.lines.map do |line|
|
|
49
|
+
in_hunk = true if line.start_with?('@@ ')
|
|
50
|
+
if !in_hunk && line.start_with?('diff --git ')
|
|
51
|
+
"diff --git a/#{display_path} b/#{display_path}\n"
|
|
52
|
+
elsif !in_hunk && line.start_with?('--- ')
|
|
53
|
+
"--- a/#{display_path}\n"
|
|
54
|
+
elsif !in_hunk && line.start_with?('+++ ')
|
|
55
|
+
"+++ b/#{display_path}\n"
|
|
56
|
+
else
|
|
57
|
+
line
|
|
58
|
+
end
|
|
59
|
+
end.join
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def display_path_for(original_path)
|
|
63
|
+
clean_path = Pathname.new(original_path).cleanpath
|
|
64
|
+
return clean_path.to_s unless clean_path.absolute?
|
|
65
|
+
|
|
66
|
+
directory = File.dirname(clean_path.to_s)
|
|
67
|
+
stdout, _stderr, status = Open3.capture3('git', 'rev-parse', '--show-toplevel', chdir: directory)
|
|
68
|
+
if status.success?
|
|
69
|
+
relative = clean_path.relative_path_from(Pathname.new(stdout.strip)).cleanpath.to_s
|
|
70
|
+
return relative unless relative == '..' || relative.start_with?('../')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
clean_path.basename.to_s
|
|
74
|
+
rescue ArgumentError, SystemCallError
|
|
75
|
+
clean_path.basename.to_s
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nContextGenerator
|
|
4
|
+
module Writers
|
|
5
|
+
# Stable machine-output encoding shared by JSON and CSV writers.
|
|
6
|
+
module ResultSerialization
|
|
7
|
+
OUTPUT_SCHEMA_VERSION = 1
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def result_sort_key(result)
|
|
12
|
+
[result.source_file.to_s, result.translation_key.to_s, result.key]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def serialize_locations(locations)
|
|
16
|
+
Array(locations).map { |location| serialize_location(location) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def serialize_location_groups(groups)
|
|
20
|
+
Array(groups).map { |group| serialize_locations(group) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def serialize_location(location)
|
|
24
|
+
return location unless location.is_a?(ChangedLocation)
|
|
25
|
+
|
|
26
|
+
location.to_h.transform_values { |value| value.is_a?(Symbol) ? value.to_s : value }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|