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,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roo"
4
+ require_relative "../comment_sheet"
5
+ require_relative "../comment"
6
+
7
+ module Commenter
8
+ class Parser
9
+ # Parser for ISO Online Standards Development (OSD) XLSX exports.
10
+ #
11
+ # Supports two variants, detected from the header row:
12
+ # - "resolved": single "Comments (N)" sheet with resolution data
13
+ # (columns: Comment ID, User name, Clause nb, ..., Resolution status,
14
+ # Motivation, Resolution Date, Stage code)
15
+ # - "unresolved": multi-sheet format with "Comments (N)",
16
+ # "Unresolved comments (N)", "Resolved comments (N)" sheets
17
+ # (columns: User name, Clause nb, ..., Comment type, Comment/Motivation,
18
+ # Comment on text, Proposal on Text, Proposed change, Replies,
19
+ # Resolution status, Justification, Resolution Date, Date, Comment number)
20
+ #
21
+ # Both variants share the same header structure in the first rows:
22
+ # Row 0: [Date, Reference, nil, "", Title EN, nil, nil, Title FR, ...]
23
+ # Row 1: ["2026-04-21", "ISO/DIS 5843-6(en)", ...]
24
+ # Row 2: empty
25
+ # Row 3: column headers
26
+ # Row 4+: data
27
+ class OsdXlsxParser
28
+ # Header names are mapped per variant onto common attribute keys so both
29
+ # variants share a single comment builder. Headers are mapped by real
30
+ # column position, tolerating gaps in the header row.
31
+ RESOLVED_COLUMN_KEYS = {
32
+ "Comment ID" => :id,
33
+ "User name" => :user_name,
34
+ "Clause nb" => :clause,
35
+ "Clause Title" => :clause_title,
36
+ "Subtype" => :comment_type,
37
+ "Comment" => :comment_text,
38
+ "Proposal on Text" => :proposal_on_text,
39
+ "Proposed change" => :proposed_change,
40
+ "Feedbacks" => :feedbacks,
41
+ "Created Date" => :created_date,
42
+ "Resolution status" => :resolution_status,
43
+ "Motivation" => :motivation,
44
+ "Resolution Date" => :resolution_date,
45
+ "Stage code" => :stage_code
46
+ }.freeze
47
+
48
+ UNRESOLVED_COLUMN_KEYS = {
49
+ "User name" => :user_name,
50
+ "Clause nb" => :clause,
51
+ "Clause Title" => :clause_title,
52
+ "Comment type" => :comment_type,
53
+ "Comment/Motivation" => :comment_text,
54
+ "Proposal on Text" => :proposal_on_text,
55
+ "Proposed change" => :proposed_change,
56
+ "Replies" => :feedbacks,
57
+ "Resolution status" => :resolution_status,
58
+ "Justification" => :motivation,
59
+ "Resolution Date" => :resolution_date,
60
+ "Date" => :created_date,
61
+ "Comment number" => :id
62
+ }.freeze
63
+
64
+ def parse(xlsx_path, options = {})
65
+ xlsx = Roo::Spreadsheet.open(xlsx_path)
66
+
67
+ sheet_name = select_sheet(xlsx, options)
68
+ sheet = xlsx.sheet(sheet_name)
69
+ header_row_num = find_header_row(sheet)
70
+ variant = detect_variant(sheet.row(header_row_num))
71
+
72
+ comments = parse_comments(sheet, header_row_num, variant, options)
73
+ CommentSheet.new(version: "osd", **extract_metadata(xlsx, sheet_name), comments: comments)
74
+ end
75
+
76
+ private
77
+
78
+ def select_sheet(xlsx, options)
79
+ sheets = xlsx.sheets
80
+
81
+ if options[:sheet]
82
+ raise "Sheet '#{options[:sheet]}' not found. Available: #{sheets.join(", ")}" unless sheets.include?(options[:sheet])
83
+
84
+ return options[:sheet]
85
+ end
86
+
87
+ if options[:resolved_only]
88
+ sheets.find { |s| s.start_with?("Resolved") } || sheets.find { |s| s.start_with?("Comments") }
89
+ elsif options[:unresolved_only]
90
+ sheets.find { |s| s.start_with?("Unresolved") } || sheets.find { |s| s.start_with?("Comments") }
91
+ else
92
+ sheets.first # Default: first sheet (usually "Comments (N)" with all comments)
93
+ end
94
+ end
95
+
96
+ def find_header_row(sheet)
97
+ (1..10).each do |row_num|
98
+ headers = sheet.row(row_num).to_a.compact.map(&:to_s)
99
+ return row_num if headers.include?("User name") || headers.include?("Comment ID")
100
+ end
101
+
102
+ raise "Could not find header row in XLSX sheet"
103
+ end
104
+
105
+ def detect_variant(header_row)
106
+ headers = header_row.to_a.compact.map(&:to_s)
107
+ if headers.include?("Comment ID") || headers.include?("Subtype")
108
+ :resolved
109
+ elsif headers.include?("Comment/Motivation") || headers.include?("Comment type")
110
+ :unresolved
111
+ else
112
+ headers.length >= 15 ? :resolved : :unresolved
113
+ end
114
+ end
115
+
116
+ def extract_metadata(xlsx, sheet_name)
117
+ values = xlsx.sheet(sheet_name).row(2)
118
+ metadata = { date: nil, document: nil, project: nil, stage: nil, title_en: nil, title_fr: nil }
119
+ return metadata if values.nil?
120
+
121
+ metadata[:date] = header_value(values[0], "Date")
122
+
123
+ reference = header_value(values[1], "Reference")
124
+ if reference
125
+ metadata[:document] = reference
126
+ metadata[:stage] = extract_stage(reference)
127
+ metadata[:project] = extract_project(reference)
128
+ end
129
+
130
+ metadata[:title_en] = header_value(values[4], "Title EN")
131
+ metadata[:title_fr] = header_value(values[7], "Title FR")
132
+ metadata
133
+ end
134
+
135
+ def header_value(raw, label)
136
+ return nil if raw.nil?
137
+
138
+ value = raw.is_a?(Date) ? raw.strftime("%Y-%m-%d") : raw.to_s.strip
139
+ value.empty? || value == label ? nil : value
140
+ end
141
+
142
+ def extract_stage(reference)
143
+ case reference
144
+ when %r{/WD\b}i then "WD"
145
+ when %r{/CD\b}i then "CD"
146
+ when %r{/DIS\b}i then "DIS"
147
+ when %r{/FDIS\b}i then "FDIS"
148
+ end
149
+ end
150
+
151
+ def extract_project(reference)
152
+ # Extract ISO number from reference like "ISO/DIS 5843-6(en)"
153
+ match = reference.match(%r{(ISO[\s/]*\w*\s*\d+[\d-]*)})
154
+ match ? match[1].strip.gsub(/\s+/, " ") : nil
155
+ end
156
+
157
+ def parse_comments(sheet, header_row_num, variant, options)
158
+ column_keys = variant == :resolved ? RESOLVED_COLUMN_KEYS : UNRESOLVED_COLUMN_KEYS
159
+ col_map = build_column_map(sheet.row(header_row_num), column_keys)
160
+
161
+ comments = []
162
+ (header_row_num + 1..sheet.last_row).each do |row_num|
163
+ row = sheet.row(row_num)
164
+ next if row.nil? || row.to_a.compact.empty?
165
+
166
+ comment = build_comment(row, col_map, options)
167
+ comments << comment if comment
168
+ end
169
+ comments
170
+ end
171
+
172
+ def build_column_map(header_row, column_keys)
173
+ header_row.to_a.each_with_index.with_object({}) do |(header, index), map|
174
+ key = column_keys[header.to_s.strip]
175
+ map[key] = index if key
176
+ end
177
+ end
178
+
179
+ def build_comment(row, col_map, options)
180
+ id = cell_value(row, col_map[:id])
181
+ return nil if id.nil?
182
+
183
+ id_str = id.is_a?(Float) ? id.to_i.to_s : id.to_s
184
+ comment_text = cell_value(row, col_map[:comment_text]) || ""
185
+ return nil if comment_text.strip.empty? && id_str.empty?
186
+
187
+ attrs = base_attributes(row, col_map, id_str, comment_text)
188
+ .merge(resolution_attributes(row, col_map))
189
+ attrs[:observations] = resolution_observations(row, col_map) unless options[:exclude_observations]
190
+ Comment.new(attrs)
191
+ end
192
+
193
+ def base_attributes(row, col_map, id_str, comment_text)
194
+ user_name = cell_value_str(row, col_map[:user_name]).to_s
195
+ clause = cell_value_str(row, col_map[:clause]).to_s
196
+ clause_title = cell_value_str(row, col_map[:clause_title]).to_s
197
+ {
198
+ id: id_str,
199
+ body: user_name,
200
+ locality: {
201
+ clause: clause.empty? ? nil : clause,
202
+ element: clause_title.empty? ? nil : clause_title
203
+ }.compact,
204
+ type: normalize_comment_type(cell_value_str(row, col_map[:comment_type]).to_s),
205
+ comments: comment_text.to_s.strip,
206
+ proposed_change: cell_value_str(row, col_map[:proposed_change]) ||
207
+ cell_value_str(row, col_map[:proposal_on_text])
208
+ }
209
+ end
210
+
211
+ def resolution_attributes(row, col_map)
212
+ {
213
+ user_name: cell_value_str(row, col_map[:user_name]).to_s,
214
+ comment_type: cell_value_str(row, col_map[:comment_type]).to_s,
215
+ resolution_status: cell_value_str(row, col_map[:resolution_status]),
216
+ resolution_date: cell_value_str(row, col_map[:resolution_date]),
217
+ feedbacks: cell_value_str(row, col_map[:feedbacks]),
218
+ motivation: cell_value_str(row, col_map[:motivation]),
219
+ created_date: cell_value_str(row, col_map[:created_date]),
220
+ stage_code: cell_value_str(row, col_map[:stage_code])
221
+ }
222
+ end
223
+
224
+ def resolution_observations(row, col_map)
225
+ parts = [cell_value_str(row, col_map[:resolution_status]),
226
+ cell_value_str(row, col_map[:motivation])].compact
227
+ parts.empty? ? nil : parts.join(". ")
228
+ end
229
+
230
+ def normalize_comment_type(type_str)
231
+ case type_str.strip.downcase
232
+ when "editorial", "ed" then "ed"
233
+ when "technical", "te" then "te"
234
+ when "general", "ge" then "ge"
235
+ else type_str.strip
236
+ end
237
+ end
238
+
239
+ def cell_value(row, index)
240
+ return nil if index.nil? || row.length <= index
241
+
242
+ value = row[index]
243
+ value.is_a?(String) && value.strip.empty? ? nil : value
244
+ end
245
+
246
+ def cell_value_str(row, index)
247
+ value = cell_value(row, index)
248
+ value.nil? || value.to_s.strip.empty? ? nil : value.to_s.strip
249
+ end
250
+ end
251
+ end
252
+ end
@@ -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.3"
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