commenter 0.2.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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "yaml"
5
+ require "fileutils"
6
+ require "commenter/parser"
7
+ require "commenter/filler"
8
+
9
+ module Commenter
10
+ class CLI < Thor
11
+ desc "import INPUT.docx", "Convert DOCX comment sheet to YAML"
12
+ option :output, type: :string, aliases: :o, default: "comments.yaml", desc: "Output YAML file"
13
+ option :exclude_observations, type: :boolean, aliases: :e, desc: "Exclude observations column"
14
+ option :schema_dir, type: :string, default: "schema", desc: "Directory for schema file"
15
+ def import(input_docx)
16
+ output_yaml = options[:output]
17
+ schema_dir = options[:schema_dir]
18
+
19
+ # Ensure schema directory exists
20
+ FileUtils.mkdir_p(schema_dir) unless Dir.exist?(schema_dir)
21
+
22
+ # Parse the DOCX file
23
+ parser = Parser.new
24
+ comment_sheet = parser.parse(input_docx, options)
25
+
26
+ # Write the YAML data file with schema reference
27
+ yaml_content = generate_yaml_with_header(comment_sheet.to_yaml_h, schema_dir)
28
+ File.write(output_yaml, yaml_content)
29
+
30
+ # Copy schema file to output directory
31
+ schema_source = File.join(__dir__, "../../schema/iso_comment_2012-03.yaml")
32
+ schema_target = File.join(schema_dir, "iso_comment_2012-03.yaml")
33
+
34
+ # Only copy if source and target are different
35
+ unless File.expand_path(schema_source) == File.expand_path(schema_target)
36
+ FileUtils.cp(schema_source, schema_target)
37
+ end
38
+
39
+ puts "Converted #{input_docx} to #{output_yaml}"
40
+ puts "Schema file created at #{schema_target}"
41
+ end
42
+
43
+ desc "fill INPUT.yaml", "Fill DOCX template from YAML comments"
44
+ option :output, type: :string, aliases: :o, default: "filled_comments.docx", desc: "Output DOCX file"
45
+ option :template, type: :string, aliases: :t, desc: "Custom template file"
46
+ option :shading, type: :boolean, aliases: :s, desc: "Apply status-based shading"
47
+ def fill(input_yaml)
48
+ output_docx = options[:output]
49
+
50
+ # Load YAML data
51
+ data = YAML.load_file(input_yaml)
52
+
53
+ # Extract comments from the structure
54
+ comments = if data.is_a?(Hash)
55
+ data["comments"] || data[:comments] || []
56
+ else
57
+ data || []
58
+ end
59
+
60
+ raise "No comments found in YAML file" if comments.empty?
61
+
62
+ # Use default template if none specified
63
+ template_path = options[:template] || File.join(__dir__, "../../data/iso_comment_template_2012-03.docx")
64
+
65
+ # Fill the template
66
+ Filler.new.fill(template_path, output_docx, comments, options)
67
+ puts "Filled template to #{output_docx}"
68
+ end
69
+
70
+ def self.exit_on_failure?
71
+ true
72
+ end
73
+
74
+ private
75
+
76
+ def generate_yaml_with_header(data, schema_dir)
77
+ schema_path = File.join(schema_dir, "iso_comment_2012-03.yaml")
78
+ header = "# yaml-language-server: $schema=#{schema_path}\n\n"
79
+ header + data.to_yaml
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commenter
4
+ class Comment
5
+ attr_accessor :id, :body, :locality, :type, :comments, :proposed_change, :observations
6
+
7
+ def initialize(attributes = {})
8
+ # Normalize input to symbols
9
+ attrs = symbolize_keys(attributes)
10
+
11
+ @id = attrs[:id]
12
+ @body = attrs[:body]
13
+ @locality = symbolize_keys(attrs[:locality] || {})
14
+ @type = attrs[:type]
15
+ @comments = attrs[:comments]
16
+ @proposed_change = attrs[:proposed_change]
17
+ @observations = attrs[:observations]
18
+ end
19
+
20
+ def line_number
21
+ @locality[:line_number]
22
+ end
23
+
24
+ def line_number=(value)
25
+ @locality[:line_number] = value
26
+ end
27
+
28
+ def clause
29
+ @locality[:clause]
30
+ end
31
+
32
+ def clause=(value)
33
+ @locality[:clause] = value
34
+ end
35
+
36
+ def element
37
+ @locality[:element]
38
+ end
39
+
40
+ def element=(value)
41
+ @locality[:element] = value
42
+ end
43
+
44
+ def to_h
45
+ {
46
+ id: @id,
47
+ body: @body,
48
+ locality: @locality,
49
+ type: @type,
50
+ comments: @comments,
51
+ proposed_change: @proposed_change,
52
+ observations: @observations
53
+ }
54
+ end
55
+
56
+ def to_yaml_h
57
+ hash = to_h
58
+ # Remove observations if it's nil or empty
59
+ hash.delete(:observations) if hash[:observations].nil? || hash[:observations] == ""
60
+ stringify_keys(hash)
61
+ end
62
+
63
+ def self.from_hash(hash)
64
+ new(hash)
65
+ end
66
+
67
+ private
68
+
69
+ def symbolize_keys(hash)
70
+ return hash unless hash.is_a?(Hash)
71
+
72
+ hash.each_with_object({}) do |(key, value), result|
73
+ new_key = key.to_sym
74
+ new_value = value.is_a?(Hash) ? symbolize_keys(value) : value
75
+ result[new_key] = new_value
76
+ end
77
+ end
78
+
79
+ def stringify_keys(hash)
80
+ return hash unless hash.is_a?(Hash)
81
+
82
+ hash.each_with_object({}) do |(key, value), result|
83
+ new_key = key.to_s
84
+ new_value = value.is_a?(Hash) ? stringify_keys(value) : value
85
+ result[new_key] = new_value
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "comment"
4
+
5
+ module Commenter
6
+ class CommentSheet
7
+ attr_accessor :version, :date, :document, :project, :comments
8
+
9
+ def initialize(attributes = {})
10
+ # Normalize input to symbols
11
+ attrs = symbolize_keys(attributes)
12
+
13
+ @version = attrs[:version] || "2012-03"
14
+ @date = attrs[:date]
15
+ @document = attrs[:document]
16
+ @project = attrs[:project]
17
+ @comments = (attrs[:comments] || []).map { |c| c.is_a?(Comment) ? c : Comment.from_hash(c) }
18
+ end
19
+
20
+ def add_comment(comment)
21
+ @comments << (comment.is_a?(Comment) ? comment : Comment.from_hash(comment))
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ version: @version,
27
+ date: @date,
28
+ document: @document,
29
+ project: @project,
30
+ comments: @comments.map(&:to_h)
31
+ }
32
+ end
33
+
34
+ def to_yaml_h
35
+ stringify_keys(to_h.merge(comments: @comments.map(&:to_yaml_h)))
36
+ end
37
+
38
+ def self.from_hash(hash)
39
+ new(hash)
40
+ end
41
+
42
+ private
43
+
44
+ def symbolize_keys(hash)
45
+ return hash unless hash.is_a?(Hash)
46
+
47
+ hash.each_with_object({}) do |(key, value), result|
48
+ new_key = key.to_sym
49
+ new_value = value.is_a?(Hash) ? symbolize_keys(value) : value
50
+ result[new_key] = new_value
51
+ end
52
+ end
53
+
54
+ def stringify_keys(hash)
55
+ return hash unless hash.is_a?(Hash)
56
+
57
+ hash.each_with_object({}) do |(key, value), result|
58
+ new_key = key.to_s
59
+ new_value = case value
60
+ when Hash
61
+ stringify_keys(value)
62
+ when Array
63
+ value.map { |item| item.is_a?(Hash) ? stringify_keys(item) : item }
64
+ else
65
+ value
66
+ end
67
+ result[new_key] = new_value
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "docx"
4
+
5
+ module Commenter
6
+ class Filler
7
+ def fill(template_path, output_path, comments, options = {})
8
+ doc = Docx::Document.open(template_path)
9
+ table = doc.tables.first
10
+
11
+ raise "No table found in template" unless table
12
+ raise "Template table must have at least one row" if table.row_count < 1
13
+
14
+ # Get the template row (first row in the table)
15
+ template_row = table.rows.first
16
+
17
+ # Add new rows for each comment by copying the template row
18
+ comments.each_with_index do |comment, index|
19
+ # Convert comment to symbol keys for consistent access
20
+ comment_data = symbolize_keys(comment)
21
+
22
+ # Copy the template row and insert it
23
+ begin
24
+ new_row = template_row.copy
25
+ new_row.insert_before(template_row)
26
+ row = new_row
27
+ rescue => e
28
+ puts "Warning: Could not add row for comment #{comment_data[:id]}: #{e.message}"
29
+ next
30
+ end
31
+
32
+ # Map comment to table cells using text substitution
33
+ set_cell_text(row.cells[0], comment_data[:id] || "")
34
+ set_cell_text(row.cells[1], comment_data.dig(:locality, :line_number) || "")
35
+ set_cell_text(row.cells[2], comment_data.dig(:locality, :clause) || "")
36
+ set_cell_text(row.cells[3], comment_data.dig(:locality, :element) || "")
37
+ set_cell_text(row.cells[4], comment_data[:type] || "")
38
+ set_cell_text(row.cells[5], comment_data[:comments] || "")
39
+ set_cell_text(row.cells[6], comment_data[:proposed_change] || "")
40
+
41
+ # Handle observations with optional shading
42
+ observations = comment_data[:observations]
43
+ if observations && !observations.empty?
44
+ set_cell_text(row.cells[7], observations)
45
+ apply_shading(row.cells[7], observations) if options[:shading]
46
+ end
47
+ end
48
+
49
+ # Remove the original template row after all comments are added
50
+ template_row.remove if template_row.respond_to?(:remove)
51
+
52
+ doc.save(output_path)
53
+ end
54
+
55
+ private
56
+
57
+ def set_cell_text(cell, text)
58
+ return if text.nil? || text.empty?
59
+
60
+ # Handle both empty cells and cells with existing text
61
+ text_set = false
62
+
63
+ cell.paragraphs.each do |paragraph|
64
+ paragraph.each_text_run do |text_run|
65
+ # Get current text and substitute it with new text
66
+ current_text = text_run.text
67
+ if current_text && !current_text.empty?
68
+ text_run.substitute(current_text, text)
69
+ text_set = true
70
+ return # Only substitute in the first text run found
71
+ end
72
+ end
73
+ end
74
+
75
+ # If no text runs with content were found, add text to the first paragraph
76
+ unless text_set
77
+ if cell.paragraphs.any?
78
+ paragraph = cell.paragraphs.first
79
+ # Try to add a text run to the paragraph
80
+ if paragraph.respond_to?(:add_text)
81
+ paragraph.add_text(text)
82
+ elsif paragraph.respond_to?(:text=)
83
+ paragraph.text = text
84
+ end
85
+ end
86
+ end
87
+ rescue => e
88
+ puts "Warning: Could not set text '#{text}' in cell: #{e.message}"
89
+ end
90
+
91
+ def symbolize_keys(hash)
92
+ return hash unless hash.is_a?(Hash)
93
+
94
+ hash.each_with_object({}) do |(key, value), result|
95
+ new_key = key.to_sym
96
+ new_value = value.is_a?(Hash) ? symbolize_keys(value) : value
97
+ result[new_key] = new_value
98
+ end
99
+ end
100
+
101
+ def apply_shading(cell, observation)
102
+ # Shading functionality is not fully supported by the docx gem
103
+ # This is a placeholder for future implementation
104
+ puts "Shading requested for: #{observation}" if observation
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "docx"
4
+ require_relative "comment_sheet"
5
+ require_relative "comment"
6
+
7
+ module Commenter
8
+ class Parser
9
+ def parse(docx_path, options = {})
10
+ doc = Docx::Document.open(docx_path)
11
+
12
+ # Extract metadata from the first table
13
+ metadata = extract_metadata(doc)
14
+
15
+ # The comments are in the second table (or first table if there's only one)
16
+ comments_table = doc.tables.length > 1 ? doc.tables[1] : doc.tables.first
17
+ raise "No comments table found in document" unless comments_table
18
+ raise "Comments table appears to be empty" if comments_table.row_count < 2
19
+
20
+ comments = []
21
+
22
+ # 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
28
+ next if cells.all?(&:empty?)
29
+
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]&.empty? ? nil : cells[1],
40
+ clause: cells[2]&.empty? ? nil : cells[2],
41
+ element: 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]&.empty? ? nil : cells[7]
51
+ end
52
+
53
+ comments << Comment.new(comment_attrs)
54
+ end
55
+
56
+ # Create comment sheet
57
+ CommentSheet.new(
58
+ version: "2012-03",
59
+ date: metadata[:date],
60
+ document: metadata[:document],
61
+ project: metadata[:project],
62
+ comments: comments
63
+ )
64
+ end
65
+
66
+ private
67
+
68
+ def extract_metadata(doc)
69
+ metadata = { date: nil, document: nil, project: nil }
70
+
71
+ # Try to extract metadata from document properties first
72
+ begin
73
+ if doc.respond_to?(:created) && doc.created
74
+ metadata[:date] = doc.created.strftime("%Y-%m-%d") rescue nil
75
+ end
76
+ rescue => e
77
+ # Ignore errors accessing document properties
78
+ end
79
+
80
+ # Search for metadata in the document text
81
+ all_text = doc.to_s
82
+
83
+ # Look for date patterns
84
+ date_match = all_text.match(/Date:\s*([0-9]{4}-[0-9]{2}-[0-9]{2})/)
85
+ metadata[:date] = date_match[1] if date_match
86
+
87
+ # Look for document patterns
88
+ doc_match = all_text.match(/Document:\s*(ISO\s+[0-9\-:]+)/)
89
+ metadata[:document] = doc_match[1] if doc_match
90
+
91
+ # Look for project patterns
92
+ project_match = all_text.match(/Project:\s*([^\n\r]+)/)
93
+ metadata[:project] = project_match[1]&.strip if project_match
94
+
95
+ # If no metadata found, try to extract from filename or other sources
96
+ # This is a fallback - in practice, users might need to provide metadata manually
97
+
98
+ metadata
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commenter
4
+ VERSION = "0.2.1"
5
+ end
data/lib/commenter.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "commenter/version"
4
+ require_relative "commenter/comment"
5
+ require_relative "commenter/comment_sheet"
6
+ require_relative "commenter/parser"
7
+ require_relative "commenter/filler"
8
+
9
+ module Commenter
10
+ class Error < StandardError; end
11
+ end
@@ -0,0 +1,69 @@
1
+ # ISO Comment Template 2012-03 Schema
2
+ # Template: "ISO/IEC/CEN/CENELEC electronic balloting commenting template/version 2012-03"
3
+ $schema: http://json-schema.org/draft-07/schema#
4
+ title: ISO Comment 2012-03
5
+ description: Schema for ISO comment sheets following the 2012-03 template format
6
+ type: object
7
+ properties:
8
+ version:
9
+ type: string
10
+ const: "2012-03"
11
+ description: Version of the ISO commenting template
12
+ comments:
13
+ type: array
14
+ description: Array of comment entries from the ISO comment sheet
15
+ items:
16
+ type: object
17
+ properties:
18
+ id:
19
+ type: string
20
+ pattern: "^[A-Z*]{2,3}-\\d{2,3}(-\\d{3})?$"
21
+ description: "Comment ID in format: {MB/NC}-{number} or {MB/NC}-{org_id}-{seq_id}"
22
+ examples:
23
+ - "US-001"
24
+ - "DE-01-002"
25
+ - "**-001"
26
+ body:
27
+ type: string
28
+ description: "Member body or National committee abbreviation (extracted from ID)"
29
+ examples:
30
+ - "US"
31
+ - "DE"
32
+ - "**"
33
+ locality:
34
+ type: object
35
+ description: "Location information for the comment"
36
+ properties:
37
+ line_number:
38
+ type: ["string", "null"]
39
+ description: "Line number reference"
40
+ clause:
41
+ type: string
42
+ description: "Clause/subclause reference"
43
+ examples:
44
+ - "_whole document"
45
+ - "5"
46
+ - "4.2.1"
47
+ element:
48
+ type: ["string", "null"]
49
+ description: "Paragraph/Figure/Table reference"
50
+ examples:
51
+ - "2-5.7, 4th column"
52
+ - "Table 1"
53
+ - "Figure 3"
54
+ required: ["clause"]
55
+ type:
56
+ type: string
57
+ enum: ["ge", "te", "ed"]
58
+ description: "Type of comment: ge=general, te=technical, ed=editorial"
59
+ comments:
60
+ type: string
61
+ description: "The actual comment text"
62
+ proposed_change:
63
+ type: string
64
+ description: "Proposed change or solution"
65
+ observations:
66
+ type: ["string", "null"]
67
+ description: "Observations of the Secretariat (optional)"
68
+ required: ["id", "body", "locality", "type", "comments", "proposed_change"]
69
+ required: ["version", "comments"]
data/sig/commenter.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Commenter
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Commenter do
4
+ it "has a version number" do
5
+ expect(Commenter::VERSION).not_to be nil
6
+ end
7
+
8
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "commenter"
4
+
5
+ RSpec.configure do |config|
6
+ # Enable flags like --only-failures and --next-failure
7
+ config.example_status_persistence_file_path = ".rspec_status"
8
+
9
+ # Disable RSpec exposing methods globally on `Module` and `main`
10
+ config.disable_monkey_patching!
11
+
12
+ config.expect_with :rspec do |c|
13
+ c.syntax = :expect
14
+ end
15
+ end
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: commenter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Ribose
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-06-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: docx
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.8'
27
+ - !ruby/object:Gem::Dependency
28
+ name: thor
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
41
+ description: Convert between ISO comment sheet DOCX and structured YAML with schema
42
+ validation.
43
+ email:
44
+ - open.source@ribose.com
45
+ executables:
46
+ - commenter
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - ".github/workflows/rake.yml"
51
+ - ".github/workflows/release.yml"
52
+ - ".gitignore"
53
+ - ".rspec"
54
+ - ".rubocop.yml"
55
+ - CODE_OF_CONDUCT.md
56
+ - Gemfile
57
+ - README.adoc
58
+ - Rakefile
59
+ - commenter.gemspec
60
+ - comments.yaml
61
+ - data/iso_comment_template_2012-03.docx
62
+ - exe/commenter
63
+ - lib/commenter.rb
64
+ - lib/commenter/cli.rb
65
+ - lib/commenter/comment.rb
66
+ - lib/commenter/comment_sheet.rb
67
+ - lib/commenter/filler.rb
68
+ - lib/commenter/parser.rb
69
+ - lib/commenter/version.rb
70
+ - schema/iso_comment_2012-03.yaml
71
+ - sig/commenter.rbs
72
+ - spec/commenter_spec.rb
73
+ - spec/spec_helper.rb
74
+ homepage: https://github.com/metanorma/commenter
75
+ licenses:
76
+ - BSD-2-Clause
77
+ metadata:
78
+ homepage_uri: https://github.com/metanorma/commenter
79
+ source_code_uri: https://github.com/metanorma/commenter
80
+ bug_tracker_uri: https://github.com/metanorma/commenter/issues
81
+ post_install_message:
82
+ rdoc_options: []
83
+ require_paths:
84
+ - lib
85
+ required_ruby_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: 2.6.0
90
+ required_rubygems_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ requirements: []
96
+ rubygems_version: 3.5.22
97
+ signing_key:
98
+ specification_version: 4
99
+ summary: Library to work with ISO comment sheets in DOCX format.
100
+ test_files: []