scorm 1.0.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,100 @@
1
+ module Scorm
2
+
3
+ # The +Metadata+ class holds meta data associated with a SCORM package in a
4
+ # hash like structure. The +Metadata+ class reads a LOM (Learning Object
5
+ # Metadata) structure and stores the data in categories. A +Category+ can
6
+ # contain any number of +DataElement+s. A +DataElement+ behaves just like
7
+ # a string but can contain the same value in many different languages,
8
+ # accessed by the DataElement#value (or DataElement#to_s) method by
9
+ # specifying the language code as the first argument.
10
+ #
11
+ # Ex.
12
+ #
13
+ # <tt>pkg.manifest.metadata.general.class -> Metadata::Category</tt>
14
+ # <tt>pkg.manifest.metadata.general.title.class -> Metadata::DataElement</tt>
15
+ # <tt>pkg.manifest.metadata.general.title.value -> 'My course'</tt>
16
+ # <tt>pkg.manifest.metadata.general.title.value('sv') -> 'Min kurs'</tt>
17
+ #
18
+ class Metadata < Hash
19
+
20
+ def self.from_xml(element)
21
+ metadata = self.new
22
+ element.elements.each do |category_el|
23
+ category = Category.from_xml(category_el)
24
+ metadata.store(category_el.name.to_s, category)
25
+ end
26
+ return metadata
27
+ end
28
+
29
+ def method_missing(sym)
30
+ self.fetch(sym.to_s, nil)
31
+ end
32
+
33
+ class Category < Hash
34
+
35
+ def self.from_xml(element)
36
+ category = Scorm::Metadata::Category.new
37
+ element.elements.each do |data_el|
38
+ category[data_el.name.to_s] = DataElement.from_xml(data_el)
39
+ end
40
+ return category
41
+ end
42
+
43
+ def method_missing(sym, *args)
44
+ data_element = self.fetch(sym.to_s, nil)
45
+ if data_element.is_a? DataElement
46
+ data_element.value(args.first)
47
+ else
48
+ data_element
49
+ end
50
+ end
51
+ end
52
+
53
+ class DataElement
54
+ def initialize(value = '', default_lang = nil)
55
+ if value.is_a? String
56
+ @langstrings = Hash.new
57
+ @langstrings['x-none'] = value
58
+ @default_lang = 'x-none'
59
+ elsif value.is_a? Hash
60
+ @langstrings = value.dup
61
+ @default_lang = default_lang || 'x-none'
62
+ end
63
+ end
64
+
65
+ def self.from_xml(element)
66
+ if element.elements.size == 0
67
+ return self.new(element.text.to_s)
68
+
69
+ elsif element.get_elements('value').size != 0
70
+ value_el = element.get_elements('value').first
71
+ return self.from_xml(value_el)
72
+
73
+ elsif element.get_elements('langstring').size != 0
74
+ langstrings = Hash.new
75
+ default_lang = nil
76
+ element.each_element('langstring') do |ls|
77
+ default_lang = ls.attribute('xml:lang').to_s if default_lang.nil?
78
+ langstrings[ls.attribute('xml:lang').to_s || 'x-none'] = ls.text.to_s
79
+ end
80
+ return self.new(langstrings, default_lang)
81
+
82
+ else
83
+ return Category.from_xml(element)
84
+
85
+ end
86
+ end
87
+
88
+ def value(lang = nil)
89
+ if lang.nil?
90
+ (@langstrings && @default_lang) ? @langstrings[@default_lang] : ''
91
+ else
92
+ (@langstrings) ? @langstrings[lang] || '' : ''
93
+ end
94
+ end
95
+
96
+ alias :to_s :value
97
+ alias :to_str :value
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,77 @@
1
+ #--
2
+ # TODO: items should be read as an hierarchy.
3
+ #
4
+ # TODO: imsss:sequencing and adlnav:presentation should be parsed and read.
5
+ #
6
+ # TODO: read <item><metadata>...</metadata></item>.
7
+ #++
8
+
9
+ module Scorm
10
+ # The +Organization+ class holds data about the organization of a SCORM
11
+ # package. An organization contains an id, title and any number of +items+.
12
+ # An +Item+ are (in most cases) the same thing as a SCO (Shareable Content
13
+ # Object).
14
+ class Organization
15
+ attr_accessor :id
16
+ attr_accessor :title
17
+ attr_accessor :items
18
+
19
+ def initialize(id, title, items)
20
+ raise InvalidManifest, 'missing organization id' if id.nil?
21
+ @id = id.to_s
22
+ @title = title.to_s
23
+ @items = items
24
+ end
25
+
26
+ def self.from_xml(element)
27
+ id = element.attribute('identifier').to_s
28
+ title = element.get_elements('title').first.text.to_s if element.get_elements('title').first
29
+ items = []
30
+ REXML::XPath.each(element, 'item') do |item_el|
31
+ items << Item.from_xml(item_el)
32
+ end
33
+ return self.new(id, title, items)
34
+ end
35
+
36
+ # An item has an id, title, and (in some cases) a parent item. An item is
37
+ # associated with a resource, which in most cases is a SCO (Shareable
38
+ # Content Object) resource.
39
+ class Item
40
+ attr_accessor :id
41
+ attr_accessor :title
42
+ attr_accessor :isvisible
43
+ attr_accessor :parameters
44
+ attr_accessor :resource_id
45
+ attr_accessor :children
46
+ attr_accessor :time_limit_action
47
+ attr_accessor :data_from_lms
48
+ attr_accessor :completion_threshold
49
+
50
+ def initialize(id, title, isvisible = true, parameters = nil, resource_id = nil, children = nil)
51
+ @id = id.to_s
52
+ @title = title.to_s
53
+ @isvisible = isvisible || true
54
+ @parameters = parameters
55
+ @resource_id = resource_id
56
+ @children = children if children.is_a? Array
57
+ end
58
+
59
+ def self.from_xml(element)
60
+ item_id = element.attribute('identifier').to_s
61
+ item_title = element.get_elements('title').first.text.to_s if element.get_elements('title').first
62
+ item_isvisible = (element.attribute('isvisible').to_s == 'true')
63
+ item_parameters = element.attribute('parameters').to_s
64
+ children = []
65
+ if element.get_elements('item').empty?
66
+ resource_id = element.attribute('identifierref').to_s
67
+ else
68
+ element.each_element('item') do |item_el|
69
+ child_item = self.from_xml(item_el)
70
+ children << child_item
71
+ end
72
+ end
73
+ return self.new(item_id, item_title, item_isvisible, item_parameters, resource_id, children)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,210 @@
1
+ require 'rubygems'
2
+ require 'zip/zip'
3
+ require 'fileutils'
4
+ require 'open-uri'
5
+ require 'scorm/datatypes'
6
+ require 'scorm/manifest'
7
+
8
+ module Scorm
9
+ class InvalidPackage < RuntimeError; end
10
+ class InvalidManifest < InvalidPackage; end
11
+
12
+ class Package
13
+ attr_accessor :name # Name of the package.
14
+ attr_accessor :manifest # An instance of +Scorm::Manifest+.
15
+ attr_accessor :path # Path to the extracted course.
16
+ attr_accessor :repository # The directory to which the packages is extracted.
17
+ attr_accessor :options # The options hash supplied when opening the package.
18
+ attr_accessor :package # The file name of the package file.
19
+
20
+ DEFAULT_LOAD_OPTIONS = {
21
+ :strict => false,
22
+ :dry_run => false,
23
+ :cleanup => true,
24
+ :force_cleanup => false,
25
+ :name => nil,
26
+ :repository => nil
27
+ }
28
+
29
+ def self.set_default_load_options(options = {})
30
+ DEFAULT_LOAD_OPTIONS.merge!(options)
31
+ end
32
+
33
+ def self.open(filename, options = {}, &block)
34
+ Package.new(filename, options, &block)
35
+ end
36
+
37
+ # This method will load a SCORM package and extract its content to the
38
+ # directory specified by the +:repository+ option. The manifest file will be
39
+ # parsed and made available through the +manifest+ instance variable. This
40
+ # method should be called with an associated block as it yields the opened
41
+ # package and then auto-magically closes it when the block has finished. It
42
+ # will also do any necessary cleanup if an exception occur anywhere in the
43
+ # block. The available options are:
44
+ #
45
+ # :+strict+: If +false+ the manifest will be parsed in a nicer way. Default: +true+.
46
+ # :+dry_run+: If +true+ nothing will be written to the file system. Default: +false+.
47
+ # :+cleanup+: If +false+ no cleanup will take place if an error occur. Default: +true+.
48
+ # :+name+: The name to use when extracting the package to the
49
+ # repository. Default: will use the filename of the package
50
+ # (minus the .zip extension).
51
+ # :+repository+: Path to the course repository. Default: the same directory as the package.
52
+ #
53
+ def initialize(filename, options = {}, &block)
54
+ @options = DEFAULT_LOAD_OPTIONS.merge(options)
55
+ @package = filename.respond_to?(:path) ? filename.path : filename
56
+
57
+ # Check if package is a directory or a file.
58
+ if File.directory?(@package)
59
+ @name = File.basename(@package)
60
+ @repository = File.dirname(@package)
61
+ @path = File.expand_path(@package)
62
+ else
63
+ i = nil
64
+ begin
65
+ # Decide on a name for the package.
66
+ @name = [(@options[:name] || File.basename(@package, File.extname(@package))), i].flatten.join
67
+
68
+ # Set the path for the extracted package.
69
+ @repository = @options[:repository] || File.dirname(@package)
70
+ @path = File.expand_path(File.join(@repository, @name))
71
+
72
+ # First try is nil, subsequent tries sets and increments the value with
73
+ # one starting at zero.
74
+ i = (i || 0) + 1
75
+
76
+ # Make sure the generated path is unique.
77
+ end while File.exists?(@path)
78
+ end
79
+
80
+ # Extract the package
81
+ extract!
82
+
83
+ # Detect and read imsmanifest.xml
84
+ if exists?('imsmanifest.xml')
85
+ @manifest = Manifest.new(self, file('imsmanifest.xml'))
86
+ else
87
+ raise InvalidPackage, "#{File.basename(@package)}: no imsmanifest.xml, maybe not SCORM compatible?"
88
+ end
89
+
90
+ # Yield to the caller.
91
+ yield(self)
92
+
93
+ # Make sure the package is closed when the caller has finished reading it.
94
+ self.close
95
+
96
+ # If an exception occur the package is auto-magically closed and any
97
+ # residual data deleted in a clean way.
98
+ rescue Exception => e
99
+ self.close
100
+ self.cleanup
101
+ raise e
102
+ end
103
+
104
+ # Closes the package.
105
+ def close
106
+ @zipfile.close if @zipfile
107
+
108
+ # Make sure the extracted package is deleted if force_cleanup_on_close
109
+ # is enabled.
110
+ self.cleanup if @options[:force_cleanup_on_close]
111
+ end
112
+
113
+ # Cleans up by deleting all extracted files. Called when an error occurs.
114
+ def cleanup
115
+ FileUtils.rmtree(@path) if @options[:cleanup] && !@options[:dry_run] && @path && File.exists?(@path) && package?
116
+ end
117
+
118
+ # Extracts the content of the package to the course repository. This will be
119
+ # done automatically when opening a package so this method will rarely be
120
+ # used. If the +dry_run+ option was set to +true+ when the package was
121
+ # opened nothing will happen. This behavior can be overridden with the
122
+ # +force+ parameter.
123
+ def extract!(force = false)
124
+ return if @options[:dry_run] && !force
125
+
126
+ # If opening an already extracted package; do nothing.
127
+ if not package?
128
+ return
129
+ end
130
+
131
+ # Create the path to the course
132
+ FileUtils.mkdir_p(@path)
133
+
134
+ Zip::ZipFile::foreach(@package) do |entry|
135
+ entry_path = File.join(@path, entry.name)
136
+ entry_dir = File.dirname(entry_path)
137
+ FileUtils.mkdir_p(entry_dir) unless File.exists?(entry_dir)
138
+ entry.extract(entry_path)
139
+ end
140
+ end
141
+
142
+ # This will only return +true+ if what was opened was an actual zip file.
143
+ def package?
144
+ # If the path to the course in the repository is the same as the path to
145
+ # the package, we can assume that what was really opened was a directory
146
+ # in the course repository and therefor not a package.
147
+ return false if File.extname(@package)
148
+ return true
149
+ end
150
+
151
+ # Reads a file from the package. If the file is not extracted yet (all files
152
+ # are extracted by default when opening the package) it will be extracted
153
+ # to the file system and its content returned. If the +dry_run+ option was
154
+ # set to +true+ when opening the package the file will <em>not</em> be
155
+ # extracted to the file system, but read directly into memory.
156
+ def file(filename)
157
+ if File.exists?(@path)
158
+ File.read(path_to(filename))
159
+ else
160
+ Zip::ZipFile.foreach(@package) do |entry|
161
+ return entry.get_input_stream {|io| io.read } if entry.name == filename
162
+ end
163
+ end
164
+ end
165
+
166
+ # Returns +true+ if the specified file (or directory) exists in the package.
167
+ def exists?(filename)
168
+ if File.exists?(@path)
169
+ File.exists?(path_to(filename))
170
+ else
171
+ Zip::ZipFile::foreach(@package) do |entry|
172
+ return true if entry.name == filename
173
+ end
174
+ false
175
+ end
176
+ end
177
+
178
+ # Computes the absolute path to a file in an extracted package given its
179
+ # relative path. The argument +relative+ can be used to get the path
180
+ # relative to the course repository.
181
+ #
182
+ # Ex.
183
+ # <tt>pkg.path => '/var/lms/courses/MyCourse/'</tt>
184
+ # <tt>pkg.course_repository => '/var/lms/courses/'</tt>
185
+ # <tt>path_to('images/myimg.jpg') => '/var/lms/courses/MyCourse/images/myimg.jpg'</tt>
186
+ # <tt>path_to('images/myimg.jpg', true) => 'MyCourse/images/myimg.jpg'</tt>
187
+ #
188
+ def path_to(relative_filename, relative = false)
189
+ if relative
190
+ File.join(@name, relative_filename)
191
+ else
192
+ File.join(@path, relative_filename)
193
+ end
194
+ end
195
+
196
+ # Returns an array with the paths to all the files in the package.
197
+ def files
198
+ if File.directory?(@package)
199
+ Dir.glob(File.join(File.join(File.expand_path(@package), '**'), '*')).reject {|f|
200
+ File.directory?(f) }.map {|f| f.sub(/^#{File.expand_path(@package)}\/?/, '') }
201
+ else
202
+ entries = []
203
+ Zip::ZipFile::foreach(@package) do |entry|
204
+ entries << entry.name unless entry.name[-1..-1] == '/'
205
+ end
206
+ entries
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,49 @@
1
+ module Scorm
2
+
3
+ # A +Resource+ is a representation/description of an actual resource (image,
4
+ # sco, pdf, etc...) in a SCORM package.
5
+ class Resource
6
+ attr_accessor :id
7
+ attr_accessor :type
8
+ attr_accessor :scorm_type
9
+ attr_accessor :href
10
+ attr_accessor :metadata
11
+ attr_accessor :files
12
+ attr_accessor :dependencies
13
+
14
+ def initialize(id, type, scorm_type, href = nil, metadata = nil, files = nil, dependencies = nil)
15
+ raise InvalidManifest, 'Missing resource id' if id.nil?
16
+ raise InvalidManifest, 'Missing resource type' if type.nil?
17
+ breakpoint if scorm_type.nil?
18
+ raise InvalidManifest, 'Missing resource scormType' if scorm_type.nil?
19
+ @id = id.to_s
20
+ @type = type.to_s
21
+ @scorm_type = scorm_type.to_s
22
+ @href = href.to_s || ''
23
+ @metadata = metadata || Hash.new
24
+ @files = files || []
25
+ @dependencies = dependencies || []
26
+ end
27
+
28
+ def self.from_xml(element)
29
+ metadata = nil
30
+ files = []
31
+ REXML::XPath.each(element, 'file') do |file_el|
32
+ files << element.attribute('xml:base').to_s + file_el.attribute('href').to_s
33
+ end
34
+ dependencies = []
35
+ REXML::XPath.each(element, 'dependency') do |dep_el|
36
+ dependencies << dep_el.attribute('identifierref').to_s
37
+ end
38
+
39
+ res = self.new(
40
+ element.attribute('identifier'),
41
+ element.attribute('type'),
42
+ element.attribute('scormType', 'adlcp') || element.attribute('scormtype', 'adlcp'),
43
+ element.attribute('xml:base').to_s + element.attribute('href').to_s,
44
+ metadata,
45
+ files,
46
+ dependencies)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,110 @@
1
+ <?xml version="1.0"?>
2
+ <!-- filename=adlcp_rootv1p2.xsd -->
3
+ <!-- Conforms to w3c http://www.w3.org/TR/xmlschema-1/ 2000-10-24-->
4
+
5
+ <xsd:schema xmlns="http://www.adlnet.org/xsd/adlcp_rootv1p2"
6
+ targetNamespace="http://www.adlnet.org/xsd/adlcp_rootv1p2"
7
+ xmlns:xml="http://www.w3.org/XML/1998/namespace"
8
+ xmlns:imscp="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
9
+ xmlns:xsd="http://www.w3.org/2001/XMLSchema"
10
+ elementFormDefault="unqualified"
11
+ version="ADL Version 1.2">
12
+
13
+ <xsd:import namespace="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
14
+ schemaLocation="imscp_rootv1p1p2.xsd"/>
15
+
16
+ <xsd:element name="location" type="locationType"/>
17
+ <xsd:element name="prerequisites" type="prerequisitesType"/>
18
+ <xsd:element name="maxtimeallowed" type="maxtimeallowedType"/>
19
+ <xsd:element name="timelimitaction" type="timelimitactionType"/>
20
+ <xsd:element name="datafromlms" type="datafromlmsType"/>
21
+ <xsd:element name="masteryscore" type="masteryscoreType"/>
22
+
23
+
24
+ <xsd:element name="schema" type="newSchemaType"/>
25
+ <xsd:simpleType name="newSchemaType">
26
+ <xsd:restriction base="imscp:schemaType">
27
+ <xsd:enumeration value="ADL SCORM"/>
28
+ </xsd:restriction>
29
+ </xsd:simpleType>
30
+
31
+ <xsd:element name="schemaversion" type="newSchemaversionType"/>
32
+ <xsd:simpleType name="newSchemaversionType">
33
+ <xsd:restriction base="imscp:schemaversionType">
34
+ <xsd:enumeration value="1.2"/>
35
+ </xsd:restriction>
36
+ </xsd:simpleType>
37
+
38
+
39
+ <xsd:attribute name="scormtype">
40
+ <xsd:simpleType>
41
+ <xsd:restriction base="xsd:string">
42
+ <xsd:enumeration value="asset"/>
43
+ <xsd:enumeration value="sco"/>
44
+ </xsd:restriction>
45
+ </xsd:simpleType>
46
+ </xsd:attribute>
47
+
48
+ <xsd:simpleType name="locationType">
49
+ <xsd:restriction base="xsd:string">
50
+ <xsd:maxLength value="2000"/>
51
+ </xsd:restriction>
52
+ </xsd:simpleType>
53
+
54
+
55
+ <xsd:complexType name="prerequisitesType">
56
+ <xsd:simpleContent>
57
+ <xsd:extension base="prerequisiteStringType">
58
+ <xsd:attributeGroup ref="attr.prerequisitetype"/>
59
+ </xsd:extension>
60
+ </xsd:simpleContent>
61
+ </xsd:complexType>
62
+
63
+ <xsd:attributeGroup name="attr.prerequisitetype">
64
+ <xsd:attribute name="type" use="required">
65
+ <xsd:simpleType>
66
+ <xsd:restriction base="xsd:string">
67
+ <xsd:enumeration value="aicc_script"/>
68
+ </xsd:restriction>
69
+ </xsd:simpleType>
70
+ </xsd:attribute>
71
+ </xsd:attributeGroup>
72
+
73
+ <xsd:simpleType name="maxtimeallowedType">
74
+ <xsd:restriction base="xsd:string">
75
+ <xsd:maxLength value="13"/>
76
+ </xsd:restriction>
77
+ </xsd:simpleType>
78
+
79
+ <xsd:simpleType name="timelimitactionType">
80
+ <xsd:restriction base="stringType">
81
+ <xsd:enumeration value="exit,no message"/>
82
+ <xsd:enumeration value="exit,message"/>
83
+ <xsd:enumeration value="continue,no message"/>
84
+ <xsd:enumeration value="continue,message"/>
85
+ </xsd:restriction>
86
+ </xsd:simpleType>
87
+
88
+ <xsd:simpleType name="datafromlmsType">
89
+ <xsd:restriction base="xsd:string">
90
+ <xsd:maxLength value="255"/>
91
+ </xsd:restriction>
92
+ </xsd:simpleType>
93
+
94
+ <xsd:simpleType name="masteryscoreType">
95
+ <xsd:restriction base="xsd:string">
96
+ <xsd:maxLength value="200"/>
97
+ </xsd:restriction>
98
+ </xsd:simpleType>
99
+
100
+ <xsd:simpleType name="stringType">
101
+ <xsd:restriction base="xsd:string"/>
102
+ </xsd:simpleType>
103
+
104
+ <xsd:simpleType name="prerequisiteStringType">
105
+ <xsd:restriction base="xsd:string">
106
+ <xsd:maxLength value="200"/>
107
+ </xsd:restriction>
108
+ </xsd:simpleType>
109
+
110
+ </xsd:schema>