diff --git a/.travis.yml b/.travis.yml index 28433656d..a9c08e055 100644 --- a/.travis.yml +++ b/.travis.yml @@ -34,7 +34,7 @@ before_script: - npm install -g browserify@11.2.0 - composer install - cp tests/travis/config.php resources/config/local.php - - bin/cm app set-deploy-version + - bin/cm app set-config deploy '{"deployVersion":'$(date +"%s")'}' script: - bin/phpunit diff --git a/client-vendor/after-body/jquery.floatbox/jquery.floatbox.js b/client-vendor/after-body/jquery.floatbox/jquery.floatbox.js index b3892f7da..9b98f6de8 100755 --- a/client-vendor/after-body/jquery.floatbox/jquery.floatbox.js +++ b/client-vendor/after-body/jquery.floatbox/jquery.floatbox.js @@ -33,6 +33,15 @@ $parent: null, $layer: null, $floatbox: null, + setOptions: function(value){ + if (_.isObject(value)) { + //`value` as object to rewrite options + _.extend(this.options, value); + this.repaint(); + } else { + throw new Error('Invalid param to floatbox.setOptions'); + } + }, show: function($element) { var $floatboxConfig = $element.find('.floatbox-config:first'); this.options.fullscreen = $floatboxConfig.data('fullscreen') || this.options.fullscreen; @@ -155,4 +164,12 @@ } }); }; + $.fn.floatbox = function(method, value) { + return this.each(function() { + var floatbox = $(this).data('floatbox'); + if (floatbox && _.isFunction(floatbox[method])) { + floatbox[method](value); + } + }); + }; })(jQuery); diff --git a/client-vendor/after-body/jquery.lazyImageSetup/jquery.lazyImageSetup.js b/client-vendor/after-body/jquery.lazyImageSetup/jquery.lazyImageSetup.js index 622a4d0c7..a706aaa1d 100644 --- a/client-vendor/after-body/jquery.lazyImageSetup/jquery.lazyImageSetup.js +++ b/client-vendor/after-body/jquery.lazyImageSetup/jquery.lazyImageSetup.js @@ -11,14 +11,14 @@ $.fn.lazyImageSetup = function(lazyLoadOptions) { return this.each(function() { var options = _.defaults(lazyLoadOptions || {}, { - threshold: 600, - failure_limit: 10 + offset: 600, + attribute: 'original' }); var $this = $(this); if ($this.closest('.scrollable').length) { options.container = $this.closest('.scrollable'); } - $this.find('img.lazy').lazyload(options); + $this.find('img.lazy').unveil(options); }); }; diff --git a/client-vendor/after-body/jquery.lazyload/jquery.lazyload.js b/client-vendor/after-body/jquery.lazyload/jquery.lazyload.js deleted file mode 100755 index 1e6843316..000000000 --- a/client-vendor/after-body/jquery.lazyload/jquery.lazyload.js +++ /dev/null @@ -1,242 +0,0 @@ -/*! - * Lazy Load - jQuery plugin for lazy loading images - * - * Copyright (c) 2007-2015 Mika Tuupola - * - * Licensed under the MIT license: - * http://www.opensource.org/licenses/mit-license.php - * - * Project home: - * http://www.appelsiini.net/projects/lazyload - * - * Version: 1.9.7 - * - */ - -(function($, window, document, undefined) { - var $window = $(window); - - $.fn.lazyload = function(options) { - var elements = this; - var $container; - var settings = { - threshold : 0, - failure_limit : 0, - event : "scroll", - effect : "show", - container : window, - data_attribute : "original", - skip_invisible : false, - appear : null, - load : null, - placeholder : "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAANSURBVBhXYzh8+PB/AAffA0nNPuCLAAAAAElFTkSuQmCC" - }; - - function update() { - var counter = 0; - - elements.each(function() { - var $this = $(this); - if (settings.skip_invisible && !$this.is(":visible")) { - return; - } - if ($.abovethetop(this, settings) || - $.leftofbegin(this, settings)) { - /* Nothing. */ - } else if (!$.belowthefold(this, settings) && - !$.rightoffold(this, settings)) { - $this.trigger("appear"); - /* if we found an image we'll load, reset the counter */ - counter = 0; - } else { - if (++counter > settings.failure_limit) { - return false; - } - } - }); - - } - - if(options) { - /* Maintain BC for a couple of versions. */ - if (undefined !== options.failurelimit) { - options.failure_limit = options.failurelimit; - delete options.failurelimit; - } - if (undefined !== options.effectspeed) { - options.effect_speed = options.effectspeed; - delete options.effectspeed; - } - - $.extend(settings, options); - } - - /* Cache container as jQuery as object. */ - $container = (settings.container === undefined || - settings.container === window) ? $window : $(settings.container); - - /* Fire one scroll event per scroll. Not one scroll event per image. */ - if (0 === settings.event.indexOf("scroll")) { - $container.bind(settings.event, function() { - return update(); - }); - } - - this.each(function() { - var self = this; - var $self = $(self); - - self.loaded = false; - - /* If no src attribute given use data:uri. */ - if ($self.attr("src") === undefined || $self.attr("src") === false) { - if ($self.is("img")) { - $self.attr("src", settings.placeholder); - } - } - - /* When appear is triggered load original image. */ - $self.one("appear", function() { - if (!this.loaded) { - if (settings.appear) { - var elements_left = elements.length; - settings.appear.call(self, elements_left, settings); - } - $("") - .bind("load", function() { - - var original = $self.attr("data-" + settings.data_attribute); - $self.hide(); - if ($self.is("img")) { - $self.attr("src", original); - } else { - $self.css("background-image", "url('" + original + "')"); - } - $self[settings.effect](settings.effect_speed); - - self.loaded = true; - - /* Remove image from array so it is not looped next time. */ - var temp = $.grep(elements, function(element) { - return !element.loaded; - }); - elements = $(temp); - - if (settings.load) { - var elements_left = elements.length; - settings.load.call(self, elements_left, settings); - } - }) - .attr("src", $self.attr("data-" + settings.data_attribute)); - } - }); - - /* When wanted event is triggered load original image */ - /* by triggering appear. */ - if (0 !== settings.event.indexOf("scroll")) { - $self.bind(settings.event, function() { - if (!self.loaded) { - $self.trigger("appear"); - } - }); - } - }); - - /* Check if something appears when window is resized. */ - $window.bind("resize", function() { - update(); - }); - - /* With IOS5 force loading images when navigating with back button. */ - /* Non optimal workaround. */ - if ((/(?:iphone|ipod|ipad).*os 5/gi).test(navigator.appVersion)) { - $window.bind("pageshow", function(event) { - if (event.originalEvent && event.originalEvent.persisted) { - elements.each(function() { - $(this).trigger("appear"); - }); - } - }); - } - - /* Force initial check if images should appear. */ - $(document).ready(function() { - update(); - }); - - return this; - }; - - /* Convenience methods in jQuery namespace. */ - /* Use as $.belowthefold(element, {threshold : 100, container : window}) */ - - $.belowthefold = function(element, settings) { - var fold; - - if (settings.container === undefined || settings.container === window) { - fold = (window.innerHeight ? window.innerHeight : $window.height()) + $window.scrollTop(); - } else { - fold = $(settings.container).offset().top + $(settings.container).height(); - } - - return fold <= $(element).offset().top - settings.threshold; - }; - - $.rightoffold = function(element, settings) { - var fold; - - if (settings.container === undefined || settings.container === window) { - fold = $window.width() + $window.scrollLeft(); - } else { - fold = $(settings.container).offset().left + $(settings.container).width(); - } - - return fold <= $(element).offset().left - settings.threshold; - }; - - $.abovethetop = function(element, settings) { - var fold; - - if (settings.container === undefined || settings.container === window) { - fold = $window.scrollTop(); - } else { - fold = $(settings.container).offset().top; - } - - return fold >= $(element).offset().top + settings.threshold + $(element).height(); - }; - - $.leftofbegin = function(element, settings) { - var fold; - - if (settings.container === undefined || settings.container === window) { - fold = $window.scrollLeft(); - } else { - fold = $(settings.container).offset().left; - } - - return fold >= $(element).offset().left + settings.threshold + $(element).width(); - }; - - $.inviewport = function(element, settings) { - return !$.rightoffold(element, settings) && !$.leftofbegin(element, settings) && - !$.belowthefold(element, settings) && !$.abovethetop(element, settings); - }; - - /* Custom selectors for your convenience. */ - /* Use as $("img:below-the-fold").something() or */ - /* $("img").filter(":below-the-fold").something() which is faster */ - - $.extend($.expr[":"], { - "below-the-fold" : function(a) { return $.belowthefold(a, {threshold : 0}); }, - "above-the-top" : function(a) { return !$.belowthefold(a, {threshold : 0}); }, - "right-of-screen": function(a) { return $.rightoffold(a, {threshold : 0}); }, - "left-of-screen" : function(a) { return !$.rightoffold(a, {threshold : 0}); }, - "in-viewport" : function(a) { return $.inviewport(a, {threshold : 0}); }, - /* Maintain BC for couple of versions. */ - "above-the-fold" : function(a) { return !$.belowthefold(a, {threshold : 0}); }, - "right-of-fold" : function(a) { return $.rightoffold(a, {threshold : 0}); }, - "left-of-fold" : function(a) { return !$.rightoffold(a, {threshold : 0}); } - }); - -})(jQuery, window, document); diff --git a/client-vendor/after-body/jquery.unveil2/README.md b/client-vendor/after-body/jquery.unveil2/README.md new file mode 100644 index 000000000..7846b480d --- /dev/null +++ b/client-vendor/after-body/jquery.unveil2/README.md @@ -0,0 +1,5 @@ +Currently we use a custom fork https://github.com/vogdb/unveil2. As soon as out changes are merged please remove this file. +Our changes are: +- https://github.com/nabble/unveil2/pull/27 +- https://github.com/nabble/unveil2/pull/28 +- https://github.com/nabble/unveil2/pull/30 \ No newline at end of file diff --git a/client-vendor/after-body/jquery.unveil2/jquery.unveil2.js b/client-vendor/after-body/jquery.unveil2/jquery.unveil2.js new file mode 100644 index 000000000..2bd7023bf --- /dev/null +++ b/client-vendor/after-body/jquery.unveil2/jquery.unveil2.js @@ -0,0 +1,378 @@ +/** + * Unveil2.js + * A very lightweight jQuery plugin to lazy load images + * Based on https://github.com/luis-almeida/unveil + * + * Licensed under the MIT license. + * Copyright 2015 Joram van den Boezem + * https://github.com/nabble/unveil2 + */ +(function(factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + define(['jquery'], factory); + } else if (typeof exports !== 'undefined') { + module.exports = factory(require('jquery')); + } else { + factory(jQuery); + } + +}(function ($) { + + "use strict"; + + /** + * # GLOBAL VARIABLES + * --- + */ + + /** + * Store the string 'unveil' in a variable to save some bytes + */ + var unveilString = 'unveil', + + /** + * Store the string 'src' in a variable to save some bytes + */ + srcString = 'src', + + /** + * Store the string 'placeholder' in a variable to save some bytes + */ + placeholderString = 'placeholder'; + + /** + * # PLUGIN + * --- + */ + + /** + * @param {object} options An object of options, see API section in README + * @returns {$} + */ + $.fn.unveil = function (options) { + + options = options || {}; + + // Initialize variables + var $window = $(window), + height = $window.height(), + defaults = { + // Public API + placeholder: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', + offset: 0, + breakpoints: [], + throttle: 250, + debug: false, + attribute: srcString, + + // Undocumented + container: $window, + retina: window.devicePixelRatio > 1, + + // Deprecated + loading: null, + loaded: null + }, + settings = $.extend(true, {}, defaults, options); + + if (settings.debug) console.log('Called unveil on', this.length, 'elements with the following options:', settings); + + /** + * Sort sizes array, arrange highest minWidth to front of array + */ + settings.breakpoints.sort(function (a, b) { + return b.minWidth - a.minWidth; + }); + + var containerContext = settings.container.data('unveil2'); + if (!containerContext) { + containerContext = { + images: $(), + initialized: false + }; + settings.container.data('unveil2', containerContext); + } + + /** + * # UNVEIL IMAGES + * --- + */ + + /** + * This is the actual plugin logic, which determines the source attribute to use based on window width and presence of a retina screen, changes the source of the image, handles class name changes and triggers a callback if set. Once the image has been loaded, start the unveil lookup because the page layout could have changed. + */ + this.one(unveilString + '.' + unveilString, function () { + var i, $this = $(this), windowWidth = $window.width(), + attrib = settings.attribute, targetSrc, defaultSrc, retinaSrc; + + // Determine attribute to extract source from + for (i = 0; i < settings.breakpoints.length; i++) { + var dataAttrib = settings.breakpoints[i].attribute.replace(/^data-/, ''); + if (windowWidth >= settings.breakpoints[i].minWidth && $this.data(dataAttrib)) { + attrib = dataAttrib; + break; + } + } + + // Extract source + defaultSrc = retinaSrc = $this.data(attrib); + + // Do we have a retina source? + if (defaultSrc && defaultSrc.indexOf("|") !== -1) { + retinaSrc = defaultSrc.split("|")[1]; + defaultSrc = defaultSrc.split("|")[0]; + } + + // Change attribute on image + if (defaultSrc) { + targetSrc = (settings.retina && retinaSrc) ? retinaSrc : defaultSrc; + + if (settings.debug) console.log('Unveiling image', { + attribute: attrib, + retina: settings.retina, + defaultSrc: defaultSrc, + retinaSrc: retinaSrc, + targetSrc: targetSrc + }); + + // Change classes + $this.addClass(unveilString + '-loading'); + + // Fire up the callback if it's a function... + if (typeof settings.loading === 'function') { + settings.loading.call(this); + } + // ...and trigger custom event + $this.trigger('loading.unveil'); + + // When new source has loaded, do stuff + $this.one('load', function () { + + // Change classes + classLoaded($this); + + // Fire up the callback if it's a function... + if (typeof settings.loaded === 'function') { + settings.loaded.call(this); + } + // ...and trigger custom event + $this.trigger('loaded.unveil'); + + // Loading the image may have modified page layout, + // so unveil again + lookup(); + }); + + // Set new source + if (this.nodeName === 'IMG') { + $this.prop(srcString, targetSrc); + } else { + $('').attr(srcString, targetSrc).one('load', function() { + $(this).remove(); + $this.css('backgroundImage', 'url(' + targetSrc + ')').trigger('load'); + }); + } + + // If the image has instantly loaded, change classes now + if (this.complete) { + classLoaded($this); + } + } + }); + + this.one('destroy.' + unveilString, function () { + $(this).off('.unveil'); + if (containerContext.images) { + containerContext.images = containerContext.images.not(this); + if (!containerContext.images.length) { + destroyContainer(); + } + } + }); + + /** + * # HELPER FUNCTIONS + * --- + */ + + /** + * Sets the classes when an image is done loading + * + * @param {object} $elm + */ + function classLoaded($elm) { + $elm.removeClass(unveilString + '-' + placeholderString + ' ' + unveilString + '-loading'); + $elm.addClass(unveilString + '-loaded'); + } + + /** + * Filter function which returns true when a given image is in the viewport. + * + * @returns {boolean} + */ + function inview() { + // jshint validthis: true + var $this = $(this); + + if ($this.is(':hidden')) { + return; + } + + var viewport = {top: 0 - settings.offset, bottom: $window.height() + settings.offset}, + isCustomContainer = settings.container[0] !== $window[0], + elementRect = this.getBoundingClientRect(); + if (isCustomContainer) { + var containerRect = settings.container[0].getBoundingClientRect(); + if (contains(viewport, containerRect)) { + var top = containerRect.top - settings.offset; + var bottom = containerRect.bottom + settings.offset; + var containerRectWithOffset = { + top: top > viewport.top ? top : viewport.top, + bottom: bottom < viewport.bottom ? bottom : viewport.bottom + }; + return contains(containerRectWithOffset, elementRect); + } + return false; + } else { + return contains(viewport, elementRect); + } + } + + /** + * Whether `viewport` contains `rect` vertically + */ + function contains(viewport, rect) { + return rect.bottom >= viewport.top && rect.top <= viewport.bottom; + } + + /** + * Sets the window height and calls the lookup function + */ + function resize() { + height = $window.height(); + lookup(); + } + + /** + * Throttle function with function call in tail. Based on http://sampsonblog.com/749/simple-throttle-function + * + * @param {function} callback + * @returns {function} + */ + function throttle(callback) { + var wait = false; // Initially, we're not waiting + return function () { // We return a throttled function + if (!wait) { // If we're not waiting + wait = true; // Prevent future invocations + setTimeout(function () { // After a period of time + callback(); // Execute users function + wait = false; // And allow future invocations + }, settings.throttle); + } + }; + } + + /** + * # LOOKUP FUNCTION + * --- + */ + + /** + * Function which filters images which are in view and triggers the unveil event on those images. + */ + function lookup() { + if (settings.debug) console.log('Unveiling'); + + if (containerContext.images) { + var batch = containerContext.images.filter(inview); + + batch.trigger(unveilString + '.' + unveilString); + containerContext.images = containerContext.images.not(batch); + + if (batch.length) { + if (settings.debug) console.log('New images in view', batch.length, ', leaves', containerContext.length, 'in collection'); + } + } + } + + function destroyContainer() { + settings.container.off('.unveil'); + containerContext.images.off('.unveil'); + settings.container.data('unveil2', null); + containerContext.initialized = false; + containerContext.images = null; + } + + /** + * # BOOTSTRAPPING + * --- + */ + + /** + * Read and reset the src attribute, to prevent loading of original images + */ + this.each(function () { + var $this = $(this), + elmPlaceholder = $this.data(srcString + '-' + placeholderString) || settings.placeholder; + + // Add element to global array + containerContext.images = $(containerContext.images).add(this); + + // If this element has been called before, + // don't set placeholder now to prevent FOUI (Flash Of Ustyled Image) + if (!$this.data(unveilString)) { + + // Set the unveil flag + $this.data(unveilString, true); + + // Set data-src if not set + if (!$this.data(settings.attribute)) { + $this.data(settings.attribute, $this.prop(settings.attribute)); + } + + // Set placeholder + $this + .one('load', function () { + var $this = $(this); + + if ($this.hasClass(unveilString + '-loaded')) { + return; + } + + $this.addClass(unveilString + '-' + placeholderString); + lookup(); + }) + .prop(srcString, '') + .prop(srcString, elmPlaceholder); + } + }); + + if (settings.debug) console.log('Images now in collection', containerContext.images.length); + + /** + * Bind global listeners + */ + {if (!containerContext.initialized) { + settings.container.on({ + 'resize.unveil': throttle(resize), + 'scroll.unveil': throttle(lookup), + 'lookup.unveil': lookup, + 'destroy.unveil': destroyContainer + }); + containerContext.initialized = true; + }} + + /** + * Wait a little bit for the placeholder to be set, so the src attribute is not empty (which will mark the image as hidden) + */ + {setTimeout(lookup, 0);} + + /** + * That's all folks! + */ + return this; + + }; + +})); diff --git a/client-vendor/after-body/promise-utils/promise-throttler.js b/client-vendor/after-body/promise-utils/promise-throttler.js index c911defe7..b19351381 100644 --- a/client-vendor/after-body/promise-utils/promise-throttler.js +++ b/client-vendor/after-body/promise-utils/promise-throttler.js @@ -41,10 +41,26 @@ function nonameThrottler(fn, options) { var promise; + function resetClosurePromise(resetPromise) { + if (resetPromise === promise) { + promise = null; + } + } + + function isPromise(promise) { + if (!promise || !(promise instanceof Promise)) { + throw new Error('Invalid usage of promiseThrottler'); + } + return true; + } + return function() { - if (!promise || !promise.isPending()) { + if (!promise) { promise = fn.apply(this, arguments); - return promise; + if (isPromise(promise)) { + promise.finally(resetClosurePromise.bind(null, promise)); + return promise; + } } var cancelLeading = options.cancelLeading; if (cancelLeading) { @@ -57,9 +73,13 @@ var leadingPromise = promise; promise = new Promise(function(resolve) { leadingPromise.finally(function() { - resolve(fn.apply(self, args)); + var promise = fn.apply(self, args); + if (isPromise(promise)) { + resolve(promise); + } }); }); + promise.finally(resetClosurePromise.bind(null, promise)); return promise; } return promise; diff --git a/library/CM/App.js b/library/CM/App.js index 73605fa9f..335097771 100644 --- a/library/CM/App.js +++ b/library/CM/App.js @@ -499,6 +499,7 @@ var CM_App = CM_Class_Abstract.extend({ teardown: function($dom) { $dom.find('.timeago').timeago('dispose'); $dom.find('textarea.autosize, .autosize textarea').trigger('autosize.destroy'); + $dom.find('img.lazy').trigger('destroy.unveil'); }, /** @@ -1433,7 +1434,7 @@ var CM_App = CM_Class_Abstract.extend({ var urlBase = cm.options.urlBase; if (0 === url.indexOf(urlSite)) { var path = url.substr(urlBase.length); - cm.getDocument().loadPage(path); + return cm.getDocument().loadPage(path); } else { window.location.assign(url); return Promise.resolve(); diff --git a/library/CM/App/Cli.php b/library/CM/App/Cli.php index 5daf461b3..a223f0a92 100644 --- a/library/CM/App/Cli.php +++ b/library/CM/App/Cli.php @@ -69,21 +69,38 @@ public function generateConfigInternal() { } /** - * @param int|null $deployVersion + * @param string $filename + * @param string $configJson + * @param bool|null $merge */ - public function setDeployVersion($deployVersion = null) { - $deployVersion = (null !== $deployVersion) ? (int) $deployVersion : time(); - $sourceCode = join(PHP_EOL, array( + public function setConfig($filename, $configJson, $merge = null) { + $merge = (bool) $merge; + $configJson = (object) CM_Util::jsonDecode($configJson); + $configFile = new CM_File(DIR_ROOT . 'resources/config/' . $filename . '.php'); + + $config = new CM_Config_Node(); + if ($merge && $configFile->exists()) { + $config->extendWithFile($configFile); + } + $config->extendWithConfig($configJson); + $configStr = $config->exportAsString('$config'); + + $indentation = ' '; + $indent = function ($content) use ($indentation) { + return preg_replace('/(:?^|[\n])/', '$1' . $indentation, $content); + }; + + $configFile->ensureParentDirectory(); + $configFile->write(join(PHP_EOL, [ 'deployVersion = ' . $deployVersion . ';', + $indent($configStr), '};', '', - )); - $targetPath = DIR_ROOT . 'resources/config/deploy.php'; - $configFile = new CM_File($targetPath); - $configFile->ensureParentDirectory(); - $configFile->write($sourceCode); + ])); + $this->_getStreamOutput()->writeln('Created `' . $configFile->getPath() . '`'); } public static function getPackageName() { diff --git a/library/CM/Db/Db.php b/library/CM/Db/Db.php index 02fa4b248..ca0035482 100644 --- a/library/CM/Db/Db.php +++ b/library/CM/Db/Db.php @@ -150,6 +150,7 @@ public static function insertIgnore($table, $fields, $values = null) { * @param string|array|null $values Column-value OR Column-values array OR Multiple Column-values array(array) * @param array|null $onDuplicateKeyValues * @return string|null + * @deprecated Removed from MySQL 5.7 */ public static function insertDelayed($table, $fields, $values = null, array $onDuplicateKeyValues = null) { $statement = (self::_getConfig()->delayedEnabled) ? 'INSERT DELAYED' : 'INSERT'; @@ -171,6 +172,7 @@ public static function replace($table, $fields, $values = null) { * @param string|array $fields Column-name OR Column-names array OR associative field=>value pair * @param string|array|null $values Column-value OR Column-values array OR Multiple Column-values array(array) * @return string|null + * @deprecated Removed from MySQL 5.7 */ public static function replaceDelayed($table, $fields, $values = null) { $statement = (self::_getConfig()->delayedEnabled) ? 'REPLACE DELAYED' : 'REPLACE'; diff --git a/library/CM/Http/Response/Abstract.php b/library/CM/Http/Response/Abstract.php index 9cd44c129..9fdf1a449 100644 --- a/library/CM/Http/Response/Abstract.php +++ b/library/CM/Http/Response/Abstract.php @@ -294,7 +294,7 @@ protected function _runWithCatching(Closure $regularCode, Closure $errorCode) { $context = new CM_Log_Context(); $context->setUser($this->getViewer()); $context->setException($ex); - $this->getServiceManager()->getLogger()->addMessage('HTML response error', $logLevel, $context); + $this->getServiceManager()->getLogger()->addMessage('Response processing error', $logLevel, $context); } if (!$catchException && ($catchPublicExceptions && $ex->isPublic())) { $errorOptions = []; diff --git a/library/CM/Http/Response/View/Abstract.php b/library/CM/Http/Response/View/Abstract.php index cc9258658..8cd8e5d14 100644 --- a/library/CM/Http/Response/View/Abstract.php +++ b/library/CM/Http/Response/View/Abstract.php @@ -109,7 +109,7 @@ public function loadPage(CM_Params $params, CM_Http_Response_View_Ajax $response } $title = $responseEmbed->getTitle(); - $menuList = array_merge($this->getSite()->getMenus(), $responseEmbed->getRender()->getMenuList()); + $menuList = array_merge($this->getSite()->getMenus($this->getRender()->getEnvironment()), $responseEmbed->getRender()->getMenuList()); $menuEntryHashList = $this->_getMenuEntryHashList($menuList, get_class($page), $page->getParams()); $jsTracking = $responseEmbed->getRender()->getServiceManager()->getTrackings()->getJs(); diff --git a/library/CM/Jobdistribution/Cli.php b/library/CM/Jobdistribution/Cli.php index 48bf554fa..be1166f48 100644 --- a/library/CM/Jobdistribution/Cli.php +++ b/library/CM/Jobdistribution/Cli.php @@ -6,7 +6,7 @@ class CM_Jobdistribution_Cli extends CM_Cli_Runnable_Abstract { * @keepalive */ public function startWorker() { - $worker = new CM_Jobdistribution_JobWorker(); + $worker = new CM_Jobdistribution_JobWorker(1000); $worker->setServiceManager($this->getServiceManager()); foreach (CM_Jobdistribution_Job_Abstract::getClassChildren() as $jobClassName) { /** @var CM_Jobdistribution_Job_Abstract $job */ diff --git a/library/CM/Jobdistribution/Job/Abstract.php b/library/CM/Jobdistribution/Job/Abstract.php index 78aee513f..31266b4b5 100644 --- a/library/CM/Jobdistribution/Job/Abstract.php +++ b/library/CM/Jobdistribution/Job/Abstract.php @@ -9,20 +9,10 @@ abstract class CM_Jobdistribution_Job_Abstract extends CM_Class_Abstract { abstract protected function _execute(CM_Params $params); /** - * @param CM_Params $params - * @return mixed - * @throws Exception + * @return CM_Jobdistribution_Priority */ - private function _executeJob(CM_Params $params) { - CM_Service_Manager::getInstance()->getNewrelic()->startTransaction('CM Job: ' . $this->_getClassName()); - try { - $return = $this->_execute($params); - CM_Service_Manager::getInstance()->getNewrelic()->endTransaction(); - return $return; - } catch (Exception $ex) { - CM_Service_Manager::getInstance()->getNewrelic()->endTransaction(); - throw $ex; - } + public function getPriority() { + return new CM_Jobdistribution_Priority('normal'); } /** @@ -63,7 +53,7 @@ public function runMultiple(array $paramsList) { foreach ($paramsList as $params) { $workload = CM_Params::encode($params, true); - $task = $gearmanClient->addTaskHigh($this->_getJobName(), $workload); + $task = $this->_addTask($workload, $gearmanClient); if (false === $task) { throw new CM_Exception('Cannot add task', null, ['jobName' => $this->_getJobName()]); } @@ -95,7 +85,20 @@ public function queue(array $params = null) { $workload = CM_Params::encode($params, true); $gearmanClient = $this->_getGearmanClient(); - $gearmanClient->doBackground($this->_getJobName(), $workload); + $priority = $this->getPriority(); + switch ($priority) { + case CM_Jobdistribution_Priority::HIGH: + $gearmanClient->doHighBackground($this->_getJobName(), $workload); + break; + case CM_Jobdistribution_Priority::NORMAL: + $gearmanClient->doBackground($this->_getJobName(), $workload); + break; + case CM_Jobdistribution_Priority::LOW: + $gearmanClient->doLowBackground($this->_getJobName(), $workload); + break; + default: + throw new CM_Exception('Invalid priority', null, ['priority' => (string) $priority]); + } } /** @@ -116,6 +119,15 @@ public function __executeGearman(GearmanJob $job) { return CM_Params::encode($this->_executeJob($params), true); } + /** + * @param string $workload + * @param GearmanClient $gearmanClient + * @return GearmanTask + */ + protected function _addTask($workload, $gearmanClient) { + return $gearmanClient->addTaskHigh($this->_getJobName(), $workload); + } + /** * @return string */ @@ -135,6 +147,23 @@ protected function _runMultipleWithoutGearman(array $paramsList) { return $resultList; } + /** + * @param CM_Params $params + * @return mixed + * @throws Exception + */ + private function _executeJob(CM_Params $params) { + CM_Service_Manager::getInstance()->getNewrelic()->startTransaction('CM Job: ' . $this->_getClassName()); + try { + $return = $this->_execute($params); + CM_Service_Manager::getInstance()->getNewrelic()->endTransaction(); + return $return; + } catch (Exception $ex) { + CM_Service_Manager::getInstance()->getNewrelic()->endTransaction(); + throw $ex; + } + } + /** * @return boolean */ diff --git a/library/CM/Jobdistribution/JobWorker.php b/library/CM/Jobdistribution/JobWorker.php index 39a1e97bd..64432b0b0 100644 --- a/library/CM/Jobdistribution/JobWorker.php +++ b/library/CM/Jobdistribution/JobWorker.php @@ -7,11 +7,21 @@ class CM_Jobdistribution_JobWorker extends CM_Class_Abstract implements CM_Servi /** @var GearmanWorker */ private $_gearmanWorker; - public function __construct() { - $worker = $this->_getGearmanWorker(); + /** @var int */ + private $_jobLimit; + + /** + * @param int $jobLimit + */ + public function __construct($jobLimit) { + $this->_jobLimit = (int) $jobLimit; + $config = self::_getConfig(); - foreach ($config->servers as $server) { - $worker->addServer($server['host'], $server['port']); + if ($config->servers) { + $worker = $this->_getGearmanWorker(); + foreach ($config->servers as $server) { + $worker->addServer($server['host'], $server['port']); + } } } @@ -19,13 +29,18 @@ public function __construct() { * @param CM_Jobdistribution_Job_Abstract $job */ public function registerJob(CM_Jobdistribution_Job_Abstract $job) { - $this->_gearmanWorker->addFunction(get_class($job), array($job, '__executeGearman')); + $this->_gearmanWorker->addFunction(get_class($job), [$job, '__executeGearman']); } public function run() { + $jobsRun = 0; while (true) { + if ($jobsRun >= $this->_jobLimit) { + return; + } $workFailed = false; try { + $jobsRun++; CM_Cache_Storage_Runtime::getInstance()->flush(); $workFailed = !$this->_getGearmanWorker()->work(); } catch (Exception $ex) { diff --git a/library/CM/Jobdistribution/Priority.php b/library/CM/Jobdistribution/Priority.php new file mode 100644 index 000000000..ac56684b2 --- /dev/null +++ b/library/CM/Jobdistribution/Priority.php @@ -0,0 +1,9 @@ +existsCollection($collectionSource)) { + throw new CM_MongoDb_Exception('Source collection does not exist', null, [ + 'collectionSource' => $collectionSource, + 'collectionTarget' => $collectionTarget, + ]); + } + if (!$dropTarget && $this->existsCollection($collectionTarget)) { + throw new CM_MongoDb_Exception('Target collection already exists', null, [ + 'collectionSource' => $collectionSource, + 'collectionTarget' => $collectionTarget, + ]); + } + $result = $this->_getClient()->selectDB('admin')->command([ + 'renameCollection' => $this->_getDatabaseName() . '.' . $collectionSource, + 'to' => $this->_getDatabaseName() . '.' . $collectionTarget, + 'dropTarget' => $dropTarget, + ]); + $this->_checkResultForErrors($result); + return $result; + } + /** * @param string $collection * @return array diff --git a/library/CM/Process.php b/library/CM/Process.php index 7be9a0eab..20a4b4d07 100644 --- a/library/CM/Process.php +++ b/library/CM/Process.php @@ -266,13 +266,15 @@ private function _wait($keepAlive = null, $nohang = null) { } if ($pid > 0 && ($forkHandler = $this->_findForkHandlerByPid($pid))) { unset($this->_forkHandlerList[$forkHandler->getIdentifier()]); - $workloadResultList[$forkHandler->getIdentifier()] = $forkHandler->receiveWorkloadResult(); + $workloadResult = $forkHandler->receiveWorkloadResult(); + $workloadResultList[$forkHandler->getIdentifier()] = $workloadResult; $forkHandler->closeIpcStream(); if (!$this->_hasForks()) { $this->unbind('exit', [$this, 'killChildren']); } if ($keepAlive) { - CM_Service_Manager::getInstance()->getLogger()->warning('Respawning dead child.', (new CM_Log_Context())->setExtra(['pid' => $pid])); + $logLevel = $workloadResult->isSuccess() ? CM_Log_Logger::INFO : CM_Log_Logger::WARNING; + CM_Service_Manager::getInstance()->getLogger()->addMessage('Respawning dead child.', $logLevel, (new CM_Log_Context())->setExtra(['pid' => $pid])); usleep(self::RESPAWN_TIMEOUT * 1000000); $this->_fork($forkHandler->getWorkload(), $forkHandler->getIdentifier()); } diff --git a/library/CM/Site/Abstract.php b/library/CM/Site/Abstract.php index 052018481..13bc8210b 100644 --- a/library/CM/Site/Abstract.php +++ b/library/CM/Site/Abstract.php @@ -82,9 +82,10 @@ public function getDocument() { } /** + * @param CM_Frontend_Environment $environment * @return CM_Menu[] */ - public function getMenus() { + public function getMenus(CM_Frontend_Environment $environment) { return array(); } diff --git a/library/CM/SmartyPlugins/function.menu.php b/library/CM/SmartyPlugins/function.menu.php index 35b34fd92..ea153931b 100644 --- a/library/CM/SmartyPlugins/function.menu.php +++ b/library/CM/SmartyPlugins/function.menu.php @@ -35,7 +35,7 @@ function smarty_function_menu(array $params, Smarty_Internal_Template $template) $name = null; if (isset($params['name'])) { $name = $params['name']; - $menuArr = $render->getSite()->getMenus(); + $menuArr = $render->getSite()->getMenus($environment); if (isset($menuArr[$name])) { $menu = $menuArr[$name]; } diff --git a/tests/helpers/CMTest/library/CMTest/ExceptionWrapper.php b/tests/helpers/CMTest/library/CMTest/ExceptionWrapper.php new file mode 100644 index 000000000..2dd3e55d8 --- /dev/null +++ b/tests/helpers/CMTest/library/CMTest/ExceptionWrapper.php @@ -0,0 +1,11 @@ +message = get_class($e) . ': ' . $e->getMessage() . PHP_EOL . $formatter->getMetaInfo($serializedException); + } +} diff --git a/tests/helpers/CMTest/library/CMTest/TestCase.php b/tests/helpers/CMTest/library/CMTest/TestCase.php index 25dc1f9fb..277cc3fda 100644 --- a/tests/helpers/CMTest/library/CMTest/TestCase.php +++ b/tests/helpers/CMTest/library/CMTest/TestCase.php @@ -28,6 +28,13 @@ public static function tearDownAfterClass() { CMTest_TH::clearEnv(); } + protected function onNotSuccessfulTest($e) { + if ($e instanceof CM_Exception) { + $e = new CMTest_ExceptionWrapper($e); + } + parent::onNotSuccessfulTest($e); + } + /** * @param mixed $object * @param string $methodName diff --git a/tests/library/CM/Jobdistribution/Job/AbstractTest.php b/tests/library/CM/Jobdistribution/Job/AbstractTest.php index d3d61ba89..709bdbc20 100644 --- a/tests/library/CM/Jobdistribution/Job/AbstractTest.php +++ b/tests/library/CM/Jobdistribution/Job/AbstractTest.php @@ -13,9 +13,8 @@ public function testRunMultiple() { CM_Config::get()->CM_Jobdistribution_Job_Abstract->gearmanEnabled = true; $mockBuilder = $this->getMockBuilder('GearmanClient'); - $mockBuilder->setMethods(['addTaskHigh', 'runTasks', 'setCompleteCallback', 'setFailCallback']); + $mockBuilder->setMethods(['addTaskNormal', 'runTasks', 'setCompleteCallback', 'setFailCallback']); $gearmanClientMock = $mockBuilder->getMock(); - $gearmanClientMock->expects($this->exactly(2))->method('addTaskHigh')->will($this->returnValue(true)); $gearmanClientMock->expects($this->exactly(1))->method('runTasks')->will($this->returnValue(true)); $gearmanClientMock->expects($this->exactly(1))->method('setCompleteCallback')->will($this->returnCallback(function ($completeCallback) { $task1 = $this->getMockBuilder('GearmanTask')->setMethods(array('data'))->getMock(); @@ -29,8 +28,9 @@ public function testRunMultiple() { $gearmanClientMock->expects($this->exactly(1))->method('setFailCallback'); /** @var GearmanClient $gearmanClientMock */ - $job = $this->getMockBuilder('CM_Jobdistribution_Job_Abstract')->setMethods(array('_getGearmanClient'))->getMockForAbstractClass(); + $job = $this->getMockBuilder('CM_Jobdistribution_Job_Abstract')->setMethods(array('_getGearmanClient', '_addTask'))->getMockForAbstractClass(); $job->expects($this->any())->method('_getGearmanClient')->will($this->returnValue($gearmanClientMock)); + $job->expects($this->exactly(2))->method('_addTask')->will($this->returnValue(true)); /** @var CM_Jobdistribution_Job_Abstract $job */ $result = $job->runMultiple(array( @@ -44,6 +44,45 @@ public function testRunMultiple() { ), $result); } + public function testQueuePriority() { + if (!extension_loaded('gearman')) { + $this->markTestSkipped('Gearman Pecl Extension not installed.'); + } + CM_Config::get()->CM_Jobdistribution_Job_Abstract->gearmanEnabled = true; + + $gearmanClient = $this->mockClass('GearmanClient')->newInstanceWithoutConstructor(); + + $mockDoHighBackground = $gearmanClient->mockMethod('doHighBackground'); + $mockDoBackground = $gearmanClient->mockMethod('doBackground'); + $mockDoLowBackground = $gearmanClient->mockMethod('doLowBackground'); + + /** @var CM_Jobdistribution_Job_Abstract|\Mocka\AbstractClassTrait $job */ + $job = $this->mockObject('CM_Jobdistribution_Job_Abstract'); + $job->mockMethod('_getGearmanClient')->set($gearmanClient); + + // standard priority + $job->queue([['foo' => 'bar']]); + $this->assertSame(1, $mockDoBackground->getCallCount()); + + // normal priority + $priorityMock = $job->mockMethod('getPriority'); + $priorityMock->set(new CM_Jobdistribution_Priority('normal')); + $job->queue([['foo' => 'bar']]); + $this->assertSame(2, $mockDoBackground->getCallCount()); + + // high priority + $priorityMock = $job->mockMethod('getPriority'); + $priorityMock->set(new CM_Jobdistribution_Priority('high')); + $job->queue([['foo' => 'bar']]); + $this->assertSame(1, $mockDoHighBackground->getCallCount()); + + // low priority + $priorityMock = $job->mockMethod('getPriority'); + $priorityMock->set(new CM_Jobdistribution_Priority('low')); + $job->queue([['foo' => 'bar']]); + $this->assertSame(1, $mockDoLowBackground->getCallCount()); + } + public function testRunMultipleWithFailures() { if (!extension_loaded('gearman')) { $this->markTestSkipped('Gearman Pecl Extension not installed.'); diff --git a/tests/library/CM/Jobdistribution/JobWorkerTest.php b/tests/library/CM/Jobdistribution/JobWorkerTest.php index 0a1923838..dc4067b2d 100644 --- a/tests/library/CM/Jobdistribution/JobWorkerTest.php +++ b/tests/library/CM/Jobdistribution/JobWorkerTest.php @@ -3,6 +3,7 @@ class CM_JobDistribution_JobWorkerTest extends CMTest_TestCase { public function testRun() { + CM_Config::get()->CM_Jobdistribution_JobWorker->servers = []; if (!extension_loaded('gearman')) { $this->markTestSkipped('Gearman Pecl Extension not installed.'); } @@ -16,9 +17,8 @@ public function testRun() { } throw new Exception('foo-bar'); })); - $mockBuilder = $this->getMockBuilder('CM_Jobdistribution_JobWorker'); + $mockBuilder = $this->getMockBuilder('CM_Jobdistribution_JobWorker')->setConstructorArgs([1000]); $mockBuilder->setMethods(['_getGearmanWorker', '_handleException']); - $mockBuilder->disableOriginalConstructor(); $jobWorkerMock = $mockBuilder->getMock(); $jobWorkerMock->expects($this->any())->method('_getGearmanWorker')->will($this->returnValue($gearmanWorkerMock)); /** @var CM_JobDistribution_JobWorker $jobWorkerMock */ @@ -44,4 +44,24 @@ public function testRun() { $this->fail('Exception not caught.'); } } + + public function testRunJobLimit() { + $serviceManager = new CM_Service_Manager(); + $logger = $this->mockObject('CM_Log_Logger'); + $serviceManager->registerInstance('logger', $logger); + + if (!extension_loaded('gearman')) { + $this->markTestSkipped('Gearman Pecl Extension not installed.'); + } + $gearmanWorker = $this->mockClass('GearmanWorker')->newInstanceWithoutConstructor(); + $workMethod = $gearmanWorker->mockMethod('work')->set(true); + + CM_Config::get()->CM_Jobdistribution_JobWorker->servers = []; + $worker = $this->mockClass(CM_Jobdistribution_JobWorker::class)->newInstance([5]); + $worker->mockMethod('_getGearmanWorker')->set($gearmanWorker); + /** @var CM_Jobdistribution_JobWorker $worker */ + $worker->setServiceManager($serviceManager); + $worker->run(); + $this->assertSame(5, $workMethod->getCallCount()); + } } diff --git a/tests/library/CM/MongoDb/ClientTest.php b/tests/library/CM/MongoDb/ClientTest.php index d1885a62a..ef1da8b6a 100644 --- a/tests/library/CM/MongoDb/ClientTest.php +++ b/tests/library/CM/MongoDb/ClientTest.php @@ -234,6 +234,58 @@ public function testDrop() { $this->assertFalse($mongoDb->existsCollection($collectionName)); } + public function testRename() { + $mongoDb = CM_Service_Manager::getInstance()->getMongoDb(); + + $mongoDb->insert('source', array('foo' => 'origin')); + $this->assertTrue($mongoDb->existsCollection('source')); + $this->assertFalse($mongoDb->existsCollection('target')); + + $mongoDb->rename('source', 'target'); + + $this->assertFalse($mongoDb->existsCollection('source')); + $this->assertTrue($mongoDb->existsCollection('target')); + $this->assertSame(1, $mongoDb->find('target', array('foo' => 'origin'))->count()); + + $mongoDb->insert('source', array('foo' => 'origin')); + $mongoDb->insert('target', array('foo' => 'existing-value')); + $this->assertSame(2, $mongoDb->count('target')); + + $mongoDb->rename('source', 'target', true); + + $this->assertFalse($mongoDb->existsCollection('source')); + $this->assertTrue($mongoDb->existsCollection('target')); + $this->assertSame(1, $mongoDb->count('target')); + $this->assertSame(1, $mongoDb->find('target', array('foo' => 'origin'))->count()); + } + + public function testRenameThrows() { + $mongoDb = CM_Service_Manager::getInstance()->getMongoDb(); + /** @var CM_MongoDb_Exception $exception */ + $exception = $this->catchException(function () use ($mongoDb) { + $mongoDb->rename('not_defined', 'target'); + }); + $this->assertInstanceOf('CM_MongoDb_Exception', $exception); + $this->assertSame('Source collection does not exist', $exception->getMessage()); + $this->assertSame([ + 'collectionSource' => 'not_defined', + 'collectionTarget' => 'target' + ], $exception->getMetaInfo()); + + $mongoDb->insert('source', array('foo' => 'origin')); + $mongoDb->insert('target', array('foo' => 'existing-value')); + /** @var CM_MongoDb_Exception $exception */ + $exception = $this->catchException(function () use ($mongoDb) { + $mongoDb->rename('source', 'target'); + }); + $this->assertInstanceOf('CM_MongoDb_Exception', $exception); + $this->assertSame('Target collection already exists', $exception->getMessage()); + $this->assertSame([ + 'collectionSource' => 'source', + 'collectionTarget' => 'target' + ], $exception->getMetaInfo()); + } + public function testRemove() { $mongoDb = CM_Service_Manager::getInstance()->getMongoDb(); $collectionName = 'remove';