github-to-canvas-quiz 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +48 -0
  4. data/CODE_OF_CONDUCT.md +84 -0
  5. data/Gemfile +5 -0
  6. data/LICENSE.txt +21 -0
  7. data/NOTES.md +53 -0
  8. data/README.md +89 -0
  9. data/Rakefile +8 -0
  10. data/bin/console +29 -0
  11. data/bin/github-to-canvas-quiz +15 -0
  12. data/bin/setup +8 -0
  13. data/github-to-canvas-quiz.gemspec +50 -0
  14. data/lib/github_to_canvas_quiz/builder/quiz.rb +110 -0
  15. data/lib/github_to_canvas_quiz/canvas_api/client.rb +80 -0
  16. data/lib/github_to_canvas_quiz/canvas_api/endpoints/quiz_questions.rb +29 -0
  17. data/lib/github_to_canvas_quiz/canvas_api/endpoints/quizzes.rb +25 -0
  18. data/lib/github_to_canvas_quiz/canvas_api/endpoints.rb +10 -0
  19. data/lib/github_to_canvas_quiz/cli.rb +31 -0
  20. data/lib/github_to_canvas_quiz/markdown_builder.rb +90 -0
  21. data/lib/github_to_canvas_quiz/markdown_converter.rb +44 -0
  22. data/lib/github_to_canvas_quiz/model/answer/fill_in_multiple_blanks.rb +34 -0
  23. data/lib/github_to_canvas_quiz/model/answer/matching.rb +35 -0
  24. data/lib/github_to_canvas_quiz/model/answer/multiple_answers.rb +33 -0
  25. data/lib/github_to_canvas_quiz/model/answer/multiple_choice.rb +33 -0
  26. data/lib/github_to_canvas_quiz/model/answer/multiple_dropdowns.rb +34 -0
  27. data/lib/github_to_canvas_quiz/model/answer/short_answer.rb +33 -0
  28. data/lib/github_to_canvas_quiz/model/answer/true_false.rb +33 -0
  29. data/lib/github_to_canvas_quiz/model/question.rb +61 -0
  30. data/lib/github_to_canvas_quiz/model/quiz.rb +62 -0
  31. data/lib/github_to_canvas_quiz/parser/canvas/answer/base.rb +19 -0
  32. data/lib/github_to_canvas_quiz/parser/canvas/answer/fill_in_multiple_blanks.rb +30 -0
  33. data/lib/github_to_canvas_quiz/parser/canvas/answer/matching.rb +31 -0
  34. data/lib/github_to_canvas_quiz/parser/canvas/answer/multiple_answers.rb +33 -0
  35. data/lib/github_to_canvas_quiz/parser/canvas/answer/multiple_choice.rb +33 -0
  36. data/lib/github_to_canvas_quiz/parser/canvas/answer/multiple_dropdowns.rb +30 -0
  37. data/lib/github_to_canvas_quiz/parser/canvas/answer/short_answer.rb +29 -0
  38. data/lib/github_to_canvas_quiz/parser/canvas/answer/true_false.rb +29 -0
  39. data/lib/github_to_canvas_quiz/parser/canvas/answer.rb +34 -0
  40. data/lib/github_to_canvas_quiz/parser/canvas/helpers.rb +25 -0
  41. data/lib/github_to_canvas_quiz/parser/canvas/question.rb +55 -0
  42. data/lib/github_to_canvas_quiz/parser/canvas/quiz.rb +44 -0
  43. data/lib/github_to_canvas_quiz/parser/markdown/answer/base.rb +22 -0
  44. data/lib/github_to_canvas_quiz/parser/markdown/answer/fill_in_multiple_blanks.rb +21 -0
  45. data/lib/github_to_canvas_quiz/parser/markdown/answer/matching.rb +23 -0
  46. data/lib/github_to_canvas_quiz/parser/markdown/answer/multiple_answers.rb +21 -0
  47. data/lib/github_to_canvas_quiz/parser/markdown/answer/multiple_choice.rb +19 -0
  48. data/lib/github_to_canvas_quiz/parser/markdown/answer/multiple_dropdowns.rb +21 -0
  49. data/lib/github_to_canvas_quiz/parser/markdown/answer/short_answer.rb +21 -0
  50. data/lib/github_to_canvas_quiz/parser/markdown/answer/true_false.rb +21 -0
  51. data/lib/github_to_canvas_quiz/parser/markdown/answer.rb +34 -0
  52. data/lib/github_to_canvas_quiz/parser/markdown/base.rb +22 -0
  53. data/lib/github_to_canvas_quiz/parser/markdown/helpers/node_parser.rb +21 -0
  54. data/lib/github_to_canvas_quiz/parser/markdown/helpers/node_scanner.rb +208 -0
  55. data/lib/github_to_canvas_quiz/parser/markdown/question.rb +76 -0
  56. data/lib/github_to_canvas_quiz/parser/markdown/quiz.rb +42 -0
  57. data/lib/github_to_canvas_quiz/repository_interface.rb +42 -0
  58. data/lib/github_to_canvas_quiz/reverse_markdown/converters/p.rb +20 -0
  59. data/lib/github_to_canvas_quiz/reverse_markdown/converters/pre.rb +51 -0
  60. data/lib/github_to_canvas_quiz/reverse_markdown/register.rb +9 -0
  61. data/lib/github_to_canvas_quiz/synchronizer/quiz.rb +140 -0
  62. data/lib/github_to_canvas_quiz/version.rb +5 -0
  63. data/lib/github_to_canvas_quiz.rb +62 -0
  64. 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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'converters/p'
4
+ require_relative 'converters/pre'
5
+
6
+ module ReverseMarkdown
7
+ Converters.register :p, Converters::P.new
8
+ Converters.register :pre, Converters::Pre.new
9
+ 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GithubToCanvasQuiz
4
+ VERSION = '0.1.0'
5
+ 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