bigbluebutton-api-ruby 1.1.1 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG.rdoc CHANGED
@@ -1,3 +1,10 @@
1
+ === 1.2.0
2
+
3
+ * Allow non standard options to be passed to some API calls. These API calls are: create_meeting, join_meeting_url,
4
+ join_meeting, get_recordings. Useful for development of for custom versions of BigBlueButton.
5
+ * Accept :record as boolean in create_meeting
6
+ * Better formatting of data returned by get_recordings
7
+
1
8
  === 1.1.1
2
9
 
3
10
  * BigBlueButtonApi can now receive http headers to be sent in all get/post requests
data/Gemfile.lock CHANGED
@@ -7,7 +7,7 @@ GIT
7
7
  PATH
8
8
  remote: .
9
9
  specs:
10
- bigbluebutton-api-ruby (1.1.1)
10
+ bigbluebutton-api-ruby (1.2.0)
11
11
  xml-simple (>= 1.1.1)
12
12
 
13
13
  GEM
data/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2011 Leonardo Crauss Daronco
1
+ Copyright (c) 2011-2013 Mconf (http://mconf.org)
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining a copy of
4
4
  this software and associated documentation files (the "Software"), to deal in
@@ -17,7 +17,8 @@ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
17
  IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
18
  CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
19
19
 
20
- This software is developed at:
21
- GT-Mconf: Multiconference system for interoperable web and mobile
20
+ This project is developed as part of Mconf (http://mconf.org).
21
+ Contact information:
22
+ Mconf: A scalable opensource multiconference system for web and mobile devices
22
23
  PRAV Labs - UFRGS - Porto Alegre - Brazil
23
24
  http://www.inf.ufrgs.br/prav/gtmconf
data/README.rdoc CHANGED
@@ -53,6 +53,6 @@ See {LICENSE}[https://github.com/mconf/bigbluebutton-api-ruby/blob/master/LICENS
53
53
 
54
54
  == Contact
55
55
 
56
- <b>Version 0.0.4+</b>: Leonardo Crauss Daronco (leonardodaronco@gmail.com), GT-Mconf: Multiconference system for interoperable web and mobile @ PRAV Labs - UFRGS. Home page: http://www.inf.ufrgs.br/prav/gtmconf
56
+ <b>Version 0.0.4+</b>: This project is developed as part of Mconf (http://mconf.org). Contact: Leonardo Crauss Daronco (leonardodaronco@gmail.com), Mconf: A scalable opensource multiconference system for web and mobile devices @ PRAV Labs - UFRGS. Home page: http://www.inf.ufrgs.br/prav/gtmconf
57
57
 
58
58
  <b>Version 0.0.3 and below</b>: Joe Kinsella (joe.kinsella@gmail.com), Home page: http://www.brownbaglunch.com/bigbluebutton
@@ -2,7 +2,7 @@ $:.push File.expand_path("../lib", __FILE__)
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.name = 'bigbluebutton-api-ruby'
5
- s.version = '1.1.1'
5
+ s.version = '1.2.0'
6
6
  s.extra_rdoc_files = ['README.rdoc', 'LICENSE', 'LICENSE_003', 'CHANGELOG.rdoc']
7
7
  s.summary = 'Provides an interface to the BigBlueButton web meeting API (https://github.com/mconf/bigbluebutton-api-ruby)'
8
8
  s.description = s.summary
@@ -30,6 +30,14 @@ begin
30
30
  puts " " + m[:meetingID] + ": " + m.inspect
31
31
  end
32
32
 
33
+ puts
34
+ puts "---------------------------------------------------"
35
+ response = @api.get_recordings
36
+ puts "Existent recordings in your server:"
37
+ response[:recordings].each do |m|
38
+ puts " " + m[:recordID] + ": " + m.inspect
39
+ end
40
+
33
41
  puts
34
42
  puts "---------------------------------------------------"
35
43
  meeting_id = SecureRandom.hex(4)
@@ -102,7 +102,7 @@ module BigBlueButton
102
102
  #
103
103
  # options = { :moderatorPW => "123", :attendeePW => "321", :welcome => "Welcome here!",
104
104
  # :dialNumber => 5190909090, :logoutURL => "http://mconf.org", :maxParticipants => 25,
105
- # :voiceBridge => 76543, :record => "true", :duration => 0, :meta_category => "Remote Class" }
105
+ # :voiceBridge => 76543, :record => true, :duration => 0, :meta_category => "Remote Class" }
106
106
  # create_meeting("My Meeting", "my-meeting", options)
107
107
  #
108
108
  # === Example with modules (see BigBlueButtonModules docs for more)
@@ -142,19 +142,12 @@ module BigBlueButton
142
142
  # }
143
143
  #
144
144
  def create_meeting(meeting_name, meeting_id, options={}, modules=nil)
145
- valid_options = [:moderatorPW, :attendeePW, :welcome, :maxParticipants,
146
- :dialNumber, :voiceBridge, :webVoice, :logoutURL]
145
+ params = { :name => meeting_name, :meetingID => meeting_id }.merge(options)
147
146
 
148
- selected_opt = options.clone
149
- if @version >= "0.8"
150
- # v0.8 added "record", "duration" and "meta_" parameters
151
- valid_options += [:record, :duration]
152
- selected_opt.reject!{ |k,v| !valid_options.include?(k) and !(k.to_s =~ /^meta_.*$/) }
153
- selected_opt[:record] = selected_opt[:record].to_s if selected_opt.has_key?(:record)
154
- else
155
- selected_opt.reject!{ |k,v| !valid_options.include?(k) }
147
+ # :record is passed as string, but we accept boolean as well
148
+ if params[:record] and !!params[:record] == params[:record]
149
+ params[:record] = params[:record].to_s
156
150
  end
157
- params = { :name => meeting_name, :meetingID => meeting_id }.merge(selected_opt)
158
151
 
159
152
  # with modules we send a post request (only for >= 0.8)
160
153
  if modules and @version >= "0.8"
@@ -209,12 +202,7 @@ module BigBlueButton
209
202
  # userID (string, int), webVoiceConf (string, int) and createTime (int).
210
203
  # For details about each see BBB API docs.
211
204
  def join_meeting_url(meeting_id, user_name, password, options={})
212
- valid_options = [:userID, :webVoiceConf]
213
- valid_options += [:createTime] if @version >= "0.8"
214
- options.reject!{ |k,v| !valid_options.include?(k) }
215
-
216
205
  params = { :meetingID => meeting_id, :password => password, :fullName => user_name }.merge(options)
217
-
218
206
  get_url(:join, params)
219
207
  end
220
208
 
@@ -232,12 +220,7 @@ module BigBlueButton
232
220
  # userID (string, int), webVoiceConf (string, int) and createTime (int).
233
221
  # For details about each see BBB API docs.
234
222
  def join_meeting(meeting_id, user_name, password, options={})
235
- valid_options = [:userID, :webVoiceConf]
236
- valid_options += [:createTime] if @version >= "0.8"
237
- options.reject!{ |k,v| !valid_options.include?(k) }
238
-
239
223
  params = { :meetingID => meeting_id, :password => password, :fullName => user_name }.merge(options)
240
-
241
224
  send_api_request(:join, params)
242
225
  end
243
226
 
@@ -361,24 +344,30 @@ module BigBlueButton
361
344
  # :meetingID => ["id1", "id2", "id3"]
362
345
  #
363
346
  # === Example responses
364
- # TODO: this example is not accurate yet
365
347
  #
366
348
  # { :returncode => true,
367
349
  # :recordings => [
368
350
  # {
369
- # :recordID => "7f5745a08b24fa27551e7a065849dda3ce65dd32-1321618219268", :meetingID=>"bd1811beecd20f24314819a52ec202bf446ab94b",
370
- # :name => "Evening Class1", :published => true,
351
+ # :recordID => "7f5745a08b24fa27551e7a065849dda3ce65dd32-1321618219268",
352
+ # :meetingID=>"bd1811beecd20f24314819a52ec202bf446ab94b",
353
+ # :name => "Evening Class1",
354
+ # :published => true,
371
355
  # :startTime => #<DateTime: 2011-11-18T12:10:23+00:00 (212188378223/86400,0/1,2299161)>,
372
356
  # :endTime => #<DateTime: 2011-11-18T12:12:25+00:00 (42437675669/17280,0/1,2299161)>,
373
- # :metadata => { :course => "Fundamentals Of JAVA",
357
+ # :metadata => { :course => "Fundamentals of JAVA",
374
358
  # :description => "List of recordings",
375
359
  # :activity => "Evening Class1" },
376
360
  # :playback => {
377
- # :format => {
378
- # :type => "slides",
379
- # :url => "http://test-install.blindsidenetworks.com/playback/slides/playback.html?meetingId=7f5745a08b24fa27551e7a065849dda3ce65dd32-1321618219268",
380
- # :length=>3
381
- # }
361
+ # :format => [
362
+ # { :type => "slides",
363
+ # :url => "http://test-install.blindsidenetworks.com/playback/slides/playback.html?meetingId=125468758b24fa27551e7a065849dda3ce65dd32-1329872486268",
364
+ # :length => 64
365
+ # },
366
+ # { :type => "presentation",
367
+ # :url => "http://test-install.blindsidenetworks.com/presentation/slides/playback.html?meetingId=125468758b24fa27551e7a065849dda3ce65dd32-1329872486268",
368
+ # :length => 64
369
+ # }
370
+ # ]
382
371
  # }
383
372
  # },
384
373
  # { :recordID => "183f0bf3a0982a127bdb8161-13085974450", :meetingID => "CS102",
@@ -391,9 +380,6 @@ module BigBlueButton
391
380
  def get_recordings(options={})
392
381
  raise BigBlueButtonException.new("Method only supported for versions >= 0.8") if @version < "0.8"
393
382
 
394
- valid_options = [:meetingID]
395
- options.reject!{ |k,v| !valid_options.include?(k) }
396
-
397
383
  # ["id1", "id2", "id3"] becomes "id1,id2,id3"
398
384
  if options.has_key?(:meetingID)
399
385
  options[:meetingID] = options[:meetingID].join(",") if options[:meetingID].instance_of?(Array)
@@ -424,7 +410,7 @@ module BigBlueButton
424
410
  raise BigBlueButtonException.new("Method only supported for versions >= 0.8") if @version < "0.8"
425
411
 
426
412
  recordIDs = recordIDs.join(",") if recordIDs.instance_of?(Array) # ["id1", "id2"] becomes "id1,id2"
427
- response = send_api_request(:publishRecordings, { :recordID => recordIDs, :publish => publish.to_s })
413
+ send_api_request(:publishRecordings, { :recordID => recordIDs, :publish => publish.to_s })
428
414
  end
429
415
 
430
416
  # Delete one or more recordings for a given recordID (or set of record IDs).
@@ -443,7 +429,7 @@ module BigBlueButton
443
429
  raise BigBlueButtonException.new("Method only supported for versions >= 0.8") if @version < "0.8"
444
430
 
445
431
  recordIDs = recordIDs.join(",") if recordIDs.instance_of?(Array) # ["id1", "id2"] becomes "id1,id2"
446
- response = send_api_request(:deleteRecordings, { :recordID => recordIDs })
432
+ send_api_request(:deleteRecordings, { :recordID => recordIDs })
447
433
  end
448
434
 
449
435
 
@@ -573,5 +559,3 @@ module BigBlueButton
573
559
 
574
560
  end
575
561
  end
576
-
577
-
@@ -117,6 +117,25 @@ module BigBlueButton
117
117
  f.to_boolean(:published)
118
118
  f.to_datetime(:startTime)
119
119
  f.to_datetime(:endTime)
120
+ if rec[:playback] and rec[:playback][:format]
121
+ if rec[:playback][:format].is_a?(Hash)
122
+ f2 = BigBlueButtonFormatter.new(rec[:playback][:format])
123
+ f2.to_int(:length)
124
+ elsif rec[:playback][:format].is_a?(Array)
125
+ rec[:playback][:format].each do |format|
126
+ f2 = BigBlueButtonFormatter.new(format)
127
+ f2.to_int(:length)
128
+ end
129
+ end
130
+ end
131
+ if rec[:metadata]
132
+ rec[:metadata].each do |key, value|
133
+ if value.nil? or value.empty? or value.split.empty?
134
+ # removes any no {}s, []s, or " "s, should always be empty string
135
+ rec[:metadata][key] = ""
136
+ end
137
+ end
138
+ end
120
139
  rec
121
140
  end
122
141
 
@@ -107,10 +107,10 @@ describe BigBlueButton::BigBlueButtonApi do
107
107
  it { expect { api.get_recordings }.to raise_error(BigBlueButton::BigBlueButtonException) }
108
108
  end
109
109
 
110
- context "discards invalid options" do
111
- let(:req_params) { { :meetingID => "meeting-id" } }
112
- before { api.should_receive(:send_api_request).with(:getRecordings, req_params).and_return(response) }
113
- it { api.get_recordings({ :meetingID => "meeting-id", :invalidParam1 => "1" }) }
110
+ context "accepts non standard options" do
111
+ let(:params) { { :meetingID => "meeting-id", :nonStandard => 1 } }
112
+ before { api.should_receive(:send_api_request).with(:getRecordings, params).and_return(response) }
113
+ it { api.get_recordings(params) }
114
114
  end
115
115
 
116
116
  context "without meeting ID" do
@@ -46,7 +46,7 @@ describe BigBlueButton::BigBlueButtonApi do
46
46
  let(:req_params) {
47
47
  { :name => "name", :meetingID => "meeting-id", :moderatorPW => "mp", :attendeePW => "ap",
48
48
  :welcome => "Welcome!", :dialNumber => 12345678, :logoutURL => "http://example.com",
49
- :maxParticipants => 25, :voiceBridge => 12345, :webVoice => "12345abc" }
49
+ :maxParticipants => 25, :voiceBridge => 12345, :webVoice => "12345abc", :record => "true" }
50
50
  }
51
51
  let(:req_response) {
52
52
  { :meetingID => 123, :moderatorPW => 111, :attendeePW => 222, :hasBeenForciblyEnded => "FALSE" }
@@ -58,32 +58,33 @@ describe BigBlueButton::BigBlueButtonApi do
58
58
  # ps: not mocking the formatter here because it's easier to just check the results (final_response)
59
59
  before { api.should_receive(:send_api_request).with(:create, req_params).and_return(req_response) }
60
60
  subject {
61
- options = { :moderatorPW => "mp", :attendeePW => "ap", :welcome => "Welcome!", :dialNumber => 12345678,
62
- :logoutURL => "http://example.com", :maxParticipants => 25, :voiceBridge => 12345, :webVoice => "12345abc" }
61
+ options = { :moderatorPW => "mp", :attendeePW => "ap", :welcome => "Welcome!",
62
+ :dialNumber => 12345678, :logoutURL => "http://example.com", :maxParticipants => 25,
63
+ :voiceBridge => 12345, :webVoice => "12345abc", :record => "true" }
63
64
  api.create_meeting("name", "meeting-id", options)
64
65
  }
65
66
  it { subject.should == final_response }
66
67
  end
67
68
 
68
- context "discards invalid options" do
69
- let(:req_params) {
70
- { :name => "name", :meetingID => "meeting-id", :moderatorPW => "mp", :attendeePW => "ap" }
71
- }
72
- before { api.should_receive(:send_api_request).with(:create, req_params) }
73
- it {
74
- options = { :invalidParam => "1", :moderatorPW => "mp", :attendeePW => "ap", :invalidParam2 => "1" }
75
- api.create_meeting("name", "meeting-id", options)
69
+ context "accepts non standard options" do
70
+ let(:params) {
71
+ { :name => "name", :meetingID => "meeting-id",
72
+ :moderatorPW => "mp", :attendeePW => "ap", :nonStandard => 1 }
76
73
  }
74
+ before { api.should_receive(:send_api_request).with(:create, params) }
75
+ it { api.create_meeting("name", "meeting-id", params) }
77
76
  end
78
77
 
79
- context "discards options for >0.7" do
78
+ context "accepts :record as boolean" do
80
79
  let(:req_params) {
81
- { :name => "name", :meetingID => "meeting-id" }
80
+ { :name => "name", :meetingID => "meeting-id",
81
+ :moderatorPW => "mp", :attendeePW => "ap", :record => "true" }
82
82
  }
83
83
  before { api.should_receive(:send_api_request).with(:create, req_params) }
84
84
  it {
85
- options = { :record => true, :duration => 25, :meta_any => "meta" }
86
- api.create_meeting("name", "meeting-id", options)
85
+ params = { :name => "name", :meetingID => "meeting-id",
86
+ :moderatorPW => "mp", :attendeePW => "ap", :record => true }
87
+ api.create_meeting("name", "meeting-id", params)
87
88
  }
88
89
  end
89
90
  end
@@ -129,26 +130,13 @@ describe BigBlueButton::BigBlueButtonApi do
129
130
  }
130
131
  end
131
132
 
132
- context "discards invalid options" do
133
+ context "accepts non standard options" do
133
134
  let(:params) {
134
- { :meetingID => "meeting-id", :password => "pw", :fullName => "Name", :userID => "id123" }
135
+ { :meetingID => "meeting-id", :password => "pw",
136
+ :fullName => "Name", :userID => "id123", :nonStandard => 1 }
135
137
  }
136
138
  before { api.should_receive(:get_url).with(:join, params) }
137
- it {
138
- options = { :invalidParam => "1", :userID => "id123", :invalidParam2 => "1" }
139
- api.join_meeting_url("meeting-id", "Name", "pw", options)
140
- }
141
- end
142
-
143
- context "discards options for <= 0.7" do
144
- let(:params) {
145
- { :meetingID => "meeting-id", :password => "pw", :fullName => "Name" }
146
- }
147
- before { api.should_receive(:get_url).with(:join, params) }
148
- it {
149
- options = { :createTime => 123456789 }
150
- api.join_meeting_url("meeting-id", "Name", "pw", options)
151
- }
139
+ it { api.join_meeting_url("meeting-id", "Name", "pw", params) }
152
140
  end
153
141
  end
154
142
 
@@ -166,26 +154,13 @@ describe BigBlueButton::BigBlueButtonApi do
166
154
  }
167
155
  end
168
156
 
169
- context "discards invalid options" do
170
- let(:params) {
171
- { :meetingID => "meeting-id", :password => "pw", :fullName => "Name", :userID => "id123" }
172
- }
173
- before { api.should_receive(:send_api_request).with(:join, params) }
174
- it {
175
- options = { :invalidParam => "1", :userID => "id123", :invalidParam2 => "1" }
176
- api.join_meeting("meeting-id", "Name", "pw", options)
177
- }
178
- end
179
-
180
- context "discards options for <= 0.7" do
157
+ context "accepts non standard options" do
181
158
  let(:params) {
182
- { :meetingID => "meeting-id", :password => "pw", :fullName => "Name" }
159
+ { :meetingID => "meeting-id", :password => "pw",
160
+ :fullName => "Name", :userID => "id123", :nonStandard => 1 }
183
161
  }
184
162
  before { api.should_receive(:send_api_request).with(:join, params) }
185
- it {
186
- options = { :createTime => 123456789 }
187
- api.join_meeting("meeting-id", "Name", "pw", options)
188
- }
163
+ it { api.join_meeting("meeting-id", "Name", "pw", params) }
189
164
  end
190
165
  end
191
166
 
@@ -224,28 +224,51 @@ describe BigBlueButton::BigBlueButtonFormatter do
224
224
  end
225
225
 
226
226
  describe ".format_recording" do
227
- let(:hash) { { :recordID => 123, :meetingID => 123, :name => 123, :published => "true",
228
- :startTime => "Thu Mar 04 14:05:56 UTC 2010",
229
- :endTime => "Thu Mar 04 15:01:01 UTC 2010",
230
- :metadata => {
231
- :title => "Test Recording", :subject => "English 232 session",
232
- :description => "First Class", :creator => "Fred Dixon",
233
- :contributor => "Richard Alam", :language => "en_US"
234
- },
235
- :playback => {
236
- :format => {
237
- :type => "simple",
238
- :url => "http://server.com/simple/playback?recordID=183f0bf3a0982a127bdb8161-1",
239
- :length => 62 }
240
- }
241
- } }
242
-
243
- subject { BigBlueButton::BigBlueButtonFormatter.format_recording(hash) }
244
- it { subject[:recordID].should == "123" }
245
- it { subject[:meetingID].should == "123" }
246
- it { subject[:name].should == "123" }
247
- it { subject[:startTime].should == DateTime.parse("Thu Mar 04 14:05:56 UTC 2010") }
248
- it { subject[:endTime].should == DateTime.parse("Thu Mar 04 15:01:01 UTC 2010") }
227
+ let(:hash) {
228
+ { :recordID => 123, :meetingID => 123, :name => 123, :published => "true",
229
+ :startTime => "Thu Mar 04 14:05:56 UTC 2010",
230
+ :endTime => "Thu Mar 04 15:01:01 UTC 2010",
231
+ :metadata => {
232
+ :title => "Test Recording",
233
+ :empty1 => nil,
234
+ :empty2 => {},
235
+ :empty3 => [],
236
+ :empty4 => " ",
237
+ :empty5 => "\n\t"
238
+ },
239
+ :playback => {
240
+ :format => [
241
+ { :type => "simple",
242
+ :url => "http://server.com/simple/playback?recordID=183f0bf3a0982a127bdb8161-1",
243
+ :length => "62" },
244
+ { :type => "simple",
245
+ :url => "http://server.com/simple/playback?recordID=183f0bf3a0982a127bdb8161-1",
246
+ :length => "48" }
247
+ ]
248
+ }
249
+ }
250
+ }
251
+
252
+ context do
253
+ subject { BigBlueButton::BigBlueButtonFormatter.format_recording(hash) }
254
+ it { subject[:recordID].should == "123" }
255
+ it { subject[:meetingID].should == "123" }
256
+ it { subject[:name].should == "123" }
257
+ it { subject[:startTime].should == DateTime.parse("Thu Mar 04 14:05:56 UTC 2010") }
258
+ it { subject[:endTime].should == DateTime.parse("Thu Mar 04 15:01:01 UTC 2010") }
259
+ it { subject[:playback][:format][0][:length].should == 62 }
260
+ it { subject[:metadata][:empty1].should == "" }
261
+ it { subject[:metadata][:empty2].should == "" }
262
+ it { subject[:metadata][:empty3].should == "" }
263
+ it { subject[:metadata][:empty4].should == "" }
264
+ it { subject[:metadata][:empty5].should == "" }
265
+ end
266
+
267
+ context "doesn't fail without playback formats" do
268
+ before { hash.delete(:playback) }
269
+ subject { BigBlueButton::BigBlueButtonFormatter.format_recording(hash) }
270
+ it { subject[:playback].should == nil }
271
+ end
249
272
  end
250
273
 
251
274
  describe "#flatten_objects" do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bigbluebutton-api-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.2.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2012-06-02 00:00:00.000000000 Z
13
+ date: 2013-03-13 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: xml-simple
@@ -103,7 +103,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
103
103
  version: '0'
104
104
  segments:
105
105
  - 0
106
- hash: -1556921647631366101
106
+ hash: 1899678905392441618
107
107
  required_rubygems_version: !ruby/object:Gem::Requirement
108
108
  none: false
109
109
  requirements:
@@ -112,7 +112,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
112
112
  version: '0'
113
113
  segments:
114
114
  - 0
115
- hash: -1556921647631366101
115
+ hash: 1899678905392441618
116
116
  requirements: []
117
117
  rubyforge_project:
118
118
  rubygems_version: 1.8.24