puppet-courseware-manager 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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