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.
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require_relative "xlsx_builder"
5
+
6
+ # Fixture data and helpers mirroring ISO OSD XLSX exports for specs.
7
+ module OsdFixtures
8
+ RESOLVED_HEADERS = [
9
+ "Comment ID", "User name", "Clause nb", "Clause Title", "Type", "Subtype",
10
+ "Comment", "Proposal on Text", "Proposed change", "Feedbacks", "Topic",
11
+ "Tags", "Created Date", "Resolution status", "Motivation", "Resolution Date", "Stage code"
12
+ ].freeze
13
+
14
+ UNRESOLVED_HEADERS = [
15
+ "User name", "Clause nb", "Clause Title", "Type", "Comment type",
16
+ "Comment/Motivation", "Comment on text", "Proposal on Text", "Proposed change",
17
+ "Replies", "Resolution status", "Justification", "Resolution Date", "Date", "Comment number"
18
+ ].freeze
19
+
20
+ METADATA_ROWS = [
21
+ ["Date", "Reference", nil, nil, "Title EN", nil, nil, "Title FR"],
22
+ ["2026-04-21", "ISO/DIS 5843-6(en)", nil, nil, "Aerodromes and heliports",
23
+ nil, nil, "Aerodromes and heliports (FR)"]
24
+ ].freeze
25
+
26
+ RESOLVED_DATA_ROWS = [
27
+ [1, "John Doe", "5.2.1", "Requirements", "Comment", "Editorial",
28
+ "The values in Table 3 are inconsistent.", "Proposal on the text",
29
+ "Correct the values in column 2.", "Feedback from the working group",
30
+ "Topic A", "tag1", "2026-04-01", "Accepted",
31
+ "Reviewed and approved by the working group.", "2026-04-10", "40.20"],
32
+ [],
33
+ [2, "Jane Roe", nil, nil, "Comment", "Technical", "General remark on the introduction.",
34
+ nil, nil, nil, nil, nil, "2026-04-02", "Rejected", nil, nil, "40.20"]
35
+ ].freeze
36
+
37
+ def resolved_rows(gap: false)
38
+ headers = RESOLVED_HEADERS.dup
39
+ data_rows = RESOLVED_DATA_ROWS.map(&:dup)
40
+ if gap
41
+ headers.insert(5, nil)
42
+ data_rows = data_rows.map { |row| row.empty? ? row : row.insert(5, nil) }
43
+ end
44
+
45
+ METADATA_ROWS + [[]] + [headers] + data_rows
46
+ end
47
+
48
+ def unresolved_workbook_sheets
49
+ [
50
+ ["Comments (3)",
51
+ header_block(UNRESOLVED_HEADERS) + [
52
+ ["John Doe", "5.2.1", "Requirements", "Comment", "Editorial",
53
+ "The scope is too narrow.", "On text remark", "Proposal on the text",
54
+ "Widen the scope to include heliports.", "Reply from secretariat",
55
+ nil, "Justification text", nil, "2026-04-05", 7],
56
+ ["Jane Roe", "3.1", "Terms", "Comment", "Technical", "Define the term consistently.",
57
+ nil, nil, "Add a definition.", nil, nil, nil, nil, "2026-04-06", 8]
58
+ ]],
59
+ ["Unresolved comments (1)",
60
+ header_block(UNRESOLVED_HEADERS) + [
61
+ ["Jack Black", "4.2", "Design", "Comment", "General", "Improve the design section.",
62
+ nil, nil, nil, nil, nil, nil, nil, "2026-04-07", 9]
63
+ ]],
64
+ ["Resolved comments (1)",
65
+ header_block(RESOLVED_HEADERS) + [
66
+ [10, "Jill Hill", "6.1", "Testing", "Comment", "Technical", "Add test requirements.",
67
+ nil, nil, nil, nil, nil, "2026-04-08", "Partially accepted",
68
+ "Compromise reached.", "2026-04-12", "40.92"]
69
+ ]]
70
+ ]
71
+ end
72
+
73
+ def with_resolved_xlsx(rows = nil)
74
+ Dir.mktmpdir do |dir|
75
+ path = File.join(dir, "comments.xlsx")
76
+ XlsxBuilder.write(path, [["Comments (2)", rows || resolved_rows]])
77
+ yield path
78
+ end
79
+ end
80
+
81
+ def with_unresolved_workbook
82
+ Dir.mktmpdir do |dir|
83
+ path = File.join(dir, "workbook.xlsx")
84
+ XlsxBuilder.write(path, unresolved_workbook_sheets)
85
+ yield path
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def header_block(headers)
92
+ METADATA_ROWS + [[]] + [headers]
93
+ end
94
+ 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
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+
5
+ # Builds minimal single-workbook XLSX files for specs, without needing a
6
+ # spreadsheet-writing dependency. Strings are stored via sharedStrings; numbers
7
+ # are written as plain numeric cells. Empty/nil cells are omitted (sparse
8
+ # rows), matching how real exports represent empty cells.
9
+ module XlsxBuilder
10
+ SHEET_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
11
+ RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
12
+ OFFICE_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
13
+ private_constant :SHEET_NS, :RELS_NS, :OFFICE_REL_NS
14
+
15
+ # sheets: array of [name, rows] pairs, where rows is an array of arrays.
16
+ # Cell values: String or Numeric. nil and "" cells are skipped.
17
+ def self.write(path, sheets)
18
+ shared = []
19
+ worksheets = sheets.map { |name, rows| [name, worksheet_xml(rows, shared)] }
20
+
21
+ zip = Zip::File.new(path, create: true)
22
+ write_entry(zip, "[Content_Types].xml", content_types_xml(worksheets.size))
23
+ write_entry(zip, "_rels/.rels", root_rels_xml)
24
+ write_entry(zip, "xl/workbook.xml", workbook_xml(worksheets.map(&:first)))
25
+ write_entry(zip, "xl/_rels/workbook.xml.rels", workbook_rels_xml(worksheets.size))
26
+ write_entry(zip, "xl/sharedStrings.xml", shared_strings_xml(shared))
27
+ write_entry(zip, "xl/styles.xml", styles_xml)
28
+ worksheets.each_with_index do |(_name, xml), index|
29
+ write_entry(zip, "xl/worksheets/sheet#{index + 1}.xml", xml)
30
+ end
31
+ zip.close
32
+ path
33
+ end
34
+
35
+ def self.write_entry(zip, name, content)
36
+ zip.get_output_stream(name) { |stream| stream.write(content) }
37
+ end
38
+
39
+ def self.worksheet_xml(rows, shared)
40
+ row_elements = rows.each_with_index.map do |values, row_number|
41
+ cells = values.each_with_index.map do |value, column_index|
42
+ cell_xml(value, row_number + 1, column_index, shared)
43
+ end
44
+ "<row r=\"#{row_number + 1}\">#{cells.compact.join}</row>"
45
+ end
46
+
47
+ <<~XML
48
+ <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
49
+ <worksheet xmlns="#{SHEET_NS}"><sheetData>#{row_elements.join}</sheetData></worksheet>
50
+ XML
51
+ end
52
+
53
+ def self.cell_xml(value, row_number, column_index, shared)
54
+ return if value.nil? || (value.is_a?(String) && value.empty?)
55
+
56
+ ref = "#{column_name(column_index)}#{row_number}"
57
+ return "<c r=\"#{ref}\"><v>#{value}</v></c>" if value.is_a?(Numeric)
58
+
59
+ text = value.to_s
60
+ index = shared.index(text)
61
+ if index.nil?
62
+ shared << text
63
+ index = shared.length - 1
64
+ end
65
+ "<c r=\"#{ref}\" t=\"s\"><v>#{index}</v></c>"
66
+ end
67
+
68
+ def self.column_name(index)
69
+ name = +""
70
+ remainder = index
71
+ loop do
72
+ name.prepend((remainder % 26 + 65).chr)
73
+ remainder = remainder / 26 - 1
74
+ break if remainder.negative?
75
+ end
76
+ name
77
+ end
78
+
79
+ def self.shared_strings_xml(shared)
80
+ entries = shared.map { |text| "<si><t>#{escape(text)}</t></si>" }
81
+ <<~XML
82
+ <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
83
+ <sst xmlns="#{SHEET_NS}" count="#{shared.length}" uniqueCount="#{shared.length}">#{entries.join}</sst>
84
+ XML
85
+ end
86
+
87
+ def self.content_types_xml(sheet_count)
88
+ overrides = [
89
+ "<Override PartName=\"/xl/workbook.xml\" " \
90
+ "ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>",
91
+ "<Override PartName=\"/xl/sharedStrings.xml\" " \
92
+ "ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml\"/>",
93
+ "<Override PartName=\"/xl/styles.xml\" " \
94
+ "ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>"
95
+ ]
96
+ (1..sheet_count).each do |index|
97
+ overrides << "<Override PartName=\"/xl/worksheets/sheet#{index}.xml\" " \
98
+ "ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>"
99
+ end
100
+ <<~XML
101
+ <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
102
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>#{overrides.join}</Types>
103
+ XML
104
+ end
105
+
106
+ def self.root_rels_xml
107
+ <<~XML
108
+ <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
109
+ <Relationships xmlns="#{RELS_NS}"><Relationship Id="rId1" Type="#{OFFICE_REL_NS}/officeDocument" Target="xl/workbook.xml"/></Relationships>
110
+ XML
111
+ end
112
+
113
+ def self.workbook_xml(sheet_names)
114
+ sheets = sheet_names.each_with_index.map do |name, index|
115
+ "<sheet name=\"#{escape(name)}\" sheetId=\"#{index + 1}\" r:id=\"rId#{index + 1}\"/>"
116
+ end
117
+ <<~XML
118
+ <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
119
+ <workbook xmlns="#{SHEET_NS}" xmlns:r="#{OFFICE_REL_NS}"><sheets>#{sheets.join}</sheets></workbook>
120
+ XML
121
+ end
122
+
123
+ def self.workbook_rels_xml(sheet_count)
124
+ relationships = (1..sheet_count).map do |index|
125
+ "<Relationship Id=\"rId#{index}\" Type=\"#{OFFICE_REL_NS}/worksheet\" Target=\"worksheets/sheet#{index}.xml\"/>"
126
+ end
127
+ relationships << "<Relationship Id=\"rId#{sheet_count + 1}\" Type=\"#{OFFICE_REL_NS}/sharedStrings\" " \
128
+ "Target=\"sharedStrings.xml\"/>"
129
+ relationships << "<Relationship Id=\"rId#{sheet_count + 2}\" Type=\"#{OFFICE_REL_NS}/styles\" " \
130
+ "Target=\"styles.xml\"/>"
131
+ <<~XML
132
+ <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
133
+ <Relationships xmlns="#{RELS_NS}">#{relationships.join}</Relationships>
134
+ XML
135
+ end
136
+
137
+ def self.styles_xml
138
+ <<~XML
139
+ <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
140
+ <styleSheet xmlns="#{SHEET_NS}"><fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts><fills count="1"><fill><patternFill patternType="none"/></fill></fills><borders count="1"><border/></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs></styleSheet>
141
+ XML
142
+ end
143
+
144
+ def self.escape(text)
145
+ text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
146
+ end
147
+ 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.2.3
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-03-16 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
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
82
  version: '6.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: roo
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '2.10'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '2.10'
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: thor
85
99
  requirement: !ruby/object:Gem::Requirement
@@ -94,8 +108,8 @@ dependencies:
94
108
  - - "~>"
95
109
  - !ruby/object:Gem::Version
96
110
  version: '1.0'
97
- description: Convert between ISO comment sheet DOCX and structured YAML with schema
98
- validation.
111
+ description: Convert between ISO comment sheets (DOCX and XLSX) and structured YAML
112
+ with schema validation.
99
113
  email:
100
114
  - open.source@ribose.com
101
115
  executables:
@@ -109,6 +123,7 @@ files:
109
123
  - ".rspec"
110
124
  - ".rubocop.yml"
111
125
  - ".rubocop_todo.yml"
126
+ - CLAUDE.md
112
127
  - CODE_OF_CONDUCT.md
113
128
  - Gemfile
114
129
  - README.adoc
@@ -124,17 +139,29 @@ files:
124
139
  - lib/commenter/cli.rb
125
140
  - lib/commenter/comment.rb
126
141
  - lib/commenter/comment_sheet.rb
142
+ - lib/commenter/comment_type.rb
127
143
  - lib/commenter/filler.rb
128
144
  - lib/commenter/github_integration.rb
145
+ - lib/commenter/github_session.rb
129
146
  - lib/commenter/parser.rb
147
+ - lib/commenter/parser/osd_xlsx_parser.rb
148
+ - lib/commenter/parser/track_change_docx_parser.rb
130
149
  - lib/commenter/version.rb
131
150
  - schema/iso_comment_2012-03.yaml
151
+ - schema/iso_comment_osd.yaml
132
152
  - sig/commenter.rbs
153
+ - spec/commenter/cli_spec.rb
133
154
  - spec/commenter/comment_sheet_spec.rb
134
155
  - spec/commenter/comment_spec.rb
156
+ - spec/commenter/comment_type_spec.rb
135
157
  - spec/commenter/github_integration_spec.rb
158
+ - spec/commenter/osd_xlsx_parser_spec.rb
159
+ - spec/commenter/track_change_docx_parser_spec.rb
136
160
  - spec/commenter_spec.rb
137
161
  - spec/spec_helper.rb
162
+ - spec/support/osd_fixtures.rb
163
+ - spec/support/redline_docx_builder.rb
164
+ - spec/support/xlsx_builder.rb
138
165
  homepage: https://github.com/metanorma/commenter
139
166
  licenses:
140
167
  - BSD-2-Clause
@@ -160,5 +187,5 @@ requirements: []
160
187
  rubygems_version: 3.5.22
161
188
  signing_key:
162
189
  specification_version: 4
163
- summary: Library to work with ISO comment sheets in DOCX format.
190
+ summary: Library to work with ISO comment sheets in DOCX and XLSX formats.
164
191
  test_files: []