commenter 0.2.3 → 0.3.1
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/.rubocop.yml +8 -0
- data/CLAUDE.md +68 -0
- data/README.adoc +115 -14
- data/commenter.gemspec +3 -2
- data/lib/commenter/cli.rb +23 -25
- data/lib/commenter/comment.rb +23 -8
- data/lib/commenter/comment_sheet.rb +18 -2
- data/lib/commenter/comment_type.rb +35 -0
- data/lib/commenter/github_integration.rb +11 -60
- data/lib/commenter/github_session.rb +35 -0
- data/lib/commenter/parser/osd_xlsx_parser.rb +243 -0
- data/lib/commenter/parser/track_change_docx_parser.rb +332 -0
- data/lib/commenter/parser.rb +93 -37
- data/lib/commenter/version.rb +1 -1
- data/lib/commenter.rb +5 -0
- data/schema/iso_comment_2012-03.yaml +2 -2
- data/schema/iso_comment_osd.yaml +112 -0
- data/spec/commenter/cli_spec.rb +35 -0
- data/spec/commenter/comment_sheet_spec.rb +25 -0
- data/spec/commenter/comment_type_spec.rb +45 -0
- data/spec/commenter/github_integration_spec.rb +65 -32
- data/spec/commenter/osd_xlsx_parser_spec.rb +180 -0
- data/spec/commenter/track_change_docx_parser_spec.rb +277 -0
- data/spec/support/osd_fixtures.rb +94 -0
- data/spec/support/redline_docx_builder.rb +88 -0
- data/spec/support/xlsx_builder.rb +147 -0
- metadata +32 -5
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zip"
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
require_relative "../comment"
|
|
6
|
+
require_relative "../comment_sheet"
|
|
7
|
+
|
|
8
|
+
module Commenter
|
|
9
|
+
class Parser
|
|
10
|
+
# Extracts tracked changes from a redlined Word document into an ISO
|
|
11
|
+
# 2012-03 comment sheet.
|
|
12
|
+
#
|
|
13
|
+
# Captured track changes (w:ins, w:del, w:moveFrom, w:moveTo):
|
|
14
|
+
# - proposed_change renders the change itself, e.g. +Insert: "text"+
|
|
15
|
+
# - locality.clause is resolved from the nearest preceding heading
|
|
16
|
+
# - locality.element is resolved from caption/inline references
|
|
17
|
+
# (Table N, Figure N, Formula (N), NOTE n) within the current clause
|
|
18
|
+
# - observations can be stamped via the observations or accept_all options
|
|
19
|
+
#
|
|
20
|
+
# Reviewer comment threads (word/comments.xml w:comment) are emitted
|
|
21
|
+
# after the track changes with -CNNN ids: the remark verbatim in
|
|
22
|
+
# comments, its instruction reworded as the proposed change, and empty
|
|
23
|
+
# observations for the owner to draft.
|
|
24
|
+
#
|
|
25
|
+
# Self-closing markers (paragraph-mark insertions inside rPr) carry no
|
|
26
|
+
# content and are skipped.
|
|
27
|
+
#
|
|
28
|
+
# word/document.xml is streamed with Nokogiri::XML::Reader because redline
|
|
29
|
+
# documents can exceed 100 MB.
|
|
30
|
+
class TrackChangeDocxParser
|
|
31
|
+
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
32
|
+
|
|
33
|
+
HEADING_STYLE = /\A(?:Heading[1-6]|h[2-5]annex\d*|ANNEX|BaseHeading)\z/i
|
|
34
|
+
KIND_COMMENT = { "ins" => "insertion", "del" => "deletion", "moveFrom" => "move (from)",
|
|
35
|
+
"moveTo" => "move (to)" }.freeze
|
|
36
|
+
KIND_LABEL = { "ins" => "Insert", "del" => "Delete", "moveFrom" => "Move from",
|
|
37
|
+
"moveTo" => "Move to" }.freeze
|
|
38
|
+
|
|
39
|
+
DEFAULT_BODY = "CS"
|
|
40
|
+
WHOLE_DOCUMENT = "_whole document"
|
|
41
|
+
ACCEPT_ALL = "Accepted. Tracked change accepted."
|
|
42
|
+
|
|
43
|
+
attr_reader :skipped_markers
|
|
44
|
+
|
|
45
|
+
def parse(path, options = {})
|
|
46
|
+
body = options[:body] || DEFAULT_BODY
|
|
47
|
+
changes = []
|
|
48
|
+
remarks = {}
|
|
49
|
+
anchors = {}
|
|
50
|
+
@skipped_markers = 0
|
|
51
|
+
|
|
52
|
+
Zip::File.open(path) do |zip|
|
|
53
|
+
entry = zip.glob("word/document.xml").first
|
|
54
|
+
raise Commenter::Error, "word/document.xml not found in #{path}" unless entry
|
|
55
|
+
|
|
56
|
+
remarks = read_remarks(zip)
|
|
57
|
+
changes = stream_changes(entry.get_input_stream, anchors)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
CommentSheet.new(
|
|
61
|
+
version: "2012-03",
|
|
62
|
+
date: sheet_date(changes),
|
|
63
|
+
document: options[:document],
|
|
64
|
+
stage: options[:stage],
|
|
65
|
+
comments: build_change_comments(changes, body, options) +
|
|
66
|
+
build_remark_comments(remarks, anchors, body, options)
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Turns a reviewer's remark phrased as a request ("Please remove this
|
|
71
|
+
# NOTE...") into the corresponding proposed change ("Remove this
|
|
72
|
+
# NOTE...").
|
|
73
|
+
def self.reword_remark(text)
|
|
74
|
+
text.to_s.strip.sub(/\APlease\s+/i, "").sub(/\A[a-z]/, &:upcase)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def read_remarks(zip)
|
|
80
|
+
entry = zip.glob("word/comments.xml").first
|
|
81
|
+
return {} unless entry
|
|
82
|
+
|
|
83
|
+
document = Nokogiri::XML(entry.get_input_stream)
|
|
84
|
+
document.xpath("//w:comment", "w" => W_NS).each_with_object({}) do |node, remarks|
|
|
85
|
+
remarks[node["w:id"]] = {
|
|
86
|
+
author: node["w:author"],
|
|
87
|
+
date: node["w:date"],
|
|
88
|
+
text: node.xpath(".//w:t", "w" => W_NS).map(&:text).join
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def sheet_date(changes)
|
|
94
|
+
dates = changes.filter_map { |change| change[:date].to_s[/\A\d{4}-\d{2}-\d{2}/] }
|
|
95
|
+
dates.max
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_change_comments(changes, body, options)
|
|
99
|
+
changes.each_with_index.map do |change, index|
|
|
100
|
+
kind = change[:kind]
|
|
101
|
+
Comment.new(
|
|
102
|
+
id: format("%<body>s-%03<index>d", body: body, index: index + 1),
|
|
103
|
+
body: body,
|
|
104
|
+
locality: { clause: change[:clause], line_number: nil, element: change[:element] },
|
|
105
|
+
type: options[:type] || "te",
|
|
106
|
+
comments: "Track change (#{KIND_COMMENT.fetch(kind, kind)})",
|
|
107
|
+
proposed_change: "#{KIND_LABEL.fetch(kind, kind)}: \"#{change[:text].strip}\"",
|
|
108
|
+
observations: observations_for(options)
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_remark_comments(remarks, anchors, body, options)
|
|
114
|
+
remarks.keys.sort_by(&:to_i).each_with_index.map do |remark_id, index|
|
|
115
|
+
remark = remarks[remark_id]
|
|
116
|
+
anchor = anchors[remark_id] || {}
|
|
117
|
+
clause = anchor[:clause].to_s.empty? ? WHOLE_DOCUMENT : anchor[:clause]
|
|
118
|
+
Comment.new(
|
|
119
|
+
id: format("%<body>s-C%03<index>d", body: body, index: index + 1),
|
|
120
|
+
body: body,
|
|
121
|
+
locality: { clause: clause, line_number: nil, element: anchor[:element] },
|
|
122
|
+
type: options[:remark_type] || "ed",
|
|
123
|
+
comments: remark[:text],
|
|
124
|
+
proposed_change: self.class.reword_remark(remark[:text]),
|
|
125
|
+
observations: nil
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def observations_for(options)
|
|
131
|
+
return nil if options[:exclude_observations]
|
|
132
|
+
|
|
133
|
+
options[:observations] || (options[:accept_all] ? ACCEPT_ALL : nil)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def stream_changes(io, anchors = {})
|
|
137
|
+
changes = []
|
|
138
|
+
change_stack = []
|
|
139
|
+
paragraph = ParagraphState.new
|
|
140
|
+
|
|
141
|
+
Nokogiri::XML::Reader(io).each do |reader|
|
|
142
|
+
case reader.node_type
|
|
143
|
+
when Nokogiri::XML::Reader::TYPE_ELEMENT
|
|
144
|
+
handle_start(reader, change_stack, paragraph, anchors)
|
|
145
|
+
when Nokogiri::XML::Reader::TYPE_TEXT, Nokogiri::XML::Reader::TYPE_SIGNIFICANT_WHITESPACE
|
|
146
|
+
handle_text(reader, change_stack, paragraph)
|
|
147
|
+
when Nokogiri::XML::Reader::TYPE_END_ELEMENT
|
|
148
|
+
handle_end(reader, change_stack, paragraph, changes)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
changes
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def handle_start(reader, change_stack, paragraph, anchors = {})
|
|
156
|
+
case reader.local_name
|
|
157
|
+
when "p"
|
|
158
|
+
paragraph.begin_paragraph
|
|
159
|
+
when "pStyle"
|
|
160
|
+
paragraph.style = reader.attribute("w:val") || reader.attribute("val")
|
|
161
|
+
when "ins", "del", "moveFrom", "moveTo"
|
|
162
|
+
start_change(reader, change_stack, paragraph)
|
|
163
|
+
when "commentRangeStart"
|
|
164
|
+
anchor_remark(reader, paragraph, anchors)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def anchor_remark(reader, paragraph, anchors)
|
|
169
|
+
id = reader.attribute("w:id")
|
|
170
|
+
anchors[id] = {
|
|
171
|
+
clause: paragraph.pending_clause || paragraph.clause,
|
|
172
|
+
element: paragraph.pending_element || paragraph.element
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def start_change(reader, change_stack, paragraph)
|
|
177
|
+
if reader.empty_element?
|
|
178
|
+
@skipped_markers += 1
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
change_stack.push(
|
|
183
|
+
kind: reader.local_name,
|
|
184
|
+
id: reader.attribute("w:id"),
|
|
185
|
+
author: reader.attribute("w:author"),
|
|
186
|
+
date: reader.attribute("w:date"),
|
|
187
|
+
text: +"",
|
|
188
|
+
clause: paragraph.clause,
|
|
189
|
+
element: paragraph.element
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def handle_text(reader, change_stack, paragraph)
|
|
194
|
+
if (current = change_stack.last)
|
|
195
|
+
current[:text] << reader.value
|
|
196
|
+
else
|
|
197
|
+
paragraph.append_text(reader.value)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def handle_end(reader, change_stack, paragraph, changes)
|
|
202
|
+
case reader.local_name
|
|
203
|
+
when "p"
|
|
204
|
+
paragraph.commit_paragraph
|
|
205
|
+
when "ins", "del", "moveFrom", "moveTo"
|
|
206
|
+
change = change_stack.pop
|
|
207
|
+
changes << finalize_change(change, paragraph) if change
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def finalize_change(change, paragraph)
|
|
212
|
+
# A change inside a heading or caption paragraph belongs to that
|
|
213
|
+
# paragraph, which is not committed yet at this point.
|
|
214
|
+
change[:clause] = paragraph.pending_clause || change[:clause]
|
|
215
|
+
change[:element] = paragraph.pending_element || change[:element]
|
|
216
|
+
change[:clause] = WHOLE_DOCUMENT if change[:clause].to_s.empty?
|
|
217
|
+
change
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Tracks the current paragraph's style and text to resolve the locality
|
|
221
|
+
# context of a change:
|
|
222
|
+
#
|
|
223
|
+
# - clause: the clause number of the most recent heading paragraph; an
|
|
224
|
+
# unnumbered sub-heading inherits its parent heading's clause
|
|
225
|
+
# - element: the nearest Table/Figure/Formula/NOTE reference, scoped to
|
|
226
|
+
# the current clause (caption paragraphs and inline mentions both
|
|
227
|
+
# count)
|
|
228
|
+
class ParagraphState
|
|
229
|
+
NUMBERED_CLAUSE = /\A(?:\d+(?:\.\d+)*|Annex\s+[A-Z](?:\.\d+)*|Bibliography|Foreword|Introduction)\z/i
|
|
230
|
+
ELEMENT_START = /\A\s*(NOTE\s+\d+|NOTE(?=\s*[—:-])|Table\s+(?:[A-Z]\.)?\d+(?:\.\d+)*|Figure\s+(?:[A-Z]\.)?\d+(?:\.\d+)*)\b/i
|
|
231
|
+
FORMULA = /\b(Formula\s*\(\d+(?:\.\d+)*\))/
|
|
232
|
+
ELEMENT_ANY = /\b(Table\s+(?:[A-Z]\.)?\d+(?:\.\d+)*|Figure\s+(?:[A-Z]\.)?\d+(?:\.\d+)*|NOTE\s+\d+)\b/i
|
|
233
|
+
|
|
234
|
+
attr_writer :style
|
|
235
|
+
attr_reader :clause, :element
|
|
236
|
+
|
|
237
|
+
def initialize
|
|
238
|
+
@style = nil
|
|
239
|
+
@text = +""
|
|
240
|
+
@clause = ""
|
|
241
|
+
@element = nil
|
|
242
|
+
@heading_stack = []
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def begin_paragraph
|
|
246
|
+
@style = nil
|
|
247
|
+
@text = +""
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def append_text(value)
|
|
251
|
+
@text << value
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def pending_clause
|
|
255
|
+
return unless heading_paragraph?
|
|
256
|
+
|
|
257
|
+
clause = self.class.clause_for(@text)
|
|
258
|
+
clause unless clause.empty?
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def pending_element
|
|
262
|
+
self.class.element_for(@text)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def commit_paragraph
|
|
266
|
+
if heading_paragraph?
|
|
267
|
+
commit_heading
|
|
268
|
+
else
|
|
269
|
+
@element = self.class.element_for(@text) || @element
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def heading_paragraph?
|
|
274
|
+
@style&.match?(HEADING_STYLE)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def self.numbered_clause?(clause)
|
|
278
|
+
clause.to_s.match?(NUMBERED_CLAUSE)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Extracts the clause identifier from heading text: the leading number
|
|
282
|
+
# ("4.2.1 Thermodynamic ..." -> "4.2.1"), an annex reference, or a
|
|
283
|
+
# well-known unnumbered section. Runs may be concatenated without
|
|
284
|
+
# spaces ("4.2.1Thermodynamic"), so the number is matched with a
|
|
285
|
+
# lookahead.
|
|
286
|
+
def self.clause_for(text)
|
|
287
|
+
s = text.to_s.strip
|
|
288
|
+
return "" if s.empty?
|
|
289
|
+
|
|
290
|
+
return Regexp.last_match(1) if s =~ /\A\s*(Annex\s+[A-Z](?:\.\d+)*)\b/i
|
|
291
|
+
return Regexp.last_match(1) if s =~ /\A\s*(Bibliography|Foreword|Introduction)\b/i
|
|
292
|
+
return Regexp.last_match(1) if s =~ /\A\s*((?:\d+\.)*\d+)(?=[A-Z\s])/
|
|
293
|
+
return Regexp.last_match(1) if s =~ /\A\s*((?:\d+\.)*\d+)\b/
|
|
294
|
+
|
|
295
|
+
s
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Resolves the element reference of a paragraph: a caption opening the
|
|
299
|
+
# paragraph ("Table 3 — ...", "NOTE 2 ...", "Formula (9):") wins over an
|
|
300
|
+
# inline mention ("the values in Table 5 shall ...").
|
|
301
|
+
def self.element_for(text)
|
|
302
|
+
s = text.to_s
|
|
303
|
+
match = s.match(ELEMENT_START) || s.match(FORMULA) || s.match(ELEMENT_ANY)
|
|
304
|
+
match[1].squeeze(" ").strip if match
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
private
|
|
308
|
+
|
|
309
|
+
def commit_heading
|
|
310
|
+
level = heading_level
|
|
311
|
+
clause = self.class.clause_for(@text)
|
|
312
|
+
clause = inherited_clause(level) unless self.class.numbered_clause?(clause)
|
|
313
|
+
@heading_stack.pop while @heading_stack.last && @heading_stack.last[0] >= level
|
|
314
|
+
@heading_stack << [level, clause]
|
|
315
|
+
@clause = clause unless clause.empty?
|
|
316
|
+
# Element references belong to the clause they appear in.
|
|
317
|
+
@element = nil
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def inherited_clause(level)
|
|
321
|
+
parent = @heading_stack.reverse.find { |(lvl, _)| lvl < level }
|
|
322
|
+
parent ? parent[1] : ""
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def heading_level
|
|
326
|
+
m = @style.match(/\A(?:Heading|h)(\d+)/i)
|
|
327
|
+
m ? m[1].to_i : 1
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
data/lib/commenter/parser.rb
CHANGED
|
@@ -1,12 +1,51 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "docx"
|
|
4
|
+
require "pathname"
|
|
4
5
|
require_relative "comment_sheet"
|
|
5
6
|
require_relative "comment"
|
|
7
|
+
require_relative "parser/osd_xlsx_parser"
|
|
8
|
+
require_relative "parser/track_change_docx_parser"
|
|
6
9
|
|
|
7
10
|
module Commenter
|
|
8
11
|
class Parser
|
|
9
|
-
def parse(
|
|
12
|
+
def parse(input_path, options = {})
|
|
13
|
+
format = detect_format(input_path, options)
|
|
14
|
+
|
|
15
|
+
case format
|
|
16
|
+
when :docx
|
|
17
|
+
parse_docx(input_path, options)
|
|
18
|
+
when :xlsx
|
|
19
|
+
parse_xlsx(input_path, options)
|
|
20
|
+
when :redline
|
|
21
|
+
parse_redline(input_path, options)
|
|
22
|
+
else
|
|
23
|
+
raise "Unsupported file format: #{input_path}. Supported formats: .docx, .xlsx"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def detect_format(path, options = {})
|
|
30
|
+
return options[:format].to_sym if options[:format]
|
|
31
|
+
|
|
32
|
+
ext = File.extname(path).downcase
|
|
33
|
+
case ext
|
|
34
|
+
when ".docx" then :docx
|
|
35
|
+
when ".xlsx", ".xls" then :xlsx
|
|
36
|
+
else :unknown
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def parse_xlsx(xlsx_path, options)
|
|
41
|
+
OsdXlsxParser.new.parse(xlsx_path, options)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parse_redline(docx_path, options)
|
|
45
|
+
TrackChangeDocxParser.new.parse(docx_path, options)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse_docx(docx_path, options)
|
|
10
49
|
doc = Docx::Document.open(docx_path)
|
|
11
50
|
|
|
12
51
|
# Extract metadata from the first table
|
|
@@ -17,41 +56,13 @@ module Commenter
|
|
|
17
56
|
raise "No comments table found in document" unless comments_table
|
|
18
57
|
raise "Comments table appears to be empty" if comments_table.row_count < 2
|
|
19
58
|
|
|
20
|
-
comments = []
|
|
21
|
-
|
|
22
59
|
# Process all rows - don't skip any rows, respect all content
|
|
23
|
-
(0..comments_table.row_count - 1).
|
|
24
|
-
|
|
25
|
-
cells = row.cells.map { |c| c.text.strip }
|
|
26
|
-
|
|
27
|
-
# Skip only completely empty rows
|
|
60
|
+
comments = (0..comments_table.row_count - 1).map do |row_index|
|
|
61
|
+
cells = comments_table.rows[row_index].cells.map { |cell| cell.text.strip }
|
|
28
62
|
next if cells.all?(&:empty?)
|
|
29
63
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
body = id.include?("-") ? id.split("-").first : id
|
|
33
|
-
|
|
34
|
-
# Create comment with symbol keys, respecting all input data
|
|
35
|
-
comment_attrs = {
|
|
36
|
-
id: id,
|
|
37
|
-
body: body,
|
|
38
|
-
locality: {
|
|
39
|
-
line_number: cells[1] && cells[1].empty? ? nil : cells[1],
|
|
40
|
-
clause: cells[2] && cells[2].empty? ? nil : cells[2],
|
|
41
|
-
element: cells[3] && cells[3].empty? ? nil : cells[3]
|
|
42
|
-
},
|
|
43
|
-
type: cells[4] || "",
|
|
44
|
-
comments: cells[5] || "",
|
|
45
|
-
proposed_change: cells[6] || ""
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
# Handle observations column
|
|
49
|
-
unless options[:exclude_observations]
|
|
50
|
-
comment_attrs[:observations] = cells[7] && cells[7].empty? ? nil : cells[7]
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
comments << Comment.new(comment_attrs)
|
|
54
|
-
end
|
|
64
|
+
osd_docx_row?(cells) ? build_osd_docx_comment(cells, options) : build_classic_docx_comment(cells, options)
|
|
65
|
+
end.compact
|
|
55
66
|
|
|
56
67
|
# Create comment sheet
|
|
57
68
|
CommentSheet.new(
|
|
@@ -63,7 +74,55 @@ module Commenter
|
|
|
63
74
|
)
|
|
64
75
|
end
|
|
65
76
|
|
|
66
|
-
|
|
77
|
+
# The DOCX from ISO OSD has 9 columns:
|
|
78
|
+
# 0: User name, 1: (empty/line), 2: Clause nb, 3: Clause Title,
|
|
79
|
+
# 4: Type, 5: Comment, 6: (empty/proposal), 7: Observations, 8: Comment number
|
|
80
|
+
# Detected by checking if last column looks like a numeric ID.
|
|
81
|
+
def osd_docx_row?(cells)
|
|
82
|
+
cells.length >= 9 && cells[8].to_s.match?(/^\d+$/)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_osd_docx_comment(cells, options)
|
|
86
|
+
attrs = {
|
|
87
|
+
id: cells[8],
|
|
88
|
+
body: cells[0].to_s.strip,
|
|
89
|
+
locality: {
|
|
90
|
+
clause: presence(cells[2]),
|
|
91
|
+
element: presence(cells[3])
|
|
92
|
+
},
|
|
93
|
+
type: CommentType.code(cells[4]),
|
|
94
|
+
comments: cells[5] || "",
|
|
95
|
+
proposed_change: presence(cells[6]),
|
|
96
|
+
user_name: cells[0].to_s.strip
|
|
97
|
+
}
|
|
98
|
+
attrs[:observations] = presence(cells[7]) unless options[:exclude_observations]
|
|
99
|
+
Comment.new(attrs)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Classic ISO comment template format (8 columns):
|
|
103
|
+
# 0: ID, 1: line_number, 2: clause, 3: element, 4: type, 5: comments,
|
|
104
|
+
# 6: proposed_change, 7: observations
|
|
105
|
+
def build_classic_docx_comment(cells, options)
|
|
106
|
+
id = cells[0] || ""
|
|
107
|
+
attrs = {
|
|
108
|
+
id: id,
|
|
109
|
+
body: id.include?("-") ? id.split("-").first : id,
|
|
110
|
+
locality: {
|
|
111
|
+
line_number: presence(cells[1]),
|
|
112
|
+
clause: presence(cells[2]),
|
|
113
|
+
element: presence(cells[3])
|
|
114
|
+
},
|
|
115
|
+
type: cells[4] || "",
|
|
116
|
+
comments: cells[5] || "",
|
|
117
|
+
proposed_change: cells[6] || ""
|
|
118
|
+
}
|
|
119
|
+
attrs[:observations] = presence(cells[7]) unless options[:exclude_observations]
|
|
120
|
+
Comment.new(attrs)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def presence(value)
|
|
124
|
+
value && !value.empty? ? value : nil
|
|
125
|
+
end
|
|
67
126
|
|
|
68
127
|
def extract_metadata(doc)
|
|
69
128
|
metadata = { date: nil, document: nil, project: nil }
|
|
@@ -96,9 +155,6 @@ module Commenter
|
|
|
96
155
|
project_match = all_text.match(/Project:\s*([^\n\r]+)/)
|
|
97
156
|
metadata[:project] = project_match[1]&.strip if project_match
|
|
98
157
|
|
|
99
|
-
# If no metadata found, try to extract from filename or other sources
|
|
100
|
-
# This is a fallback - in practice, users might need to provide metadata manually
|
|
101
|
-
|
|
102
158
|
metadata
|
|
103
159
|
end
|
|
104
160
|
end
|
data/lib/commenter/version.rb
CHANGED
data/lib/commenter.rb
CHANGED
|
@@ -7,6 +7,11 @@ require_relative "commenter/parser"
|
|
|
7
7
|
require_relative "commenter/filler"
|
|
8
8
|
require_relative "commenter/github_integration"
|
|
9
9
|
|
|
10
|
+
# Commenter converts ISO comment sheets (DOCX/XLSX) to structured YAML and
|
|
11
|
+
# syncs comments to GitHub issues.
|
|
10
12
|
module Commenter
|
|
11
13
|
class Error < StandardError; end
|
|
14
|
+
|
|
15
|
+
autoload :CommentType, "commenter/comment_type"
|
|
16
|
+
autoload :GitHubSession, "commenter/github_session"
|
|
12
17
|
end
|
|
@@ -68,8 +68,8 @@ properties:
|
|
|
68
68
|
required: ["clause"]
|
|
69
69
|
type:
|
|
70
70
|
type: string
|
|
71
|
-
enum: ["ge", "te", "ed"]
|
|
72
|
-
description: "Type of comment: ge=general, te=technical, ed=editorial"
|
|
71
|
+
enum: ["ge", "te", "ed", "general", "technical", "editorial"]
|
|
72
|
+
description: "Type of comment: ge=general, te=technical, ed=editorial. Codes are expanded to full names in output"
|
|
73
73
|
comments:
|
|
74
74
|
type: string
|
|
75
75
|
description: "The actual comment text"
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# ISO Comment Schema for ISO Online Standards Development (OSD) format
|
|
2
|
+
# Supports both resolved and unresolved XLSX exports from ISO OSD
|
|
3
|
+
$schema: http://json-schema.org/draft-07/schema#
|
|
4
|
+
title: ISO Comment OSD
|
|
5
|
+
description: Schema for ISO comments exported from ISO Online Standards Development (OSD) platform
|
|
6
|
+
type: object
|
|
7
|
+
properties:
|
|
8
|
+
version:
|
|
9
|
+
type: string
|
|
10
|
+
const: "osd"
|
|
11
|
+
description: Version identifier for OSD format
|
|
12
|
+
date:
|
|
13
|
+
type: ["string", "null"]
|
|
14
|
+
description: Date from the XLSX header
|
|
15
|
+
format: date
|
|
16
|
+
document:
|
|
17
|
+
type: ["string", "null"]
|
|
18
|
+
description: Document reference (e.g. ISO/DIS 5843-6(en))
|
|
19
|
+
project:
|
|
20
|
+
type: ["string", "null"]
|
|
21
|
+
description: Project name
|
|
22
|
+
title_en:
|
|
23
|
+
type: ["string", "null"]
|
|
24
|
+
description: Document title in English
|
|
25
|
+
title_fr:
|
|
26
|
+
type: ["string", "null"]
|
|
27
|
+
description: Document title in French
|
|
28
|
+
stage:
|
|
29
|
+
type: ["string", "null"]
|
|
30
|
+
description: Approval stage (WD/CD/DIS/FDIS/PRF/PUB)
|
|
31
|
+
enum: [null, "WD", "CD", "DIS", "FDIS", "PRF", "PUB"]
|
|
32
|
+
comments:
|
|
33
|
+
type: array
|
|
34
|
+
description: Array of comment entries from the ISO OSD export
|
|
35
|
+
items:
|
|
36
|
+
type: object
|
|
37
|
+
properties:
|
|
38
|
+
id:
|
|
39
|
+
type: ["string", "number"]
|
|
40
|
+
description: "Comment ID (numeric from OSD)"
|
|
41
|
+
body:
|
|
42
|
+
type: ["string", "null"]
|
|
43
|
+
description: "Member body or user name"
|
|
44
|
+
locality:
|
|
45
|
+
type: object
|
|
46
|
+
description: "Location information for the comment"
|
|
47
|
+
properties:
|
|
48
|
+
line_number:
|
|
49
|
+
type: ["string", "null"]
|
|
50
|
+
clause:
|
|
51
|
+
type: ["string", "null"]
|
|
52
|
+
element:
|
|
53
|
+
type: ["string", "null"]
|
|
54
|
+
description: "Clause Title from OSD"
|
|
55
|
+
type:
|
|
56
|
+
type: ["string", "null"]
|
|
57
|
+
description: "Type of comment; codes (ge/te/ed) are expanded to general/technical/editorial in output"
|
|
58
|
+
enum: [null, "ge", "te", "ed", "general", "technical", "editorial"]
|
|
59
|
+
comments:
|
|
60
|
+
type: ["string", "null"]
|
|
61
|
+
description: "The comment text"
|
|
62
|
+
proposed_change:
|
|
63
|
+
type: ["string", "null"]
|
|
64
|
+
description: "Proposed change text"
|
|
65
|
+
observations:
|
|
66
|
+
type: ["string", "null"]
|
|
67
|
+
description: "Resolution status or observations"
|
|
68
|
+
user_name:
|
|
69
|
+
type: ["string", "null"]
|
|
70
|
+
description: "User name from OSD"
|
|
71
|
+
comment_type:
|
|
72
|
+
type: ["string", "null"]
|
|
73
|
+
description: "Comment type/subtype from OSD (e.g. Editorial, General, Technical)"
|
|
74
|
+
resolution_status:
|
|
75
|
+
type: ["string", "null"]
|
|
76
|
+
description: "Resolution status (Accepted, Partially accepted, Rejected, etc.)"
|
|
77
|
+
resolution_date:
|
|
78
|
+
type: ["string", "null"]
|
|
79
|
+
description: "Date when the comment was resolved"
|
|
80
|
+
feedbacks:
|
|
81
|
+
type: ["string", "null"]
|
|
82
|
+
description: "Feedback/replies from the discussion"
|
|
83
|
+
motivation:
|
|
84
|
+
type: ["string", "null"]
|
|
85
|
+
description: "Motivation/justification for resolution"
|
|
86
|
+
created_date:
|
|
87
|
+
type: ["string", "null"]
|
|
88
|
+
description: "Date when the comment was created"
|
|
89
|
+
stage_code:
|
|
90
|
+
type: ["string", "null"]
|
|
91
|
+
description: "ISO stage code (e.g. 40.20)"
|
|
92
|
+
github:
|
|
93
|
+
type: ["object", "null"]
|
|
94
|
+
description: "GitHub integration information"
|
|
95
|
+
properties:
|
|
96
|
+
issue_number:
|
|
97
|
+
type: integer
|
|
98
|
+
issue_url:
|
|
99
|
+
type: string
|
|
100
|
+
format: uri
|
|
101
|
+
status:
|
|
102
|
+
type: string
|
|
103
|
+
enum: ["open", "closed"]
|
|
104
|
+
created_at:
|
|
105
|
+
type: string
|
|
106
|
+
format: date-time
|
|
107
|
+
updated_at:
|
|
108
|
+
type: string
|
|
109
|
+
format: date-time
|
|
110
|
+
required: ["issue_number", "issue_url", "status"]
|
|
111
|
+
required: ["id", "locality", "comments"]
|
|
112
|
+
required: ["version", "comments"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "commenter/cli"
|
|
5
|
+
require_relative "../support/osd_fixtures"
|
|
6
|
+
require_relative "../support/xlsx_builder"
|
|
7
|
+
|
|
8
|
+
RSpec.describe Commenter::Cli do
|
|
9
|
+
include OsdFixtures
|
|
10
|
+
|
|
11
|
+
describe "#import" do
|
|
12
|
+
it "writes OSD YAML and copies the OSD schema for XLSX input" do
|
|
13
|
+
Dir.mktmpdir do |dir|
|
|
14
|
+
xlsx = File.join(dir, "comments.xlsx")
|
|
15
|
+
rows = header_block(OsdFixtures::RESOLVED_HEADERS) + [
|
|
16
|
+
[1, "John Doe", "5.2.1", "Requirements", "Comment", "Editorial",
|
|
17
|
+
"The values in Table 3 are inconsistent.", nil, "Correct the values.", nil, nil, nil,
|
|
18
|
+
"2026-04-01", "Accepted", nil, "2026-04-10", "40.20"]
|
|
19
|
+
]
|
|
20
|
+
XlsxBuilder.write(xlsx, [["Comments (1)", rows]])
|
|
21
|
+
output = File.join(dir, "out", "comments.yaml")
|
|
22
|
+
schema_dir = File.join(dir, "out", "schema")
|
|
23
|
+
|
|
24
|
+
described_class.start(["import", xlsx, "--output", output, "--schema-dir", schema_dir])
|
|
25
|
+
|
|
26
|
+
data = YAML.safe_load_file(output)
|
|
27
|
+
expect(data["version"]).to eq("osd")
|
|
28
|
+
expect(data["comments"].length).to eq(1)
|
|
29
|
+
expect(data["comments"].first["id"]).to eq("1")
|
|
30
|
+
expect(File.read(output)).to start_with("# yaml-language-server: $schema=")
|
|
31
|
+
expect(File).to exist(File.join(schema_dir, "iso_comment_osd.yaml"))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|