alma_course_loader 0.9.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +63 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +586 -0
- data/Rakefile +10 -0
- data/alma_course_loader.gemspec +33 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/exe/course_loader_diff +11 -0
- data/lib/alma_course_loader.rb +8 -0
- data/lib/alma_course_loader/cli/course_loader.rb +129 -0
- data/lib/alma_course_loader/cli/diff.rb +85 -0
- data/lib/alma_course_loader/diff.rb +173 -0
- data/lib/alma_course_loader/filter.rb +272 -0
- data/lib/alma_course_loader/reader.rb +298 -0
- data/lib/alma_course_loader/version.rb +3 -0
- data/lib/alma_course_loader/writer.rb +116 -0
- metadata +162 -0
data/Rakefile
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
lib = File.expand_path('../lib', __FILE__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
require 'alma_course_loader/version'
|
6
|
+
|
7
|
+
Gem::Specification.new do |spec|
|
8
|
+
spec.name = 'alma_course_loader'
|
9
|
+
spec.version = AlmaCourseLoader::VERSION
|
10
|
+
spec.authors = ['Lancaster University Library']
|
11
|
+
spec.email = ['library.dit@lancaster.ac.uk']
|
12
|
+
|
13
|
+
spec.summary = 'Support for creating Alma course loader files'
|
14
|
+
spec.description = 'This gem provides basic support for creating Alma' \
|
15
|
+
'course loader files.'
|
16
|
+
spec.homepage = 'https://github.com/lulibrary/alma_course_loader'
|
17
|
+
spec.license = 'MIT'
|
18
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
19
|
+
f.match(%r{^(test|spec|features)/})
|
20
|
+
end
|
21
|
+
spec.bindir = 'exe'
|
22
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
23
|
+
spec.require_paths = ['lib']
|
24
|
+
|
25
|
+
spec.add_dependency 'clamp'
|
26
|
+
spec.add_dependency 'dotenv'
|
27
|
+
|
28
|
+
spec.add_development_dependency 'bundler'
|
29
|
+
spec.add_development_dependency 'rake'
|
30
|
+
spec.add_development_dependency 'minitest'
|
31
|
+
spec.add_development_dependency 'minitest-reporters'
|
32
|
+
spec.add_development_dependency 'rubocop'
|
33
|
+
end
|
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "alma_course_loader"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
@@ -0,0 +1,8 @@
|
|
1
|
+
require 'alma_course_loader/diff'
|
2
|
+
require 'alma_course_loader/filter'
|
3
|
+
require 'alma_course_loader/reader'
|
4
|
+
require 'alma_course_loader/writer'
|
5
|
+
require 'alma_course_loader/version'
|
6
|
+
|
7
|
+
# A framework for generating Alma course loader files
|
8
|
+
module AlmaCourseLoader; end
|
@@ -0,0 +1,129 @@
|
|
1
|
+
require 'clamp'
|
2
|
+
require 'dotenv'
|
3
|
+
|
4
|
+
require 'alma_course_loader/filter'
|
5
|
+
|
6
|
+
module AlmaCourseLoader
|
7
|
+
module CLI
|
8
|
+
# The abstract base class for course loader command line processing
|
9
|
+
# @abstract Loader implementations should subclass this class and implement
|
10
|
+
# the #extractors, #reader and #time_period methods
|
11
|
+
class CourseLoader < Clamp::Command
|
12
|
+
# Exit codes
|
13
|
+
EXIT_OK = 0
|
14
|
+
|
15
|
+
# Clamp command-line options
|
16
|
+
option %w[-d --delete], :flag, 'generate a course delete file'
|
17
|
+
option %w[-e --env-file], 'ENV_FILE', 'environment definitions file'
|
18
|
+
option %w[-f --filter], 'FILTER',
|
19
|
+
'filter condition: [field][op]value', multivalued: true do |value|
|
20
|
+
AlmaCourseLoader::Filter.parse(value, extractors)
|
21
|
+
end
|
22
|
+
option %w[-F --fields], :flag, 'list the fields available to filters'
|
23
|
+
option %w[-l --log-file], 'LOG_FILE', 'the activity log file'
|
24
|
+
option %w[-L --log-level], 'LOG_LEVEL',
|
25
|
+
'the log level (fatal|error|warn|info|debug)' do |value|
|
26
|
+
{
|
27
|
+
debug: Logger::DEBUG,
|
28
|
+
error: Logger::ERROR,
|
29
|
+
fatal: Logger::FATAL,
|
30
|
+
info: Logger::INFO,
|
31
|
+
warn: Logger::WARN
|
32
|
+
}[value.downcase.to_sym] || Logger::ERROR
|
33
|
+
end
|
34
|
+
option %w[-o --out-file], 'OUT_FILE', 'the output file'
|
35
|
+
option %w[-r --rollover], :flag, 'generate a course rollover file'
|
36
|
+
option %w[-t --time-period], 'PERIOD',
|
37
|
+
'the academic year (2016 etc.)', multivalued: true do |value|
|
38
|
+
time_period(value)
|
39
|
+
end
|
40
|
+
option %w[-T --current-time-period], 'CURRENT_PERIOD',
|
41
|
+
'the current academic year' do |value|
|
42
|
+
time_period(value)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Displays an error message and exits
|
46
|
+
# @param msg [String, nil] the error message
|
47
|
+
# @param code [Integer] the exit code - do not exit if nil
|
48
|
+
# @return [void]
|
49
|
+
def error(msg = nil, code = nil)
|
50
|
+
STDERR.puts(msg) if msg
|
51
|
+
exit(code) if code
|
52
|
+
end
|
53
|
+
|
54
|
+
# Returns a hash of named field value extractor descriptions. Keys
|
55
|
+
# correspond to the keys of the extractors hash, values should be short
|
56
|
+
# descriptions of each field extractor.
|
57
|
+
# @abstract Subclasses should implement this method
|
58
|
+
# @return [Hash<String|Symbol, String>] the field extractor descriptions
|
59
|
+
def extractor_details
|
60
|
+
nil
|
61
|
+
end
|
62
|
+
|
63
|
+
# Returns a hash of named field value extractors
|
64
|
+
# @abstract Subclasses must implement this method
|
65
|
+
# @return [Hash<String|Symbol, Method|Proc>] the field extractors
|
66
|
+
def extractors
|
67
|
+
raise NotImplementedError
|
68
|
+
end
|
69
|
+
|
70
|
+
# Clamp entry point - executes the command
|
71
|
+
# @return [void]
|
72
|
+
def execute
|
73
|
+
# List the available field extractors and exit if required
|
74
|
+
list_fields if fields?
|
75
|
+
# Otherwise write a course loader file and exit
|
76
|
+
write_file
|
77
|
+
end
|
78
|
+
|
79
|
+
# Lists the available field extractors and exits
|
80
|
+
# @return [void]
|
81
|
+
def list_fields
|
82
|
+
d = extractor_details || {}
|
83
|
+
extractors.each_key { |k| puts "#{k}#{d[k] ? ': ' : ''}#{d[k] || ''}" }
|
84
|
+
exit(EXIT_OK)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Creates a Logger instance
|
88
|
+
# @return [Logger] the Logger instance
|
89
|
+
def logger
|
90
|
+
return nil unless log_file
|
91
|
+
logger = Logger.new(log_file)
|
92
|
+
logger.level = log_level
|
93
|
+
logger
|
94
|
+
end
|
95
|
+
|
96
|
+
# Creates a Reader instance to retrieve course data
|
97
|
+
# @abstract Subclasses must implement this method.
|
98
|
+
# Filters are defined in the filter_list array:
|
99
|
+
# MyReader.new(..., filters: filter_list)
|
100
|
+
# @return [AlmaCourseLoader::Reader] a subclass of Reader
|
101
|
+
def reader
|
102
|
+
raise NotImplementedError
|
103
|
+
end
|
104
|
+
|
105
|
+
# Parses a time period string and returns an appropriate representation
|
106
|
+
# @abstract Subclasses may implement this method
|
107
|
+
# @param time_period_s [String] the time period string
|
108
|
+
# @return [Object] the subclass-specific representation of the time period
|
109
|
+
def time_period(time_period_s)
|
110
|
+
time_period_s
|
111
|
+
end
|
112
|
+
|
113
|
+
# Writes an Alma course loader file and exits
|
114
|
+
# @return [void]
|
115
|
+
def write_file
|
116
|
+
op = if rollover?
|
117
|
+
:rollover
|
118
|
+
elsif delete?
|
119
|
+
:delete
|
120
|
+
else
|
121
|
+
:update
|
122
|
+
end
|
123
|
+
Dotenv.load(env_file) unless env_file.nil? || env_file.empty?
|
124
|
+
AlmaCourseLoader::Writer.write(out_file, op, reader)
|
125
|
+
exit(EXIT_OK)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
require 'clamp'
|
2
|
+
require 'logger'
|
3
|
+
|
4
|
+
module AlmaCourseLoader
|
5
|
+
module CLI
|
6
|
+
# Implements the course_loader_diff command-line interface
|
7
|
+
class Diff < Clamp::Command
|
8
|
+
# Exit codes
|
9
|
+
EXIT_OK = 0
|
10
|
+
# Log file alignment for multi-line entries
|
11
|
+
LOG_SPACE = ' '.freeze
|
12
|
+
private_constant :LOG_SPACE
|
13
|
+
# Log file timestamp
|
14
|
+
LOG_TIME = '%Y-%m-%d %H:%M:%S: '.freeze
|
15
|
+
|
16
|
+
option %w[-c --create], 'FILE', 'write new courses to FILE'
|
17
|
+
option %w[-d --delete], 'FILE', 'write deleted courses to FILE'
|
18
|
+
option %w[-l --log], 'FILE', 'log activity to FILE',
|
19
|
+
attribute_name: :log_file
|
20
|
+
option %w[-r --rollover], :flag,
|
21
|
+
'create courses with rollover if rollover course/section is given'
|
22
|
+
option %w[-u --update], 'FILE', 'write updated courses to FILE'
|
23
|
+
option %w[-v --verbose], :flag, 'enable verbose logging'
|
24
|
+
parameter 'OLD', 'the old course loader file', attribute_name: :old_file
|
25
|
+
parameter 'NEW', 'the new course loader file', attribute_name: :new_file
|
26
|
+
|
27
|
+
# Clamp entry point - executes the command
|
28
|
+
def execute
|
29
|
+
filename = log_filename
|
30
|
+
@logger = logger(filename)
|
31
|
+
::AlmaCourseLoader::Diff.diff(old_file, new_file,
|
32
|
+
create: create, delete: delete,
|
33
|
+
rollover: rollover?,
|
34
|
+
update: update) do |old, new, op, opts|
|
35
|
+
log(old, new, op, opts) if @logger
|
36
|
+
end
|
37
|
+
@logger.close if @logger && filename
|
38
|
+
exit(EXIT_OK)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Logs a course update operation
|
42
|
+
# @param old [String] the course entry from the old file
|
43
|
+
# @param new [String] the course entry from the new file
|
44
|
+
# @param op [Symbol] the course operation (:create|:delete|:update)
|
45
|
+
# @param opts [Hash<Symbol, Object>] the diff options
|
46
|
+
# @return [void]
|
47
|
+
def log(old, new, op, opts)
|
48
|
+
fields = log_course_fields(old, new, op, opts)
|
49
|
+
course = "#{fields[0]}:#{fields[2]}"
|
50
|
+
@logger.info("#{op.to_s.capitalize} #{course}\n")
|
51
|
+
return unless verbose?
|
52
|
+
@logger.debug("#{old.nil? ? '' : '< '}#{old}\n") if old
|
53
|
+
@logger.debug("#{new.nil? ? '' : '> '}#{new}\n") if new
|
54
|
+
end
|
55
|
+
|
56
|
+
# Returns the course entry fields
|
57
|
+
# @param old [String] the course entry from the old file
|
58
|
+
# @param new [String] the course entry from the new file
|
59
|
+
# @param op [Symbol] the course operation (:create|:delete|:update)
|
60
|
+
# @param opts [Hash<Symbol, Object>] the diff options
|
61
|
+
def log_course_fields(old, new, op, opts)
|
62
|
+
op == :delete ? old.split("\t") : new.split("\t")
|
63
|
+
end
|
64
|
+
|
65
|
+
# Returns the command-line log filename or nil to force logging to STDOUT
|
66
|
+
# @return [String, nil] the log filename
|
67
|
+
def log_filename
|
68
|
+
log_file.nil? || log_file.empty? || log_file == '-' ? nil : log_file
|
69
|
+
end
|
70
|
+
|
71
|
+
# Returns a Logger instance
|
72
|
+
# @param filename [String, nil] the log filename, or nil to log to STDOUT
|
73
|
+
# @return [Logger] the logger
|
74
|
+
def logger(filename = nil)
|
75
|
+
logger = Logger.new(filename || STDOUT)
|
76
|
+
logger.level = verbose? ? Logger::DEBUG : Logger::INFO
|
77
|
+
logger.formatter = proc do |severity, datetime, _prog_name, msg|
|
78
|
+
time_s = severity == 'DEBUG' ? LOG_SPACE : datetime.strftime(LOG_TIME)
|
79
|
+
"#{time_s}#{msg}"
|
80
|
+
end
|
81
|
+
logger
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,173 @@
|
|
1
|
+
require 'logger'
|
2
|
+
|
3
|
+
module AlmaCourseLoader
|
4
|
+
# Exception raised by the diff block to ignore the current course
|
5
|
+
class SkipCourse < StandardError; end
|
6
|
+
|
7
|
+
# Compares two course loader files and outputs separate files of new, deleted
|
8
|
+
# and updated courses
|
9
|
+
class Diff
|
10
|
+
# Course loader operations
|
11
|
+
OPS = %i[create delete update].freeze
|
12
|
+
|
13
|
+
class << self
|
14
|
+
# Reports differences between old and new course loader files
|
15
|
+
# @param old_file [String] the old course loader filename
|
16
|
+
# @param new_file [String] the new course loader filename
|
17
|
+
# @param opts [Hash] the diff options
|
18
|
+
# @option opts [IO] :create the new courses file
|
19
|
+
# @option opts [IO] :delete the deleted courses file
|
20
|
+
# @option opts [Boolean] :rollover if true, create new courses as
|
21
|
+
# rollovers when rollover course/section are present
|
22
|
+
# @option opts [IO] :update the updated courses file
|
23
|
+
# @yield [old_line, new_line, op, opts] passes course loader lines,
|
24
|
+
# operation (:create|:delete|:update) and diff options to the block; the
|
25
|
+
# block is only called for differences between files
|
26
|
+
# @yieldparam old_line [String] the old course loader line
|
27
|
+
# @yieldparam new_line [String] the new course loader line
|
28
|
+
# @yieldparam op [Symbol] the operation (:create|:delete|:update)
|
29
|
+
# @yieldparam opts [Hash] the diff options
|
30
|
+
# @return [void]
|
31
|
+
def diff(old_file = nil, new_file = nil, **opts, &block)
|
32
|
+
# Read the course loader data from the old and new files
|
33
|
+
old = read(old_file)
|
34
|
+
new = read(new_file)
|
35
|
+
# Create the creations, deletions and updates output files
|
36
|
+
files = open_output_files(opts)
|
37
|
+
# Perform the diff
|
38
|
+
process(old, new, files, opts, &block)
|
39
|
+
ensure
|
40
|
+
close_output_files(files)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
# Returns true if the course entry has rollover course/section, else false
|
46
|
+
# @param fields [Array<String>] the course loader fields
|
47
|
+
# @return [Boolean] true if rollover course/section are set, else false
|
48
|
+
def can_rollover?(fields)
|
49
|
+
# Rollover requires course code and section
|
50
|
+
return false if fields[29].nil? || fields[29].empty?
|
51
|
+
return false if fields[30].nil? || fields[30].empty?
|
52
|
+
true
|
53
|
+
end
|
54
|
+
|
55
|
+
# Closes output files
|
56
|
+
# @param files [Hash<Symbol, IO>] the output files
|
57
|
+
# @return [void]
|
58
|
+
def close_output_files(files = nil)
|
59
|
+
files.values.each(&:close) if files
|
60
|
+
end
|
61
|
+
|
62
|
+
# Formats the course loader line for the specified operation
|
63
|
+
# @param line [String] the course loader line
|
64
|
+
# @param op [Symbol] the operation (:create|:delete|:update)
|
65
|
+
# @param opts [Hash<Symbol, Object>] the diff options
|
66
|
+
# @return [String] the course loader line with the specified operation
|
67
|
+
def format(line, op, opts = {})
|
68
|
+
# Format the line (rollover code/section are never needed)
|
69
|
+
line = line.split("\t")
|
70
|
+
if op == :create && opts[:rollover] && can_rollover?(line)
|
71
|
+
line[28] = 'ROLLOVER' # Rollover code/section already present
|
72
|
+
elsif op == :delete
|
73
|
+
line[28..30] = ['DELETE', '', ''] # Rollover code/section not needed
|
74
|
+
else # op is either :create without rollover or :update
|
75
|
+
line[28..30] = ['', '', ''] # Update, rollover code/section not needed
|
76
|
+
end
|
77
|
+
# Return the formatted line
|
78
|
+
line.join("\t")
|
79
|
+
end
|
80
|
+
|
81
|
+
# Returns the key for the course loader data hash
|
82
|
+
# @param fields [Array<String>] the course loader entry fields
|
83
|
+
# @return [String] the key for the course loader data hash
|
84
|
+
def key(fields)
|
85
|
+
# course-code:section-id
|
86
|
+
"#{fields[0]}:#{fields[2]}"
|
87
|
+
end
|
88
|
+
|
89
|
+
# Creates an output file
|
90
|
+
# @param file [IO, String] the output file instance or filename
|
91
|
+
# @param mode [String] the output file mode
|
92
|
+
# @return [IO] the output file
|
93
|
+
def open(file, mode = 'w')
|
94
|
+
return file if file.nil? || file.is_a?(IO)
|
95
|
+
raise ArgumentError('IO or filename expected') unless file.is_a?(String)
|
96
|
+
File.open(file, mode)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Creates output files
|
100
|
+
# @param opts [Hash] the diff options
|
101
|
+
# @return [Hash<Symbol, IO>] the output files, indexed by operation
|
102
|
+
# (:create|:delete|:update)
|
103
|
+
def open_output_files(opts)
|
104
|
+
files = {}
|
105
|
+
OPS.each { |op| files[op] = open(opts[op]) }
|
106
|
+
files
|
107
|
+
end
|
108
|
+
|
109
|
+
# Returns the diff operation
|
110
|
+
# @param old_line [String] the old course loader line
|
111
|
+
# @param new_line [String] the new course loader line
|
112
|
+
# @return [Symbol, nil] the operation (:create|:delete|:update) or nil if
|
113
|
+
# there are no changes
|
114
|
+
def operation(old_line = nil, new_line = nil)
|
115
|
+
return nil if old_line == new_line
|
116
|
+
return :create if old_line.nil?
|
117
|
+
return :delete if new_line.nil?
|
118
|
+
:update
|
119
|
+
end
|
120
|
+
|
121
|
+
# Process the input files
|
122
|
+
# @param old [Hash<String, String>] the old course loader data
|
123
|
+
# @param new [Hash<String, String>] the new course loader data
|
124
|
+
# @param files [Hash<Symbol, IO>] the output files, indexed by operation
|
125
|
+
# (:create|:delete|:update)
|
126
|
+
# @param opts [Hash] the diff options
|
127
|
+
# @return [void]
|
128
|
+
def process(old, new, files = nil, opts = {}, &block)
|
129
|
+
# Handle deletions and updates to the old file
|
130
|
+
old.each do |course, line|
|
131
|
+
write(line, new[course], files, opts, &block)
|
132
|
+
end
|
133
|
+
# Handle new additions to the old file
|
134
|
+
new.each do |course, line|
|
135
|
+
write(nil, line, files, opts, &block) unless old.key?(course)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
# Returns the course loader file data as a hash: { course => loader line }
|
140
|
+
# @param filename [String] the course loader filename
|
141
|
+
# @return [Hash<String, String>] the course loader data
|
142
|
+
def read(filename)
|
143
|
+
result = {}
|
144
|
+
File.readlines(filename).each do |line|
|
145
|
+
line.chomp!
|
146
|
+
fields = line.split("\t")
|
147
|
+
result[key(fields)] = line
|
148
|
+
end
|
149
|
+
result
|
150
|
+
end
|
151
|
+
|
152
|
+
# Write the diff result to the appropriate output file
|
153
|
+
# @param old_line [String] the old course loader line
|
154
|
+
# @param new_line [String] the new course loader line
|
155
|
+
# @param files [Hash<Symbol, IO>] the output files, indexed by operation
|
156
|
+
# (:create|:delete|:update)
|
157
|
+
# @param opts [Hash] the diff options
|
158
|
+
# @return [void]
|
159
|
+
def write(old_line = nil, new_line = nil, files = nil, opts = {})
|
160
|
+
# Determine the diff operation
|
161
|
+
op = operation(old_line, new_line)
|
162
|
+
return if op.nil?
|
163
|
+
# Call the block
|
164
|
+
yield(old_line, new_line, op, opts) if block_given?
|
165
|
+
# Write the line to the update file
|
166
|
+
line = op == :delete ? old_line : new_line
|
167
|
+
files[op].write("#{format(line, op, opts)}\n") if files[op]
|
168
|
+
rescue SkipCourse
|
169
|
+
# The block requested that this course is skipped
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|