puppet-courseware-manager 0.5.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.
@@ -0,0 +1,129 @@
1
+ require 'courseware/printer'
2
+ require 'courseware/repository'
3
+ require 'io/console'
4
+ require 'nokogiri'
5
+
6
+ class Courseware::Manager
7
+ require 'courseware/manager/validators'
8
+
9
+ attr_reader :coursename, :prefix, :warnings, :errors
10
+
11
+ def initialize(config, repository=nil, generator=nil, printer=nil)
12
+ @config = config
13
+ @repository = repository || Courseware::Repository.new(config)
14
+ @generator = generator || Courseware::Generator.new(config)
15
+ @warnings = 0
16
+ @errors = 0
17
+
18
+ if File.exists?(@config[:presfile])
19
+ showoff = JSON.parse(File.read(@config[:presfile]))
20
+ @coursename = showoff['name']
21
+ @prefix = showoff['name'].gsub(' ', '_')
22
+ @sections = showoff['sections']
23
+ end
24
+ end
25
+
26
+ def releasenotes
27
+ courselevel!
28
+ master!
29
+ clean!
30
+
31
+ @repository.update
32
+ current = @repository.current(@coursename)
33
+ version = Courseware.increment(current)
34
+ tag = "#{@coursename}-#{current}"
35
+
36
+ notes = @repository.releasenotes(tag, version)
37
+
38
+ # print to screen
39
+ puts notes
40
+
41
+ # and copy if on OS X
42
+ begin
43
+ IO.popen('pbcopy', 'w') { |f| f.puts notes }
44
+ puts
45
+ puts "{{ Copied to clipboard }}"
46
+ rescue
47
+ end
48
+ end
49
+
50
+ def release(type)
51
+ courselevel!
52
+ master!
53
+ clean!
54
+
55
+ @repository.update
56
+ version = Courseware.increment(@repository.current(@coursename), type)
57
+ Courseware.bailout?("Building a release for #{@coursename} version #{version}.")
58
+
59
+ raise "Release notes not updated for #{version}" unless Courseware.grep(version, 'Release-Notes.md')
60
+
61
+ Courseware.dialog('Last Repository Commit', @repository.last_commit)
62
+ Courseware.bailout?('Abort now if the commit message displayed is not what you expected.')
63
+ build_pdfs(version)
64
+ point_of_no_return
65
+ Courseware.bailout?('Please inspect the generated PDF files and abort if corrections must be made.') do
66
+ @repository.discard(@config[:stylesheet])
67
+ end
68
+
69
+ @repository.commit(@config[:stylesheet], "Updating for #{@coursename} release #{version}")
70
+ @repository.tag("#{@prefix}-#{version}", "Releasing #{@coursename} version #{version}")
71
+ puts "Release shipped. Please upload PDF files to printer and break out the bubbly."
72
+ end
73
+
74
+ def wordcount(subject)
75
+ $logger.debug "Counting words for #{subject}"
76
+ opts = print_opts(@repository.current(prefix))
77
+ puts "Words longer than a single character:"
78
+ Courseware::Printer.new(@config, opts) do |printer|
79
+ subject.each do |item|
80
+ printer.generate_html(item)
81
+ doc = Nokogiri::HTML(File.read('static/index.html'))
82
+ count = doc.css('body').text.split.select {|w| w.size > 1 }.count
83
+
84
+ puts " * #{item}: #{count}"
85
+
86
+ FileUtils.rm_rf('static')
87
+ end
88
+ end
89
+ end
90
+
91
+ private
92
+ def toplevel!
93
+ raise 'This task must be run from the repository root.' unless @repository.toplevel?
94
+ end
95
+
96
+ def courselevel!
97
+ raise 'This task must be run from within a course directory' unless @repository.courselevel?
98
+ end
99
+
100
+ def master!
101
+ raise 'You should release from the master branch' unless @repository.on_branch? 'master'
102
+ end
103
+
104
+ def clean!
105
+ raise 'Your working directory has local modifications' unless @repository.clean?
106
+ end
107
+
108
+ def point_of_no_return
109
+ Courseware.dialog('Point of No Return', 'Proceeding past this point will result in permanent repository changes.')
110
+ end
111
+
112
+ def build_pdfs(version)
113
+ @generator.styles(@coursename, version)
114
+ Courseware::Printer.new(@config, print_opts(version)) do |printer|
115
+ printer.print
116
+ end
117
+ system("open #{@config[:output]} >/dev/null 2>&1")
118
+ end
119
+
120
+ def print_opts(version)
121
+ {
122
+ :course => @coursename,
123
+ :prefix => @prefix,
124
+ :version => version,
125
+ :variant => Courseware.choose_variant,
126
+ }
127
+ end
128
+
129
+ end
@@ -0,0 +1,72 @@
1
+ class Courseware::Manager
2
+
3
+ def obsolete
4
+ puts "Obsolete images:"
5
+ Dir.glob('**/_images/*') do |file|
6
+ next if File.symlink? file
7
+ next if File.directory? file
8
+ next if system("grep #{file} *.css */*.md >/dev/null 2>&1")
9
+
10
+ puts " * #{file}"
11
+ @warnings += 1
12
+ end
13
+
14
+ puts "Obsolete slides:"
15
+ Dir.glob('**/*.md') do |file|
16
+ next if File.symlink? file
17
+ next if File.directory? file
18
+ next if file =~ /^_.*$|^[^\/]*$/
19
+ next if @sections.include? file
20
+
21
+ puts " * #{file}"
22
+ @warnings += 1
23
+ end
24
+ end
25
+
26
+ def missing
27
+ sections = @sections.dup
28
+
29
+ # This seems backwards, but we do it this way to get a case sensitive match
30
+ Dir.glob('**/*.md') do |file|
31
+ sections.delete(file)
32
+ end
33
+ return if sections.empty?
34
+
35
+ puts "Missing slides:"
36
+ sections.each do |slide|
37
+ puts " * #{slide}"
38
+ @errors += 1
39
+ end
40
+ end
41
+
42
+ def lint
43
+ puts "Checking Markdown style:"
44
+ style = File.join(@config[:cachedir], 'templates', 'markdown_style.rb')
45
+ style = File.exists?(style) ? style : 'all'
46
+ issues = 0
47
+
48
+ unless system('mdl', '--version')
49
+ puts ' * Markdown linter not found: gem install mdl'
50
+ puts
51
+ @warnings += 1
52
+ return
53
+ end
54
+
55
+ Dir.glob('**/*.md') do |file|
56
+ next if File.symlink? file
57
+ next if File.directory? file
58
+ next if file =~ /^_.*$|^[^\/]*$/
59
+
60
+ issues += 1 unless system('mdl', '-s', style, file)
61
+ end
62
+
63
+ if issues > 0
64
+ puts
65
+ puts 'Rule explanations can be found at:'
66
+ puts ' * https://github.com/mivok/markdownlint/blob/master/docs/RULES.md'
67
+ puts
68
+ @warnings += issues
69
+ end
70
+ end
71
+
72
+ end
@@ -0,0 +1,224 @@
1
+ require 'fileutils'
2
+ class Courseware::Printer
3
+
4
+ def initialize(config, opts)
5
+ @config = config
6
+ @course = opts[:course] or raise 'Course is a required option'
7
+ @prefix = opts[:prefix] or raise 'Prefix is a required option'
8
+ @version = opts[:version] or raise 'Version is a required option'
9
+ @varfile = opts[:variant] or raise 'Variant is a required option'
10
+
11
+ @variant = File.basename(@varfile, '.json') unless @varfile == 'showoff.json'
12
+
13
+ raise unless can_print?
14
+
15
+ @pdfopts = "--pdf-title '#{@course}' --pdf-author '#{@config[:pdf][:author]}' --pdf-subject '#{@config[:pdf][:subject]}'"
16
+ @pdfopts << " --disallow-modify" if @config[:pdf][:protected]
17
+
18
+ if @config[:pdf][:watermark]
19
+ @event_id = Courseware.question('Enter the Event ID:')
20
+ @password = Courseware.question('Enter desired password:', (@event_id[/-?(\w*)$/, 1] rescue nil))
21
+ @watermark_style = File.join(@config[:cachedir], 'templates', 'watermark.css')
22
+ @watermark_pdf = File.join(@config[:cachedir], 'templates', 'watermark.pdf')
23
+ end
24
+
25
+ FileUtils.mkdir(config[:output]) unless File.directory?(config[:output])
26
+
27
+ if block_given?
28
+ yield self
29
+ FileUtils.rm_rf('static')
30
+ end
31
+ end
32
+
33
+ def print
34
+ handouts
35
+ exercises
36
+ solutions
37
+ end
38
+
39
+ def handouts
40
+ $logger.info "Generating handouts pdf for #{@course} #{@version}..."
41
+
42
+ generate_pdf(:print)
43
+ end
44
+
45
+ def exercises
46
+ $logger.info "Generating exercise guide pdf for #{@course} #{@version}..."
47
+
48
+ generate_pdf(:exercises)
49
+ end
50
+
51
+ def solutions
52
+ $logger.info "Generating solutions guide pdf for #{@course} #{@version}..."
53
+
54
+ generate_pdf(:solutions)
55
+ end
56
+
57
+ def guide
58
+ $logger.info "Generating instructor guide pdf for #{@course} #{@version}..."
59
+
60
+ generate_pdf(:guide)
61
+ end
62
+
63
+ # Ensure that the printing toolchain is in place.
64
+ def can_print?
65
+ case @config[:renderer]
66
+ when :wkhtmltopdf
67
+ # Fonts must be installed locally for wkhtmltopdf to find them
68
+ if RUBY_PLATFORM =~ /darwin/
69
+ fontpath = File.expand_path('~/Library/Fonts')
70
+ Dir.glob('_fonts/*').each do |font|
71
+ destination = "#{fontpath}/#{File.basename(font)}"
72
+ FileUtils.cp(font, destination) unless File.exists?(destination)
73
+ end
74
+ end
75
+
76
+ unless system 'wkhtmltopdf --version >/dev/null 2>&1' and system 'pdftk --version >/dev/null 2>&1'
77
+ msg = "We use wkhtmltopdf and pdftk to generate courseware PDF files.\n" +
78
+ "\n" +
79
+ "You should install them using Homebrew or directly from:\n" +
80
+ " * http://wkhtmltopdf.org/downloads.htm\n" +
81
+ " * https://www.pdflabs.com/tools/pdftk-server/#download"
82
+
83
+ unless RUBY_PLATFORM =~ /darwin/
84
+ msg << "\n\n"
85
+ msg << 'Please install all the fonts in the `_fonts` directory to your system.'
86
+ end
87
+
88
+ Courseware.dialog('Printing toolchain unavailable.', msg)
89
+ return false
90
+ end
91
+
92
+ when :prince
93
+ unless system 'prince --version >/dev/null 2>&1'
94
+ msg = "This course is configured to use PrinceXMLto generate PDF files.\n" +
95
+ "\n" +
96
+ "You should install version 9 from:\n" +
97
+ " * http://www.princexml.com/download/\n" +
98
+ "\n" +
99
+ "And the license from:\n" +
100
+ " * https://confluence.puppet.com/display/EDU/Licenses"
101
+
102
+ Courseware.dialog('Printing toolchain unavailable.', msg)
103
+ return false
104
+ end
105
+ end
106
+
107
+ return true
108
+ end
109
+
110
+ # clean out the static dir and build the source html
111
+ def generate_html(subject)
112
+ case subject
113
+ when :handouts, :print
114
+ subject = 'print'
115
+ when :exercises, :solutions
116
+ subject = "supplemental #{subject}"
117
+ when :guide
118
+ subject = 'print guide'
119
+ else
120
+ raise "I don't know how to generate HTML of #{subject}."
121
+ end
122
+
123
+ begin
124
+ # Until showoff static knows about -f, we have to schlup around files
125
+ if @variant
126
+ FileUtils.mv 'showoff.json', 'showoff.json.tmp'
127
+ FileUtils.cp @varfile, 'showoff.json'
128
+ end
129
+
130
+ FileUtils.rm_rf('static')
131
+ system("showoff static #{subject}")
132
+ if File.exists? 'cobrand.png'
133
+ FileUtils.mkdir(File.join('static', 'image'))
134
+ FileUtils.cp('cobrand.png', File.join('static', 'image', 'cobrand.png'))
135
+ end
136
+ ensure
137
+ FileUtils.mv('showoff.json.tmp', 'showoff.json') if File.exist? 'showoff.json.tmp'
138
+ end
139
+ end
140
+
141
+ def generate_pdf(subject)
142
+ # TODO screen printing
143
+ # @pdfopts << " #{SCREENHACK}"
144
+ # @suffix = "-screen"
145
+
146
+ # Build the filename for the output PDF. This is ugly.
147
+ output = File.join(@config[:output], "#{@prefix}-")
148
+ output << "#{@variant}-" if @variant
149
+ output << 'w-' if @config[:pdf][:watermark]
150
+ case subject
151
+ when :print
152
+ output << "#{@version}.pdf"
153
+ when :exercises, :solutions, :guide
154
+ output << "#{@version}-#{subject}.pdf"
155
+ else
156
+ raise "I don't know how to generate a PDF of #{subject}."
157
+ end
158
+
159
+ generate_html(subject)
160
+ FileUtils.mkdir(@config[:output]) unless File.directory?(@config[:output])
161
+
162
+ case @config[:renderer]
163
+ when :wkhtmltopdf
164
+ infofile = File.join(@config[:output], 'info.txt')
165
+ scratchfile = File.join(@config[:output], 'scratch.pdf')
166
+
167
+ command = ['wkhtmltopdf', '-s', 'Letter', '--print-media-type', '--quiet']
168
+ command << ['--footer-left', "#{@course} #{@version}", '--footer-center', '[page]']
169
+ command << ['--footer-right', "©#{Time.now.year} Puppet", '--header-center', '[section]']
170
+ command << ['--title', @course, File.join('static', 'index.html'), output]
171
+ system(*command.flatten)
172
+ raise 'Error generating PDF files' unless $?.success?
173
+
174
+ if `pdftk #{output} dump_data | grep NumberOfPages`.chomp == 'NumberOfPages: 1'
175
+ puts "#{output} is empty; aborting and cleaning up."
176
+ FileUtils.rm(output)
177
+ return
178
+ end
179
+
180
+ # We can't add metadata in the same run. It requires dumping, modifying, and updating
181
+ if @event_id
182
+ command = ['pdftk', output, 'dump_data', 'output', infofile]
183
+ system(*command.flatten)
184
+ raise 'Error retrieving PDF info' unless $?.success?
185
+ info = File.read(infofile)
186
+ File.open(infofile, 'w+') do |file|
187
+ file.write("InfoBegin\n")
188
+ file.write("InfoKey: Subject\n")
189
+ file.write("InfoValue: #{@event_id}\n")
190
+ file.write(info)
191
+ end
192
+ command = ['pdftk', output, 'update_info', infofile, 'output', scratchfile]
193
+ system(*command.flatten)
194
+ raise 'Error updating PDF info' unless $?.success?
195
+ else
196
+ FileUtils.mv(output, scratchfile)
197
+ end
198
+
199
+ command = ['pdftk', scratchfile, 'output', output]
200
+ command << ['owner_pw', @config[:pdf][:password], 'allow', 'printing', 'CopyContents']
201
+ command << ['background', @watermark_pdf] if @config[:pdf][:watermark]
202
+ command << ['user_pw', @password] if @password
203
+ system(*command.flatten)
204
+ raise 'Error watermarking PDF files' unless $?.success?
205
+
206
+ FileUtils.rm(infofile) if File.exists? infofile
207
+ FileUtils.rm(scratchfile) if File.exists? scratchfile
208
+
209
+ when :prince
210
+ command = ['prince', File.join('static', 'index.html')]
211
+ command << ['--pdf-title', @course, '--pdf-author', @config[:pdf][:author], '--disallow-modify']
212
+ command << ['--pdf-subject', @event_id] if @event_id
213
+ command << ['--style', @watermark_style] if @config[:pdf][:watermark]
214
+ command << ['--encrypt', '--user-password', @password] if @password
215
+ command << ['--license-file', @config[:pdf][:license]] if (@config[:pdf][:license] and File.exists? @config[:pdf][:license])
216
+ command << ['-o', output]
217
+ system(*command.flatten)
218
+ raise 'Error generating PDF files' unless $?.success?
219
+ end
220
+
221
+ FileUtils.rm_rf('static')
222
+ end
223
+
224
+ end
@@ -0,0 +1,189 @@
1
+ require 'fileutils'
2
+ require 'rubygems'
3
+ class Courseware::Repository
4
+
5
+ def initialize(config)
6
+ raise 'This is not a courseware repository' unless Courseware::Repository.repository?
7
+ @config = config
8
+
9
+ configure_courseware
10
+ end
11
+
12
+ def tag(tag, message=nil)
13
+ if tag
14
+ system("git tag -a #{tag} -m '#{message}'")
15
+ else
16
+ system("git tag #{tag}")
17
+ end
18
+
19
+ system("git push upstream master")
20
+ system("git push upstream #{tag}")
21
+ system("git push courseware master")
22
+ system("git push courseware #{tag}")
23
+ end
24
+
25
+ def update
26
+ system('git fetch upstream')
27
+ system('git fetch upstream --tags')
28
+ end
29
+
30
+ def create(branch)
31
+ system("git checkout -b #{branch}")
32
+ system("git push upstream #{branch}")
33
+ end
34
+
35
+ def checkout(branch, pull=false)
36
+ system("git checkout #{branch}")
37
+ pull(branch) if pull
38
+ end
39
+
40
+ def pull(branch)
41
+ system('git', 'pull', 'upstream', branch)
42
+ end
43
+
44
+ def merge(branch)
45
+ system('git checkout master')
46
+ system("git merge #{branch}")
47
+ system('git push upstream master')
48
+ end
49
+
50
+ def delete(branch)
51
+ system("git branch -d #{branch}")
52
+ system("git push upstream --delete #{branch}")
53
+ end
54
+
55
+ def commit(*args)
56
+ message = args.pop
57
+ args.each do |file|
58
+ system('git', 'add', file)
59
+ end
60
+ system('git', 'commit', '-m', message)
61
+ end
62
+
63
+ def discard(*args)
64
+ args.each do |file|
65
+ $logger.warn "Discarding changes to #{file}"
66
+ system('git', 'checkout', '--', file)
67
+ end
68
+ end
69
+
70
+ def last_commit
71
+ `git show --name-status --no-color`.chomp.gsub("\t", ' ')
72
+ end
73
+
74
+ def on_branch?(branch='master')
75
+ `git symbolic-ref -q --short HEAD`.chomp == branch
76
+ end
77
+
78
+ def clean?
79
+ system('git diff-index --quiet HEAD')
80
+ end
81
+
82
+ # clean working tree and no untracked files
83
+ def pristine?
84
+ clean? and `git ls-files --other --directory --exclude-standard`.empty?
85
+ end
86
+
87
+ def branch_exists?(branch)
88
+ `git branch --list '#{branch}'` != ''
89
+ end
90
+
91
+ def toplevel?
92
+ Dir.pwd == `git rev-parse --show-toplevel`.chomp
93
+ end
94
+
95
+ def courselevel?
96
+ File.expand_path("#{Dir.pwd}/..") == `git rev-parse --show-toplevel`.chomp
97
+ end
98
+
99
+ def outstanding_commits(prefix, verbose=false)
100
+ last = current(prefix)
101
+ commits = `git log --no-merges --oneline #{prefix}-#{last}..HEAD -- .`.each_line.map {|line| line.chomp }
102
+
103
+ verbose ? commits : commits.count
104
+ end
105
+
106
+ def releasenotes(last, version)
107
+ str = "### #{version}\n"
108
+ str << "{{{Please summarize the release here}}}\n"
109
+ str << "\n"
110
+ str << `git log --no-merges --pretty="format:* (%h) %s [%aN]" #{last}..HEAD -- .`
111
+ str
112
+ end
113
+
114
+ # This gets a list of all tags matching a prefix.
115
+ def tags(prefix, count=1)
116
+ prefix ||= 'v' # even if we pass in nil, we want to default to this
117
+ tags = `git tag -l '#{prefix}*'`.split("\n").sort_by { |tag| version(tag) }.last(count)
118
+ tags.empty? ? ['v0.0.0'] : tags
119
+ end
120
+
121
+ def current(prefix)
122
+ tags(prefix).first.gsub(/^#{prefix}-/, '')
123
+ end
124
+
125
+ private
126
+
127
+ def configure_courseware
128
+ courseware = "#{@config[:github][:public]}/#{@config[:github][:repository]}"
129
+ upstream = "#{@config[:github][:development]}/#{@config[:github][:repository]}"
130
+
131
+ # Check the origin to see which scheme we should use
132
+ origin = `git config --get remote.origin.url`.chomp
133
+ if origin =~ /^(git@|https:\/\/)github.com[:\/].*\/#{@config[:github][:repository]}(?:-.*)?(?:.git)?$/
134
+ case $1
135
+ when 'git@'
136
+ ensure_remote('courseware', "git@github.com:#{courseware}.git")
137
+ ensure_remote('upstream', "git@github.com:#{upstream}.git")
138
+ when 'https://'
139
+ ensure_remote('courseware', "https://github.com/#{courseware}.git")
140
+ ensure_remote('upstream', "https://github.com/#{upstream}.git")
141
+ end
142
+ elsif origin.empty?
143
+ $logger.warn 'Your origin remote is not set properly.'
144
+ $logger.warn 'Generating PDF files and other local operations will work properly, but many repository actions will fail.'
145
+ else
146
+ raise "Your origin (#{origin}) does not appear to be configured correctly."
147
+ end
148
+ end
149
+
150
+ def ensure_remote(remote, url)
151
+ # If we *have* the remote, but it's not correct, then let's repair it.
152
+ if `git config --get remote.#{remote}.url`.chomp != url and $?.success?
153
+ if Courseware.confirm("Your '#{remote}' remote should be #{url}. May I correct this?")
154
+ raise "Error correcting remote." unless system("git remote remove #{remote}")
155
+ else
156
+ raise "Please configure your '#{remote}' remote before proceeding."
157
+ end
158
+ end
159
+
160
+ # add the remote, either for the first time or because we removed it
161
+ unless system("git config --get remote.#{remote}.url > /dev/null")
162
+ # Add the remote if it doesn't already exist
163
+ unless system("git remote add #{remote} #{url}")
164
+ raise "Could not add the '#{remote}' remote."
165
+ end
166
+ end
167
+
168
+ # for pedantry, validate the refspec too
169
+ unless `git config --get remote.#{remote}.fetch`.chomp == "+refs/heads/*:refs/remotes/#{remote}/*"
170
+ if Courseware.confirm("Your '#{remote}' remote has an invalid refspec. May I correct this?")
171
+ unless system("git config remote.#{remote}.fetch '+refs/heads/*:refs/remotes/#{remote}/*'")
172
+ raise "Could not repair the '#{remote}' refspec."
173
+ end
174
+ else
175
+ raise "Please configure your '#{remote}' remote before proceeding."
176
+ end
177
+
178
+ end
179
+ end
180
+
181
+ # Gem::Version is used simply for semantic version comparisons.
182
+ def version(tag)
183
+ Gem::Version.new(tag.gsub(/^.*-?v/, '')) rescue Gem::Version.new(0)
184
+ end
185
+
186
+ def self.repository?
187
+ system('git status >/dev/null 2>&1')
188
+ end
189
+ end