commenter 0.2.2 → 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.
@@ -1,12 +1,44 @@
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"
6
8
 
7
9
  module Commenter
8
10
  class Parser
9
- def parse(docx_path, options = {})
11
+ def parse(input_path, options = {})
12
+ format = detect_format(input_path, options)
13
+
14
+ case format
15
+ when :docx
16
+ parse_docx(input_path, options)
17
+ when :xlsx
18
+ parse_xlsx(input_path, options)
19
+ else
20
+ raise "Unsupported file format: #{input_path}. Supported formats: .docx, .xlsx"
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def detect_format(path, options = {})
27
+ return options[:format].to_sym if options[:format]
28
+
29
+ ext = File.extname(path).downcase
30
+ case ext
31
+ when ".docx" then :docx
32
+ when ".xlsx", ".xls" then :xlsx
33
+ else :unknown
34
+ end
35
+ end
36
+
37
+ def parse_xlsx(xlsx_path, options)
38
+ OsdXlsxParser.new.parse(xlsx_path, options)
39
+ end
40
+
41
+ def parse_docx(docx_path, options)
10
42
  doc = Docx::Document.open(docx_path)
11
43
 
12
44
  # Extract metadata from the first table
@@ -17,41 +49,13 @@ module Commenter
17
49
  raise "No comments table found in document" unless comments_table
18
50
  raise "Comments table appears to be empty" if comments_table.row_count < 2
19
51
 
20
- comments = []
21
-
22
52
  # Process all rows - don't skip any rows, respect all content
23
- (0..comments_table.row_count - 1).each do |i|
24
- row = comments_table.rows[i]
25
- cells = row.cells.map { |c| c.text.strip }
26
-
27
- # Skip only completely empty rows
53
+ comments = (0..comments_table.row_count - 1).map do |row_index|
54
+ cells = comments_table.rows[row_index].cells.map { |cell| cell.text.strip }
28
55
  next if cells.all?(&:empty?)
29
56
 
30
- # Extract body from ID (e.g., "DE-001" -> "DE")
31
- id = cells[0] || ""
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
57
+ osd_docx_row?(cells) ? build_osd_docx_comment(cells, options) : build_classic_docx_comment(cells, options)
58
+ end.compact
55
59
 
56
60
  # Create comment sheet
57
61
  CommentSheet.new(
@@ -63,7 +67,64 @@ module Commenter
63
67
  )
64
68
  end
65
69
 
66
- private
70
+ # The DOCX from ISO OSD has 9 columns:
71
+ # 0: User name, 1: (empty/line), 2: Clause nb, 3: Clause Title,
72
+ # 4: Type, 5: Comment, 6: (empty/proposal), 7: Observations, 8: Comment number
73
+ # Detected by checking if last column looks like a numeric ID.
74
+ def osd_docx_row?(cells)
75
+ cells.length >= 9 && cells[8].to_s.match?(/^\d+$/)
76
+ end
77
+
78
+ def build_osd_docx_comment(cells, options)
79
+ attrs = {
80
+ id: cells[8],
81
+ body: cells[0].to_s.strip,
82
+ locality: {
83
+ clause: presence(cells[2]),
84
+ element: presence(cells[3])
85
+ },
86
+ type: normalize_type(cells[4]),
87
+ comments: cells[5] || "",
88
+ proposed_change: presence(cells[6]),
89
+ user_name: cells[0].to_s.strip
90
+ }
91
+ attrs[:observations] = presence(cells[7]) unless options[:exclude_observations]
92
+ Comment.new(attrs)
93
+ end
94
+
95
+ # Classic ISO comment template format (8 columns):
96
+ # 0: ID, 1: line_number, 2: clause, 3: element, 4: type, 5: comments,
97
+ # 6: proposed_change, 7: observations
98
+ def build_classic_docx_comment(cells, options)
99
+ id = cells[0] || ""
100
+ attrs = {
101
+ id: id,
102
+ body: id.include?("-") ? id.split("-").first : id,
103
+ locality: {
104
+ line_number: presence(cells[1]),
105
+ clause: presence(cells[2]),
106
+ element: presence(cells[3])
107
+ },
108
+ type: cells[4] || "",
109
+ comments: cells[5] || "",
110
+ proposed_change: cells[6] || ""
111
+ }
112
+ attrs[:observations] = presence(cells[7]) unless options[:exclude_observations]
113
+ Comment.new(attrs)
114
+ end
115
+
116
+ def presence(value)
117
+ value && !value.empty? ? value : nil
118
+ end
119
+
120
+ def normalize_type(type_str)
121
+ case type_str.to_s.strip.downcase
122
+ when "editorial" then "ed"
123
+ when "technical" then "te"
124
+ when "general" then "ge"
125
+ else type_str.to_s.strip
126
+ end
127
+ end
67
128
 
68
129
  def extract_metadata(doc)
69
130
  metadata = { date: nil, document: nil, project: nil }
@@ -96,9 +157,6 @@ module Commenter
96
157
  project_match = all_text.match(/Project:\s*([^\n\r]+)/)
97
158
  metadata[:project] = project_match[1]&.strip if project_match
98
159
 
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
160
  metadata
103
161
  end
104
162
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Commenter
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  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
@@ -22,7 +22,7 @@ RSpec.describe Commenter::Comment do
22
22
  expect(comment.clause).to eq("5.1")
23
23
  expect(comment.element).to eq("Table 1")
24
24
  expect(comment.line_number).to eq("42")
25
- expect(comment.type).to eq("te")
25
+ expect(comment.type).to eq("technical")
26
26
  expect(comment.comments).to eq("Test comment")
27
27
  expect(comment.proposed_change).to eq("Test change")
28
28
  expect(comment.observations).to eq("Test observations")
@@ -173,7 +173,7 @@ RSpec.describe Commenter::Comment do
173
173
  expect(hash[:id]).to eq("US-001")
174
174
  expect(hash[:body]).to eq("US")
175
175
  expect(hash[:locality][:clause]).to eq("5.1")
176
- expect(hash[:type]).to eq("te")
176
+ expect(hash[:type]).to eq("technical")
177
177
  expect(hash[:comments]).to eq("Test")
178
178
  expect(hash[:proposed_change]).to eq("Change")
179
179
  expect(hash[:observations]).to eq("Obs")
@@ -120,7 +120,7 @@ RSpec.describe Commenter::GitHubIssueCreator do
120
120
  expect(result[:title]).to eq("US-001: Clause 5.1, Table 1: Test comment text")
121
121
  expect(result[:body]).to include("Comment: Test comment text")
122
122
  expect(result[:body]).to include("Type: Technical")
123
- expect(result[:labels]).to include("comment-review", "draft-international-standard", "te")
123
+ expect(result[:labels]).to include("comment-review", "draft-international-standard", "technical")
124
124
  expect(result[:assignees]).to eq(["test-assignee"])
125
125
  end
126
126
  end
@@ -144,7 +144,7 @@ RSpec.describe Commenter::GitHubIssueCreator do
144
144
  expect(variables["stage"]).to eq("DIS")
145
145
  expect(variables["document"]).to eq("Test Document")
146
146
  expect(variables["comment_id"]).to eq("US-001")
147
- expect(variables["type"]).to eq("te")
147
+ expect(variables["type"]).to eq("technical")
148
148
  expect(variables["type_full_name"]).to eq("Technical")
149
149
  expect(variables["clause"]).to eq("5.1")
150
150
  expect(variables["element"]).to eq("Table 1")
@@ -164,7 +164,7 @@ RSpec.describe Commenter::GitHubIssueCreator do
164
164
 
165
165
  expect(labels).to include("comment-review") # default
166
166
  expect(labels).to include("draft-international-standard") # stage-specific
167
- expect(labels).to include("te") # comment type
167
+ expect(labels).to include("technical") # comment type (expanded)
168
168
  expect(labels.uniq).to eq(labels) # no duplicates
169
169
  end
170
170
  end
@@ -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