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';