|
| 1 | +#! /usr/bin/env ruby |
| 2 | +# Parse vocabulary definition in CSV to generate Context+Vocabulary in JSON-LD or Turtle |
| 3 | + |
| 4 | +require 'getoptlong' |
| 5 | +require 'csv' |
| 6 | +require 'json' |
| 7 | +require 'erubis' |
| 8 | + |
| 9 | +class Vocab |
| 10 | + JSON_STATE = JSON::State.new( |
| 11 | + :indent => " ", |
| 12 | + :space => " ", |
| 13 | + :space_before => "", |
| 14 | + :object_nl => "\n", |
| 15 | + :array_nl => "\n" |
| 16 | + ) |
| 17 | + |
| 18 | + TITLE = "Verifiable Credentials Vocabulary v2.0".freeze |
| 19 | + DESCRIPTION = %(This document describes the RDFS vocabulary description used for Verifiable Credentials [[VC-DATA-MODEL]].).freeze |
| 20 | + attr_accessor :prefixes, :terms, :properties, :classes, :contexts, :instances, :datatypes, |
| 21 | + :imports, :date, :commit, :seeAlso |
| 22 | + |
| 23 | + def initialize |
| 24 | + path = File.expand_path("../vocab.csv", __FILE__) |
| 25 | + csv = CSV.new(File.open(path)) |
| 26 | + @prefixes, @terms, @properties, @classes, @datatypes, @instances = {}, {}, {}, {}, {}, {} |
| 27 | + @contexts, @imports, @seeAlso = [], [], [] |
| 28 | + #git_info = %x{git log -1 #{path}}.split("\n") |
| 29 | + #@commit = "https://github.com/w3c/vc-vocab/commit/" + (git_info[0] || 'uncommitted').split.last |
| 30 | + date_line = nil #git_info.detect {|l| l.start_with?("Date:")} |
| 31 | + @date = Date.parse((date_line || Date.today.to_s).split(":",2).last).strftime("%Y-%m-%d") |
| 32 | + |
| 33 | + columns = [] |
| 34 | + csv.shift.each_with_index {|c, i| columns[i] = c.to_sym if c} |
| 35 | + |
| 36 | + csv.sort_by(&:to_s).each do |line| |
| 37 | + entry = {} |
| 38 | + # Create entry as object indexed by symbolized column name |
| 39 | + line.each_with_index {|v, i| entry[columns[i]] = v ? v.gsub("\r", "\n").gsub("\\", "\\\\") : nil} |
| 40 | + |
| 41 | + case entry[:type] |
| 42 | + when '@context' then (@contexts ||= []) << entry[:subClassOf] |
| 43 | + when 'prefix' then @prefixes[entry[:id]] = entry |
| 44 | + when 'term' then @terms[entry[:id]] = entry |
| 45 | + when 'rdf:Property' then @properties[entry[:id]] = entry |
| 46 | + when 'rdfs:Class' then @classes[entry[:id]] = entry |
| 47 | + when 'rdfs:Datatype' then @datatypes[entry[:id]] = entry |
| 48 | + when 'owl:imports' then @imports << entry[:subClassOf] |
| 49 | + when 'rdfs:seeAlso' then @seeAlso << entry[:subClassOf] |
| 50 | + else @instances[entry[:id]] = entry |
| 51 | + end |
| 52 | + end |
| 53 | + |
| 54 | + end |
| 55 | + |
| 56 | + def namespaced(term) |
| 57 | + term.include?(":") ? term : "cred:#{term}" |
| 58 | + end |
| 59 | + |
| 60 | + # The full JSON-LD file without the RDFS parts |
| 61 | + def to_context |
| 62 | + ctx = JSON.parse(to_jsonld) |
| 63 | + ctx.delete('@graph') |
| 64 | + ctx.to_json(JSON_STATE) |
| 65 | + end |
| 66 | + |
| 67 | + def to_jsonld |
| 68 | + rdfs_context = {} |
| 69 | + context = ::JSON.parse %({ |
| 70 | + "dc": "http://purl.org/dc/terms/", |
| 71 | + "owl": "http://www.w3.org/2002/07/owl#", |
| 72 | + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", |
| 73 | + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", |
| 74 | + "dc:title": {"@container": "@language"}, |
| 75 | + "dc:description": {"@container": "@language"}, |
| 76 | + "dc:date": {"@type": "xsd:date"}, |
| 77 | + "rdfs:comment": {"@container": "@language"}, |
| 78 | + "rdfs:domain": {"@type": "@id"}, |
| 79 | + "rdfs:label": {"@container": "@language"}, |
| 80 | + "rdfs:range": {"@type": "@id"}, |
| 81 | + "rdfs:seeAlso": {"@type": "@id"}, |
| 82 | + "rdfs:subClassOf": {"@type": "@id"}, |
| 83 | + "rdfs:subPropertyOf": {"@type": "@id"}, |
| 84 | + "owl:equivalentClass": {"@type": "@vocab"}, |
| 85 | + "owl:equivalentProperty": {"@type": "@vocab"}, |
| 86 | + "owl:oneOf": {"@container": "@list", "@type": "@vocab"}, |
| 87 | + "owl:imports": {"@type": "@id"}, |
| 88 | + "owl:versionInfo": {"@type": "@id"}, |
| 89 | + "owl:inverseOf": {"@type": "@vocab"}, |
| 90 | + "owl:unionOf": {"@type": "@vocab", "@container": "@list"}, |
| 91 | + "rdfs_classes": {"@reverse": "rdfs:isDefinedBy", "@type": "@id"}, |
| 92 | + "rdfs_properties": {"@reverse": "rdfs:isDefinedBy", "@type": "@id"}, |
| 93 | + "rdfs_datatypes": {"@reverse": "rdfs:isDefinedBy", "@type": "@id"}, |
| 94 | + "rdfs_instances": {"@reverse": "rdfs:isDefinedBy", "@type": "@id"} |
| 95 | + }) |
| 96 | + rdfs_classes, rdfs_properties, rdfs_datatypes, rdfs_instances = [], [], [], [] |
| 97 | + |
| 98 | + prefixes.each do |id, entry| |
| 99 | + context[id] = entry[:subClassOf] |
| 100 | + end |
| 101 | + |
| 102 | + terms.each do |id, entry| |
| 103 | + next if entry[:@type] == '@null' |
| 104 | + context[id] = if [:@container, :@type].any? {|k| entry[k]} |
| 105 | + {'@id' => entry[:subClassOf]}. |
| 106 | + merge(entry[:@container] ? {'@container' => entry[:@container]} : {}). |
| 107 | + merge(entry[:@type] ? {'@type' => entry[:@type]} : {}) |
| 108 | + else |
| 109 | + entry[:subClassOf] |
| 110 | + end |
| 111 | + end |
| 112 | + |
| 113 | + classes.each do |id, entry| |
| 114 | + term = entry[:term] || id |
| 115 | + # context[term] = namespaced(id) unless entry[:@type] == '@null' |
| 116 | + |
| 117 | + # Class definition |
| 118 | + node = { |
| 119 | + '@id' => namespaced(id), |
| 120 | + '@type' => 'rdfs:Class', |
| 121 | + 'rdfs:label' => {"en" => entry[:label].to_s}, |
| 122 | + 'rdfs:comment' => {"en" => entry[:comment].to_s}, |
| 123 | + } |
| 124 | + node['rdfs:subClassOf'] = namespaced(entry[:subClassOf]) if entry[:subClassOf] |
| 125 | + rdfs_classes << node |
| 126 | + end |
| 127 | + |
| 128 | + properties.each do |id, entry| |
| 129 | + defn = {"@id" => namespaced(id)} |
| 130 | + case entry[:range] |
| 131 | + when "xsd:string" then defn['@language'] = nil |
| 132 | + when /xsd:/ then defn['@type'] = entry[:range].split(',').first |
| 133 | + when nil, |
| 134 | + 'rdfs:Literal' then ; |
| 135 | + else defn['@type'] = '@id' |
| 136 | + end |
| 137 | + |
| 138 | + defn['@container'] = entry[:@container] if entry[:@container] |
| 139 | + defn['@type'] = entry[:@type] if entry[:@type] |
| 140 | + |
| 141 | + term = entry[:term] || id |
| 142 | + # context[term] = defn unless entry[:@type] == '@null' |
| 143 | + |
| 144 | + # Property definition |
| 145 | + node = { |
| 146 | + '@id' => namespaced(id), |
| 147 | + '@type' => 'rdf:Property', |
| 148 | + 'rdfs:label' => {"en" => entry[:label].to_s}, |
| 149 | + 'rdfs:comment' => {"en" => entry[:comment].to_s}, |
| 150 | + } |
| 151 | + node['rdfs:subPropertyOf'] = namespaced(entry[:subClassOf]) if entry[:subClassOf] |
| 152 | + |
| 153 | + domains = entry[:domain].to_s.split(',') |
| 154 | + case domains.length |
| 155 | + when 0 then ; |
| 156 | + when 1 then node['rdfs:domain'] = namespaced(domains.first) |
| 157 | + else node['rdfs:domain'] = {'owl:unionOf' => domains.map {|d| namespaced(d)}} |
| 158 | + end |
| 159 | + |
| 160 | + ranges = entry[:range].to_s.split(',') |
| 161 | + case ranges.length |
| 162 | + when 0 then ; |
| 163 | + when 1 then node['rdfs:range'] = namespaced(ranges.first) |
| 164 | + else node['rdfs:range'] = {'owl:unionOf' => ranges.map {|r| namespaced(r)}} |
| 165 | + end |
| 166 | + |
| 167 | + rdfs_properties << node |
| 168 | + end |
| 169 | + |
| 170 | + datatypes.each do |id, entry| |
| 171 | + # context[id] = namespaced(id) unless entry[:@type] == '@null' |
| 172 | + |
| 173 | + # Datatype definition |
| 174 | + node = { |
| 175 | + '@id' => namespaced(id), |
| 176 | + '@type' => 'rdfs:Datatype', |
| 177 | + 'rdfs:label' => {"en" => entry[:label].to_s}, |
| 178 | + 'rdfs:comment' => {"en" => entry[:comment].to_s}, |
| 179 | + } |
| 180 | + node['rdfs:subClassOf'] = namespaced(entry[:subClassOf]) if entry[:subClassOf] |
| 181 | + rdfs_datatypes << node |
| 182 | + end |
| 183 | + |
| 184 | + instances.each do |id, entry| |
| 185 | + # context[id] = namespaced(id) unless entry[:@type] == '@null' |
| 186 | + # Instance definition |
| 187 | + rdfs_instances << { |
| 188 | + '@id' => namespaced(id), |
| 189 | + '@type' => entry[:type], |
| 190 | + 'rdfs:label' => {"en" => entry[:label].to_s}, |
| 191 | + 'rdfs:comment' => {"en" => entry[:comment].to_s}, |
| 192 | + } |
| 193 | + end |
| 194 | + |
| 195 | + # Use separate rdfs context so as not to polute the context. |
| 196 | + ontology = { |
| 197 | + "@context" => rdfs_context, |
| 198 | + "@id" => prefixes["cred"][:subClassOf], |
| 199 | + "@type" => "owl:Ontology", |
| 200 | + "dc:title" => {"en" => TITLE}, |
| 201 | + "dc:description" => {"en" => DESCRIPTION}, |
| 202 | + "dc:date" => date, |
| 203 | + "owl:imports" => imports, |
| 204 | + #"owl:versionInfo" => commit, |
| 205 | + "rdfs:seeAlso" => seeAlso, |
| 206 | + "rdfs_classes" => rdfs_classes, |
| 207 | + "rdfs_properties" => rdfs_properties, |
| 208 | + "rdfs_datatypes" => rdfs_datatypes, |
| 209 | + "rdfs_instances" => rdfs_instances |
| 210 | + }.delete_if {|k,v| Array(v).empty?} |
| 211 | + |
| 212 | + # Imported contexts |
| 213 | + context = contexts + [context]unless contexts.empty? |
| 214 | + |
| 215 | + { |
| 216 | + "@context" => context, |
| 217 | + "@graph" => ontology |
| 218 | + }.to_json(JSON_STATE) |
| 219 | + end |
| 220 | + |
| 221 | + def to_html |
| 222 | + json = JSON.parse(to_jsonld) |
| 223 | + eruby = Erubis::Eruby.new(File.read("template.html")) |
| 224 | + eruby.result(ont: json['@graph'], context: json['@context'].is_a?(Array) ? json['@context'].last : json['@context']) |
| 225 | + end |
| 226 | + |
| 227 | + def to_ttl |
| 228 | + output = [] |
| 229 | + |
| 230 | + prefixes = { |
| 231 | + "dc" => {subClassOf: "http://purl.org/dc/terms/"}, |
| 232 | + "owl" => {subClassOf: "http://www.w3.org/2002/07/owl#"}, |
| 233 | + "rdf" => {subClassOf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#"}, |
| 234 | + "rdfs" => {subClassOf: "http://www.w3.org/2000/01/rdf-schema#"}, |
| 235 | + }.merge(@prefixes).dup |
| 236 | + prefixes.each {|id, entry| output << "@prefix #{id}: <#{entry[:subClassOf]}> ."} |
| 237 | + |
| 238 | + output << "\n# CSVM Ontology definition" |
| 239 | + output << "cred: a owl:Ontology;" |
| 240 | + output << %( dc:title "#{TITLE}"@en;) |
| 241 | + output << %( dc:description """#{DESCRIPTION}"""@en;) |
| 242 | + output << %( dc:date "#{date}"^^xsd:date;) |
| 243 | + #output << %( dc:imports #{imports.map {|i| '<' + i + '>'}.join(", ")};) |
| 244 | + #output << %( owl:versionInfo <#{commit}>;) |
| 245 | + output << %( rdfs:seeAlso #{seeAlso.map {|i| '<' + i + '>'}.join(", ")};) |
| 246 | + output << "." |
| 247 | + |
| 248 | + output << "\n# Class definitions" unless @classes.empty? |
| 249 | + @classes.each do |id, entry| |
| 250 | + output << "cred:#{id} a rdfs:Class;" |
| 251 | + output << %( rdfs:label "#{entry[:label]}"@en;) |
| 252 | + output << %( rdfs:comment """#{entry[:comment]}"""@en;) |
| 253 | + output << %( rdfs:subClassOf #{namespaced(entry[:subClassOf])};) if entry[:subClassOf] |
| 254 | + output << %( rdfs:isDefinedBy cred: .) |
| 255 | + end |
| 256 | + |
| 257 | + output << "\n# Property definitions" unless @properties.empty? |
| 258 | + @properties.each do |id, entry| |
| 259 | + output << "cred:#{id} a rdf:Property;" |
| 260 | + output << %( rdfs:label "#{entry[:label]}"@en;) |
| 261 | + output << %( rdfs:comment """#{entry[:comment]}"""@en;) |
| 262 | + output << %( rdfs:subPropertyOf #{namespaced(entry[:subClassOf])};) if entry[:subClassOf] |
| 263 | + domains = entry[:domain].to_s.split(',') |
| 264 | + case domains.length |
| 265 | + when 0 then ; |
| 266 | + when 1 then output << %( rdfs:domain #{namespaced(entry[:domain])};) |
| 267 | + else |
| 268 | + output << %( rdfs:domain [ owl:unionOf (#{domains.map {|d| namespaced(d)}.join(' ')})];) |
| 269 | + end |
| 270 | + |
| 271 | + ranges = entry[:range].to_s.split(',') |
| 272 | + case ranges.length |
| 273 | + when 0 then ; |
| 274 | + when 1 then output << %( rdfs:range #{namespaced(entry[:range])};) |
| 275 | + else |
| 276 | + output << %( rdfs:range [ owl:unionOf (#{ranges.map {|d| namespaced(d)}.join(' ')})];) |
| 277 | + end |
| 278 | + output << %( rdfs:isDefinedBy cred: .) |
| 279 | + end |
| 280 | + |
| 281 | + output << "\n# Datatype definitions" unless @datatypes.empty? |
| 282 | + @datatypes.each do |id, entry| |
| 283 | + output << "cred:#{id} a rdfs:Datatype;" |
| 284 | + output << %( rdfs:label "#{entry[:label]}"@en;) |
| 285 | + output << %( rdfs:comment """#{entry[:comment]}"""@en;) |
| 286 | + output << %( rdfs:subClassOf #{namespaced(entry[:subClassOf])};) if entry[:subClassOf] |
| 287 | + output << %( rdfs:isDefinedBy cred: .) |
| 288 | + end |
| 289 | + |
| 290 | + output << "\n# Instance definitions" unless @instances.empty? |
| 291 | + @instances.each do |id, entry| |
| 292 | + output << "cred:#{id} a #{namespaced(entry[:type])};" |
| 293 | + output << %( rdfs:label "#{entry[:label]}"@en;) |
| 294 | + output << %( rdfs:comment """#{entry[:comment]}"""@en;) |
| 295 | + output << %( rdfs:isDefinedBy cred: .) |
| 296 | + end |
| 297 | + |
| 298 | + output.join("\n") |
| 299 | + end |
| 300 | +end |
| 301 | + |
| 302 | +options = { |
| 303 | + output: $stdout |
| 304 | +} |
| 305 | + |
| 306 | +OPT_ARGS = [ |
| 307 | + ["--format", "-f", GetoptLong::REQUIRED_ARGUMENT,"Output format, default #{options[:format].inspect}"], |
| 308 | + ["--output", "-o", GetoptLong::REQUIRED_ARGUMENT,"Output to the specified file path"], |
| 309 | + ["--quiet", GetoptLong::NO_ARGUMENT, "Supress most output other than progress indicators"], |
| 310 | + ["--help", "-?", GetoptLong::NO_ARGUMENT, "This message"] |
| 311 | +] |
| 312 | +def usage |
| 313 | + STDERR.puts %{Usage: #{$0} [options] URL ...} |
| 314 | + width = OPT_ARGS.map do |o| |
| 315 | + l = o.first.length |
| 316 | + l += o[1].length + 2 if o[1].is_a?(String) |
| 317 | + l |
| 318 | + end.max |
| 319 | + OPT_ARGS.each do |o| |
| 320 | + s = " %-*s " % [width, (o[1].is_a?(String) ? "#{o[0,2].join(', ')}" : o[0])] |
| 321 | + s += o.last |
| 322 | + STDERR.puts s |
| 323 | + end |
| 324 | + exit(1) |
| 325 | +end |
| 326 | + |
| 327 | +opts = GetoptLong.new(*OPT_ARGS.map {|o| o[0..-2]}) |
| 328 | + |
| 329 | +opts.each do |opt, arg| |
| 330 | + case opt |
| 331 | + when '--format' then options[:format] = arg.to_sym |
| 332 | + when '--output' then options[:output] = File.open(arg, "w") |
| 333 | + when '--quiet' then options[:quiet] = true |
| 334 | + when '--help' then usage |
| 335 | + end |
| 336 | +end |
| 337 | + |
| 338 | +vocab = Vocab.new |
| 339 | +case options[:format] |
| 340 | +when :context then options[:output].puts(vocab.to_context) |
| 341 | +when :jsonld then options[:output].puts(vocab.to_jsonld) |
| 342 | +when :ttl then options[:output].puts(vocab.to_ttl) |
| 343 | +when :html then options[:output].puts(vocab.to_html) |
| 344 | +else |
| 345 | + [:jsonld, :ttl, :html].each do |format| |
| 346 | + fn = {jsonld: "vocabulary.jsonld", ttl: "vocabulary.ttl", html: "vocabulary.html"}[format] |
| 347 | + File.open(fn, "w") do |output| |
| 348 | + output.puts(vocab.send("to_#{format}".to_sym)) |
| 349 | + end |
| 350 | + end |
| 351 | +end |
0 commit comments