quby-compiler 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.
Files changed (107) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +13 -0
  3. data/.gitlab-ci.yml +5 -0
  4. data/.rspec +3 -0
  5. data/.ruby-version +1 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/Dockerfile +11 -0
  8. data/Gemfile +8 -0
  9. data/Gemfile.lock +133 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +44 -0
  12. data/Rakefile +6 -0
  13. data/bin/console +14 -0
  14. data/bin/rspec +29 -0
  15. data/bin/setup +8 -0
  16. data/config/locales/de.yml +58 -0
  17. data/config/locales/en.yml +57 -0
  18. data/config/locales/nl.yml +57 -0
  19. data/config/locales/rails-i18n/README.md +4 -0
  20. data/config/locales/rails-i18n/de.yml +223 -0
  21. data/config/locales/rails-i18n/en.yml +216 -0
  22. data/config/locales/rails-i18n/nl.yml +214 -0
  23. data/exe/quby-compile +56 -0
  24. data/lib/quby/array_attribute_valid_validator.rb +15 -0
  25. data/lib/quby/attribute_valid_validator.rb +14 -0
  26. data/lib/quby/compiler.rb +50 -0
  27. data/lib/quby/compiler/dsl.rb +29 -0
  28. data/lib/quby/compiler/dsl/base.rb +20 -0
  29. data/lib/quby/compiler/dsl/calls_custom_methods.rb +29 -0
  30. data/lib/quby/compiler/dsl/charting/bar_chart_builder.rb +14 -0
  31. data/lib/quby/compiler/dsl/charting/chart_builder.rb +95 -0
  32. data/lib/quby/compiler/dsl/charting/line_chart_builder.rb +34 -0
  33. data/lib/quby/compiler/dsl/charting/overview_chart_builder.rb +31 -0
  34. data/lib/quby/compiler/dsl/charting/radar_chart_builder.rb +14 -0
  35. data/lib/quby/compiler/dsl/helpers.rb +53 -0
  36. data/lib/quby/compiler/dsl/panel_builder.rb +80 -0
  37. data/lib/quby/compiler/dsl/question_builder.rb +40 -0
  38. data/lib/quby/compiler/dsl/questionnaire_builder.rb +279 -0
  39. data/lib/quby/compiler/dsl/questions/base.rb +180 -0
  40. data/lib/quby/compiler/dsl/questions/checkbox_question_builder.rb +20 -0
  41. data/lib/quby/compiler/dsl/questions/date_question_builder.rb +18 -0
  42. data/lib/quby/compiler/dsl/questions/deprecated_question_builder.rb +18 -0
  43. data/lib/quby/compiler/dsl/questions/float_question_builder.rb +21 -0
  44. data/lib/quby/compiler/dsl/questions/integer_question_builder.rb +21 -0
  45. data/lib/quby/compiler/dsl/questions/radio_question_builder.rb +20 -0
  46. data/lib/quby/compiler/dsl/questions/select_question_builder.rb +18 -0
  47. data/lib/quby/compiler/dsl/questions/string_question_builder.rb +20 -0
  48. data/lib/quby/compiler/dsl/questions/text_question_builder.rb +22 -0
  49. data/lib/quby/compiler/dsl/score_builder.rb +22 -0
  50. data/lib/quby/compiler/dsl/score_schema_builder.rb +53 -0
  51. data/lib/quby/compiler/dsl/standardized_panel_generators.rb +33 -0
  52. data/lib/quby/compiler/dsl/table_builder.rb +48 -0
  53. data/lib/quby/compiler/entities.rb +38 -0
  54. data/lib/quby/compiler/entities/charting/bar_chart.rb +17 -0
  55. data/lib/quby/compiler/entities/charting/chart.rb +101 -0
  56. data/lib/quby/compiler/entities/charting/charts.rb +42 -0
  57. data/lib/quby/compiler/entities/charting/line_chart.rb +38 -0
  58. data/lib/quby/compiler/entities/charting/overview_chart.rb +20 -0
  59. data/lib/quby/compiler/entities/charting/plottable.rb +20 -0
  60. data/lib/quby/compiler/entities/charting/radar_chart.rb +17 -0
  61. data/lib/quby/compiler/entities/definition.rb +26 -0
  62. data/lib/quby/compiler/entities/fields.rb +119 -0
  63. data/lib/quby/compiler/entities/flag.rb +55 -0
  64. data/lib/quby/compiler/entities/item.rb +40 -0
  65. data/lib/quby/compiler/entities/lookup_tables.rb +71 -0
  66. data/lib/quby/compiler/entities/outcome_table.rb +31 -0
  67. data/lib/quby/compiler/entities/panel.rb +82 -0
  68. data/lib/quby/compiler/entities/question.rb +365 -0
  69. data/lib/quby/compiler/entities/question_option.rb +96 -0
  70. data/lib/quby/compiler/entities/questionnaire.rb +440 -0
  71. data/lib/quby/compiler/entities/questions/checkbox_question.rb +82 -0
  72. data/lib/quby/compiler/entities/questions/date_question.rb +84 -0
  73. data/lib/quby/compiler/entities/questions/deprecated_question.rb +19 -0
  74. data/lib/quby/compiler/entities/questions/float_question.rb +15 -0
  75. data/lib/quby/compiler/entities/questions/integer_question.rb +15 -0
  76. data/lib/quby/compiler/entities/questions/radio_question.rb +19 -0
  77. data/lib/quby/compiler/entities/questions/select_question.rb +19 -0
  78. data/lib/quby/compiler/entities/questions/string_question.rb +15 -0
  79. data/lib/quby/compiler/entities/questions/text_question.rb +15 -0
  80. data/lib/quby/compiler/entities/score_calculation.rb +35 -0
  81. data/lib/quby/compiler/entities/score_schema.rb +25 -0
  82. data/lib/quby/compiler/entities/subscore_schema.rb +23 -0
  83. data/lib/quby/compiler/entities/table.rb +143 -0
  84. data/lib/quby/compiler/entities/text.rb +71 -0
  85. data/lib/quby/compiler/entities/textvar.rb +23 -0
  86. data/lib/quby/compiler/entities/validation.rb +17 -0
  87. data/lib/quby/compiler/entities/version.rb +23 -0
  88. data/lib/quby/compiler/entities/visibility_rule.rb +71 -0
  89. data/lib/quby/compiler/instance.rb +72 -0
  90. data/lib/quby/compiler/output.rb +13 -0
  91. data/lib/quby/compiler/outputs.rb +4 -0
  92. data/lib/quby/compiler/outputs/quby_frontend_v1_serializer.rb +362 -0
  93. data/lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb +15 -0
  94. data/lib/quby/compiler/outputs/roqua_serializer.rb +108 -0
  95. data/lib/quby/compiler/outputs/seed_serializer.rb +34 -0
  96. data/lib/quby/compiler/services/definition_validator.rb +330 -0
  97. data/lib/quby/compiler/services/quby_proxy.rb +405 -0
  98. data/lib/quby/compiler/services/seed_diff.rb +116 -0
  99. data/lib/quby/compiler/services/text_transformation.rb +30 -0
  100. data/lib/quby/compiler/version.rb +5 -0
  101. data/lib/quby/markdown_parser.rb +38 -0
  102. data/lib/quby/range_categories.rb +38 -0
  103. data/lib/quby/settings.rb +86 -0
  104. data/lib/quby/text_transformation.rb +26 -0
  105. data/lib/quby/type_validator.rb +12 -0
  106. data/quby-compiler.gemspec +39 -0
  107. metadata +277 -0
@@ -0,0 +1,15 @@
1
+ module Quby
2
+ module Compiler
3
+ module Outputs
4
+ class QubyFrontendV2Serializer
5
+ def initialize(questionnaire)
6
+ @questionnaire = questionnaire
7
+ end
8
+
9
+ def as_json(options = {})
10
+ @questionnaire.as_json
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,108 @@
1
+ module Quby
2
+ module Compiler
3
+ module Outputs
4
+ class RoquaSerializer
5
+ attr_reader :questionnaire
6
+
7
+ def initialize(questionnaire)
8
+ @questionnaire = questionnaire
9
+ end
10
+
11
+ def as_json(options = {})
12
+ {
13
+ key: questionnaire.key,
14
+ versions: versions,
15
+ keys: questionnaire.roqua_keys,
16
+ roqua_keys: questionnaire.roqua_keys,
17
+ sbg_key: questionnaire.sbg_key,
18
+ sbg_domains: questionnaire.sbg_domains,
19
+ outcome_regeneration_requested_at: questionnaire.outcome_regeneration_requested_at,
20
+ deactivate_answers_requested_at: questionnaire.deactivate_answers_requested_at,
21
+ respondent_types: questionnaire.respondent_types,
22
+ tags: questionnaire.tags.to_h.keys,
23
+ charts: charts,
24
+ outcome_tables_schema: outcome_tables_schema,
25
+ }
26
+ end
27
+
28
+ def versions
29
+ questionnaire.versions.map do |version|
30
+ {
31
+ number: version.number,
32
+ release_notes: version.release_notes,
33
+ regenerate_outcome: version.regenerate_outcome,
34
+ deactivate_answers: version.deactivate_answers
35
+ }
36
+ end
37
+ end
38
+
39
+ def charts
40
+ {
41
+ overview: questionnaire.charts.overview && {
42
+ subscore: questionnaire.charts.overview.subscore,
43
+ y_max: questionnaire.charts.overview.y_max,
44
+ },
45
+ others: questionnaire.charts.map do |chart|
46
+ case chart
47
+ when Quby::Compiler::Entities::Charting::LineChart
48
+ {
49
+ y_label: chart.y_label,
50
+ tonality: chart.tonality,
51
+ baseline: YAML.dump(chart.baseline),
52
+ clinically_relevant_change: chart.clinically_relevant_change,
53
+ }
54
+ when Quby::Compiler::Entities::Charting::OverviewChart
55
+ {
56
+ subscore: chart.subscore,
57
+ y_max: chart.y_max,
58
+ }
59
+ else
60
+ {}
61
+ end.merge(
62
+ key: chart.key,
63
+ type: chart.type,
64
+ title: chart.title,
65
+ plottables: chart.plottables,
66
+ y_categories: chart.y_categories,
67
+ y_range_categories: chart.y_range_categories,
68
+ chart_type: chart.chart_type,
69
+ y_range: chart.y_range,
70
+ tick_interval: chart.tick_interval,
71
+ plotbands: chart.plotbands,
72
+ plotlines: chart.plotlines
73
+ )
74
+ end
75
+ }
76
+ end
77
+
78
+ # configuration for outcome tables.
79
+ # tables:
80
+ # <outcome_table_name:Symbol>: # each entry is a table.
81
+ # score_keys: Set[<schema.key:Symbol>] # rows in the table
82
+ # subscore_keys: Set[<subschema.key:Symbol>] # columns in the table
83
+ # headers:
84
+ # <subscore_key:Symbol>: <subscore.label:String> # headers for each subscore key for all tables.
85
+
86
+ def outcome_tables_schema
87
+ # hash of tables, with the score keys (rows) and subscore keys (columns) used for each
88
+ tables = Hash.new{ |hash, key| hash[key] = {score_keys: Set.new, subscore_keys: Set.new } }
89
+ # hash of `subscore_key: subscore_label` pairs used in tables
90
+ headers = {}
91
+
92
+ questionnaire.score_schemas.values.each do |schema|
93
+ schema.subscore_schemas.each do |subschema|
94
+ tables[subschema.outcome_table][:subscore_keys] << subschema.key
95
+ tables[subschema.outcome_table][:score_keys] << schema.key
96
+ headers[subschema.key] = subschema.label
97
+ end
98
+ end
99
+
100
+ {
101
+ headers: headers,
102
+ tables: tables,
103
+ }
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,34 @@
1
+ require 'quby/compiler/services/quby_proxy'
2
+
3
+ module Quby
4
+ module Compiler
5
+ module Outputs
6
+ class SeedSerializer
7
+ attr_reader :questionnaire
8
+ attr_reader :seeds
9
+
10
+ def initialize(questionnaire, seeds)
11
+ @questionnaire = questionnaire
12
+ @seeds = seeds || []
13
+ end
14
+
15
+ def generate
16
+ roqua_keys = seeds.present? ? seeds.map { |seed| seed["key"] } : questionnaire.roqua_keys
17
+
18
+ roqua_keys.map do |roqua_key|
19
+ seed = seeds.find { |seed| seed["key"] == roqua_key } || {}
20
+
21
+ new_seed = Services::QubyProxy.new(
22
+ questionnaire,
23
+ quby_key: questionnaire.key,
24
+ roqua_key: roqua_key,
25
+ skip_score_keys_consistency_check: true
26
+ ).generate(seed)
27
+
28
+ Services::SeedDiff.new.apply_patch(new_seed, questionnaire.seeds_patch[roqua_key])
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+ require 'nokogumbo'
3
+ require 'active_model'
4
+ require 'quby/compiler/entities/questionnaire'
5
+
6
+ module Quby
7
+ module Compiler
8
+ module Services
9
+ class DefinitionValidator < ActiveModel::Validator
10
+ MAX_KEY_LENGTH = 19
11
+ KEY_PREFIX = 'v_'
12
+
13
+ attr_reader :definition
14
+ attr_reader :questionnaire
15
+
16
+ def validate(definition)
17
+ questionnaire = DSL.build_from_definition(definition)
18
+ validate_fields(questionnaire)
19
+ validate_title(questionnaire)
20
+ validate_questions(questionnaire)
21
+ validate_scores(questionnaire)
22
+ validate_table_edgecases(questionnaire)
23
+ validate_flags(questionnaire)
24
+ validate_respondent_types(questionnaire)
25
+ validate_outcome_tables(questionnaire)
26
+ validate_markdown_fields(questionnaire) if questionnaire.validate_html
27
+ validate_raw_content_items(questionnaire) if questionnaire.validate_html
28
+ validate_scores(questionnaire)
29
+ # Some compilation errors are Exceptions (pure syntax errors) and some StandardErrors (NameErrors)
30
+ rescue Exception => exception # rubocop:disable Lint/RescueException
31
+ definition.errors.add(:sourcecode, {message: "Questionnaire error: #{definition.key}\n" \
32
+ "#{exception.message}",
33
+ backtrace: exception.backtrace[0..5].join("<br/>")})
34
+ end
35
+
36
+ def validate_fields(questionnaire)
37
+ questionnaire.fields.input_keys
38
+ .find { |k| !k.is_a?(Symbol) }
39
+ &.tap { |k| fail "Input key #{k} is not a symbol" }
40
+ questionnaire.fields.answer_keys
41
+ .find { |k| !k.is_a?(Symbol) }
42
+ &.tap { |k| fail "Answer key #{k} is not a symbol" }
43
+ end
44
+
45
+ def validate_title(questionnaire)
46
+ if questionnaire.title.blank?
47
+ fail "Questionnaire title is missing."
48
+ end
49
+ end
50
+
51
+ def validate_questions(questionnaire)
52
+ questionnaire.answer_keys.each do |key|
53
+ validate_key_format(key)
54
+ end
55
+
56
+ questionnaire.question_hash.each_value do |question|
57
+ validate_question(question)
58
+ subquestions_cant_have_default_invisible question
59
+ validate_subquestion_absence_in_select question
60
+ validate_placeholder_options_nil_values question
61
+ validate_values_unique question
62
+
63
+ validate_question_options(questionnaire, question)
64
+ validate_presence_of_titles question
65
+ validate_no_spaces_before_question_nr_in_title question
66
+ end
67
+ end
68
+
69
+ def validate_question(question)
70
+ unless question.valid?
71
+ fail "Question #{question.key} is invalid: #{question.errors.full_messages.join(', ')}"
72
+ end
73
+ end
74
+
75
+ def validate_scores(questionnaire)
76
+ questionnaire.scores.each do |score|
77
+ validate_score_key_length(score)
78
+ validate_score_label_present(score)
79
+
80
+ score_schema = questionnaire.score_schemas[score.key]
81
+ fail "Score #{score.key} does not have a score schema" unless score_schema
82
+ fail "Score label langer dan 100 tekens (geeft problemen oru accare)\n #{score_schema.label}" if score_schema.label&.length > 100
83
+ end
84
+ end
85
+
86
+ def validate_question_options(questionnaire, question)
87
+ question.options.each do |option|
88
+ msg_base = "Question #{option.question.key} option #{option.key}"
89
+ to_be_hidden_questions_exist_and_not_subquestion?(questionnaire, option, msg_base: msg_base)
90
+ to_be_shown_questions_exist_and_not_subquestion?(questionnaire, option, msg_base: msg_base)
91
+ end
92
+ end
93
+
94
+ def validate_presence_of_titles(question)
95
+ return if question.allow_blank_titles
96
+ if !question.subquestion? && question.title.blank? && question.context_free_title.blank?
97
+ fail "Question #{question.key} must define either `:title` or `:context_free_title`."
98
+ end
99
+ end
100
+
101
+ def validate_table_edgecases(questionnaire)
102
+ questionnaire.panels.each do |panel|
103
+ tables = panel.items.select { |item| item.is_a?(Entities::Table) }
104
+ tables.each do |table|
105
+ questions = table.items.select { |item| item.is_a?(Entities::Question) }
106
+ questions.each { |question| validate_table_question(question) }
107
+ end
108
+ end
109
+ end
110
+
111
+ def validate_flags(questionnaire)
112
+ questionnaire.flags.each_value do |flag|
113
+ validate_flag_shows(questionnaire, flag)
114
+ validate_flag_hides(questionnaire, flag)
115
+ validate_flag_depends_on(questionnaire, flag)
116
+ end
117
+ end
118
+
119
+ def validate_flag_shows(questionnaire, flag)
120
+ unknown_questions = flag.shows_questions.select { |key| !questionnaire.key_in_use?(key) }
121
+ return if unknown_questions.blank?
122
+
123
+ fail ArgumentError, "Flag '#{key}' has unknown shows_questions keys #{unknown_questions}"
124
+ end
125
+
126
+ def validate_flag_hides(questionnaire, flag)
127
+ unknown_questions = flag.hides_questions.select { |key| !questionnaire.key_in_use?(key) }
128
+ return if unknown_questions.blank?
129
+
130
+ fail ArgumentError, "Flag '#{key}' has unknown hides_questions keys #{unknown_questions}"
131
+ end
132
+
133
+ def validate_flag_depends_on(questionnaire, flag)
134
+ return if flag.depends_on.blank? || questionnaire.flags.key?(flag.depends_on)
135
+
136
+ fail ArgumentError, "Flag #{flag.key} depends_on nonexistent flag '#{flag.depends_on}'"
137
+ end
138
+
139
+ def validate_respondent_types(questionnaire)
140
+ valid_respondent_types = Entities::Questionnaire::RESPONDENT_TYPES
141
+
142
+ invalid_types = questionnaire.respondent_types - valid_respondent_types
143
+
144
+ if invalid_types.present?
145
+ fail "Invalid respondent types: :#{invalid_types.join(', :')}\n"\
146
+ "Choose one or more from: :#{valid_respondent_types.join(', :')}"
147
+ end
148
+ end
149
+
150
+ def validate_outcome_tables(questionnaire)
151
+ questionnaire.outcome_tables.each do |table|
152
+ next if table.valid?
153
+ fail "Outcome table #{table.errors.full_messages}"
154
+ end
155
+ end
156
+
157
+ def to_be_hidden_questions_exist_and_not_subquestion?(questionnaire, option, msg_base:)
158
+ return if option.hides_questions.blank?
159
+ msg_base += " hides_questions"
160
+ option.hides_questions.each do |key|
161
+ validate_question_key_exists?(questionnaire, key, msg_base: msg_base)
162
+ validate_not_subquestion(questionnaire, key, msg_base: msg_base)
163
+ end
164
+ end
165
+
166
+ def to_be_shown_questions_exist_and_not_subquestion?(questionnaire, option, msg_base:)
167
+ return if option.shows_questions.blank?
168
+ msg_base += " shows_questions"
169
+ option.shows_questions.each do |key|
170
+ validate_question_key_exists?(questionnaire, key, msg_base: msg_base)
171
+ validate_not_subquestion(questionnaire, key, msg_base: msg_base)
172
+ end
173
+ end
174
+
175
+ def subquestions_cant_have_default_invisible(question)
176
+ if question.subquestion? && question.default_invisible
177
+ fail "Question #{question.key} is a subquestion with default_invisible."
178
+ end
179
+ end
180
+
181
+ def self.check_score_keys_consistency(seed)
182
+ score_keys = seed["properties"][:score_keys]
183
+ most_keys = score_keys.map { |score| keys_for_score(score).join }.max_by(&:length)
184
+
185
+ faulty_scores = score_keys.reject do |score|
186
+ most_keys.starts_with? keys_for_score(score).join
187
+ end
188
+
189
+ if faulty_scores.present?
190
+ raise "scores mismatch other scores, check if this was intentional: #{faulty_scores}
191
+
192
+ If this was intentional, rerun quby proxy with the flag '--skip_score_keys_consistency_check' *and* manually add \
193
+ scores_schema tables to the resulting seed."
194
+ end
195
+ end
196
+
197
+ def self.check_duplicate_headers(seed)
198
+ return
199
+ # TODO
200
+ # column_headers = DataExport::QuestionnaireHeaders.new(questionnaire).headers
201
+ # duplicate_header_names = column_headers.find_all { |e| column_headers.rindex(e) != column_headers.index(e) }.uniq
202
+ # raise "key clashes for: #{duplicate_header_names}" if duplicate_header_names.present?
203
+ end
204
+
205
+
206
+ private
207
+
208
+ # Don't write question numbers as " 1. Title", but as "1\\. Title".
209
+ def validate_no_spaces_before_question_nr_in_title(question)
210
+ if question.title && question.title.match(/^\s{2,}\d+\\\./)
211
+ fail "Question with number does not need leading spaces."
212
+ end
213
+ end
214
+
215
+ def validate_question_key_exists?(questionnaire, key, msg_base:)
216
+ unless questionnaire.question_hash[key]
217
+ fail msg_base + " references nonexistent question #{key}"
218
+ end
219
+ end
220
+
221
+ def validate_not_subquestion(questionnaire, key, msg_base:)
222
+ if questionnaire.question_hash[key].subquestion?
223
+ fail msg_base + " references subquestion #{key}"
224
+ end
225
+ end
226
+
227
+ def validate_key_format(key)
228
+ if key.to_s.length > MAX_KEY_LENGTH
229
+ fail "Key '#{key}' should contain at most #{MAX_KEY_LENGTH} characters."
230
+ end
231
+ unless key.to_s.start_with?(KEY_PREFIX)
232
+ fail "Key '#{key}' should start with '#{KEY_PREFIX}'."
233
+ end
234
+ end
235
+
236
+ def validate_score_key_length(score)
237
+ if score.key.to_s.length > MAX_KEY_LENGTH
238
+ fail "Score key `#{score.key}` should contain at most #{MAX_KEY_LENGTH} characters."
239
+ end
240
+ end
241
+
242
+ def validate_score_label_present(score)
243
+ fail "Score #{score.key} label must be passed in as an option." unless score.label.present?
244
+
245
+
246
+ end
247
+
248
+ def validate_subquestion_absence_in_select(question)
249
+ return unless question.type == :select
250
+ question.options.each do |option|
251
+ unless option.questions.empty?
252
+ fail "Question '#{question.key}' of type ':select' may not include other questions."
253
+ end
254
+ end
255
+ end
256
+
257
+ def validate_placeholder_options_nil_values(question)
258
+ question.options.each do |question_option|
259
+ if question_option.placeholder && question_option.value.present?
260
+ fail "#{question.key}:#{question_option.key}: Placeholder options should not have values defined."
261
+ end
262
+ end
263
+ end
264
+
265
+ def validate_values_unique(question)
266
+ return if question.type == :check_box || question.allow_duplicate_option_values
267
+
268
+ question.options.each_with_object([]) do |question_option, seen_values|
269
+ next if question_option.placeholder || question_option.inner_title
270
+
271
+ fail "#{question.key}:#{question_option.key}: Has no option value defined." if question_option.value.blank?
272
+ if seen_values.include?(question_option.value)
273
+ fail "#{question.key}:#{question_option.key}: " \
274
+ "Another option with value #{question_option.value} is already defined."
275
+ end
276
+ seen_values << question_option.value
277
+ end
278
+ end
279
+
280
+ def validate_table_question(question)
281
+ question.subquestions.each do |subquestion|
282
+ if subquestion.presentation != :next_to_title
283
+ fail "Question #{question.key} is inside a table, but has a subquestion #{subquestion.key}, " \
284
+ "which is not allowed."
285
+ end
286
+ end
287
+ end
288
+
289
+ def validate_markdown_fields(questionnaire)
290
+ questionnaire.panels.each do |panel|
291
+ panel.items.select { |item| item.is_a?(Entities::Text) }.each do |text_item|
292
+ validate_markdown(text_item.str, text_item.str)
293
+ end
294
+ end
295
+ questionnaire.questions.each do |question|
296
+ Entities::Question::MARKDOWN_ATTRIBUTES.each do |attr|
297
+ validate_markdown(question.send(attr), "#{question.key}.#{attr}")
298
+ question.options.each do |option|
299
+ Entities::QuestionOption::MARKDOWN_ATTRIBUTES.each do |option_attr|
300
+ validate_markdown(option.send(option_attr), "#{question.key}:#{option.key}.#{option_attr}")
301
+ end
302
+ end
303
+ end
304
+ end
305
+ end
306
+
307
+ def validate_raw_content_items(questionnaire)
308
+ questionnaire.panels.each do |panel|
309
+ panel.items.each do |item|
310
+ next if item.raw_content.blank?
311
+
312
+ validate_html(item.raw_content)
313
+ end
314
+ end
315
+ end
316
+
317
+ def validate_markdown(markdown, key)
318
+ validate_html(MarkdownParser.new(markdown).to_html, key)
319
+ end
320
+
321
+ def validate_html(html, key = nil)
322
+ fragment = Nokogiri::HTML5.fragment(html, max_errors: 3)
323
+ return unless fragment.errors.present?
324
+
325
+ fail "#{key || html} contains invalid html: #{fragment.errors.map(&:to_s).join(', ')}."
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end