MDL-62748 course: maxsections is a limit
[moodle.git] / course / amd / src / actions.js
CommitLineData
4b6728e4
MG
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/**
17 * Various actions on modules and sections in the editing mode - hiding, duplicating, deleting, etc.
18 *
19 * @module core_course/actions
20 * @package core
21 * @copyright 2016 Marina Glancy
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 * @since 3.3
24 */
f24e17c9
MG
25define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str', 'core/url', 'core/yui',
26 'core/modal_factory', 'core/modal_events', 'core/key_codes'],
27 function($, ajax, templates, notification, str, url, Y, ModalFactory, ModalEvents, KeyCodes) {
4b6728e4
MG
28 var CSS = {
29 EDITINPROGRESS: 'editinprogress',
30 SECTIONDRAGGABLE: 'sectiondraggable',
31 EDITINGMOVE: 'editing_move'
32 };
33 var SELECTOR = {
34 ACTIVITYLI: 'li.activity',
35 ACTIONAREA: '.actions',
36 ACTIVITYACTION: 'a.cm-edit-action',
37 MENU: '.moodle-actionmenu[data-enhance=moodle-core-actionmenu]',
38 TOGGLE: '.toggle-display,.dropdown-toggle',
39 SECTIONLI: 'li.section',
f24e17c9
MG
40 SECTIONACTIONMENU: '.section_action_menu',
41 ADDSECTIONS: '#changenumsections [data-add-sections]'
4b6728e4
MG
42 };
43
44 Y.use('moodle-course-coursebase', function() {
45 var courseformatselector = M.course.format.get_section_selector();
46 if (courseformatselector) {
47 SELECTOR.SECTIONLI = courseformatselector;
48 }
49 });
50
51 /**
52 * Wrapper for Y.Moodle.core_course.util.cm.getId
53 *
54 * @param {JQuery} element
55 * @returns {Integer}
56 */
57 var getModuleId = function(element) {
58 var id;
59 Y.use('moodle-course-util', function(Y) {
60 id = Y.Moodle.core_course.util.cm.getId(Y.Node(element.get(0)));
61 });
62 return id;
63 };
64
65 /**
66 * Wrapper for Y.Moodle.core_course.util.cm.getName
67 *
68 * @param {JQuery} element
69 * @returns {String}
70 */
71 var getModuleName = function(element) {
72 var name;
73 Y.use('moodle-course-util', function(Y) {
74 name = Y.Moodle.core_course.util.cm.getName(Y.Node(element.get(0)));
75 });
76 return name;
77 };
78
79 /**
80 * Wrapper for M.util.add_spinner for an activity
81 *
82 * @param {JQuery} activity
83 * @returns {Node}
84 */
85 var addActivitySpinner = function(activity) {
86 activity.addClass(CSS.EDITINPROGRESS);
87 var actionarea = activity.find(SELECTOR.ACTIONAREA).get(0);
88 if (actionarea) {
89 var spinner = M.util.add_spinner(Y, Y.Node(actionarea));
90 spinner.show();
91 return spinner;
92 }
93 return null;
94 };
95
96 /**
97 * Wrapper for M.util.add_spinner for a section
98 *
99 * @param {JQuery} sectionelement
100 * @returns {Node}
101 */
102 var addSectionSpinner = function(sectionelement) {
103 sectionelement.addClass(CSS.EDITINPROGRESS);
104 var actionarea = sectionelement.find(SELECTOR.SECTIONACTIONMENU).get(0);
105 if (actionarea) {
106 var spinner = M.util.add_spinner(Y, Y.Node(actionarea));
107 spinner.show();
108 return spinner;
109 }
110 return null;
111 };
112
113 /**
114 * Wrapper for M.util.add_lightbox
115 *
116 * @param {JQuery} sectionelement
117 * @returns {Node}
118 */
119 var addSectionLightbox = function(sectionelement) {
120 var lightbox = M.util.add_lightbox(Y, Y.Node(sectionelement.get(0)));
121 lightbox.show();
122 return lightbox;
123 };
124
125 /**
126 * Removes the spinner element
127 *
128 * @param {JQuery} element
129 * @param {Node} spinner
130 * @param {Number} delay
131 */
132 var removeSpinner = function(element, spinner, delay) {
133 window.setTimeout(function() {
134 element.removeClass(CSS.EDITINPROGRESS);
135 if (spinner) {
136 spinner.hide();
137 }
138 }, delay);
139 };
140
141 /**
142 * Removes the lightbox element
143 *
144 * @param {Node} lightbox lighbox YUI element returned by addSectionLightbox
145 * @param {Number} delay
146 */
147 var removeLightbox = function(lightbox, delay) {
148 if (lightbox) {
149 window.setTimeout(function() {
150 lightbox.hide();
151 }, delay);
152 }
153 };
154
155 /**
156 * Initialise action menu for the element (section or module)
157 *
158 * @param {String} elementid CSS id attribute of the element
159 * @param {Boolean} openmenu whether to open menu - this can be used when re-initiating menu after indent action was pressed
160 */
161 var initActionMenu = function(elementid, openmenu) {
162 // Initialise action menu in the new activity.
163 Y.use('moodle-course-coursebase', function() {
164 M.course.coursebase.invoke_function('setup_for_resource', '#' + elementid);
165 });
166 if (M.core.actionmenu && M.core.actionmenu.newDOMNode) {
167 M.core.actionmenu.newDOMNode(Y.one('#' + elementid));
168 }
169 // Open action menu if the original element had data-keepopen.
170 if (openmenu) {
171 // We must use YUI click simulate here so the toggle works in Clean theme. This toggle is not
172 // needed in Boost because we use standard bootstrapbase action menu.
173 var toggle = Y.one('#' + elementid + ' ' + SELECTOR.MENU).one(SELECTOR.TOGGLE);
174 if (toggle && toggle.simulate) {
175 toggle.simulate('click');
176 }
177 }
178 };
179
180 /**
181 * Returns focus to the element that was clicked or "Edit" link if element is no longer visible.
182 *
183 * @param {String} elementId CSS id attribute of the element
184 * @param {String} action data-action property of the element that was clicked
185 */
186 var focusActionItem = function(elementId, action) {
187 var mainelement = $('#' + elementId);
188 var selector = '[data-action=' + action + ']';
189 if (action === 'groupsseparate' || action === 'groupsvisible' || action === 'groupsnone') {
190 // New element will have different data-action.
191 selector = '[data-action=groupsseparate],[data-action=groupsvisible],[data-action=groupsnone]';
192 }
193 if (mainelement.find(selector).is(':visible')) {
194 mainelement.find(selector).focus();
195 } else {
196 // Element not visible, focus the "Edit" link.
197 mainelement.find(SELECTOR.MENU).find(SELECTOR.TOGGLE).focus();
198 }
199 };
200
201 /**
202 * Find next <a> after the element
203 *
204 * @param {JQuery} mainElement element that is about to be deleted
205 * @returns {JQuery}
206 */
207 var findNextFocusable = function(mainElement) {
208 var tabables = $("a:visible");
d9dff92f
DP
209 var isInside = false;
210 var foundElement = null;
4b6728e4
MG
211 tabables.each(function() {
212 if ($.contains(mainElement[0], this)) {
213 isInside = true;
214 } else if (isInside) {
215 foundElement = this;
216 return false; // Returning false in .each() is equivalent to "break;" inside the loop in php.
217 }
218 });
219 return foundElement;
220 };
221
222 /**
223 * Performs an action on a module (moving, deleting, duplicating, hiding, etc.)
224 *
225 * @param {JQuery} moduleElement activity element we perform action on
226 * @param {Number} cmid
227 * @param {JQuery} target the element (menu item) that was clicked
228 */
229 var editModule = function(moduleElement, cmid, target) {
230 var keepopen = target.attr('data-keepopen'),
231 action = target.attr('data-action');
232 var spinner = addActivitySpinner(moduleElement);
233 var promises = ajax.call([{
234 methodname: 'core_course_edit_module',
235 args: {id: cmid,
236 action: action,
237 sectionreturn: target.attr('data-sectionreturn') ? target.attr('data-sectionreturn') : 0
238 }
239 }], true);
240
241 var lightbox;
242 if (action === 'duplicate') {
243 lightbox = addSectionLightbox(target.closest(SELECTOR.SECTIONLI));
244 }
245 $.when.apply($, promises)
246 .done(function(data) {
247 var elementToFocus = findNextFocusable(moduleElement);
248 moduleElement.replaceWith(data);
249 // Initialise action menu for activity(ies) added as a result of this.
250 $('<div>' + data + '</div>').find(SELECTOR.ACTIVITYLI).each(function(index) {
251 initActionMenu($(this).attr('id'), keepopen);
252 if (index === 0) {
253 focusActionItem($(this).attr('id'), action);
254 elementToFocus = null;
255 }
256 });
257 // In case of activity deletion focus the next focusable element.
258 if (elementToFocus) {
259 elementToFocus.focus();
260 }
261 // Remove spinner and lightbox with a delay.
262 removeSpinner(moduleElement, spinner, 400);
263 removeLightbox(lightbox, 400);
264 // Trigger event that can be observed by course formats.
265 moduleElement.trigger($.Event('coursemoduleedited', {ajaxreturn: data, action: action}));
266 }).fail(function(ex) {
267 // Remove spinner and lightbox.
268 removeSpinner(moduleElement, spinner);
269 removeLightbox(lightbox);
270 // Trigger event that can be observed by course formats.
271 var e = $.Event('coursemoduleeditfailed', {exception: ex, action: action});
272 moduleElement.trigger(e);
273 if (!e.isDefaultPrevented()) {
274 notification.exception(ex);
275 }
276 });
277 };
278
279 /**
280 * Requests html for the module via WS core_course_get_module and updates the module on the course page
281 *
282 * Used after d&d of the module to another section
283 *
284 * @param {JQuery} activityElement
285 * @param {Number} cmid
286 * @param {Number} sectionreturn
287 */
288 var refreshModule = function(activityElement, cmid, sectionreturn) {
289 var spinner = addActivitySpinner(activityElement);
290 var promises = ajax.call([{
291 methodname: 'core_course_get_module',
292 args: {id: cmid, sectionreturn: sectionreturn}
293 }], true);
294
295 $.when.apply($, promises)
296 .done(function(data) {
297 removeSpinner(activityElement, spinner, 400);
298 replaceActivityHtmlWith(data);
299 }).fail(function() {
300 removeSpinner(activityElement, spinner);
301 });
302 };
303
304 /**
305 * Displays the delete confirmation to delete a module
306 *
307 * @param {JQuery} mainelement activity element we perform action on
308 * @param {function} onconfirm function to execute on confirm
309 */
310 var confirmDeleteModule = function(mainelement, onconfirm) {
311 var modtypename = mainelement.attr('class').match(/modtype_([^\s]*)/)[1];
312 var modulename = getModuleName(mainelement);
313
314 str.get_string('pluginname', modtypename).done(function(pluginname) {
315 var plugindata = {
316 type: pluginname,
317 name: modulename
318 };
319 str.get_strings([
320 {key: 'confirm'},
321 {key: modulename === null ? 'deletechecktype' : 'deletechecktypename', param: plugindata},
322 {key: 'yes'},
323 {key: 'no'}
324 ]).done(function(s) {
325 notification.confirm(s[0], s[1], s[2], s[3], onconfirm);
326 }
327 );
328 });
329 };
330
331 /**
332 * Displays the delete confirmation to delete a section
333 *
334 * @param {String} message confirmation message
335 * @param {function} onconfirm function to execute on confirm
336 */
337 var confirmEditSection = function(message, onconfirm) {
338 str.get_strings([
339 {key: 'confirm'}, // TODO link text
340 {key: 'yes'},
341 {key: 'no'}
342 ]).done(function(s) {
343 notification.confirm(s[0], message, s[1], s[2], onconfirm);
344 }
345 );
346 };
347
348 /**
349 * Replaces an action menu item with another one (for example Show->Hide, Set marker->Remove marker)
350 *
351 * @param {JQuery} actionitem
352 * @param {String} image new image name ("i/show", "i/hide", etc.)
353 * @param {String} stringname new string for the action menu item
354 * @param {String} stringcomponent
355 * @param {String} titlestr string for "title" attribute (if different from stringname)
356 * @param {String} titlecomponent
357 * @param {String} newaction new value for data-action attribute of the link
a1ce3266 358 * @return {Promise} promise which is resolved when the replacement has completed
4b6728e4
MG
359 */
360 var replaceActionItem = function(actionitem, image, stringname,
361 stringcomponent, titlestr, titlecomponent, newaction) {
6586ad36 362
a1ce3266
DP
363
364 var stringRequests = [{key: stringname, component: stringcomponent}];
4b6728e4 365 if (titlestr) {
a1ce3266 366 stringRequests.push({key: titlestr, component: titlecomponent});
4b6728e4 367 }
a1ce3266
DP
368
369 return str.get_strings(stringRequests).then(function(strings) {
370 actionitem.find('span.menu-action-text').html(strings[0]);
371 actionitem.attr('title', strings[0]);
372
373 var title = '';
374 if (titlestr) {
375 title = strings[1];
376 actionitem.attr('title', title);
377 }
378 return templates.renderPix(image, 'core', title);
379 }).then(function(pixhtml) {
380 actionitem.find('.icon').replaceWith(pixhtml);
381 actionitem.attr('data-action', newaction);
382 return;
383 }).catch(notification.exception);
4b6728e4
MG
384 };
385
386 /**
387 * Default post-processing for section AJAX edit actions.
388 *
389 * This can be overridden in course formats by listening to event coursesectionedited:
390 *
391 * $('body').on('coursesectionedited', 'li.section', function(e) {
392 * var action = e.action,
393 * sectionElement = $(e.target),
394 * data = e.ajaxreturn;
395 * // ... Do some processing here.
396 * e.preventDefault(); // Prevent default handler.
397 * });
398 *
399 * @param {JQuery} sectionElement
400 * @param {JQuery} actionItem
401 * @param {Object} data
402 * @param {String} courseformat
403 */
404 var defaultEditSectionHandler = function(sectionElement, actionItem, data, courseformat) {
405 var action = actionItem.attr('data-action');
406 if (action === 'hide' || action === 'show') {
407 if (action === 'hide') {
408 sectionElement.addClass('hidden');
409 replaceActionItem(actionItem, 'i/show',
410 'showfromothers', 'format_' + courseformat, null, null, 'show');
411 } else {
412 sectionElement.removeClass('hidden');
413 replaceActionItem(actionItem, 'i/hide',
414 'hidefromothers', 'format_' + courseformat, null, null, 'hide');
415 }
416 // Replace the modules with new html (that indicates that they are now hidden or not hidden).
417 if (data.modules !== undefined) {
418 for (var i in data.modules) {
419 replaceActivityHtmlWith(data.modules[i]);
420 }
421 }
422 // Replace the section availability information.
423 if (data.section_availability !== undefined) {
424 sectionElement.find('.section_availability').first().replaceWith(data.section_availability);
425 }
426 } else if (action === 'setmarker') {
427 var oldmarker = $(SELECTOR.SECTIONLI + '.current'),
428 oldActionItem = oldmarker.find(SELECTOR.SECTIONACTIONMENU + ' ' + 'a[data-action=removemarker]');
429 oldmarker.removeClass('current');
430 replaceActionItem(oldActionItem, 'i/marker',
431 'highlight', 'core', 'markthistopic', 'core', 'setmarker');
432 sectionElement.addClass('current');
433 replaceActionItem(actionItem, 'i/marked',
434 'highlightoff', 'core', 'markedthistopic', 'core', 'removemarker');
435 } else if (action === 'removemarker') {
436 sectionElement.removeClass('current');
437 replaceActionItem(actionItem, 'i/marker',
438 'highlight', 'core', 'markthistopic', 'core', 'setmarker');
439 }
440 };
441
442 /**
443 * Replaces the course module with the new html (used to update module after it was edited or its visibility was changed).
444 *
445 * @param {String} activityHTML
446 */
447 var replaceActivityHtmlWith = function(activityHTML) {
448 $('<div>' + activityHTML + '</div>').find(SELECTOR.ACTIVITYLI).each(function() {
449 // Extract id from the new activity html.
450 var id = $(this).attr('id');
451 // Find the existing element with the same id and replace its contents with new html.
452 $(SELECTOR.ACTIVITYLI + '#' + id).replaceWith(activityHTML);
453 // Initialise action menu.
454 initActionMenu(id, false);
455 });
456 };
457
458 /**
459 * Performs an action on a module (moving, deleting, duplicating, hiding, etc.)
460 *
461 * @param {JQuery} sectionElement section element we perform action on
462 * @param {Nunmber} sectionid
463 * @param {JQuery} target the element (menu item) that was clicked
464 * @param {String} courseformat
465 */
466 var editSection = function(sectionElement, sectionid, target, courseformat) {
467 var action = target.attr('data-action'),
468 sectionreturn = target.attr('data-sectionreturn') ? target.attr('data-sectionreturn') : 0;
469 var spinner = addSectionSpinner(sectionElement);
470 var promises = ajax.call([{
471 methodname: 'core_course_edit_section',
472 args: {id: sectionid, action: action, sectionreturn: sectionreturn}
473 }], true);
474
475 var lightbox = addSectionLightbox(sectionElement);
476 $.when.apply($, promises)
477 .done(function(dataencoded) {
478 var data = $.parseJSON(dataencoded);
479 removeSpinner(sectionElement, spinner);
480 removeLightbox(lightbox);
481 sectionElement.find(SELECTOR.SECTIONACTIONMENU).find(SELECTOR.TOGGLE).focus();
482 // Trigger event that can be observed by course formats.
483 var e = $.Event('coursesectionedited', {ajaxreturn: data, action: action});
484 sectionElement.trigger(e);
485 if (!e.isDefaultPrevented()) {
486 defaultEditSectionHandler(sectionElement, target, data, courseformat);
487 }
488 }).fail(function(ex) {
489 // Remove spinner and lightbox.
490 removeSpinner(sectionElement, spinner);
491 removeLightbox(lightbox);
492 // Trigger event that can be observed by course formats.
493 var e = $.Event('coursesectioneditfailed', {exception: ex, action: action});
494 sectionElement.trigger(e);
495 if (!e.isDefaultPrevented()) {
496 notification.exception(ex);
497 }
498 });
499 };
500
501 // Register a function to be executed after D&D of an activity.
502 Y.use('moodle-course-coursebase', function() {
503 M.course.coursebase.register_module({
504 // Ignore camelcase eslint rule for the next line because it is an expected name of the callback.
505 // eslint-disable-next-line camelcase
506 set_visibility_resource_ui: function(args) {
507 var mainelement = $(args.element.getDOMNode());
508 var cmid = getModuleId(mainelement);
509 if (cmid) {
510 var sectionreturn = mainelement.find('.' + CSS.EDITINGMOVE).attr('data-sectionreturn');
511 refreshModule(mainelement, cmid, sectionreturn);
512 }
513 }
514 });
515 });
516
517 return /** @alias module:core_course/actions */ {
518
519 /**
520 * Initialises course page
521 *
522 * @method init
523 * @param {String} courseformat name of the current course format (for fetching strings)
524 */
525 initCoursePage: function(courseformat) {
526
527 // Add a handler for course module actions.
528 $('body').on('click keypress', SELECTOR.ACTIVITYLI + ' ' +
529 SELECTOR.ACTIVITYACTION + '[data-action]', function(e) {
530 if (e.type === 'keypress' && e.keyCode !== 13) {
531 return;
532 }
533 var actionItem = $(this),
534 moduleElement = actionItem.closest(SELECTOR.ACTIVITYLI),
535 action = actionItem.attr('data-action'),
536 moduleId = getModuleId(moduleElement);
537 switch (action) {
538 case 'moveleft':
539 case 'moveright':
540 case 'delete':
541 case 'duplicate':
542 case 'hide':
543 case 'stealth':
544 case 'show':
545 case 'groupsseparate':
546 case 'groupsvisible':
547 case 'groupsnone':
548 break;
549 default:
550 // Nothing to do here!
551 return;
552 }
553 if (!moduleId) {
554 return;
555 }
556 e.preventDefault();
557 if (action === 'delete') {
558 // Deleting requires confirmation.
559 confirmDeleteModule(moduleElement, function() {
560 editModule(moduleElement, moduleId, actionItem);
561 });
562 } else {
563 editModule(moduleElement, moduleId, actionItem);
564 }
565 });
566
567 // Add a handler for section show/hide actions.
568 $('body').on('click keypress', SELECTOR.SECTIONLI + ' ' +
569 SELECTOR.SECTIONACTIONMENU + '[data-sectionid] ' +
570 'a[data-action]', function(e) {
571 if (e.type === 'keypress' && e.keyCode !== 13) {
572 return;
573 }
574 var actionItem = $(this),
575 sectionElement = actionItem.closest(SELECTOR.SECTIONLI),
576 sectionId = actionItem.closest(SELECTOR.SECTIONACTIONMENU).attr('data-sectionid');
577 e.preventDefault();
578 if (actionItem.attr('data-confirm')) {
579 // Action requires confirmation.
580 confirmEditSection(actionItem.attr('data-confirm'), function() {
581 editSection(sectionElement, sectionId, actionItem, courseformat);
582 });
583 } else {
584 editSection(sectionElement, sectionId, actionItem, courseformat);
585 }
586 });
f24e17c9
MG
587
588 // Add a handler for "Add sections" link to ask for a number of sections to add.
589 str.get_string('numberweeks').done(function(strNumberSections) {
590 var trigger = $(SELECTOR.ADDSECTIONS),
7c05d8a3
DW
591 modalTitle = trigger.attr('data-add-sections'),
592 newSections = trigger.attr('new-sections');
f24e17c9 593 var modalBody = $('<div><label for="add_section_numsections"></label> ' +
7c05d8a3 594 '<input id="add_section_numsections" type="number" min="1" max="' + newSections + '" value="1"></div>');
f24e17c9
MG
595 modalBody.find('label').html(strNumberSections);
596 ModalFactory.create({
597 title: modalTitle,
598 type: ModalFactory.types.SAVE_CANCEL,
599 body: modalBody.html()
600 }, trigger)
601 .done(function(modal) {
602 var numSections = $(modal.getBody()).find('#add_section_numsections'),
603 addSections = function() {
604 // Check if value of the "Number of sections" is a valid positive integer and redirect
605 // to adding a section script.
606 if ('' + parseInt(numSections.val()) === numSections.val() && parseInt(numSections.val()) >= 1) {
607 document.location = trigger.attr('href') + '&numsections=' + parseInt(numSections.val());
608 }
609 };
610 modal.setSaveButtonText(modalTitle);
611 modal.getRoot().on(ModalEvents.shown, function() {
612 // When modal is shown focus and select the input and add a listener to keypress of "Enter".
613 numSections.focus().select().on('keydown', function(e) {
614 if (e.keyCode === KeyCodes.enter) {
615 addSections();
616 }
617 });
618 });
619 modal.getRoot().on(ModalEvents.save, function(e) {
620 // When modal "Add" button is pressed.
621 e.preventDefault();
622 addSections();
623 });
624 });
625 });
4b6728e4
MG
626 },
627
628 /**
629 * Replaces a section action menu item with another one (for example Show->Hide, Set marker->Remove marker)
630 *
631 * This method can be used by course formats in their listener to the coursesectionedited event
632 *
633 * @param {JQuery} sectionelement
634 * @param {String} selector CSS selector inside the section element, for example "a[data-action=show]"
635 * @param {String} image new image name ("i/show", "i/hide", etc.)
636 * @param {String} stringname new string for the action menu item
637 * @param {String} stringcomponent
638 * @param {String} titlestr string for "title" attribute (if different from stringname)
639 * @param {String} titlecomponent
640 * @param {String} newaction new value for data-action attribute of the link
641 */
642 replaceSectionActionItem: function(sectionelement, selector, image, stringname,
643 stringcomponent, titlestr, titlecomponent, newaction) {
644 var actionitem = sectionelement.find(SELECTOR.SECTIONACTIONMENU + ' ' + selector);
645 replaceActionItem(actionitem, image, stringname, stringcomponent, titlestr, titlecomponent, newaction);
646 }
647 };
6586ad36 648 });