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,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
|