scorm 1.0.0

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