openphar 0.1.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.
- checksums.yaml +7 -0
- data/README.adoc +221 -0
- data/lib/openphar/cli/main.rb +137 -0
- data/lib/openphar/cli.rb +11 -0
- data/lib/openphar/core/slug_generator.rb +107 -0
- data/lib/openphar/core.rb +11 -0
- data/lib/openphar/errors/configuration_error.rb +8 -0
- data/lib/openphar/errors/monograph_not_found_error.rb +15 -0
- data/lib/openphar/errors/parse_error.rb +18 -0
- data/lib/openphar/errors/unknown_format_error.rb +8 -0
- data/lib/openphar/errors/unknown_publisher_error.rb +8 -0
- data/lib/openphar/errors/unknown_type_error.rb +8 -0
- data/lib/openphar/errors/validation_error.rb +15 -0
- data/lib/openphar/errors.rb +17 -0
- data/lib/openphar/exporters/coverage_validator.rb +265 -0
- data/lib/openphar/exporters/neo4j/model_registry.rb +130 -0
- data/lib/openphar/exporters/neo4j/node_builder.rb +84 -0
- data/lib/openphar/exporters/neo4j/property_mapper.rb +154 -0
- data/lib/openphar/exporters/neo4j/relationship_builder.rb +87 -0
- data/lib/openphar/exporters/neo4j.rb +16 -0
- data/lib/openphar/exporters/neo4j_exporter.rb +199 -0
- data/lib/openphar/exporters.rb +15 -0
- data/lib/openphar/linkers/chp/cross_edition_linker.rb +101 -0
- data/lib/openphar/linkers/chp.rb +11 -0
- data/lib/openphar/linkers/cross_publisher_linker.rb +295 -0
- data/lib/openphar/linkers/herbapedia_linker.rb +125 -0
- data/lib/openphar/linkers.rb +13 -0
- data/lib/openphar/migrators/chp/classifier.rb +86 -0
- data/lib/openphar/migrators/chp.rb +15 -0
- data/lib/openphar/migrators/chp_migrator.rb +130 -0
- data/lib/openphar/migrators/jp_migrator.rb +345 -0
- data/lib/openphar/migrators/phint_migrator.rb +426 -0
- data/lib/openphar/migrators.rb +15 -0
- data/lib/openphar/models/ahp.rb +25 -0
- data/lib/openphar/models/amino_acid_monograph.rb +28 -0
- data/lib/openphar/models/api.rb +29 -0
- data/lib/openphar/models/assay_specification.rb +19 -0
- data/lib/openphar/models/base_entity.rb +221 -0
- data/lib/openphar/models/biological_substance_monograph.rb +31 -0
- data/lib/openphar/models/chemical_drug_monograph.rb +39 -0
- data/lib/openphar/models/chp/biologic.rb +28 -0
- data/lib/openphar/models/chp/chemical_preparation.rb +28 -0
- data/lib/openphar/models/chp/chemical_substance.rb +28 -0
- data/lib/openphar/models/chp/general_chapter.rb +30 -0
- data/lib/openphar/models/chp/monograph.rb +116 -0
- data/lib/openphar/models/chp/section.rb +37 -0
- data/lib/openphar/models/chp/tcm_crude_drug.rb +28 -0
- data/lib/openphar/models/chp/tcm_extract.rb +29 -0
- data/lib/openphar/models/chp/tcm_formulation.rb +28 -0
- data/lib/openphar/models/chp.rb +33 -0
- data/lib/openphar/models/crude_drug_monograph.rb +31 -0
- data/lib/openphar/models/edition.rb +41 -0
- data/lib/openphar/models/formulation_monograph.rb +31 -0
- data/lib/openphar/models/hkcmms.rb +26 -0
- data/lib/openphar/models/identification_specification.rb +19 -0
- data/lib/openphar/models/jp/kampo_formula.rb +94 -0
- data/lib/openphar/models/jp.rb +21 -0
- data/lib/openphar/models/limit.rb +54 -0
- data/lib/openphar/models/mineral_substance_monograph.rb +28 -0
- data/lib/openphar/models/monograph.rb +58 -0
- data/lib/openphar/models/ph_int/buffer_solution.rb +38 -0
- data/lib/openphar/models/ph_int/dosage_form_monograph.rb +43 -0
- data/lib/openphar/models/ph_int/radiopharmaceutical_monograph.rb +52 -0
- data/lib/openphar/models/ph_int/reagent.rb +50 -0
- data/lib/openphar/models/ph_int/reference_substance.rb +50 -0
- data/lib/openphar/models/ph_int/test_method.rb +50 -0
- data/lib/openphar/models/ph_int/test_solution.rb +39 -0
- data/lib/openphar/models/ph_int/volumetric_solution.rb +38 -0
- data/lib/openphar/models/ph_int.rb +37 -0
- data/lib/openphar/models/physical_specification.rb +17 -0
- data/lib/openphar/models/publisher.rb +98 -0
- data/lib/openphar/models/purity_specification.rb +16 -0
- data/lib/openphar/models/supplement.rb +31 -0
- data/lib/openphar/models/test_specification.rb +29 -0
- data/lib/openphar/models/thp.rb +24 -0
- data/lib/openphar/models/vitamin_monograph.rb +28 -0
- data/lib/openphar/models.rb +58 -0
- data/lib/openphar/monograph_merger.rb +374 -0
- data/lib/openphar/parsers/base_monograph_parser.rb +108 -0
- data/lib/openphar/parsers/jp_html_parser.rb +92 -0
- data/lib/openphar/parsers/jp_html_parser_base.rb +113 -0
- data/lib/openphar/parsers/jp_ja_html_parser.rb +220 -0
- data/lib/openphar/parsers/phint_json_parser.rb +332 -0
- data/lib/openphar/parsers.rb +21 -0
- data/lib/openphar/registry/publisher_registry.rb +46 -0
- data/lib/openphar/registry/type_registry.rb +147 -0
- data/lib/openphar/registry.rb +24 -0
- data/lib/openphar/repositories/monograph_repository.rb +350 -0
- data/lib/openphar/repositories.rb +11 -0
- data/lib/openphar/transformers/jsonld_transformer.rb +106 -0
- data/lib/openphar/transformers.rb +11 -0
- data/lib/openphar/version.rb +5 -0
- data/lib/openphar.rb +119 -0
- data/ontology/bibliographic/edition.ttl +267 -0
- data/ontology/context/chp.jsonld +69 -0
- data/ontology/context/crude-drugs.jsonld +56 -0
- data/ontology/context/pharmacopoeia.jsonld +631 -0
- data/ontology/core/identification.ttl +456 -0
- data/ontology/core/pharmacopoeia.ttl +3427 -0
- data/ontology/core/substance-form.ttl +302 -0
- data/ontology/core/unit.ttl +493 -0
- data/ontology/publisher/chp/extensions.ttl +186 -0
- data/ontology/publisher/phint/extensions.ttl +327 -0
- data/ontology/quality/test-method.ttl +610 -0
- data/shapes/chp/monograph.ttl +153 -0
- data/shapes/edition-shapes.ttl +230 -0
- data/shapes/monograph-shapes.ttl +1324 -0
- metadata +319 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Openphar
|
|
7
|
+
module Migrators
|
|
8
|
+
# Migrates Japan Pharmacopoeia data to Open Pharmacopoeia ontology format
|
|
9
|
+
#
|
|
10
|
+
# This class handles the transformation of JP HTML monograph files into
|
|
11
|
+
# individual JSON-LD files and aggregate files following the OP ontology.
|
|
12
|
+
class JpMigrator
|
|
13
|
+
attr_reader :source_dir, :output_dir, :edition_id
|
|
14
|
+
|
|
15
|
+
# Category mapping for organizing monographs
|
|
16
|
+
CATEGORIES = {
|
|
17
|
+
"crude_drugs" => "crude-drugs",
|
|
18
|
+
"chemical_drugs" => "chemical-drugs",
|
|
19
|
+
"formulations" => "formulations",
|
|
20
|
+
"biologicals" => "biologicals",
|
|
21
|
+
"excipients" => "excipients",
|
|
22
|
+
"vitamins" => "vitamins",
|
|
23
|
+
"amino_acids" => "amino-acids",
|
|
24
|
+
"minerals" => "minerals"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# @param source_dir [String] Directory containing JP HTML files
|
|
28
|
+
# @param output_dir [String] Output directory for JSON-LD files
|
|
29
|
+
# @param edition_id [String] Edition IRI (default: JP18)
|
|
30
|
+
def initialize(source_dir:, output_dir:, edition_id: "https://www.openphar.org/data/edition/jp/jp18")
|
|
31
|
+
@source_dir = source_dir
|
|
32
|
+
@output_dir = output_dir
|
|
33
|
+
@edition_id = edition_id
|
|
34
|
+
@monographs = []
|
|
35
|
+
@stats = { total: 0, success: 0, failed: 0, categories: Hash.new(0) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Run the migration
|
|
39
|
+
# @return [Hash] Statistics about the migration
|
|
40
|
+
def run
|
|
41
|
+
puts "Starting JP migration..."
|
|
42
|
+
puts " Source: #{source_dir}"
|
|
43
|
+
puts " Output: #{output_dir}"
|
|
44
|
+
|
|
45
|
+
# Ensure output directories exist
|
|
46
|
+
ensure_output_directories
|
|
47
|
+
|
|
48
|
+
# Process HTML files
|
|
49
|
+
process_html_files
|
|
50
|
+
|
|
51
|
+
# Generate aggregate files
|
|
52
|
+
generate_aggregate_files
|
|
53
|
+
|
|
54
|
+
# Print statistics
|
|
55
|
+
print_statistics
|
|
56
|
+
|
|
57
|
+
@stats
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def ensure_output_directories
|
|
63
|
+
CATEGORIES.each_value do |category|
|
|
64
|
+
dir = File.join(output_dir, "monographs", category)
|
|
65
|
+
FileUtils.mkdir_p(dir)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def process_html_files
|
|
70
|
+
html_files = Dir.glob(File.join(source_dir, "**/*.html"))
|
|
71
|
+
puts "Found #{html_files.count} HTML files"
|
|
72
|
+
|
|
73
|
+
html_files.each do |html_file|
|
|
74
|
+
process_html_file(html_file)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def process_html_file(html_file)
|
|
79
|
+
puts "Processing #{html_file}..."
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
html_content = File.read(html_file, encoding: "UTF-8")
|
|
83
|
+
parser = Parsers::JpHtmlParser.new(html_content)
|
|
84
|
+
parsed_monographs = parser.parse
|
|
85
|
+
|
|
86
|
+
parsed_monographs.each do |monograph_data|
|
|
87
|
+
process_monograph(monograph_data)
|
|
88
|
+
end
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
puts " ERROR: #{e.message}"
|
|
91
|
+
puts " #{e.backtrace.first(5).join("\n ")}"
|
|
92
|
+
@stats[:failed] += 1
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def process_monograph(data)
|
|
97
|
+
@stats[:total] += 1
|
|
98
|
+
|
|
99
|
+
# Determine category
|
|
100
|
+
category = determine_category(data)
|
|
101
|
+
@stats[:categories][category] += 1
|
|
102
|
+
|
|
103
|
+
# Generate slug from monograph name
|
|
104
|
+
slug = generate_slug(data[:name])
|
|
105
|
+
|
|
106
|
+
# Build JSON-LD structure
|
|
107
|
+
jsonld = build_monograph_jsonld(data, slug, category)
|
|
108
|
+
|
|
109
|
+
# Write individual file
|
|
110
|
+
output_path = File.join(output_dir, "monographs", category, "#{slug}.jsonld")
|
|
111
|
+
File.write(output_path, JSON.pretty_generate(jsonld))
|
|
112
|
+
|
|
113
|
+
@monographs << jsonld
|
|
114
|
+
@stats[:success] += 1
|
|
115
|
+
|
|
116
|
+
puts " Created: #{output_path}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def determine_category(data)
|
|
120
|
+
# Determine based on monograph type and characteristics
|
|
121
|
+
return "crude-drugs" if data[:type] == "CrudeDrugMonograph"
|
|
122
|
+
return "formulations" if data[:dosage_form]
|
|
123
|
+
return "vitamins" if data[:name]&.match?(/vitamin|ascorbic|retinol|tocopherol/i)
|
|
124
|
+
return "amino-acids" if data[:name]&.match?(/amino acid|alanine|arginine|cysteine/i)
|
|
125
|
+
return "biologicals" if data[:type] == "BiologicalSubstanceMonograph"
|
|
126
|
+
return "excipients" if data[:name]&.match?(/lactose|starch|cellulose|silicate/i)
|
|
127
|
+
return "minerals" if data[:name]&.match?(/aluminum|calcium|magnesium|zinc|iron/i)
|
|
128
|
+
|
|
129
|
+
"chemical-drugs"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def generate_slug(name)
|
|
133
|
+
return "unknown-#{@stats[:total]}" unless name
|
|
134
|
+
|
|
135
|
+
name.downcase
|
|
136
|
+
.gsub(/[^a-z0-9\s-]/, "")
|
|
137
|
+
.gsub(/\s+/, "-")
|
|
138
|
+
.gsub(/-+/, "-")
|
|
139
|
+
.strip
|
|
140
|
+
.sub(/^-/, "")
|
|
141
|
+
.sub(/-$/, "")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def build_monograph_jsonld(data, slug, category)
|
|
145
|
+
base_iri = "https://www.openphar.org/data/jp/monographs/#{category}/#{slug}"
|
|
146
|
+
|
|
147
|
+
jsonld = {
|
|
148
|
+
"@context" => "https://www.openphar.org/ontology/context/pharmacopoeia.jsonld",
|
|
149
|
+
"@id" => base_iri,
|
|
150
|
+
"@type" => data[:type] || "ChemicalDrugMonograph",
|
|
151
|
+
"prefLabel" => { "en" => data[:name] },
|
|
152
|
+
"publisher" => "JP",
|
|
153
|
+
"monographId" => data[:monograph_id],
|
|
154
|
+
"belongsToEdition" => @edition_id
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# Add Japanese name if available
|
|
158
|
+
if data[:japanese_name]
|
|
159
|
+
jsonld["altLabel"] = [{ "ja" => data[:japanese_name] }]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Add chemical properties
|
|
163
|
+
if data[:molecular_formula]
|
|
164
|
+
jsonld["molecularFormula"] = data[:molecular_formula]
|
|
165
|
+
end
|
|
166
|
+
if data[:molecular_weight]
|
|
167
|
+
jsonld["molecularWeight"] = data[:molecular_weight]
|
|
168
|
+
end
|
|
169
|
+
if data[:cas_number]
|
|
170
|
+
jsonld["casNumber"] = data[:cas_number]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Add definition
|
|
174
|
+
if data[:definition]
|
|
175
|
+
jsonld["definition"] = { "en" => data[:definition] }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Add appearance
|
|
179
|
+
if data[:appearance]
|
|
180
|
+
jsonld["appearance"] = { "en" => data[:appearance] }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Add test specifications
|
|
184
|
+
if data[:tests]&.any?
|
|
185
|
+
jsonld["testSpecification"] = data[:tests].map do |test|
|
|
186
|
+
build_test_specification(test)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Add storage
|
|
191
|
+
if data[:storage]
|
|
192
|
+
jsonld["storageConditions"] = { "en" => data[:storage] }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Add botanical source for crude drugs
|
|
196
|
+
if data[:botanical_source]
|
|
197
|
+
jsonld["botanicalSource"] = build_botanical_source(data[:botanical_source])
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Add macroscopic description for crude drugs
|
|
201
|
+
if data[:macroscopic_description]
|
|
202
|
+
jsonld["macroscopicDescription"] = {
|
|
203
|
+
"color" => { "en" => data[:macroscopic_description][:color] },
|
|
204
|
+
"shape" => { "en" => data[:macroscopic_description][:shape] },
|
|
205
|
+
"odor" => { "en" => data[:macroscopic_description][:odor] },
|
|
206
|
+
"taste" => { "en" => data[:macroscopic_description][:taste] }
|
|
207
|
+
}.compact
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
jsonld.compact
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def build_test_specification(test)
|
|
214
|
+
spec = {
|
|
215
|
+
"@type" => test[:spec_type] || "TestSpecification",
|
|
216
|
+
"testName" => { "en" => test[:name] }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if test[:test_type]
|
|
220
|
+
spec["testType"] = "TestType-#{test[:test_type]}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
if test[:method]
|
|
224
|
+
spec["harmonizedMethod"] = test[:method]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
if test[:limit]
|
|
228
|
+
spec["limit"] = build_limit(test[:limit])
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
if test[:conditions]
|
|
232
|
+
spec["testConditions"] = { "en" => test[:conditions] }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
spec.compact
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def build_limit(limit_data)
|
|
239
|
+
limit = { "@type" => "Limit" }
|
|
240
|
+
|
|
241
|
+
if limit_data[:type]
|
|
242
|
+
limit["limitType"] = "LimitType-#{limit_data[:type]}"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
if limit_data[:value]
|
|
246
|
+
limit["limitValue"] = limit_data[:value]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
if limit_data[:unit]
|
|
250
|
+
limit["limitUnit"] = limit_data[:unit]
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
if limit_data[:lower]
|
|
254
|
+
limit["lowerLimit"] = limit_data[:lower]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
if limit_data[:upper]
|
|
258
|
+
limit["upperLimit"] = limit_data[:upper]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
limit.compact
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def build_botanical_source(source)
|
|
265
|
+
{
|
|
266
|
+
"@type" => "BotanicalSource",
|
|
267
|
+
"plantPartUsed" => { "en" => source[:part] }
|
|
268
|
+
}.tap do |bs|
|
|
269
|
+
if source[:latin_name]
|
|
270
|
+
bs["systematicName"] = { "la" => source[:latin_name] }
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def generate_aggregate_files
|
|
276
|
+
puts "\nGenerating aggregate files..."
|
|
277
|
+
|
|
278
|
+
# Generate JSON-LD aggregate
|
|
279
|
+
jsonld_aggregate = {
|
|
280
|
+
"@context" => "https://www.openphar.org/ontology/context/pharmacopoeia.jsonld",
|
|
281
|
+
"@id" => "https://www.openphar.org/data/jp/jp-monographs",
|
|
282
|
+
"@type" => "Collection",
|
|
283
|
+
"prefLabel" => { "en" => "Japanese Pharmacopoeia 18 - All Monographs" },
|
|
284
|
+
"belongsToEdition" => @edition_id,
|
|
285
|
+
"@graph" => @monographs
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
jsonld_path = File.join(output_dir, "jp-monographs.jsonld")
|
|
289
|
+
File.write(jsonld_path, JSON.pretty_generate(jsonld_aggregate))
|
|
290
|
+
puts " Created: #{jsonld_path}"
|
|
291
|
+
|
|
292
|
+
# Generate TTL aggregate (via transformation)
|
|
293
|
+
ttl_content = generate_ttl_aggregate
|
|
294
|
+
ttl_path = File.join(output_dir, "jp-monographs.ttl")
|
|
295
|
+
File.write(ttl_path, ttl_content)
|
|
296
|
+
puts " Created: #{ttl_path}"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def generate_ttl_aggregate
|
|
300
|
+
# Generate a basic Turtle representation
|
|
301
|
+
ttl = StringIO.new
|
|
302
|
+
ttl.puts "@prefix op: <https://www.openphar.org/ontology/core/> ."
|
|
303
|
+
ttl.puts "@prefix bib: <https://www.openphar.org/ontology/bibliographic/> ."
|
|
304
|
+
ttl.puts "@prefix skos: <http://www.w3.org/2004/02/skos/core#> ."
|
|
305
|
+
ttl.puts "@prefix xsd: <http://www.w3.org/2001/XMLSchema#> ."
|
|
306
|
+
ttl.puts ""
|
|
307
|
+
ttl.puts "<https://www.openphar.org/data/jp/jp-monographs>"
|
|
308
|
+
ttl.puts " a op:Collection ;"
|
|
309
|
+
ttl.puts " skos:prefLabel \"Japanese Pharmacopoeia 18 - All Monographs\"@en ;"
|
|
310
|
+
ttl.puts " op:belongsToEdition <#{@edition_id}> ;"
|
|
311
|
+
ttl.puts " op:monographCount #{@monographs.count} ."
|
|
312
|
+
ttl.puts ""
|
|
313
|
+
|
|
314
|
+
@monographs.each do |monograph|
|
|
315
|
+
id = monograph["@id"]
|
|
316
|
+
type = monograph["@type"]
|
|
317
|
+
label = monograph["prefLabel"]&.dig("en") || "Unknown"
|
|
318
|
+
|
|
319
|
+
ttl.puts "<#{id}>"
|
|
320
|
+
ttl.puts " a op:#{type} ;"
|
|
321
|
+
ttl.puts " skos:prefLabel #{label.inspect} ;"
|
|
322
|
+
ttl.puts " op:publisher op:JP ;"
|
|
323
|
+
ttl.puts " op:belongsToEdition <#{@edition_id}> ."
|
|
324
|
+
ttl.puts ""
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
ttl.string
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def print_statistics
|
|
331
|
+
puts "\n" + "=" * 50
|
|
332
|
+
puts "Migration Statistics"
|
|
333
|
+
puts "=" * 50
|
|
334
|
+
puts "Total monographs: #{@stats[:total]}"
|
|
335
|
+
puts "Successfully migrated: #{@stats[:success]}"
|
|
336
|
+
puts "Failed: #{@stats[:failed]}"
|
|
337
|
+
puts "\nBy category:"
|
|
338
|
+
@stats[:categories].each do |category, count|
|
|
339
|
+
puts " #{category}: #{count}"
|
|
340
|
+
end
|
|
341
|
+
puts "=" * 50
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|