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