github-to-canvas-quiz 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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,208 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Markdown
|
6
|
+
module Helpers
|
7
|
+
# Loosely based on the Ruby `StringScanner` class. Allows position-based
|
8
|
+
# traversal of a `Nokogiri::XML::NodeSet`:
|
9
|
+
#
|
10
|
+
# html = '<h1>Hello</h1><h2>World</h2><h3>end.</h3>'
|
11
|
+
# scanner = HTML::Scanner.new(html)
|
12
|
+
#
|
13
|
+
# # scan and return nodes before the first H3
|
14
|
+
# nodes = scanner.scan_before('h3')
|
15
|
+
# nodes.first.content # => 'Hello'
|
16
|
+
# nodes.last.content # => 'World'
|
17
|
+
# scanner.cursor # => 2
|
18
|
+
#
|
19
|
+
# # scan the current node if it is a H3
|
20
|
+
# h3 = scanner.scan('h3')
|
21
|
+
# h3.content # => 'end.'
|
22
|
+
# scanner.eof? # => true
|
23
|
+
class NodeScanner
|
24
|
+
attr_reader :node_set
|
25
|
+
attr_accessor :cursor
|
26
|
+
|
27
|
+
#
|
28
|
+
# Create a new instance from a Nokogiri::XML::NodeSet or HTML string
|
29
|
+
#
|
30
|
+
# @param [Nokogiri::XML::NodeSet, String] node_set HTML nodes to be scanned
|
31
|
+
#
|
32
|
+
def initialize(nodes)
|
33
|
+
case nodes
|
34
|
+
when Nokogiri::XML::NodeSet
|
35
|
+
@node_set = nodes
|
36
|
+
when String
|
37
|
+
@node_set = Nokogiri::HTML5.fragment(nodes).children
|
38
|
+
else
|
39
|
+
raise TypeError, "expected a Nokogiri::XML::NodeSet or String, got #{nodes.class.name}"
|
40
|
+
end
|
41
|
+
|
42
|
+
@cursor = 0
|
43
|
+
end
|
44
|
+
|
45
|
+
# Returns whether or not the scanner is at the end of the `node_set`
|
46
|
+
def eof?
|
47
|
+
cursor >= node_set.length
|
48
|
+
end
|
49
|
+
|
50
|
+
# Returns the node at the current cursor position
|
51
|
+
def current
|
52
|
+
node_set[cursor]
|
53
|
+
end
|
54
|
+
|
55
|
+
# Scans the current node to see if it matches the selector. If it does,
|
56
|
+
# update the cursor position to the index **after** the found node and
|
57
|
+
# returns the found node. Otherwise, return `nil`.
|
58
|
+
#
|
59
|
+
# html = '<h1>Hello</h1><h2>World</h2><h3>end.</h3>'
|
60
|
+
# scanner = HTML::Scanner.new(html)
|
61
|
+
#
|
62
|
+
# h1 = scanner.scan('h1')
|
63
|
+
# h1.content # => 'Hello'
|
64
|
+
# scanner.cursor # => 1
|
65
|
+
def scan(selector)
|
66
|
+
scanned_node = current
|
67
|
+
return unless scanned_node.matches?(selector)
|
68
|
+
|
69
|
+
self.cursor += 1
|
70
|
+
scanned_node
|
71
|
+
end
|
72
|
+
|
73
|
+
# Scans until the node matching the selector is reached, and updates the
|
74
|
+
# cursor position to the index **after** the matched node.
|
75
|
+
#
|
76
|
+
# Returns a `NodeSet` of all nodes between the previous cursor position
|
77
|
+
# and the found node.
|
78
|
+
#
|
79
|
+
# html = '<h1>Hello</h1><h2>World</h2><h3>end.</h3>'
|
80
|
+
# scanner = HTML::Scanner.new(html)
|
81
|
+
#
|
82
|
+
# nodes = scanner.scan_until('h2')
|
83
|
+
# nodes.last.content # => 'World'
|
84
|
+
# scanner.cursor # => 2
|
85
|
+
def scan_until(selector)
|
86
|
+
scan_cursor = cursor
|
87
|
+
while scan_cursor < node_set.length
|
88
|
+
if node_set[scan_cursor].matches?(selector)
|
89
|
+
found_nodes = node_set[cursor..scan_cursor]
|
90
|
+
self.cursor = scan_cursor + 1
|
91
|
+
return found_nodes
|
92
|
+
end
|
93
|
+
|
94
|
+
scan_cursor += 1
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Scans until the node matching the selector is reached, and updates the
|
99
|
+
# cursor position to the index **of** the matched node.
|
100
|
+
#
|
101
|
+
# Returns a `NodeSet` of all nodes between the previous cursor position
|
102
|
+
# and **before** the found node.
|
103
|
+
#
|
104
|
+
# html = '<h1>Hello</h1><h2>World</h2><h3>end.</h3>'
|
105
|
+
# scanner = HTML::Scanner.new(html)
|
106
|
+
#
|
107
|
+
# nodes = scanner.scan_before('h2')
|
108
|
+
# nodes.last.content # => 'Hello'
|
109
|
+
# scanner.cursor # => 1
|
110
|
+
def scan_before(selector)
|
111
|
+
scan_cursor = cursor + 1
|
112
|
+
while scan_cursor < node_set.length
|
113
|
+
if node_set[scan_cursor].matches?(selector)
|
114
|
+
found_nodes = node_set[cursor..scan_cursor - 1]
|
115
|
+
self.cursor = scan_cursor
|
116
|
+
return found_nodes
|
117
|
+
end
|
118
|
+
|
119
|
+
scan_cursor += 1
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
# Scans until the end of the node set, and updates the cursor position to the end.
|
124
|
+
# Returns a `NodeSet` of all the nodes between the cursor position and the end.
|
125
|
+
#
|
126
|
+
# html = '<h1>Hello</h1><h2>World</h2><h3>end.</h3>'
|
127
|
+
# scanner = HTML::Scanner.new(html)
|
128
|
+
#
|
129
|
+
# nodes = scanner.scan_before('h2')
|
130
|
+
# nodes.last.content # => 'Hello'
|
131
|
+
# scanner.cursor # => 1
|
132
|
+
# nodes = scanner.scan_rest
|
133
|
+
# nodes.last.content # => 'end.
|
134
|
+
# scanner.cursor # => 3
|
135
|
+
def scan_rest
|
136
|
+
found_nodes = node_set[cursor..node_set.length - 1]
|
137
|
+
self.cursor = node_set.length
|
138
|
+
found_nodes
|
139
|
+
end
|
140
|
+
|
141
|
+
# Does not update cursor. Checks the current node to see if it matches the selector.
|
142
|
+
# If it does, returns the found node. Otherwise, returns `nil`.
|
143
|
+
#
|
144
|
+
# html = '<h1>Hello</h1><h2>World</h2><h3>end.</h3>'
|
145
|
+
# scanner = HTML::Scanner.new(html)
|
146
|
+
#
|
147
|
+
# h1 = scanner.check('h1')
|
148
|
+
# h1.content # => 'Hello'
|
149
|
+
# scanner.cursor # => 0
|
150
|
+
def check(selector)
|
151
|
+
return if eof? || !current.matches?(selector)
|
152
|
+
|
153
|
+
current
|
154
|
+
end
|
155
|
+
|
156
|
+
# Does not update cursor. Checks until the node matching the selector is reached, and
|
157
|
+
# updates the cursor position to the index **after** the matched node.
|
158
|
+
#
|
159
|
+
# Returns a `NodeSet` of all nodes between the previous cursor position
|
160
|
+
# and the found node.
|
161
|
+
#
|
162
|
+
# html = '<h1>Hello</h1><h2>World</h2><h3>end.</h3>'
|
163
|
+
# scanner = HTML::Scanner.new(html)
|
164
|
+
#
|
165
|
+
# nodes = scanner.check_until('h2')
|
166
|
+
# nodes.last.content # => 'World'
|
167
|
+
# scanner.cursor # => 0
|
168
|
+
def check_until(selector)
|
169
|
+
scan_cursor = cursor
|
170
|
+
while scan_cursor < node_set.length
|
171
|
+
if node_set[scan_cursor].matches?(selector)
|
172
|
+
found_nodes = node_set[cursor..scan_cursor]
|
173
|
+
return found_nodes
|
174
|
+
end
|
175
|
+
|
176
|
+
scan_cursor += 1
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
# Does not update cursor. Checks until the node matching the selector is reached,
|
181
|
+
# and updates the cursor position to the index **of** the matched node.
|
182
|
+
#
|
183
|
+
# Returns a `NodeSet` of all nodes between the previous cursor position
|
184
|
+
# and **before** the found node.
|
185
|
+
#
|
186
|
+
# html = '<h1>Hello</h1><h2>World</h2><h3>end.</h3>'
|
187
|
+
# scanner = HTML::Scanner.new(html)
|
188
|
+
#
|
189
|
+
# nodes = scanner.check_before('h2')
|
190
|
+
# nodes.last.content # => 'Hello'
|
191
|
+
# scanner.cursor # => 0
|
192
|
+
def check_before(selector)
|
193
|
+
scan_cursor = cursor + 1
|
194
|
+
while scan_cursor < node_set.length
|
195
|
+
if node_set[scan_cursor].matches?(selector)
|
196
|
+
found_nodes = node_set[cursor..scan_cursor - 1]
|
197
|
+
self.cursor = scan_cursor
|
198
|
+
return found_nodes
|
199
|
+
end
|
200
|
+
|
201
|
+
scan_cursor += 1
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Markdown
|
6
|
+
class Question < Base
|
7
|
+
include Helpers::NodeParser
|
8
|
+
|
9
|
+
# Parse the frontmatter/HTML from the Markdown document and return a Question and its associated Answers
|
10
|
+
def parse
|
11
|
+
Model::Question.new(
|
12
|
+
course_id: frontmatter['course_id'],
|
13
|
+
quiz_id: frontmatter['quiz_id'],
|
14
|
+
id: frontmatter['id'],
|
15
|
+
type: frontmatter['type'],
|
16
|
+
sources: frontmatter['sources'],
|
17
|
+
name: name,
|
18
|
+
description: description,
|
19
|
+
answers: answers,
|
20
|
+
distractors: distractors
|
21
|
+
)
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
# Convert the markdown to HTML for scanning
|
27
|
+
def html
|
28
|
+
@html ||= MarkdownConverter.new(markdown).to_html
|
29
|
+
end
|
30
|
+
|
31
|
+
# Name - contents of first H1
|
32
|
+
def name
|
33
|
+
scanner = Helpers::NodeScanner.new(html)
|
34
|
+
scanner.scan_until('h1').last.content
|
35
|
+
end
|
36
|
+
|
37
|
+
# Description - contents between H1 and first H2
|
38
|
+
def description
|
39
|
+
scanner = Helpers::NodeScanner.new(html)
|
40
|
+
scanner.scan_until('h1')
|
41
|
+
scanner.scan_before('h2').to_html.strip
|
42
|
+
end
|
43
|
+
|
44
|
+
# Each H2 and the content before the next H2 represent an answer
|
45
|
+
def answers
|
46
|
+
scanner = Helpers::NodeScanner.new(html)
|
47
|
+
scanner.scan_before('h2')
|
48
|
+
answers = []
|
49
|
+
while scanner.check('h2')
|
50
|
+
title = scanner.scan('h2').content
|
51
|
+
unless frontmatter['type'] == 'matching_question' && title == 'Incorrect'
|
52
|
+
nodes = scanner.scan_before('h2') || scanner.scan_rest
|
53
|
+
answers << Parser::Markdown::Answer.for(frontmatter['type'], title, nodes)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
answers
|
57
|
+
end
|
58
|
+
|
59
|
+
# Distractors only apply to incorrect answers for the matching_question type
|
60
|
+
def distractors
|
61
|
+
return [] unless frontmatter['type'] == 'matching_question'
|
62
|
+
|
63
|
+
scanner = Helpers::NodeScanner.new(html)
|
64
|
+
scanner.scan_before('h2')
|
65
|
+
while scanner.check('h2')
|
66
|
+
title = scanner.scan('h2').content
|
67
|
+
nodes = scanner.scan_before('h2') || scanner.scan_rest
|
68
|
+
return parse_text_from_nodes(nodes, 'li') if title == 'Incorrect'
|
69
|
+
end
|
70
|
+
|
71
|
+
[]
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
module Parser
|
5
|
+
module Markdown
|
6
|
+
# Parses a markdown file and returns a Quiz
|
7
|
+
class Quiz < Base
|
8
|
+
include Helpers::NodeParser
|
9
|
+
|
10
|
+
def parse
|
11
|
+
Model::Quiz.new(
|
12
|
+
course_id: frontmatter['course_id'],
|
13
|
+
id: frontmatter['id'],
|
14
|
+
repo: frontmatter['repo'],
|
15
|
+
title: title,
|
16
|
+
description: description
|
17
|
+
)
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
# Convert the markdown to HTML for scanning
|
23
|
+
def html
|
24
|
+
@html ||= MarkdownConverter.new(markdown).to_html
|
25
|
+
end
|
26
|
+
|
27
|
+
# Title - contents of first H1
|
28
|
+
def title
|
29
|
+
scanner = Helpers::NodeScanner.new(html)
|
30
|
+
scanner.scan_until('h1').last.content
|
31
|
+
end
|
32
|
+
|
33
|
+
# Description - rest of document after the first H1
|
34
|
+
def description
|
35
|
+
scanner = Helpers::NodeScanner.new(html)
|
36
|
+
scanner.scan_until('h1')
|
37
|
+
scanner.scan_rest.to_html.strip
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
# Interface for working with a local git repo
|
5
|
+
class RepositoryInterface
|
6
|
+
attr_reader :path, :git
|
7
|
+
|
8
|
+
def initialize(path)
|
9
|
+
path = File.expand_path(path)
|
10
|
+
raise DirectoryNotFoundError unless Pathname(path).directory?
|
11
|
+
|
12
|
+
@path = path
|
13
|
+
@git = Git.init(path)
|
14
|
+
end
|
15
|
+
|
16
|
+
def commit_files(*filepaths, message)
|
17
|
+
relative_paths = filepaths.map { |filepath| relative_path(filepath) }
|
18
|
+
return unless new_repo? || relative_paths.any? { |filepath| pending_changes?(filepath) }
|
19
|
+
|
20
|
+
git.add(relative_paths)
|
21
|
+
git.commit("AUTO: #{message}")
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def relative_path(filepath)
|
27
|
+
pathname = Pathname(filepath)
|
28
|
+
pathname.relative? ? pathname.to_s : pathname.relative_path_from(path).to_s
|
29
|
+
end
|
30
|
+
|
31
|
+
def pending_changes?(filepath)
|
32
|
+
git.status.untracked?(filepath) || git.status.changed?(filepath) || git.status.added?(filepath)
|
33
|
+
end
|
34
|
+
|
35
|
+
def new_repo?
|
36
|
+
git.log.size.zero?
|
37
|
+
rescue Git::GitExecuteError => e
|
38
|
+
# ruby-git raises an exception when calling git.log.size on a repo with no commits
|
39
|
+
/does not have any commits yet/.match?(e.message)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Override p blocks to enable line wrapping
|
4
|
+
# https://github.com/xijo/reverse_markdown/blob/master/lib/reverse_markdown/converters/p.rb
|
5
|
+
module ReverseMarkdown
|
6
|
+
module Converters
|
7
|
+
class P < Base
|
8
|
+
def convert(node, state = {})
|
9
|
+
content = treat_children(node, state)
|
10
|
+
"\n\n#{wrap(content).strip}\n\n"
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def wrap(text, width = 80)
|
16
|
+
text.gsub(/(.{1,#{width}})(\s+|\Z)/, "\\1\n")
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Override pre blocks to get language using Rouge selector
|
4
|
+
# https://github.com/xijo/reverse_markdown/blob/master/lib/reverse_markdown/converters/pre.rb
|
5
|
+
module ReverseMarkdown
|
6
|
+
module Converters
|
7
|
+
class Pre < Base
|
8
|
+
def convert(node, state = {})
|
9
|
+
content = treat_children(node, state)
|
10
|
+
if ReverseMarkdown.config.github_flavored
|
11
|
+
"\n```#{language(node)}\n#{content.strip}\n```\n"
|
12
|
+
else
|
13
|
+
"\n\n #{content.lines.to_a.join(' ')}\n\n"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
# Override #treat as proposed in https://github.com/xijo/reverse_markdown/pull/69
|
20
|
+
def treat(node, state)
|
21
|
+
case node.name
|
22
|
+
# preserve newline in <span>, <code> and text blocks
|
23
|
+
when 'text', 'span', 'code'
|
24
|
+
node.text
|
25
|
+
when 'br'
|
26
|
+
"\n"
|
27
|
+
else
|
28
|
+
super
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def language(node)
|
33
|
+
lang = language_from_highlight_class(node)
|
34
|
+
lang || language_from_confluence_class(node)
|
35
|
+
lang || language_from_rouge_class(node)
|
36
|
+
end
|
37
|
+
|
38
|
+
def language_from_highlight_class(node)
|
39
|
+
node.parent['class'].to_s[/highlight-([a-zA-Z0-9]+)/, 1]
|
40
|
+
end
|
41
|
+
|
42
|
+
def language_from_confluence_class(node)
|
43
|
+
node['class'].to_s[/brush:\s?(:?.*);/, 1]
|
44
|
+
end
|
45
|
+
|
46
|
+
def language_from_rouge_class(node)
|
47
|
+
node['class'].to_s[/highlight\s(.*)/, 1]
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,140 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubToCanvasQuiz
|
4
|
+
# Markdown => CanvasAPI
|
5
|
+
module Synchronizer
|
6
|
+
# Synchronize a quiz to Canvas based on the contents of a given directory
|
7
|
+
# Given a directory with valid markdown files:
|
8
|
+
#
|
9
|
+
# phase-1-quiz-arrays
|
10
|
+
# | questions
|
11
|
+
# | |-- 00.md
|
12
|
+
# | |-- 01.md
|
13
|
+
# |-- README.md
|
14
|
+
#
|
15
|
+
# Useage:
|
16
|
+
#
|
17
|
+
# client = CanvasAPI::Client.new(host: host, api_key: api_key)
|
18
|
+
# Synchronizer::Quiz.new(client, 'phase-1-quiz-arrays')
|
19
|
+
class Quiz
|
20
|
+
attr_reader :client, :path, :repo, :quiz, :questions_with_path
|
21
|
+
|
22
|
+
def initialize(client, path)
|
23
|
+
path = File.expand_path(path)
|
24
|
+
raise DirectoryNotFoundError unless Pathname(path).directory?
|
25
|
+
|
26
|
+
@client = client
|
27
|
+
@path = path
|
28
|
+
@repo = RepositoryInterface.new(path)
|
29
|
+
@quiz = parse_quiz
|
30
|
+
@questions_with_path = parse_questions_with_path
|
31
|
+
end
|
32
|
+
|
33
|
+
def sync
|
34
|
+
backup_canvas_to_json!
|
35
|
+
sync_quiz!
|
36
|
+
sync_questions!
|
37
|
+
backup_canvas_to_json!
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
# Get quiz data from the Markdown file and return a Model::Quiz
|
43
|
+
def parse_quiz
|
44
|
+
raise GithubToCanvasQuiz::FileNotFoundError unless Pathname(quiz_path).exist?
|
45
|
+
|
46
|
+
Parser::Markdown::Quiz.new(quiz_path).parse
|
47
|
+
end
|
48
|
+
|
49
|
+
# Get question data from Markdown files and return a Model::Question along with its path
|
50
|
+
def parse_questions_with_path
|
51
|
+
Dir["#{path}/questions/*.md"].map do |question_path|
|
52
|
+
question = Parser::Markdown::Question.new(File.read(question_path)).parse
|
53
|
+
question.quiz_id = quiz.id
|
54
|
+
question.course_id = quiz.course_id
|
55
|
+
[question, question_path] # need that question path... gotta be a better way!
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# create or update quiz on Canvas
|
60
|
+
def sync_quiz!
|
61
|
+
if quiz.id
|
62
|
+
update_quiz!
|
63
|
+
else
|
64
|
+
create_quiz_and_update_frontmatter!
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def update_quiz!
|
69
|
+
client.update_quiz(quiz.course_id, quiz.id, { 'quiz' => quiz.to_h })
|
70
|
+
end
|
71
|
+
|
72
|
+
def create_quiz_and_update_frontmatter!
|
73
|
+
canvas_quiz = client.create_quiz(quiz.course_id, { 'quiz' => quiz.to_h })
|
74
|
+
quiz.id = canvas_quiz['id']
|
75
|
+
update_frontmatter(quiz_path, quiz)
|
76
|
+
end
|
77
|
+
|
78
|
+
def quiz_path
|
79
|
+
File.join(path, 'README.md')
|
80
|
+
end
|
81
|
+
|
82
|
+
# Create or update questions on Canvas
|
83
|
+
def sync_questions!
|
84
|
+
questions_with_path.each do |question_with_path|
|
85
|
+
question, path = question_with_path
|
86
|
+
if question.id
|
87
|
+
update_question!(question)
|
88
|
+
else
|
89
|
+
create_question_and_update_frontmatter!(question, path)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
remove_deleted_questions!
|
93
|
+
end
|
94
|
+
|
95
|
+
def create_question_and_update_frontmatter!(question, path)
|
96
|
+
canvas_question = client.create_question(question.course_id, question.quiz_id, { 'question' => question.to_h })
|
97
|
+
question.id = canvas_question['id']
|
98
|
+
update_frontmatter(path, question)
|
99
|
+
end
|
100
|
+
|
101
|
+
def update_question!(question)
|
102
|
+
client.update_question(question.course_id, question.quiz_id, question.id, { 'question' => question.to_h })
|
103
|
+
end
|
104
|
+
|
105
|
+
def remove_deleted_questions!
|
106
|
+
ids = questions_with_path.map { |question_with_path| question_with_path.first.id }
|
107
|
+
client.list_questions(quiz.course_id, quiz.id).each do |canvas_question|
|
108
|
+
id = canvas_question['id']
|
109
|
+
unless ids.include?(id)
|
110
|
+
# delete questions that are no longer present in the repo
|
111
|
+
client.delete_question(quiz.course_id, quiz.id, id)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def backup_canvas_to_json!
|
117
|
+
quiz_data = client.get_single_quiz(quiz.course_id, quiz.id)
|
118
|
+
questions_data = client.list_questions(quiz.course_id, quiz.id)
|
119
|
+
|
120
|
+
json_data = JSON.pretty_generate({ quiz: quiz_data, questions: questions_data })
|
121
|
+
File.write(json_path, json_data)
|
122
|
+
repo.commit_files(json_path, 'Created Canvas snapshot')
|
123
|
+
end
|
124
|
+
|
125
|
+
def json_path
|
126
|
+
File.join(path, '.canvas-snapshot.json')
|
127
|
+
end
|
128
|
+
|
129
|
+
def update_frontmatter(filepath, markdownable)
|
130
|
+
parsed = FrontMatterParser::Parser.parse_file(filepath)
|
131
|
+
new_markdown = MarkdownBuilder.build do |md|
|
132
|
+
md.frontmatter(markdownable.frontmatter_hash)
|
133
|
+
md.md(parsed.content)
|
134
|
+
end
|
135
|
+
File.write(filepath, new_markdown)
|
136
|
+
repo.commit_files(filepath, 'Updated frontmatter')
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'front_matter_parser'
|
4
|
+
require 'git'
|
5
|
+
require 'htmlentities'
|
6
|
+
require 'json'
|
7
|
+
require 'nokogiri'
|
8
|
+
require 'redcarpet'
|
9
|
+
require 'rest-client'
|
10
|
+
require 'reverse_markdown'
|
11
|
+
require 'rouge'
|
12
|
+
require 'rouge/plugins/redcarpet'
|
13
|
+
require 'thor'
|
14
|
+
require 'yaml'
|
15
|
+
|
16
|
+
require 'github_to_canvas_quiz/builder/quiz'
|
17
|
+
|
18
|
+
require 'github_to_canvas_quiz/canvas_api/endpoints/quizzes'
|
19
|
+
require 'github_to_canvas_quiz/canvas_api/endpoints/quiz_questions'
|
20
|
+
require 'github_to_canvas_quiz/canvas_api/endpoints'
|
21
|
+
require 'github_to_canvas_quiz/canvas_api/client'
|
22
|
+
|
23
|
+
require 'github_to_canvas_quiz/model/answer/fill_in_multiple_blanks'
|
24
|
+
require 'github_to_canvas_quiz/model/answer/matching'
|
25
|
+
require 'github_to_canvas_quiz/model/answer/multiple_answers'
|
26
|
+
require 'github_to_canvas_quiz/model/answer/multiple_choice'
|
27
|
+
require 'github_to_canvas_quiz/model/answer/multiple_dropdowns'
|
28
|
+
require 'github_to_canvas_quiz/model/answer/short_answer'
|
29
|
+
require 'github_to_canvas_quiz/model/answer/true_false'
|
30
|
+
require 'github_to_canvas_quiz/model/quiz'
|
31
|
+
require 'github_to_canvas_quiz/model/question'
|
32
|
+
|
33
|
+
require 'github_to_canvas_quiz/parser/canvas/helpers'
|
34
|
+
require 'github_to_canvas_quiz/parser/canvas/answer'
|
35
|
+
require 'github_to_canvas_quiz/parser/canvas/question'
|
36
|
+
require 'github_to_canvas_quiz/parser/canvas/quiz'
|
37
|
+
require 'github_to_canvas_quiz/parser/markdown/helpers/node_parser'
|
38
|
+
require 'github_to_canvas_quiz/parser/markdown/helpers/node_scanner'
|
39
|
+
require 'github_to_canvas_quiz/parser/markdown/answer'
|
40
|
+
require 'github_to_canvas_quiz/parser/markdown/base'
|
41
|
+
require 'github_to_canvas_quiz/parser/markdown/question'
|
42
|
+
require 'github_to_canvas_quiz/parser/markdown/quiz'
|
43
|
+
|
44
|
+
require 'github_to_canvas_quiz/reverse_markdown/register'
|
45
|
+
|
46
|
+
require 'github_to_canvas_quiz/synchronizer/quiz'
|
47
|
+
|
48
|
+
require 'github_to_canvas_quiz/cli'
|
49
|
+
require 'github_to_canvas_quiz/markdown_builder'
|
50
|
+
require 'github_to_canvas_quiz/markdown_converter'
|
51
|
+
require 'github_to_canvas_quiz/repository_interface'
|
52
|
+
require 'github_to_canvas_quiz/version'
|
53
|
+
|
54
|
+
module GithubToCanvasQuiz
|
55
|
+
class UnknownQuestionType < StandardError; end
|
56
|
+
|
57
|
+
class FileNotFoundError < StandardError; end
|
58
|
+
|
59
|
+
class DirectoryNotFoundError < StandardError; end
|
60
|
+
|
61
|
+
class Error < StandardError; end
|
62
|
+
end
|