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.
- checksums.yaml +7 -0
- data/CHANGELOG.txt +60 -0
- data/LICENSE +202 -0
- data/README.txt +100 -0
- data/bin/courseware +146 -0
- data/lib/courseware.rb +165 -0
- data/lib/courseware/cache.rb +35 -0
- data/lib/courseware/composer.rb +135 -0
- data/lib/courseware/dummy.rb +5 -0
- data/lib/courseware/generator.rb +91 -0
- data/lib/courseware/help.rb +108 -0
- data/lib/courseware/manager.rb +129 -0
- data/lib/courseware/manager/validators.rb +72 -0
- data/lib/courseware/printer.rb +224 -0
- data/lib/courseware/repository.rb +189 -0
- data/lib/courseware/utils.rb +118 -0
- data/lib/courseware/version.rb +4 -0
- metadata +107 -0
data/lib/courseware.rb
ADDED
@@ -0,0 +1,165 @@
|
|
1
|
+
class Courseware
|
2
|
+
require 'courseware/cache'
|
3
|
+
require 'courseware/composer'
|
4
|
+
require 'courseware/generator'
|
5
|
+
require 'courseware/manager'
|
6
|
+
require 'courseware/printer'
|
7
|
+
require 'courseware/repository'
|
8
|
+
require 'courseware/utils'
|
9
|
+
|
10
|
+
def initialize(config, configfile)
|
11
|
+
@config = config
|
12
|
+
@configfile = configfile
|
13
|
+
@cache = Courseware::Cache.new(config)
|
14
|
+
@generator = Courseware::Generator.new(config)
|
15
|
+
@composer = Courseware::Composer.new(config)
|
16
|
+
|
17
|
+
if Courseware::Repository.repository?
|
18
|
+
@repository = Courseware::Repository.new(config)
|
19
|
+
@manager = Courseware::Manager.new(config, @repository)
|
20
|
+
else
|
21
|
+
require 'courseware/dummy'
|
22
|
+
@repository = Courseware::Dummy.new
|
23
|
+
@manager = Courseware::Dummy.new
|
24
|
+
$logger.debug "Running outside a valid git repository."
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def options(opts)
|
29
|
+
raise ArgumentError, "One or two arguments expected, not #{opts.inspect}" unless opts.size.between?(1,2)
|
30
|
+
if opts.include? :section
|
31
|
+
section = opts[:section]
|
32
|
+
setting, value = opts.reject {|key, value| key == :section }.first
|
33
|
+
@config[section][setting] = value
|
34
|
+
else
|
35
|
+
setting, value = opts.first
|
36
|
+
@config[setting] = value
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def print(subject)
|
41
|
+
$logger.debug "Printing #{subject}"
|
42
|
+
|
43
|
+
#TODO: This should not be duplicated!
|
44
|
+
opts = {
|
45
|
+
:course => @manager.coursename,
|
46
|
+
:prefix => @manager.prefix,
|
47
|
+
:version => @repository.current(@manager.prefix),
|
48
|
+
:variant => Courseware.choose_variant,
|
49
|
+
}
|
50
|
+
Courseware::Printer.new(@config, opts) do |printer|
|
51
|
+
subject.each do |item|
|
52
|
+
case item
|
53
|
+
when :handouts
|
54
|
+
printer.handouts
|
55
|
+
|
56
|
+
when :exercises
|
57
|
+
printer.exercises
|
58
|
+
|
59
|
+
when :solutions
|
60
|
+
printer.solutions
|
61
|
+
|
62
|
+
when :guide
|
63
|
+
printer.guide
|
64
|
+
|
65
|
+
else
|
66
|
+
$logger.error "The #{item} document type does not exist."
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def wordcount(subject)
|
73
|
+
@manager.wordcount(subject)
|
74
|
+
end
|
75
|
+
|
76
|
+
def generate(subject)
|
77
|
+
$logger.debug "Generating #{subject}"
|
78
|
+
if subject.first == :skeleton
|
79
|
+
subject.shift
|
80
|
+
subject.each do |course|
|
81
|
+
@generator.skeleton course.to_s
|
82
|
+
end
|
83
|
+
else
|
84
|
+
subject.each do |item|
|
85
|
+
case item
|
86
|
+
when :config
|
87
|
+
@generator.saveconfig @configfile
|
88
|
+
|
89
|
+
when :styles
|
90
|
+
course = @manager.coursename
|
91
|
+
prefix = @manager.prefix
|
92
|
+
@generator.styles(course, @repository.current(prefix))
|
93
|
+
|
94
|
+
when :links
|
95
|
+
@generator.links
|
96
|
+
|
97
|
+
when :metadata
|
98
|
+
@generator.metadata
|
99
|
+
|
100
|
+
when :rakefile
|
101
|
+
@generator.rakefile
|
102
|
+
|
103
|
+
when :shared
|
104
|
+
@generator.shared
|
105
|
+
|
106
|
+
else
|
107
|
+
$logger.error "I don't know how to generate #{item}!"
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
end
|
113
|
+
|
114
|
+
def validate(subject)
|
115
|
+
$logger.debug "Validating #{subject}"
|
116
|
+
subject.each do |item|
|
117
|
+
case item
|
118
|
+
when :obsolete
|
119
|
+
@manager.obsolete
|
120
|
+
|
121
|
+
when :missing
|
122
|
+
@manager.missing
|
123
|
+
|
124
|
+
when :lint
|
125
|
+
@manager.lint
|
126
|
+
|
127
|
+
else
|
128
|
+
$logger.error "I don't know how to do that yet!"
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
$logger.warn "Found #{@manager.errors} errors and #{@manager.warnings} warnings."
|
133
|
+
exit @manager.errors + @manager.warnings
|
134
|
+
end
|
135
|
+
|
136
|
+
def release(subject)
|
137
|
+
case subject
|
138
|
+
|
139
|
+
when :major, :minor, :point
|
140
|
+
$logger.debug "Creating a #{subject} release."
|
141
|
+
@manager.release subject
|
142
|
+
|
143
|
+
when :notes
|
144
|
+
$logger.debug "Generating release notes."
|
145
|
+
@manager.releasenotes
|
146
|
+
|
147
|
+
else
|
148
|
+
$logger.error "I don't know how to do that yet!"
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def compose(subject)
|
153
|
+
@composer.build(subject)
|
154
|
+
end
|
155
|
+
|
156
|
+
def package(subject)
|
157
|
+
@composer.package(subject)
|
158
|
+
end
|
159
|
+
|
160
|
+
def debug
|
161
|
+
require 'pry'
|
162
|
+
binding.pry
|
163
|
+
end
|
164
|
+
|
165
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
class Courseware::Cache
|
3
|
+
|
4
|
+
def initialize(config)
|
5
|
+
@config = config
|
6
|
+
|
7
|
+
clone
|
8
|
+
update
|
9
|
+
end
|
10
|
+
|
11
|
+
def clone
|
12
|
+
templates = File.join(@config[:cachedir], 'templates')
|
13
|
+
|
14
|
+
FileUtils.mkdir_p(@config[:cachedir]) unless File.exists? @config[:cachedir]
|
15
|
+
system('git', 'clone', @config[:templates], templates) unless File.exists? templates
|
16
|
+
end
|
17
|
+
|
18
|
+
def update
|
19
|
+
$logger.debug "Updating template cache..."
|
20
|
+
git('templates', 'pull', '--quiet', 'origin', 'master')
|
21
|
+
git('templates', 'reset', '--quiet', '--hard', 'HEAD')
|
22
|
+
end
|
23
|
+
|
24
|
+
def clear
|
25
|
+
FileUtils.rm_rf @config[:cachedir]
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
def git(repo, *args)
|
30
|
+
worktree = File.join(@config[:cachedir], repo)
|
31
|
+
gitdir = File.join(worktree, '.git')
|
32
|
+
system('git', '--git-dir', gitdir, '--work-tree', worktree, *args)
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
class Courseware::Composer
|
2
|
+
def initialize(config, repository=nil)
|
3
|
+
@config = config
|
4
|
+
@repository = repository || Courseware::Repository.new(config)
|
5
|
+
|
6
|
+
if File.exists?(@config[:presfile])
|
7
|
+
@showoff = JSON.parse(File.read(@config[:presfile]))
|
8
|
+
@coursename = @showoff['name']
|
9
|
+
@prefix = @showoff['name'].gsub(' ', '_')
|
10
|
+
@sections = @showoff['sections']
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def build(subject)
|
15
|
+
courselevel!
|
16
|
+
|
17
|
+
if subject.nil?
|
18
|
+
display_tags
|
19
|
+
raise "Please re-run this task with a list of tags to include."
|
20
|
+
end
|
21
|
+
|
22
|
+
subject.each do |tag|
|
23
|
+
key = "tag:#{tag}"
|
24
|
+
@showoff['sections'].each do |section|
|
25
|
+
raise 'All sections must be represented as Hashes to customize.' unless section.is_a? Hash
|
26
|
+
|
27
|
+
if section.include? key
|
28
|
+
raise "A section in showoff.json refers to #{section[key]}, which does not exist." unless File.exist? section[key]
|
29
|
+
section['include'] = section[key]
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# normalize the output and trim unused tags
|
35
|
+
@showoff['sections'].each do |section|
|
36
|
+
section.select! { |k,v| k == 'include' }
|
37
|
+
end
|
38
|
+
@showoff['sections'].reject! { |section| section.empty? }
|
39
|
+
|
40
|
+
name = Courseware.question('What would you like to call this variant?', 'custom').gsub(/\W+/, '_')
|
41
|
+
desc = Courseware.question("Enter a customized description if you'd like:")
|
42
|
+
|
43
|
+
@showoff['description'] = desc if desc
|
44
|
+
|
45
|
+
File.write("#{name}.json", JSON.pretty_generate(@showoff))
|
46
|
+
puts "Run your presentation with `showoff serve -f #{name}.json` or `rake present`"
|
47
|
+
end
|
48
|
+
|
49
|
+
def package(subject)
|
50
|
+
courselevel!
|
51
|
+
on_release!
|
52
|
+
pristine!
|
53
|
+
|
54
|
+
subject ||= Courseware.choose_variant
|
55
|
+
subject = subject.to_s
|
56
|
+
content = JSON.parse(File.read(subject))
|
57
|
+
variant = File.basename(subject, '.json')
|
58
|
+
current = @repository.current(@coursename)
|
59
|
+
|
60
|
+
if variant == 'showoff'
|
61
|
+
variant = @prefix
|
62
|
+
output = @prefix
|
63
|
+
else
|
64
|
+
output = "#{@prefix}-#{variant}"
|
65
|
+
end
|
66
|
+
|
67
|
+
FileUtils.rm_rf "build/#{variant}"
|
68
|
+
FileUtils.mkdir_p "build/#{variant}"
|
69
|
+
FileUtils.cp subject, "build/#{variant}/showoff.json"
|
70
|
+
|
71
|
+
content['sections'].each do |section|
|
72
|
+
path = section['include']
|
73
|
+
next if path.nil?
|
74
|
+
|
75
|
+
dir = File.dirname path
|
76
|
+
FileUtils.mkdir_p "build/#{variant}/#{dir}"
|
77
|
+
FileUtils.cp path, "build/#{variant}/#{path}"
|
78
|
+
|
79
|
+
files = JSON.parse(File.read(path))
|
80
|
+
files.each do |file|
|
81
|
+
FileUtils.cp "#{dir}/#{file}", "build/#{variant}/#{dir}/#{file}"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# support is special
|
86
|
+
FileUtils.cp_r '../_support', "build/#{variant}/_support"
|
87
|
+
FileUtils.rm_f "build/#{variant}/_support/*.pem"
|
88
|
+
FileUtils.rm_f "build/#{variant}/_support/*.pub"
|
89
|
+
FileUtils.rm_f "build/#{variant}/_support/aws_credentials"
|
90
|
+
|
91
|
+
# duplicate from cwd to build/variant everything not already copied
|
92
|
+
Dir.glob('*').each do |thing|
|
93
|
+
next if thing == 'build'
|
94
|
+
next if File.extname(thing) == '.json'
|
95
|
+
next if File.exist? "build/#{variant}/#{thing}"
|
96
|
+
FileUtils.ln_s "../../#{thing}", "build/#{variant}/#{thing}"
|
97
|
+
end
|
98
|
+
|
99
|
+
system("tar -C build --dereference -czf build/#{output}-#{current}.tar.gz #{variant}")
|
100
|
+
if Courseware.confirm("Would you like to clean up the output build directory?")
|
101
|
+
FileUtils.rm_rf "build/#{variant}"
|
102
|
+
end
|
103
|
+
|
104
|
+
end
|
105
|
+
|
106
|
+
private
|
107
|
+
def display_tags
|
108
|
+
courselevel!
|
109
|
+
|
110
|
+
raise "This course has no tags to choose from." unless @showoff['tags']
|
111
|
+
|
112
|
+
puts
|
113
|
+
puts 'Available tags:'
|
114
|
+
@showoff['tags'].each do |tag, desc|
|
115
|
+
printf " * %-10s: %s\n", tag, desc
|
116
|
+
end
|
117
|
+
puts
|
118
|
+
end
|
119
|
+
|
120
|
+
def courselevel!
|
121
|
+
raise 'This task must be run from within a course directory' unless @repository.courselevel?
|
122
|
+
end
|
123
|
+
|
124
|
+
def pristine!
|
125
|
+
raise 'Your working directory has local modifications or untracked files' unless @repository.clean?
|
126
|
+
end
|
127
|
+
|
128
|
+
def on_release!
|
129
|
+
count = @repository.outstanding_commits(@prefix)
|
130
|
+
unless count == 0
|
131
|
+
raise "There have been #{count} commits since release. Either make a release or check out a tagged release."
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'erb'
|
2
|
+
require 'json'
|
3
|
+
require 'yaml'
|
4
|
+
require 'fileutils'
|
5
|
+
class Courseware::Generator
|
6
|
+
|
7
|
+
def initialize(config)
|
8
|
+
@config = config
|
9
|
+
end
|
10
|
+
|
11
|
+
def saveconfig(configfile)
|
12
|
+
$logger.info "Saving configuration to #{configfile}"
|
13
|
+
File.write(configfile, @config.to_yaml)
|
14
|
+
end
|
15
|
+
|
16
|
+
def skeleton(dest)
|
17
|
+
source = File.join(@config[:cachedir], 'templates', 'skeleton')
|
18
|
+
FileUtils.cp_r(source, dest)
|
19
|
+
|
20
|
+
Dir.chdir(dest) do
|
21
|
+
course = metadata()
|
22
|
+
styles(course, '0.0.1')
|
23
|
+
|
24
|
+
default = Courseware::Repository.repository? ? 'P' : 'S'
|
25
|
+
case Courseware.question('Should this be a (S)tandalone course or share assets with a (P)arent repository?', default)
|
26
|
+
when 's','S'
|
27
|
+
shared
|
28
|
+
$logger.warn "Don't forget to create the GitHub repository for your new course following the EDU standard."
|
29
|
+
else
|
30
|
+
links
|
31
|
+
$logger.warn "Don't forget to add, commit, & push this new course directory to the repository."
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def shared
|
37
|
+
source = File.join(@config[:cachedir], 'templates', 'shared')
|
38
|
+
FileUtils.cp_r(source, '.')
|
39
|
+
end
|
40
|
+
|
41
|
+
def rakefile
|
42
|
+
raise 'Update the toplevel Rakefile instead.' if File.symlink? 'Rakefile'
|
43
|
+
source = File.join(@config[:cachedir], 'templates', 'Rakefile')
|
44
|
+
FileUtils.cp(source, 'Rakefile')
|
45
|
+
end
|
46
|
+
|
47
|
+
def styles(course=nil, version=nil)
|
48
|
+
File.open(@config[:stylesheet], 'w') do |f|
|
49
|
+
$logger.info "Updating stylesheet for #{course} version #{version}."
|
50
|
+
template = File.join(@config[:cachedir], 'templates', 'showoff.css.erb')
|
51
|
+
f.write ERB.new(File.read(template), nil, '-').result(binding)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def links
|
56
|
+
filename = File.join(@config[:cachedir], 'templates', 'links.json')
|
57
|
+
JSON.parse(File.read(filename)).each do |file, target|
|
58
|
+
$logger.info "Linking #{file} -> #{target}"
|
59
|
+
FileUtils.rm_rf(file) if File.exists?(file)
|
60
|
+
FileUtils.ln_sf(target, file)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def metadata
|
65
|
+
location = File.basename Dir.pwd
|
66
|
+
if File.exists?(@config[:presfile])
|
67
|
+
metadata = JSON.parse(File.read(@config[:presfile]))
|
68
|
+
coursename = metadata['name']
|
69
|
+
description = metadata['description']
|
70
|
+
component = metadata['issues'].match(/components=(\d*)/)[1]
|
71
|
+
else
|
72
|
+
template = File.join(@config[:cachedir], 'templates', 'showoff.json')
|
73
|
+
metadata = JSON.parse(File.read(template))
|
74
|
+
coursename = location.capitalize
|
75
|
+
description = nil
|
76
|
+
component = nil
|
77
|
+
end
|
78
|
+
coursename = Courseware.question('Choose a short name for this course:', coursename)
|
79
|
+
description = Courseware.question('Please enter a description of the course:', description)
|
80
|
+
component = Courseware.get_component(component)
|
81
|
+
|
82
|
+
metadata['name'] = coursename
|
83
|
+
metadata['description'] = description
|
84
|
+
metadata['edit'] = "https://github.com/puppetlabs/#{@config[:github][:repository]}/edit/qa/review/#{coursename}/#{location}/"
|
85
|
+
metadata['issues'] = "http://tickets.puppetlabs.com/secure/CreateIssueDetails!init.jspa?pid=10302&issuetype=1&components=#{component}&priority=6&summary="
|
86
|
+
|
87
|
+
$logger.info "Updating presentation file #{@config[:presfile]}"
|
88
|
+
File.write(@config[:presfile], JSON.pretty_generate(metadata))
|
89
|
+
coursename
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
class Courseware
|
2
|
+
def self.help
|
3
|
+
IO.popen("less", "w") do |less|
|
4
|
+
less.puts <<-EOF.gsub(/^ {6}/, '')
|
5
|
+
Courseware Manager
|
6
|
+
|
7
|
+
SYNOPSIS
|
8
|
+
courseware [-c CONFIG] [-d] <verb> [subject] [subject] ...
|
9
|
+
|
10
|
+
DESCRIPTION
|
11
|
+
Manage the development lifecycle of Puppet courseware. This tool is not
|
12
|
+
required for presenting the material or for contributing minor updates.
|
13
|
+
|
14
|
+
The following verbs are recognized:
|
15
|
+
|
16
|
+
* print
|
17
|
+
Render course material as PDF files. This verb accepts one or more of the
|
18
|
+
following arguments, where the default is all.
|
19
|
+
|
20
|
+
Arguments (optional):
|
21
|
+
handouts: Generate handout notes
|
22
|
+
exercises: Generate the lab exercise manual
|
23
|
+
solutions: Generate the solution guide
|
24
|
+
guide: Generate the instructor guide
|
25
|
+
|
26
|
+
* watermark
|
27
|
+
Render watermarked PDF files. Accepts same arguements as the `print` verb.
|
28
|
+
|
29
|
+
* generate or update
|
30
|
+
Build new or update certain kinds of configuration. By default, this will
|
31
|
+
update the stylesheet.
|
32
|
+
|
33
|
+
Arguments (optional):
|
34
|
+
skeleton <name>: Build a new course directory named <name> and generate
|
35
|
+
required metadata for a Showoff presentation.
|
36
|
+
|
37
|
+
config: Write current configuration to a `courseware.yaml` file.
|
38
|
+
|
39
|
+
styles: Generate or update the stylesheet for the current version.
|
40
|
+
|
41
|
+
links: Ensure that all required symlinks are in place.
|
42
|
+
|
43
|
+
metadata: Interactively generate or update the `showoff.json` file.
|
44
|
+
|
45
|
+
* validate
|
46
|
+
Runs validation checks on the presentation. Defaults to running all the checks.
|
47
|
+
|
48
|
+
Validators:
|
49
|
+
obsolete: Lists all unreferenced images and slides. This reference checks
|
50
|
+
all slides and all CSS stylesheets. Case sensitive.
|
51
|
+
|
52
|
+
missing: Lists all slides that are missing. Note that this does not check
|
53
|
+
for missing image files yet. Case sensitive.
|
54
|
+
|
55
|
+
lint: Runs a markdown linter on each slide file, using our own style
|
56
|
+
definition.
|
57
|
+
|
58
|
+
* release [type]
|
59
|
+
Orchestrate a courseware release. Defaults to `point`.
|
60
|
+
|
61
|
+
All instructors are expected to deliver the most current point release, except
|
62
|
+
in extraordinary cases. We follow Semver, as closely as it can be adapted to
|
63
|
+
classroom usage. Instructors can trust that updates with high potential to cause
|
64
|
+
classroom disruptions will never make it into a point release.
|
65
|
+
|
66
|
+
http://semver.org
|
67
|
+
|
68
|
+
Release types:
|
69
|
+
major: This is a major release with "breaking" changes, such as a major
|
70
|
+
product update, or significant classroom workflow changes. This
|
71
|
+
is not necessarily tied to product releases. Instructors should
|
72
|
+
expect to spend significant time studying the new material thoroughly.
|
73
|
+
|
74
|
+
minor: This indicates a significant change in content. Instructors
|
75
|
+
should take extra time to review updates in minor releases.
|
76
|
+
The release cadence is roughly once a quarter, give or take.
|
77
|
+
|
78
|
+
point: Release early and release often. Changes made in the regular
|
79
|
+
maintenance cycle will typically fit into this category.
|
80
|
+
|
81
|
+
notes: Display release notes since last release and copy to clipboard.
|
82
|
+
|
83
|
+
* wordcount [type]
|
84
|
+
Display a simple wordcount of one or more content types.
|
85
|
+
|
86
|
+
Arguments (optional):
|
87
|
+
handouts: Counts words in the student handout guide
|
88
|
+
exercises: Counts words in the lab exercise manual
|
89
|
+
solutions: Counts words in the solution guide
|
90
|
+
|
91
|
+
* compose [comma,separated,list,of,tags]
|
92
|
+
Generates a variant of the complete course, using tags defined in `showoff.json`.
|
93
|
+
The practical effect of this action is to generate a new presentation `.json` file,
|
94
|
+
which can be displayed directly by passing the `-f` flag to Showoff, or by choosing
|
95
|
+
a variant in the classroom `rake present` task.
|
96
|
+
|
97
|
+
* package [variant.json]
|
98
|
+
Package up a standalone form of a given variant of the presentation. You can pass
|
99
|
+
in a `variant.json` file, or choose from a menu. Tarballs will be saved into the
|
100
|
+
`build` directory and you can optionally retain the full working directory.
|
101
|
+
|
102
|
+
* help
|
103
|
+
You're looking at it.
|
104
|
+
EOF
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|