github-to-canvas-quiz 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/.rspec +3 -0
- data/.rubocop.yml +48 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +21 -0
- data/NOTES.md +53 -0
- data/README.md +89 -0
- data/Rakefile +8 -0
- data/bin/console +29 -0
- data/bin/github-to-canvas-quiz +15 -0
- data/bin/setup +8 -0
- data/github-to-canvas-quiz.gemspec +50 -0
- data/lib/github_to_canvas_quiz/builder/quiz.rb +110 -0
- data/lib/github_to_canvas_quiz/canvas_api/client.rb +80 -0
- data/lib/github_to_canvas_quiz/canvas_api/endpoints/quiz_questions.rb +29 -0
- data/lib/github_to_canvas_quiz/canvas_api/endpoints/quizzes.rb +25 -0
- data/lib/github_to_canvas_quiz/canvas_api/endpoints.rb +10 -0
- data/lib/github_to_canvas_quiz/cli.rb +31 -0
- data/lib/github_to_canvas_quiz/markdown_builder.rb +90 -0
- data/lib/github_to_canvas_quiz/markdown_converter.rb +44 -0
- data/lib/github_to_canvas_quiz/model/answer/fill_in_multiple_blanks.rb +34 -0
- data/lib/github_to_canvas_quiz/model/answer/matching.rb +35 -0
- data/lib/github_to_canvas_quiz/model/answer/multiple_answers.rb +33 -0
- data/lib/github_to_canvas_quiz/model/answer/multiple_choice.rb +33 -0
- data/lib/github_to_canvas_quiz/model/answer/multiple_dropdowns.rb +34 -0
- data/lib/github_to_canvas_quiz/model/answer/short_answer.rb +33 -0
- data/lib/github_to_canvas_quiz/model/answer/true_false.rb +33 -0
- data/lib/github_to_canvas_quiz/model/question.rb +61 -0
- data/lib/github_to_canvas_quiz/model/quiz.rb +62 -0
- data/lib/github_to_canvas_quiz/parser/canvas/answer/base.rb +19 -0
- data/lib/github_to_canvas_quiz/parser/canvas/answer/fill_in_multiple_blanks.rb +30 -0
- data/lib/github_to_canvas_quiz/parser/canvas/answer/matching.rb +31 -0
- data/lib/github_to_canvas_quiz/parser/canvas/answer/multiple_answers.rb +33 -0
- data/lib/github_to_canvas_quiz/parser/canvas/answer/multiple_choice.rb +33 -0
- data/lib/github_to_canvas_quiz/parser/canvas/answer/multiple_dropdowns.rb +30 -0
- data/lib/github_to_canvas_quiz/parser/canvas/answer/short_answer.rb +29 -0
- data/lib/github_to_canvas_quiz/parser/canvas/answer/true_false.rb +29 -0
- data/lib/github_to_canvas_quiz/parser/canvas/answer.rb +34 -0
- data/lib/github_to_canvas_quiz/parser/canvas/helpers.rb +25 -0
- data/lib/github_to_canvas_quiz/parser/canvas/question.rb +55 -0
- data/lib/github_to_canvas_quiz/parser/canvas/quiz.rb +44 -0
- data/lib/github_to_canvas_quiz/parser/markdown/answer/base.rb +22 -0
- data/lib/github_to_canvas_quiz/parser/markdown/answer/fill_in_multiple_blanks.rb +21 -0
- data/lib/github_to_canvas_quiz/parser/markdown/answer/matching.rb +23 -0
- data/lib/github_to_canvas_quiz/parser/markdown/answer/multiple_answers.rb +21 -0
- data/lib/github_to_canvas_quiz/parser/markdown/answer/multiple_choice.rb +19 -0
- data/lib/github_to_canvas_quiz/parser/markdown/answer/multiple_dropdowns.rb +21 -0
- data/lib/github_to_canvas_quiz/parser/markdown/answer/short_answer.rb +21 -0
- data/lib/github_to_canvas_quiz/parser/markdown/answer/true_false.rb +21 -0
- data/lib/github_to_canvas_quiz/parser/markdown/answer.rb +34 -0
- data/lib/github_to_canvas_quiz/parser/markdown/base.rb +22 -0
- data/lib/github_to_canvas_quiz/parser/markdown/helpers/node_parser.rb +21 -0
- data/lib/github_to_canvas_quiz/parser/markdown/helpers/node_scanner.rb +208 -0
- data/lib/github_to_canvas_quiz/parser/markdown/question.rb +76 -0
- data/lib/github_to_canvas_quiz/parser/markdown/quiz.rb +42 -0
- data/lib/github_to_canvas_quiz/repository_interface.rb +42 -0
- data/lib/github_to_canvas_quiz/reverse_markdown/converters/p.rb +20 -0
- data/lib/github_to_canvas_quiz/reverse_markdown/converters/pre.rb +51 -0
- data/lib/github_to_canvas_quiz/reverse_markdown/register.rb +9 -0
- data/lib/github_to_canvas_quiz/synchronizer/quiz.rb +140 -0
- data/lib/github_to_canvas_quiz/version.rb +5 -0
- data/lib/github_to_canvas_quiz.rb +62 -0
- metadata +348 -0
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Canvas
|
6
|
+
module Answer
|
7
|
+
class MultipleDropdowns < Base
|
8
|
+
def parse
|
9
|
+
Model::Answer::MultipleDropdowns.new(
|
10
|
+
title: title,
|
11
|
+
text: data['text'],
|
12
|
+
comments: comments,
|
13
|
+
blank_id: data['blank_id']
|
14
|
+
)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def title
|
20
|
+
data.fetch('weight', 0).positive? ? 'Correct' : 'Incorrect'
|
21
|
+
end
|
22
|
+
|
23
|
+
def comments
|
24
|
+
choose_text(data.fetch('comments', ''), data.fetch('comments_html', ''))
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Canvas
|
6
|
+
module Answer
|
7
|
+
class ShortAnswer < Base
|
8
|
+
def parse
|
9
|
+
Model::Answer::ShortAnswer.new(
|
10
|
+
title: title,
|
11
|
+
text: data['text'],
|
12
|
+
comments: comments
|
13
|
+
)
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def title
|
19
|
+
data.fetch('weight', 0).positive? ? 'Correct' : 'Incorrect'
|
20
|
+
end
|
21
|
+
|
22
|
+
def comments
|
23
|
+
choose_text(data.fetch('comments', ''), data.fetch('comments_html', ''))
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Canvas
|
6
|
+
module Answer
|
7
|
+
class TrueFalse < Base
|
8
|
+
def parse
|
9
|
+
Model::Answer::TrueFalse.new(
|
10
|
+
title: title,
|
11
|
+
text: data['text'],
|
12
|
+
comments: comments
|
13
|
+
)
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def title
|
19
|
+
data.fetch('weight', 0).positive? ? 'Correct' : 'Incorrect'
|
20
|
+
end
|
21
|
+
|
22
|
+
def comments
|
23
|
+
choose_text(data.fetch('comments', ''), data.fetch('comments_html', ''))
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'answer/base'
|
4
|
+
require_relative 'answer/fill_in_multiple_blanks'
|
5
|
+
require_relative 'answer/matching'
|
6
|
+
require_relative 'answer/multiple_answers'
|
7
|
+
require_relative 'answer/multiple_choice'
|
8
|
+
require_relative 'answer/multiple_dropdowns'
|
9
|
+
require_relative 'answer/short_answer'
|
10
|
+
require_relative 'answer/true_false'
|
11
|
+
|
12
|
+
module GithubToCanvasQuiz
|
13
|
+
module Parser
|
14
|
+
module Canvas
|
15
|
+
module Answer
|
16
|
+
CLASSES = {
|
17
|
+
'fill_in_multiple_blanks_question' => FillInMultipleBlanks,
|
18
|
+
'matching_question' => Matching,
|
19
|
+
'multiple_answers_question' => MultipleAnswers,
|
20
|
+
'multiple_choice_question' => MultipleChoice,
|
21
|
+
'multiple_dropdowns_question' => MultipleDropdowns,
|
22
|
+
'short_answer_question' => ShortAnswer,
|
23
|
+
'true_false_question' => TrueFalse
|
24
|
+
}.freeze
|
25
|
+
|
26
|
+
def self.for(type, data)
|
27
|
+
raise UnknownQuestionType, type unless CLASSES.key?(type)
|
28
|
+
|
29
|
+
CLASSES[type].new(data).parse
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Canvas
|
6
|
+
module Helpers
|
7
|
+
def choose_text(text, html)
|
8
|
+
html.empty? ? text : clean_html(html)
|
9
|
+
end
|
10
|
+
|
11
|
+
def clean_html(html)
|
12
|
+
html = remove_canvas_cruft(html)
|
13
|
+
HTMLEntities.new.decode(html)
|
14
|
+
end
|
15
|
+
|
16
|
+
def remove_canvas_cruft(html)
|
17
|
+
nodes = Nokogiri::HTML5.fragment(html)
|
18
|
+
nodes.css('.screenreader-only').remove
|
19
|
+
cleaned_html = nodes.to_html
|
20
|
+
cleaned_html.gsub(/\(?Links to an external site.\)?/, '')
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Canvas
|
6
|
+
class Question
|
7
|
+
include Helpers
|
8
|
+
|
9
|
+
attr_reader :data
|
10
|
+
|
11
|
+
def initialize(data)
|
12
|
+
@data = data
|
13
|
+
end
|
14
|
+
|
15
|
+
def parse
|
16
|
+
Model::Question.new(
|
17
|
+
course_id: data['course_id'],
|
18
|
+
quiz_id: data['quiz_id'],
|
19
|
+
id: data['id'],
|
20
|
+
type: data['question_type'],
|
21
|
+
name: data['question_name'],
|
22
|
+
description: clean_html(data['question_text']),
|
23
|
+
sources: sources,
|
24
|
+
answers: answers,
|
25
|
+
distractors: distractors
|
26
|
+
)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def sources
|
32
|
+
html = data['neutral_comments_html']
|
33
|
+
return unless html
|
34
|
+
|
35
|
+
Nokogiri::HTML5.fragment(html).css('a').map do |node|
|
36
|
+
{
|
37
|
+
'name' => clean_html(node.content),
|
38
|
+
'url' => node['href']
|
39
|
+
}
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def answers
|
44
|
+
data['answers'].map do |answer|
|
45
|
+
Answer.for(data['question_type'], answer)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def distractors
|
50
|
+
(data['matching_answer_incorrect_matches'] || '').split("\n")
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Canvas
|
6
|
+
# Parses a quiz from the Canvas API and returns a Quiz
|
7
|
+
class Quiz
|
8
|
+
include Helpers
|
9
|
+
|
10
|
+
attr_reader :data
|
11
|
+
|
12
|
+
def initialize(data)
|
13
|
+
@data = data
|
14
|
+
end
|
15
|
+
|
16
|
+
def parse
|
17
|
+
Model::Quiz.new(
|
18
|
+
course_id: data['course_id'],
|
19
|
+
id: data['id'],
|
20
|
+
repo: repo,
|
21
|
+
title: data['title'],
|
22
|
+
description: description
|
23
|
+
)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
# Remove header elements
|
29
|
+
def description
|
30
|
+
nodes = Nokogiri::HTML5.fragment(data['description'])
|
31
|
+
nodes.css('#git-data-element').remove
|
32
|
+
nodes.css('.fis-header').remove
|
33
|
+
nodes.to_html.strip
|
34
|
+
end
|
35
|
+
|
36
|
+
# Parse the repo from the #git-data-element
|
37
|
+
def repo
|
38
|
+
data_element = Nokogiri::HTML5.fragment(data['description']).css('#git-data-element').first
|
39
|
+
data_element ? data_element['data-repo'] : nil
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Markdown
|
6
|
+
module Answer
|
7
|
+
class Base
|
8
|
+
include Helpers::NodeParser
|
9
|
+
|
10
|
+
attr_reader :answer_nodes, :comment, :title
|
11
|
+
|
12
|
+
def initialize(title, nodes)
|
13
|
+
@title = title
|
14
|
+
scanner = Helpers::NodeScanner.new(nodes)
|
15
|
+
@answer_nodes = scanner.scan_before('blockquote') || scanner.scan_rest
|
16
|
+
@comment = scanner.eof? ? '' : scanner.scan_rest.first.inner_html.strip
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Markdown
|
6
|
+
module Answer
|
7
|
+
class FillInMultipleBlanks < Base
|
8
|
+
def parse
|
9
|
+
text, blank_id = parse_text_from_nodes(answer_nodes, 'li')
|
10
|
+
Model::Answer::FillInMultipleBlanks.new(
|
11
|
+
title: title,
|
12
|
+
text: text,
|
13
|
+
comments: comment,
|
14
|
+
blank_id: blank_id
|
15
|
+
)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Markdown
|
6
|
+
module Answer
|
7
|
+
class Matching < Base
|
8
|
+
def parse
|
9
|
+
left, right = parse_text_from_nodes(answer_nodes, 'li')
|
10
|
+
|
11
|
+
Model::Answer::Matching.new(
|
12
|
+
title: title,
|
13
|
+
left: left,
|
14
|
+
right: right,
|
15
|
+
text: left,
|
16
|
+
comments: comment
|
17
|
+
)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Markdown
|
6
|
+
module Answer
|
7
|
+
class MultipleAnswers < Base
|
8
|
+
def parse
|
9
|
+
text = answer_nodes.to_html.strip
|
10
|
+
|
11
|
+
Model::Answer::MultipleAnswers.new(
|
12
|
+
title: title,
|
13
|
+
text: text,
|
14
|
+
comments: comment
|
15
|
+
)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Markdown
|
6
|
+
module Answer
|
7
|
+
class MultipleChoice < Base
|
8
|
+
def parse
|
9
|
+
Model::Answer::MultipleChoice.new(
|
10
|
+
title: title,
|
11
|
+
text: answer_nodes.to_html.strip,
|
12
|
+
comments: comment
|
13
|
+
)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Markdown
|
6
|
+
module Answer
|
7
|
+
class MultipleDropdowns < Base
|
8
|
+
def parse
|
9
|
+
text, blank_id = parse_text_from_nodes(answer_nodes, 'li')
|
10
|
+
Model::Answer::MultipleDropdowns.new(
|
11
|
+
title: title,
|
12
|
+
text: text,
|
13
|
+
comments: comment,
|
14
|
+
blank_id: blank_id
|
15
|
+
)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Markdown
|
6
|
+
module Answer
|
7
|
+
class ShortAnswer < Base
|
8
|
+
def parse
|
9
|
+
text = parse_text_from_nodes(answer_nodes, 'p').first
|
10
|
+
|
11
|
+
Model::Answer::ShortAnswer.new(
|
12
|
+
title: title,
|
13
|
+
text: text,
|
14
|
+
comments: comment
|
15
|
+
)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Markdown
|
6
|
+
module Answer
|
7
|
+
class TrueFalse < Base
|
8
|
+
def parse
|
9
|
+
text = parse_text_from_nodes(answer_nodes, 'p').first
|
10
|
+
|
11
|
+
Model::Answer::TrueFalse.new(
|
12
|
+
title: title,
|
13
|
+
text: text,
|
14
|
+
comments: comment
|
15
|
+
)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'answer/base'
|
4
|
+
require_relative 'answer/fill_in_multiple_blanks'
|
5
|
+
require_relative 'answer/matching'
|
6
|
+
require_relative 'answer/multiple_answers'
|
7
|
+
require_relative 'answer/multiple_choice'
|
8
|
+
require_relative 'answer/multiple_dropdowns'
|
9
|
+
require_relative 'answer/short_answer'
|
10
|
+
require_relative 'answer/true_false'
|
11
|
+
|
12
|
+
module GithubToCanvasQuiz
|
13
|
+
module Parser
|
14
|
+
module Markdown
|
15
|
+
module Answer
|
16
|
+
CLASSES = {
|
17
|
+
'fill_in_multiple_blanks_question' => FillInMultipleBlanks,
|
18
|
+
'matching_question' => Matching,
|
19
|
+
'multiple_answers_question' => MultipleAnswers,
|
20
|
+
'multiple_choice_question' => MultipleChoice,
|
21
|
+
'multiple_dropdowns_question' => MultipleDropdowns,
|
22
|
+
'short_answer_question' => ShortAnswer,
|
23
|
+
'true_false_question' => TrueFalse
|
24
|
+
}.freeze
|
25
|
+
|
26
|
+
def self.for(type, title, nodes)
|
27
|
+
raise UnknownQuestionType, type unless CLASSES.key?(type)
|
28
|
+
|
29
|
+
CLASSES[type].new(title, nodes).parse
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Markdown
|
6
|
+
class Base
|
7
|
+
attr_reader :frontmatter, :markdown
|
8
|
+
|
9
|
+
def initialize(markdown)
|
10
|
+
# Separate the frontmatter and the rest of the markdown content
|
11
|
+
parsed = if Pathname(markdown).exist?
|
12
|
+
FrontMatterParser::Parser.parse_file(markdown)
|
13
|
+
else
|
14
|
+
FrontMatterParser::Parser.new(:md).call(markdown)
|
15
|
+
end
|
16
|
+
@frontmatter = parsed.front_matter
|
17
|
+
@markdown = parsed.content
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Markdown
|
6
|
+
module Helpers
|
7
|
+
module NodeParser
|
8
|
+
def parse_text_from_nodes(nodes, selector)
|
9
|
+
nodes.css(selector).map do |node|
|
10
|
+
parse_text_from_node(node)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def parse_text_from_node(node)
|
15
|
+
CGI.unescapeHTML(node.content).strip
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|