MDL-62815 mod_lti: fix error when clicking 'Select content' option
[moodle.git] / mod / lti / mod_form.js
CommitLineData
61eb12d4
CS
1// This file is part of Moodle - http://moodle.org/
2//
3// Moodle is free software: you can redistribute it and/or modify
4// it under the terms of the GNU General Public License as published by
5// the Free Software Foundation, either version 3 of the License, or
6// (at your option) any later version.
7//
8// Moodle is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
15
16/**
8f45215d 17 * Javascript extensions for the External Tool activity editor.
61eb12d4
CS
18 *
19 * @package mod
20 * @subpackage lti
8f45215d 21 * @copyright Copyright (c) 2011 Moodlerooms Inc. (http://www.moodlerooms.com)
61eb12d4
CS
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
6831c7cd
CS
24(function(){
25 var Y;
e27cb316 26
6831c7cd
CS
27 M.mod_lti = M.mod_lti || {};
28
4c598b13
CS
29 M.mod_lti.LTI_SETTING_NEVER = 0;
30 M.mod_lti.LTI_SETTING_ALWAYS = 1;
31 M.mod_lti.LTI_SETTING_DELEGATE = 2;
32
6831c7cd
CS
33 M.mod_lti.editor = {
34 init: function(yui3, settings){
35 if(yui3){
36 Y = yui3;
996b0fd9 37 }
e27cb316 38
16e8f130 39 var self = this;
6831c7cd
CS
40 this.settings = Y.JSON.parse(settings);
41
42 this.urlCache = {};
038d8e30 43 this.toolTypeCache = {};
6831c7cd 44
d8d04121
CS
45 var updateToolMatches = function(){
46 self.updateAutomaticToolMatch(Y.one('#id_toolurl'));
47 self.updateAutomaticToolMatch(Y.one('#id_securetoolurl'));
48 };
49
6831c7cd 50 var typeSelector = Y.one('#id_typeid');
b93c99aa
JP
51 if (typeSelector) {
52 this.addOptGroups();
144c1497 53
b93c99aa
JP
54 typeSelector.on('change', function(e){
55 // Reset configuration fields when another preconfigured tool is selected.
56 self.resetToolFields();
e27cb316 57
b93c99aa 58 updateToolMatches();
e3f69b58 59
b93c99aa 60 self.toggleEditButtons();
e3f69b58 61
b93c99aa
JP
62 if (self.getSelectedToolTypeOption().getAttribute('toolproxy')){
63 var allowname = Y.one('#id_instructorchoicesendname');
64 allowname.set('checked', !self.getSelectedToolTypeOption().getAttribute('noname'));
e3f69b58 65
b93c99aa
JP
66 var allowemail = Y.one('#id_instructorchoicesendemailaddr');
67 allowemail.set('checked', !self.getSelectedToolTypeOption().getAttribute('noemail'));
e3f69b58 68
b93c99aa
JP
69 var allowgrades = Y.one('#id_instructorchoiceacceptgrades');
70 allowgrades.set('checked', !self.getSelectedToolTypeOption().getAttribute('nogrades'));
71 self.toggleGradeSection();
72 }
73 });
6831c7cd 74
b93c99aa 75 this.createTypeEditorButtons();
6831c7cd 76
b93c99aa
JP
77 this.toggleEditButtons();
78 }
79
80 var contentItemButton = Y.one('[name="selectcontent"]');
81 if (contentItemButton) {
82 var contentItemUrl = contentItemButton.getAttribute('data-contentitemurl');
83 // Handle configure from link button click.
84 contentItemButton.on('click', function() {
85 var contentItemId = self.getContentItemId();
86 if (contentItemId) {
87 // Get activity name and description values.
88 var title = Y.one('#id_name').get('value').trim();
89 var text = Y.one('#id_introeditor').get('value').trim();
90
91 // Set data to be POSTed.
92 var postData = {
93 id: contentItemId,
94 course: self.settings.courseId,
95 title: title,
96 text: text
97 };
98
99 require(['mod_lti/contentitem'], function(contentitem) {
100 contentitem.init(contentItemUrl, postData, function() {
101 M.mod_lti.editor.toggleGradeSection();
102 });
103 });
104 }
105 });
106 }
e27cb316 107
6831c7cd
CS
108 var textAreas = new Y.NodeList([
109 Y.one('#id_toolurl'),
d8d04121 110 Y.one('#id_securetoolurl'),
6831c7cd
CS
111 Y.one('#id_resourcekey'),
112 Y.one('#id_password')
113 ]);
e27cb316 114
6831c7cd
CS
115 var debounce;
116 textAreas.on('keyup', function(e){
117 clearTimeout(debounce);
118
7715c88d 119 // If no more changes within 2 seconds, look up the matching tool URL
6831c7cd 120 debounce = setTimeout(function(){
d8d04121 121 updateToolMatches();
6831c7cd
CS
122 }, 2000);
123 });
e27cb316 124
8fa50fdd
MN
125 var allowgrades = Y.one('#id_instructorchoiceacceptgrades');
126 allowgrades.on('change', this.toggleGradeSection, this);
127
b93c99aa
JP
128 if (typeSelector) {
129 updateToolMatches();
130 }
6831c7cd
CS
131 },
132
8fa50fdd
MN
133 toggleGradeSection: function(e) {
134 if (e) {
135 e.preventDefault();
136 }
137 var allowgrades = Y.one('#id_instructorchoiceacceptgrades');
9b3b8e51 138 var gradefieldset = Y.one('#id_modstandardgrade');
8fa50fdd
MN
139 if (!allowgrades.get('checked')) {
140 gradefieldset.hide();
141 } else {
142 gradefieldset.show();
143 }
144 },
145
16e8f130
CS
146 clearToolCache: function(){
147 this.urlCache = {};
038d8e30 148 this.toolTypeCache = {};
16e8f130
CS
149 },
150
d8d04121 151 updateAutomaticToolMatch: function(field){
dcb0f01f
DS
152 if (!field) {
153 return;
154 }
155
16e8f130 156 var self = this;
e27cb316 157
d8d04121 158 var toolurl = field;
606ab1a1 159 var typeSelector = Y.one('#id_typeid');
e27cb316 160
d8d04121
CS
161 var id = field.get('id') + '_lti_automatch_tool';
162 var automatchToolDisplay = Y.one('#' + id);
6831c7cd
CS
163
164 if(!automatchToolDisplay){
165 automatchToolDisplay = Y.Node.create('<span />')
d8d04121 166 .set('id', id)
6831c7cd 167 .setStyle('padding-left', '1em');
e27cb316 168
6831c7cd
CS
169 toolurl.insert(automatchToolDisplay, 'after');
170 }
171
172 var url = toolurl.get('value');
996b0fd9 173
7715c88d 174 // Hide the display if the url box is empty
16e8f130 175 if(!url){
6831c7cd 176 automatchToolDisplay.setStyle('display', 'none');
16e8f130
CS
177 } else {
178 automatchToolDisplay.set('innerHTML', '');
179 automatchToolDisplay.setStyle('display', '');
180 }
181
038d8e30 182 var selectedToolType = parseInt(typeSelector.get('value'));
89b7bbe9 183 var selectedOption = typeSelector.one('option[value="' + selectedToolType + '"]');
16e8f130 184
7715c88d
EL
185 // A specific tool type is selected (not "auto")
186 // We still need to check with the server to get privacy settings
16e8f130 187 if(selectedToolType > 0){
7715c88d 188 // If the entered domain matches the domain of the tool configuration...
16e8f130
CS
189 var domainRegex = /(?:https?:\/\/)?(?:www\.)?([^\/]+)(?:\/|$)/i;
190 var match = domainRegex.exec(url);
191 if(match && match[1] && match[1].toLowerCase() === selectedOption.getAttribute('domain').toLowerCase()){
64e7aa4d 192 automatchToolDisplay.set('innerHTML', '<img style="vertical-align:text-bottom" src="' + self.settings.green_check_icon_url + '" />' + M.util.get_string('using_tool_configuration', 'lti') + selectedOption.get('text'));
16e8f130 193 } else {
7715c88d 194 // The entered URL does not match the domain of the tool configuration
64e7aa4d 195 automatchToolDisplay.set('innerHTML', '<img style="vertical-align:text-bottom" src="' + self.settings.warning_icon_url + '" />' + M.util.get_string('domain_mismatch', 'lti'));
16e8f130 196 }
6831c7cd
CS
197 }
198
199 var key = Y.one('#id_resourcekey');
200 var secret = Y.one('#id_password');
201
7715c88d
EL
202 // Indicate the tool is manually configured
203 // We still check the Launch URL with the server as course/site tools may override privacy settings
6831c7cd 204 if(key.get('value') !== '' && secret.get('value') !== ''){
64e7aa4d 205 automatchToolDisplay.set('innerHTML', '<img style="vertical-align:text-bottom" src="' + self.settings.green_check_icon_url + '" />' + M.util.get_string('custom_config', 'lti'));
7715c88d
EL
206 }
207
8fa50fdd
MN
208 var continuation = function(toolInfo, inputfield){
209 if (inputfield === undefined || (inputfield.get('id') != 'id_securetoolurl' || inputfield.get('value'))) {
210 self.updatePrivacySettings(toolInfo);
211 }
4c598b13 212 if(toolInfo.toolname){
64e7aa4d 213 automatchToolDisplay.set('innerHTML', '<img style="vertical-align:text-bottom" src="' + self.settings.green_check_icon_url + '" />' + M.util.get_string('using_tool_configuration', 'lti') + toolInfo.toolname);
038d8e30 214 } else if(!selectedToolType) {
7715c88d 215 // Inform them custom configuration is in use
4c598b13 216 if(key.get('value') === '' || secret.get('value') === ''){
64e7aa4d 217 automatchToolDisplay.set('innerHTML', '<img style="vertical-align:text-bottom" src="' + self.settings.warning_icon_url + '" />' + M.util.get_string('tool_config_not_found', 'lti'));
4c598b13
CS
218 }
219 }
af9d3a92
JO
220 if (toolInfo.cartridge) {
221 automatchToolDisplay.set('innerHTML', '<img style="vertical-align:text-bottom" src="' + self.settings.green_check_icon_url +
222 '" />' + M.util.get_string('using_tool_cartridge', 'lti'));
223 }
4c598b13
CS
224 };
225
7715c88d
EL
226 // Cache urls which have already been checked to increase performance
227 // Don't use URL cache if tool type manually selected
038d8e30
CS
228 if(selectedToolType && self.toolTypeCache[selectedToolType]){
229 return continuation(self.toolTypeCache[selectedToolType]);
230 } else if(self.urlCache[url] && !selectedToolType){
231 return continuation(self.urlCache[url]);
232 } else if(!selectedToolType && !url) {
7715c88d 233 // No tool type or url set
8fa50fdd 234 return continuation({}, field);
4c598b13 235 } else {
038d8e30 236 self.findToolByUrl(url, selectedToolType, function(toolInfo){
4c598b13 237 if(toolInfo){
7715c88d 238 // Cache the result based on whether the URL or tool type was used to look up the tool
038d8e30
CS
239 if(!selectedToolType){
240 self.urlCache[url] = toolInfo;
241 } else {
242 self.toolTypeCache[selectedToolType] = toolInfo;
243 }
7715c88d 244
ea5d0515
AF
245 Y.one('#id_urlmatchedtypeid').set('value', toolInfo.toolid);
246
6831c7cd 247 continuation(toolInfo);
4c598b13
CS
248 }
249 });
250 }
251 },
252
253 /**
254 * Updates display of privacy settings to show course / site tool configuration settings.
255 */
256 updatePrivacySettings: function(toolInfo){
038d8e30 257 if(!toolInfo || !toolInfo.toolid){
4c598b13
CS
258 toolInfo = {
259 sendname: M.mod_lti.LTI_SETTING_DELEGATE,
260 sendemailaddr: M.mod_lti.LTI_SETTING_DELEGATE,
59fc5c54 261 acceptgrades: M.mod_lti.LTI_SETTING_DELEGATE
4c598b13
CS
262 }
263 }
7715c88d 264
4c598b13 265 var setting, control;
7715c88d 266
4c598b13 267 var privacyControls = {
af282bcc
DW
268 sendname: Y.one('#id_instructorchoicesendname'),
269 sendemailaddr: Y.one('#id_instructorchoicesendemailaddr'),
270 acceptgrades: Y.one('#id_instructorchoiceacceptgrades')
4c598b13 271 };
7715c88d
EL
272
273 // Store a copy of user entered privacy settings as we may overwrite them
4c598b13
CS
274 if(!this.userPrivacySettings){
275 this.userPrivacySettings = {};
276 }
277
278 for(setting in privacyControls){
279 if(privacyControls.hasOwnProperty(setting)){
280 control = privacyControls[setting];
281
7715c88d 282 // Only store the value if it hasn't been forced by the editor
4c598b13
CS
283 if(!control.get('disabled')){
284 this.userPrivacySettings[setting] = control.get('checked');
285 }
286 }
287 }
7715c88d
EL
288
289 // Update UI based on course / site tool configuration
4c598b13
CS
290 for(setting in privacyControls){
291 if(privacyControls.hasOwnProperty(setting)){
292 var settingValue = toolInfo[setting];
293 control = privacyControls[setting];
7715c88d 294
4c598b13
CS
295 if(settingValue == M.mod_lti.LTI_SETTING_NEVER){
296 control.set('disabled', true);
297 control.set('checked', false);
64e7aa4d 298 control.set('title', M.util.get_string('forced_help', 'lti'));
4c598b13
CS
299 } else if(settingValue == M.mod_lti.LTI_SETTING_ALWAYS){
300 control.set('disabled', true);
301 control.set('checked', true);
64e7aa4d 302 control.set('title', M.util.get_string('forced_help', 'lti'));
4c598b13
CS
303 } else if(settingValue == M.mod_lti.LTI_SETTING_DELEGATE){
304 control.set('disabled', false);
7715c88d
EL
305
306 // Get the value out of the stored copy
307 control.set('checked', this.userPrivacySettings[setting]);
4c598b13
CS
308 control.set('title', '');
309 }
6831c7cd
CS
310 }
311 }
8fa50fdd
MN
312
313 this.toggleGradeSection();
6831c7cd
CS
314 },
315
316 getSelectedToolTypeOption: function(){
317 var typeSelector = Y.one('#id_typeid');
318
89b7bbe9 319 return typeSelector.one('option[value="' + typeSelector.get('value') + '"]');
6831c7cd
CS
320 },
321
322 /**
323 * Separate tool listing into option groups. Server-side select control
324 * doesn't seem to support this.
325 */
326 addOptGroups: function(){
327 var typeSelector = Y.one('#id_typeid');
328
329 if(typeSelector.one('option[courseTool=1]')){
7715c88d 330 // One ore more course tools exist
6831c7cd
CS
331
332 var globalGroup = Y.Node.create('<optgroup />')
333 .set('id', 'global_tool_group')
64e7aa4d 334 .set('label', M.util.get_string('global_tool_types', 'lti'));
6831c7cd
CS
335
336 var courseGroup = Y.Node.create('<optgroup />')
337 .set('id', 'course_tool_group')
64e7aa4d 338 .set('label', M.util.get_string('course_tool_types', 'lti'));
6831c7cd 339
606ab1a1 340 var globalOptions = typeSelector.all('option[globalTool=1]').remove().each(function(node){
6831c7cd
CS
341 globalGroup.append(node);
342 });
343
606ab1a1 344 var courseOptions = typeSelector.all('option[courseTool=1]').remove().each(function(node){
6831c7cd 345 courseGroup.append(node);
996b0fd9 346 });
6831c7cd 347
606ab1a1
CS
348 if(globalOptions.size() > 0){
349 typeSelector.append(globalGroup);
350 }
e27cb316 351
606ab1a1
CS
352 if(courseOptions.size() > 0){
353 typeSelector.append(courseGroup);
354 }
996b0fd9 355 }
6831c7cd
CS
356 },
357
358 /**
359 * Adds buttons for creating, editing, and deleting tool types.
360 * Javascript is a requirement to edit course level tools at this point.
361 */
362 createTypeEditorButtons: function(){
16e8f130 363 var self = this;
e27cb316 364
6831c7cd
CS
365 var typeSelector = Y.one('#id_typeid');
366
367 var createIcon = function(id, tooltip, iconUrl){
e27cb316 368 return Y.Node.create('<a />')
6831c7cd
CS
369 .set('id', id)
370 .set('title', tooltip)
371 .setStyle('margin-left', '.5em')
372 .set('href', 'javascript:void(0);')
373 .append(Y.Node.create('<img src="' + iconUrl + '" />'));
374 }
375
64e7aa4d
AN
376 var addIcon = createIcon('lti_add_tool_type', M.util.get_string('addtype', 'lti'), this.settings.add_icon_url);
377 var editIcon = createIcon('lti_edit_tool_type', M.util.get_string('edittype', 'lti'), this.settings.edit_icon_url);
378 var deleteIcon = createIcon('lti_delete_tool_type', M.util.get_string('deletetype', 'lti'), this.settings.delete_icon_url);
6831c7cd
CS
379
380 editIcon.on('click', function(e){
381 var toolTypeId = typeSelector.get('value');
382
383 if(self.getSelectedToolTypeOption().getAttribute('editable')){
384 window.open(self.settings.instructor_tool_type_edit_url + '&action=edit&typeid=' + toolTypeId, 'edit_tool');
385 } else {
64e7aa4d 386 alert(M.util.get_string('cannot_edit', 'lti'));
6831c7cd
CS
387 }
388 });
389
390 addIcon.on('click', function(e){
391 window.open(self.settings.instructor_tool_type_edit_url + '&action=add', 'add_tool');
392 });
393
394 deleteIcon.on('click', function(e){
395 var toolTypeId = typeSelector.get('value');
396
397 if(self.getSelectedToolTypeOption().getAttribute('editable')){
64e7aa4d 398 if(confirm(M.util.get_string('delete_confirmation', 'lti'))){
16e8f130 399 self.deleteTool(toolTypeId);
6831c7cd
CS
400 }
401 } else {
64e7aa4d 402 alert(M.util.get_string('cannot_delete', 'lti'));
6831c7cd
CS
403 }
404 });
405
406 typeSelector.insert(addIcon, 'after');
407 addIcon.insert(editIcon, 'after');
408 editIcon.insert(deleteIcon, 'after');
409 },
410
411 toggleEditButtons: function(){
412 var lti_edit_tool_type = Y.one('#lti_edit_tool_type');
413 var lti_delete_tool_type = Y.one('#lti_delete_tool_type');
414
7715c88d
EL
415 // Make the edit / delete icons look enabled / disabled.
416 // Does not work in older browsers, but alerts will catch those cases.
6831c7cd
CS
417 if(this.getSelectedToolTypeOption().getAttribute('editable')){
418 lti_edit_tool_type.setStyle('opacity', '1');
419 lti_delete_tool_type.setStyle('opacity', '1');
420 } else {
421 lti_edit_tool_type.setStyle('opacity', '.2');
422 lti_delete_tool_type.setStyle('opacity', '.2');
423 }
424 },
425
16e8f130 426 addToolType: function(toolType){
6831c7cd
CS
427 var typeSelector = Y.one('#id_typeid');
428 var course_tool_group = Y.one('#course_tool_group');
429
430 var option = Y.Node.create('<option />')
16e8f130
CS
431 .set('text', toolType.name)
432 .set('value', toolType.id)
6831c7cd
CS
433 .set('selected', 'selected')
434 .setAttribute('editable', '1')
16e8f130
CS
435 .setAttribute('courseTool', '1')
436 .setAttribute('domain', toolType.tooldomain);
6831c7cd
CS
437
438 if(course_tool_group){
439 course_tool_group.append(option);
440 } else {
441 typeSelector.append(option);
442 }
e27cb316 443
7715c88d 444 // Adding the new tool may affect which tool gets matched automatically
16e8f130 445 this.clearToolCache();
89b7bbe9
CS
446 this.updateAutomaticToolMatch(Y.one('#id_toolurl'));
447 this.updateAutomaticToolMatch(Y.one('#id_securetoolurl'));
af9d3a92
JO
448 this.toggleEditButtons();
449
450 require(["core/notification"], function (notification) {
451 notification.addNotification({
452 message: M.util.get_string('tooltypeadded', 'lti'),
453 type: "success"
454 });
455 });
6831c7cd
CS
456 },
457
16e8f130 458 updateToolType: function(toolType){
6831c7cd
CS
459 var typeSelector = Y.one('#id_typeid');
460
89b7bbe9 461 var option = typeSelector.one('option[value="' + toolType.id + '"]');
16e8f130
CS
462 option.set('text', toolType.name)
463 .set('domain', toolType.tooldomain);
e27cb316 464
7715c88d 465 // Editing the tool may affect which tool gets matched automatically
16e8f130 466 this.clearToolCache();
89b7bbe9
CS
467 this.updateAutomaticToolMatch(Y.one('#id_toolurl'));
468 this.updateAutomaticToolMatch(Y.one('#id_securetoolurl'));
af9d3a92
JO
469
470 require(["core/notification"], function (notification) {
471 notification.addNotification({
472 message: M.util.get_string('tooltypeupdated', 'lti'),
473 type: "success"
474 });
475 });
16e8f130
CS
476 },
477
478 deleteTool: function(toolTypeId){
479 var self = this;
e27cb316 480
16e8f130
CS
481 Y.io(self.settings.instructor_tool_type_edit_url + '&action=delete&typeid=' + toolTypeId, {
482 on: {
483 success: function(){
484 self.getSelectedToolTypeOption().remove();
e27cb316 485
7715c88d 486 // Editing the tool may affect which tool gets matched automatically
16e8f130 487 self.clearToolCache();
89b7bbe9
CS
488 self.updateAutomaticToolMatch(Y.one('#id_toolurl'));
489 self.updateAutomaticToolMatch(Y.one('#id_securetoolurl'));
af9d3a92
JO
490
491 require(["core/notification"], function (notification) {
492 notification.addNotification({
493 message: M.util.get_string('tooltypedeleted', 'lti'),
494 type: "success"
495 });
496 });
16e8f130
CS
497 },
498 failure: function(){
af9d3a92
JO
499 require(["core/notification"], function (notification) {
500 notification.addNotification({
501 message: M.util.get_string('tooltypenotdeleted', 'lti'),
502 type: "problem"
503 });
504 });
16e8f130
CS
505 }
506 }
507 });
6831c7cd
CS
508 },
509
038d8e30 510 findToolByUrl: function(url, toolId, callback){
16e8f130 511 var self = this;
7715c88d 512
e27cb316 513 Y.io(self.settings.ajax_url, {
16e8f130 514 data: {action: 'find_tool_config',
6831c7cd 515 course: self.settings.courseId,
038d8e30
CS
516 toolurl: url,
517 toolid: toolId || 0
6831c7cd 518 },
996b0fd9 519
6831c7cd
CS
520 on: {
521 success: function(transactionid, xhr){
522 var response = xhr.response;
e27cb316 523
6831c7cd 524 var toolInfo = Y.JSON.parse(response);
e27cb316 525
6831c7cd
CS
526 callback(toolInfo);
527 },
528 failure: function(){
996b0fd9 529
6831c7cd
CS
530 }
531 }
532 });
d8f9109a 533 },
534
c1fae2b9
JP
535 /**
536 * Gets the tool type ID of the selected tool that supports Content-Item selection.
537 *
538 * @returns {number|boolean} The ID of the tool type if it supports Content-Item selection. False, otherwise.
539 */
540 getContentItemId: function() {
b791ed15
DS
541 try {
542 var selected = this.getSelectedToolTypeOption();
543 if (selected.getAttribute('data-contentitem')) {
544 return selected.getAttribute('data-id');
545 }
546 return false;
547 } catch (err) {
548 // Tool selector not available - check for hidden fields instead.
549 var content = Y.one('input[name="contentitem"]');
550 if (!content || !content.get('value')) {
551 return false;
552 }
553 return Y.one('input[name="typeid"]').get('value');
d8f9109a 554 }
1b669ebe
JP
555 },
556
557 /**
558 * Resets the values of fields related to the LTI tool settings.
559 */
560 resetToolFields: function() {
561 // Reset values for all text fields.
562 var fields = Y.all('#id_toolurl, #id_securetoolurl, #id_instructorcustomparameters, #id_icon, #id_secureicon');
563 fields.set('value', null);
564
565 // Reset value for launch container select box.
566 Y.one('#id_launchcontainer').set('value', 1);
6831c7cd 567 }
6831c7cd 568 };
61eb12d4 569})();