commenter 0.3.0 → 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.
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "../support/redline_docx_builder"
5
+
6
+ RSpec.describe Commenter::Parser::TrackChangeDocxParser do
7
+ def author = "CHEN Yvonne"
8
+
9
+ def change(kind, id, text, date: "2025-12-05T20:10:00Z")
10
+ { change: kind, id: id, author: author, date: date, text: text }
11
+ end
12
+
13
+ def with_redline(paragraphs, comments: [], **options)
14
+ file = Tempfile.new(%w[redline .docx])
15
+ RedlineDocxBuilder.write(file.path, paragraphs, comments: comments)
16
+ yield described_class.new.parse(file.path, options)
17
+ ensure
18
+ file.close
19
+ file.unlink
20
+ end
21
+
22
+ let(:base_paragraphs) do
23
+ [
24
+ { change: :ins, id: "1", author: author, date: "2025-12-05T20:10:00Z", text: "ISO boilerplate" },
25
+ { heading: true, level: 1, text: "4 Basic principles" },
26
+ { change: :del, id: "2", author: author, date: "2025-12-05T20:11:00Z", text: "old text" },
27
+ { heading: true, level: 2, text: "4.2.1Thermodynamic temperature tangent" },
28
+ { marker: true, id: "9", author: author },
29
+ { parts: [{ text: "Body " }, change(:ins, "3", "inserted")] },
30
+ { heading: true, level: 1, text: "Annex A" },
31
+ { change: :moveTo, id: "4", author: author, date: "2025-12-06T10:00:00Z", text: "moved" }
32
+ ]
33
+ end
34
+
35
+ it "extracts track changes in document order with rendered proposed_change" do
36
+ with_redline(base_paragraphs) do |sheet|
37
+ comments = sheet.comments
38
+
39
+ expect(comments.map(&:proposed_change)).to eq([
40
+ 'Insert: "ISO boilerplate"',
41
+ 'Delete: "old text"',
42
+ 'Insert: "inserted"',
43
+ 'Move to: "moved"'
44
+ ])
45
+ expect(comments.map(&:comments)).to eq([
46
+ "Track change (insertion)",
47
+ "Track change (deletion)",
48
+ "Track change (insertion)",
49
+ "Track change (move (to))"
50
+ ])
51
+ end
52
+ end
53
+
54
+ it "assigns the clause of the nearest preceding heading, including sub-clauses" do
55
+ with_redline(base_paragraphs) do |sheet|
56
+ expect(sheet.comments.map(&:clause)).to eq(["_whole document", "4", "4.2.1", "Annex A"])
57
+ end
58
+ end
59
+
60
+ it "skips self-closing paragraph-mark markers without corrupting later capture" do
61
+ with_redline(base_paragraphs) do |sheet|
62
+ expect(sheet.comments.length).to eq(4)
63
+ end
64
+ end
65
+
66
+ it "captures nested track changes" do
67
+ paragraphs = [
68
+ { change: :ins, id: "1", author: author, date: "2025-12-05T20:10:00Z", text: "",
69
+ nested: [change(:ins, "2", "inner text")] }
70
+ ]
71
+
72
+ with_redline(paragraphs) do |sheet|
73
+ # The inner change ends first, so it is emitted before its wrapper.
74
+ expect(sheet.comments.map(&:proposed_change)).to eq(['Insert: "inner text"', 'Insert: ""'])
75
+ end
76
+ end
77
+
78
+ it "builds CS-prefixed sequential IDs by default" do
79
+ with_redline(base_paragraphs) do |sheet|
80
+ expect(sheet.comments.map(&:id)).to eq(%w[CS-001 CS-002 CS-003 CS-004])
81
+ expect(sheet.comments.map(&:body).uniq).to eq(["CS"])
82
+ end
83
+ end
84
+
85
+ it "uses a custom body code and technical type" do
86
+ with_redline(base_paragraphs, body: "GB", type: "ge") do |sheet|
87
+ expect(sheet.comments.first.id).to eq("GB-001")
88
+ expect(sheet.comments.first.type).to eq("general")
89
+ end
90
+ end
91
+
92
+ it "stamps observations on every entry when given" do
93
+ with_redline(base_paragraphs, observations: "Accepted. ISO/CS tracked change accepted.") do |sheet|
94
+ expect(sheet.comments.map(&:observations).uniq)
95
+ .to eq(["Accepted. ISO/CS tracked change accepted."])
96
+ end
97
+ end
98
+
99
+ it "leaves observations blank by default and honours exclude_observations" do
100
+ with_redline(base_paragraphs) do |sheet|
101
+ expect(sheet.comments.first.observations).to be_nil
102
+ end
103
+
104
+ with_redline(base_paragraphs, observations: "Accepted.", exclude_observations: true) do |sheet|
105
+ expect(sheet.comments.first.observations).to be_nil
106
+ end
107
+ end
108
+
109
+ it "builds 2012-03 sheet metadata from the changes and options" do
110
+ with_redline(base_paragraphs, document: "ISO 2533:2026", stage: "DIS") do |sheet|
111
+ expect(sheet.version).to eq("2012-03")
112
+ expect(sheet.schema_name).to eq("iso_comment_2012-03.yaml")
113
+ expect(sheet.document).to eq("ISO 2533:2026")
114
+ expect(sheet.stage).to eq("DIS")
115
+ expect(sheet.date).to eq("2025-12-06")
116
+ end
117
+ end
118
+
119
+ it "is reachable through Parser dispatch with format redline" do
120
+ file = Tempfile.new(%w[redline .docx])
121
+ RedlineDocxBuilder.write(file.path, base_paragraphs)
122
+
123
+ sheet = Commenter::Parser.new.parse(file.path, format: "redline")
124
+ expect(sheet.comments.length).to eq(4)
125
+ ensure
126
+ file.close
127
+ file.unlink
128
+ end
129
+
130
+ describe "element locality" do
131
+ def table_paragraphs
132
+ [
133
+ { heading: true, level: 1, text: "4 Basic principles" },
134
+ { text: "Table 3 — Atmospheric properties at altitude" },
135
+ { parts: [{ text: "Body " }, change(:ins, "5", "edited")] },
136
+ { text: "Formula (9):" },
137
+ { change: :del, id: "6", author: author, date: "2025-12-05T20:10:00Z", text: "old formula text" },
138
+ { text: "NOTE 2 — This note provides clarification." },
139
+ { parts: [{ text: "note body " }, change(:ins, "7", "edited")] }
140
+ ]
141
+ end
142
+
143
+ it "resolves the element from caption paragraphs and carries it to following changes" do
144
+ with_redline(table_paragraphs) do |sheet|
145
+ expect(sheet.comments.map(&:element)).to eq(["Table 3", "Formula (9)", "NOTE 2"])
146
+ end
147
+ end
148
+
149
+ it "resolves inline element mentions" do
150
+ paragraphs = [
151
+ { heading: true, level: 1, text: "5 Data tables" },
152
+ { parts: [{ text: "The values in Table 5 shall be " }, change(:ins, "8", "interpreted thus")] }
153
+ ]
154
+
155
+ with_redline(paragraphs) do |sheet|
156
+ expect(sheet.comments.first.element).to eq("Table 5")
157
+ end
158
+ end
159
+
160
+ it "resolves annex figure captions" do
161
+ paragraphs = [
162
+ { heading: true, level: 1, text: "Annex A" },
163
+ { text: "Figure A.2 — Temperature profile" },
164
+ { change: :ins, id: "9", author: author, date: "2025-12-05T20:10:00Z", text: "new caption text" }
165
+ ]
166
+
167
+ with_redline(paragraphs) do |sheet|
168
+ expect(sheet.comments.first.element).to eq("Figure A.2")
169
+ end
170
+ end
171
+
172
+ it "resets the element when the clause changes" do
173
+ paragraphs = table_paragraphs + [
174
+ { heading: true, level: 1, text: "5 Data tables" },
175
+ { parts: [{ text: "Plain body " }, change(:ins, "10", "edited")] }
176
+ ]
177
+
178
+ with_redline(paragraphs) do |sheet|
179
+ expect(sheet.comments.last.element).to be_nil
180
+ end
181
+ end
182
+ end
183
+
184
+ describe "unnumbered sub-headings" do
185
+ it "inherit the parent heading clause" do
186
+ paragraphs = [
187
+ { heading: true, level: 1, text: "4 Basic principles" },
188
+ { heading: true, level: 2, text: "4.2 Temperature" },
189
+ { heading: true, level: 3, text: "Justification" },
190
+ { parts: [{ text: "Body " }, change(:ins, "11", "edited")] }
191
+ ]
192
+
193
+ with_redline(paragraphs) do |sheet|
194
+ expect(sheet.comments.first.clause).to eq("4.2")
195
+ end
196
+ end
197
+ end
198
+
199
+ describe "reviewer comment threads" do
200
+ let(:remark_paragraphs) do
201
+ [
202
+ { change: :ins, id: "1", author: author, date: "2025-12-05T20:10:00Z", text: "tracked" },
203
+ { heading: true, level: 1, text: "1 Scope" },
204
+ { anchor: "74", text: "Values in feet." },
205
+ { heading: true, level: 1, text: "2 Normative references" },
206
+ { anchor: "88", text: "Introduction paragraph." }
207
+ ]
208
+ end
209
+
210
+ let(:remarks) do
211
+ [
212
+ { id: "74", author: author, date: "2025-12-05T20:10:00Z",
213
+ text: "Please provide values in km. Equivalent values in ft can be provided in brackets." },
214
+ { id: "88", author: author, date: "2025-12-05T20:12:00Z",
215
+ text: "Remove this paragraph from the Introduction." }
216
+ ]
217
+ end
218
+
219
+ it "emits remarks after track changes with -CNNN ids and verbatim text" do
220
+ with_redline(remark_paragraphs, comments: remarks) do |sheet|
221
+ expect(sheet.comments.length).to eq(3)
222
+
223
+ remark = sheet.comments.last
224
+ expect(sheet.comments[1].id).to eq("CS-C001")
225
+ expect(remark.id).to eq("CS-C002")
226
+ expect(remark.comments).to eq("Remove this paragraph from the Introduction.")
227
+ end
228
+ end
229
+
230
+ it "rewords the remark into proposed_change" do
231
+ with_redline(remark_paragraphs, comments: remarks) do |sheet|
232
+ expect(sheet.comments[1].proposed_change)
233
+ .to eq("Provide values in km. Equivalent values in ft can be provided in brackets.")
234
+ end
235
+ end
236
+
237
+ it "leaves remark observations empty for the owner to draft" do
238
+ with_redline(remark_paragraphs, comments: remarks) do |sheet|
239
+ expect(sheet.comments[1].observations).to be_nil
240
+ end
241
+ end
242
+
243
+ it "anchors remarks to the clause of their commentRangeStart" do
244
+ with_redline(remark_paragraphs, comments: remarks) do |sheet|
245
+ expect(sheet.comments[1].clause).to eq("1")
246
+ expect(sheet.comments.last.clause).to eq("2")
247
+ end
248
+ end
249
+
250
+ it "defaults unanchored remarks to _whole document" do
251
+ unanchored = [{ id: "99", author: author, date: "2025-12-05T20:10:00Z", text: "General remark." }]
252
+
253
+ with_redline([{ text: "Body" }], comments: unanchored) do |sheet|
254
+ expect(sheet.comments.first.clause).to eq("_whole document")
255
+ end
256
+ end
257
+
258
+ it "stamps accept_all on track changes but not on remarks" do
259
+ with_redline(remark_paragraphs, accept_all: true, comments: remarks) do |sheet|
260
+ expect(sheet.comments.first.observations).to eq("Accepted. Tracked change accepted.")
261
+ expect(sheet.comments[1].observations).to be_nil
262
+ end
263
+ end
264
+
265
+ it "prefers an explicit observations stamp over accept_all" do
266
+ with_redline(remark_paragraphs, accept_all: true, observations: "Custom.", comments: remarks) do |sheet|
267
+ expect(sheet.comments.first.observations).to eq("Custom.")
268
+ end
269
+ end
270
+
271
+ it "handles documents without comments.xml content" do
272
+ with_redline([{ text: "No changes, no remarks." }]) do |sheet|
273
+ expect(sheet.comments).to be_empty
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+
5
+ # Builds minimal redline DOCX files for specs, without binary fixtures. The
6
+ # document body is assembled from a paragraph DSL:
7
+ #
8
+ # { heading: "1", level: 1, text: "Scope" }
9
+ # { text: "Body text " }
10
+ # { change: :ins, id: "1", author: "CHEN Yvonne", date: "2025-12-05T20:10:00Z",
11
+ # text: "inserted" }
12
+ # { marker: true } # self-closing paragraph-mark insertion inside rPr
13
+ # { anchor: "74", text: "Anchored text" } # w:commentRangeStart/End around text
14
+ #
15
+ # Multiple entries in one paragraph can be given via :parts.
16
+ #
17
+ # comments: [{ id: "74", author: "CHEN Yvonne", date: "...", text: "remark" }]
18
+ # are written to word/comments.xml.
19
+ module RedlineDocxBuilder
20
+ W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
21
+ private_constant :W_NS
22
+
23
+ HEADING_LEVEL_STYLE = { 1 => "Heading1", 2 => "Heading2", 3 => "Heading3" }.freeze
24
+ CHANGE_TAG = { ins: "w:ins", del: "w:del", moveFrom: "w:moveFrom", moveTo: "w:moveTo" }.freeze
25
+ private_constant :HEADING_LEVEL_STYLE, :CHANGE_TAG
26
+
27
+ def self.write(path, paragraphs, comments: [])
28
+ zip = Zip::File.new(path, create: true)
29
+ zip.get_output_stream("word/document.xml") { |stream| stream.write(document_xml(paragraphs)) }
30
+ zip.get_output_stream("word/comments.xml") { |stream| stream.write(comments_xml(comments)) }
31
+ zip.close
32
+ path
33
+ end
34
+
35
+ def self.document_xml(paragraphs)
36
+ <<~XML
37
+ <?xml version="1.0" encoding="UTF-8"?>
38
+ <w:document xmlns:w="#{W_NS}"><w:body>#{paragraphs.map { |p| paragraph_xml(p) }.join}</w:body></w:document>
39
+ XML
40
+ end
41
+
42
+ def self.comments_xml(comments)
43
+ entries = comments.map do |c|
44
+ attrs = %(w:id="#{c[:id]}" w:author="#{c[:author]}" w:date="#{c[:date]}" w:initials="X")
45
+ %(<w:comment #{attrs}><w:p><w:r><w:t>#{c[:text]}</w:t></w:r></w:p></w:comment>)
46
+ end
47
+
48
+ <<~XML
49
+ <?xml version="1.0" encoding="UTF-8"?>
50
+ <w:comments xmlns:w="#{W_NS}">#{entries.join}</w:comments>
51
+ XML
52
+ end
53
+
54
+ def self.paragraph_xml(paragraph)
55
+ if paragraph[:marker]
56
+ return %(<w:p><w:pPr><w:rPr><w:ins w:id="#{paragraph[:id]}" w:author="#{paragraph[:author]}"/></w:rPr></w:pPr></w:p>)
57
+ end
58
+
59
+ inner = if paragraph[:parts]
60
+ paragraph[:parts].map { |part| part_xml(part) }.join
61
+ elsif paragraph[:heading]
62
+ %(<w:r><w:t>#{paragraph[:text]}</w:t></w:r>)
63
+ elsif paragraph[:change]
64
+ change_xml(paragraph)
65
+ else
66
+ %(<w:r><w:t>#{paragraph[:text]}</w:t></w:r>)
67
+ end
68
+
69
+ ppr = paragraph[:heading] ? %(<w:pPr><w:pStyle w:val="#{HEADING_LEVEL_STYLE.fetch(paragraph[:level], 1)}"/></w:pPr>) : ""
70
+ anchor = paragraph[:anchor] ? %(<w:commentRangeStart w:id="#{paragraph[:anchor]}"/>) : ""
71
+ anchor_end = paragraph[:anchor] ? %(<w:commentRangeEnd w:id="#{paragraph[:anchor]}"/>) : ""
72
+ "<w:p>#{ppr}#{anchor}#{inner}#{anchor_end}</w:p>"
73
+ end
74
+
75
+ def self.part_xml(part)
76
+ part[:change] ? change_xml(part) : %(<w:r><w:t>#{part[:text]}</w:t></w:r>)
77
+ end
78
+
79
+ def self.change_xml(change)
80
+ tag = CHANGE_TAG.fetch(change[:change])
81
+ attrs = %(w:id="#{change[:id]}" w:author="#{change[:author]}" w:date="#{change[:date]}")
82
+
83
+ return %(<#{tag} #{attrs}>#{change[:nested].map { |child| change_xml(child) }.join}</#{tag}>) if change[:nested]
84
+
85
+ text_tag = change[:change] == :del ? "w:delText" : "w:t"
86
+ %(<#{tag} #{attrs}><w:r><#{text_tag}>#{change[:text]}</#{text_tag}></w:r></#{tag}>)
87
+ end
88
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: commenter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-26 00:00:00.000000000 Z
11
+ date: 2026-08-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -139,10 +139,13 @@ files:
139
139
  - lib/commenter/cli.rb
140
140
  - lib/commenter/comment.rb
141
141
  - lib/commenter/comment_sheet.rb
142
+ - lib/commenter/comment_type.rb
142
143
  - lib/commenter/filler.rb
143
144
  - lib/commenter/github_integration.rb
145
+ - lib/commenter/github_session.rb
144
146
  - lib/commenter/parser.rb
145
147
  - lib/commenter/parser/osd_xlsx_parser.rb
148
+ - lib/commenter/parser/track_change_docx_parser.rb
146
149
  - lib/commenter/version.rb
147
150
  - schema/iso_comment_2012-03.yaml
148
151
  - schema/iso_comment_osd.yaml
@@ -150,11 +153,14 @@ files:
150
153
  - spec/commenter/cli_spec.rb
151
154
  - spec/commenter/comment_sheet_spec.rb
152
155
  - spec/commenter/comment_spec.rb
156
+ - spec/commenter/comment_type_spec.rb
153
157
  - spec/commenter/github_integration_spec.rb
154
158
  - spec/commenter/osd_xlsx_parser_spec.rb
159
+ - spec/commenter/track_change_docx_parser_spec.rb
155
160
  - spec/commenter_spec.rb
156
161
  - spec/spec_helper.rb
157
162
  - spec/support/osd_fixtures.rb
163
+ - spec/support/redline_docx_builder.rb
158
164
  - spec/support/xlsx_builder.rb
159
165
  homepage: https://github.com/metanorma/commenter
160
166
  licenses: