commenter 0.2.3 → 0.3.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.
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "../support/osd_fixtures"
5
+
6
+ RSpec.describe Commenter::Parser::OsdXlsxParser do
7
+ include OsdFixtures
8
+
9
+ describe "resolved variant" do
10
+ it "extracts sheet metadata from the header rows" do
11
+ with_resolved_xlsx do |path|
12
+ sheet = described_class.new.parse(path)
13
+
14
+ expect(sheet.version).to eq("osd")
15
+ expect(sheet.date).to eq("2026-04-21")
16
+ expect(sheet.document).to eq("ISO/DIS 5843-6(en)")
17
+ expect(sheet.stage).to eq("DIS")
18
+ expect(sheet.project).to eq("ISO/DIS 5843-6")
19
+ expect(sheet.title_en).to eq("Aerodromes and heliports")
20
+ expect(sheet.title_fr).to eq("Aerodromes and heliports (FR)")
21
+ end
22
+ end
23
+
24
+ it "maps columns to comment attributes" do
25
+ with_resolved_xlsx do |path|
26
+ comment = described_class.new.parse(path).comments.first
27
+
28
+ expect(comment.id).to eq("1")
29
+ expect(comment.body).to eq("John Doe")
30
+ expect(comment.locality).to eq({ clause: "5.2.1", element: "Requirements" })
31
+ expect(comment.comments).to eq("The values in Table 3 are inconsistent.")
32
+ expect(comment.proposed_change).to eq("Correct the values in column 2.")
33
+ expect(comment.comment_type).to eq("Editorial")
34
+ expect(comment.resolution_status).to eq("Accepted")
35
+ expect(comment.resolution_date).to eq("2026-04-10")
36
+ expect(comment.feedbacks).to eq("Feedback from the working group")
37
+ expect(comment.motivation).to eq("Reviewed and approved by the working group.")
38
+ expect(comment.created_date).to eq("2026-04-01")
39
+ expect(comment.stage_code).to eq("40.20")
40
+ end
41
+ end
42
+
43
+ it "builds observations from resolution status and motivation" do
44
+ with_resolved_xlsx do |path|
45
+ comments = described_class.new.parse(path).comments
46
+
47
+ expect(comments.first.observations).to eq("Accepted. Reviewed and approved by the working group.")
48
+ expect(comments.last.observations).to eq("Rejected")
49
+ end
50
+ end
51
+
52
+ it "normalizes numeric comment ids to strings" do
53
+ with_resolved_xlsx do |path|
54
+ comments = described_class.new.parse(path).comments
55
+
56
+ expect(comments.map(&:id)).to eq(%w[1 2])
57
+ end
58
+ end
59
+
60
+ it "skips empty rows and keeps comments without clause data" do
61
+ with_resolved_xlsx do |path|
62
+ comments = described_class.new.parse(path).comments
63
+
64
+ expect(comments.length).to eq(2)
65
+ expect(comments.last.locality).to eq({})
66
+ expect(comments.last.proposed_change).to be_nil
67
+ end
68
+ end
69
+
70
+ it "falls back to proposal-on-text when proposed change is blank" do
71
+ rows = resolved_rows
72
+ rows[4][8] = nil # Proposed change
73
+ with_resolved_xlsx(rows) do |path|
74
+ sheet = described_class.new.parse(path)
75
+
76
+ expect(sheet.comments.first.proposed_change).to eq("Proposal on the text")
77
+ end
78
+ end
79
+
80
+ it "excludes observations when exclude_observations is set" do
81
+ with_resolved_xlsx do |path|
82
+ sheet = described_class.new.parse(path, exclude_observations: true)
83
+
84
+ expect(sheet.comments.first.observations).to be_nil
85
+ end
86
+ end
87
+
88
+ it "maps columns by real position when the header row has gaps" do
89
+ with_resolved_xlsx(resolved_rows(gap: true)) do |path|
90
+ comment = described_class.new.parse(path).comments.first
91
+
92
+ expect(comment.id).to eq("1")
93
+ expect(comment.type).to eq("editorial")
94
+ expect(comment.comments).to eq("The values in Table 3 are inconsistent.")
95
+ expect(comment.resolution_status).to eq("Accepted")
96
+ expect(comment.stage_code).to eq("40.20")
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+ RSpec.describe Commenter::Parser::OsdXlsxParser do
103
+ include OsdFixtures
104
+
105
+ describe "unresolved variant" do
106
+ it "parses the first sheet by default" do
107
+ with_unresolved_workbook do |path|
108
+ sheet = described_class.new.parse(path)
109
+
110
+ expect(sheet.comments.map(&:id)).to eq(%w[7 8])
111
+ expect(sheet.comments.first.body).to eq("John Doe")
112
+ expect(sheet.comments.first.type).to eq("editorial")
113
+ expect(sheet.comments.first.feedbacks).to eq("Reply from secretariat")
114
+ expect(sheet.comments.first.motivation).to eq("Justification text")
115
+ expect(sheet.comments.first.created_date).to eq("2026-04-05")
116
+ expect(sheet.comments.last.observations).to be_nil
117
+ end
118
+ end
119
+
120
+ it "uses the unresolved sheet when unresolved_only is set" do
121
+ with_unresolved_workbook do |path|
122
+ sheet = described_class.new.parse(path, unresolved_only: true)
123
+
124
+ expect(sheet.comments.map(&:id)).to eq(["9"])
125
+ expect(sheet.comments.first.type).to eq("general")
126
+ end
127
+ end
128
+
129
+ it "uses the resolved sheet when resolved_only is set" do
130
+ with_unresolved_workbook do |path|
131
+ sheet = described_class.new.parse(path, resolved_only: true)
132
+
133
+ expect(sheet.comments.map(&:id)).to eq(["10"])
134
+ expect(sheet.comments.first.observations).to eq("Partially accepted. Compromise reached.")
135
+ end
136
+ end
137
+
138
+ it "parses the sheet given by name" do
139
+ with_unresolved_workbook do |path|
140
+ sheet = described_class.new.parse(path, sheet: "Resolved comments (1)")
141
+
142
+ expect(sheet.comments.map(&:id)).to eq(["10"])
143
+ end
144
+ end
145
+
146
+ it "raises when the requested sheet does not exist" do
147
+ with_unresolved_workbook do |path|
148
+ expect { described_class.new.parse(path, sheet: "Nope") }
149
+ .to raise_error(/Sheet 'Nope' not found/)
150
+ end
151
+ end
152
+ end
153
+ end
154
+
155
+ RSpec.describe Commenter::Parser::OsdXlsxParser do
156
+ include OsdFixtures
157
+
158
+ describe "round-trip stability" do
159
+ it "produces identical YAML when reloaded from its own output" do
160
+ with_resolved_xlsx do |path|
161
+ sheet = described_class.new.parse(path)
162
+ reparsed = Commenter::CommentSheet.from_hash(sheet.to_yaml_h)
163
+
164
+ expect(reparsed.to_yaml_h).to eq(sheet.to_yaml_h)
165
+ end
166
+ end
167
+
168
+ it "omits nil titles from YAML output" do
169
+ rows = resolved_rows
170
+ rows[1] = ["2026-04-21", "ISO/CD 12345(en)"]
171
+ with_resolved_xlsx(rows) do |path|
172
+ sheet = described_class.new.parse(path)
173
+
174
+ expect(sheet.stage).to eq("CD")
175
+ expect(sheet.to_yaml_h).not_to have_key("title_en")
176
+ expect(sheet.to_yaml_h).not_to have_key("title_fr")
177
+ end
178
+ end
179
+ end
180
+ end
@@ -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,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.0
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-26 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
@@ -127,14 +142,20 @@ files:
127
142
  - lib/commenter/filler.rb
128
143
  - lib/commenter/github_integration.rb
129
144
  - lib/commenter/parser.rb
145
+ - lib/commenter/parser/osd_xlsx_parser.rb
130
146
  - lib/commenter/version.rb
131
147
  - schema/iso_comment_2012-03.yaml
148
+ - schema/iso_comment_osd.yaml
132
149
  - sig/commenter.rbs
150
+ - spec/commenter/cli_spec.rb
133
151
  - spec/commenter/comment_sheet_spec.rb
134
152
  - spec/commenter/comment_spec.rb
135
153
  - spec/commenter/github_integration_spec.rb
154
+ - spec/commenter/osd_xlsx_parser_spec.rb
136
155
  - spec/commenter_spec.rb
137
156
  - spec/spec_helper.rb
157
+ - spec/support/osd_fixtures.rb
158
+ - spec/support/xlsx_builder.rb
138
159
  homepage: https://github.com/metanorma/commenter
139
160
  licenses:
140
161
  - BSD-2-Clause
@@ -160,5 +181,5 @@ requirements: []
160
181
  rubygems_version: 3.5.22
161
182
  signing_key:
162
183
  specification_version: 4
163
- summary: Library to work with ISO comment sheets in DOCX format.
184
+ summary: Library to work with ISO comment sheets in DOCX and XLSX formats.
164
185
  test_files: []