summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/core
diff options
context:
space:
mode:
authorGéry Debongnie <ged@odoo.com>2017-03-05 10:05:56 +0100
committerAaron Bohy <aab@odoo.com>2017-04-11 19:44:38 +0200
commit905e01921f3c3ef43ace6fc537d1d8c0d280002c (patch)
tree265c43ab21c5401a5d9f0f3d64d86f1ac417451c /addons/web/static/src/js/core
parent54d4c73af4ad05c7640d28c1892247c9538b8017 (diff)
[REF] web, *: redesign all JS views
This commit introduce a full redesign of all JS views. We started basically from scratch. The goal was to unify all the various views under a common framework, to make them testable, to make then usable in different conditions (in studio, or in the frontend), and to make our lives easier. Some important points are: - we introduced new coding guidelines (camelCase, 80 chars width, ...) - we have a brand new testing framework (still QUnit based) - kanban view moved to the web addon - calendar view (formerly web_calender) moved to web as well - the tree view was removed - all new code should be documented We hope that this code is the start of a new era for the Odoo web client, we want to have a high quality codebase, well documented, well tested, well designed. Work done by the framework team: mostly aab, ged, chm, dmo, qsm
Diffstat (limited to 'addons/web/static/src/js/core')
-rw-r--r--addons/web/static/src/js/core/abstract_service.js20
-rw-r--r--addons/web/static/src/js/core/ajax.js399
-rw-r--r--addons/web/static/src/js/core/bus.js16
-rw-r--r--addons/web/static/src/js/core/class.js154
-rw-r--r--addons/web/static/src/js/core/collections.js44
-rw-r--r--addons/web/static/src/js/core/concurrency.js237
-rw-r--r--addons/web/static/src/js/core/dialog.js208
-rw-r--r--addons/web/static/src/js/core/dom.js164
-rw-r--r--addons/web/static/src/js/core/domain.js225
-rw-r--r--addons/web/static/src/js/core/local_storage.js34
-rw-r--r--addons/web/static/src/js/core/misc.js182
-rw-r--r--addons/web/static/src/js/core/mixins.js472
-rw-r--r--addons/web/static/src/js/core/pyeval.js1036
-rw-r--r--addons/web/static/src/js/core/qweb.js51
-rw-r--r--addons/web/static/src/js/core/registry.js97
-rw-r--r--addons/web/static/src/js/core/session.js412
-rw-r--r--addons/web/static/src/js/core/time.js299
-rw-r--r--addons/web/static/src/js/core/translation.js117
-rw-r--r--addons/web/static/src/js/core/utils.js436
-rw-r--r--addons/web/static/src/js/core/widget.js397
20 files changed, 5000 insertions, 0 deletions
diff --git a/addons/web/static/src/js/core/abstract_service.js b/addons/web/static/src/js/core/abstract_service.js
new file mode 100644
index 00000000000..6ae0c3183da
--- /dev/null
+++ b/addons/web/static/src/js/core/abstract_service.js
@@ -0,0 +1,20 @@
+odoo.define('web.AbstractService', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+
+var AbstractService = Class.extend({
+ Services: [],
+ name: null,
+});
+
+var realExtend = AbstractService.extend;
+
+AbstractService.extend = function() {
+ var Service = realExtend.apply(this, arguments);
+ AbstractService.prototype.Services.push(Service);
+ return Service;
+};
+
+return AbstractService;
+});
diff --git a/addons/web/static/src/js/core/ajax.js b/addons/web/static/src/js/core/ajax.js
new file mode 100644
index 00000000000..dee6f4599b4
--- /dev/null
+++ b/addons/web/static/src/js/core/ajax.js
@@ -0,0 +1,399 @@
+odoo.define('web.ajax', function (require) {
+"use strict";
+
+var core = require('web.core');
+var utils = require('web.utils');
+var time = require('web.time');
+
+function genericJsonRpc (fct_name, params, fct) {
+ var data = {
+ jsonrpc: "2.0",
+ method: fct_name,
+ params: params,
+ id: Math.floor(Math.random() * 1000 * 1000 * 1000)
+ };
+ var xhr = fct(data);
+ var result = xhr.pipe(function(result) {
+ core.bus.trigger('rpc:result', data, result);
+ if (result.error !== undefined) {
+ if (result.error.data.arguments[0] !== "bus.Bus not available in test mode") {
+ console.error("Server application error", JSON.stringify(result.error));
+ }
+ return $.Deferred().reject("server", result.error);
+ } else {
+ return result.result;
+ }
+ }, function() {
+ //console.error("JsonRPC communication error", _.toArray(arguments));
+ var def = $.Deferred();
+ return def.reject.apply(def, ["communication"].concat(_.toArray(arguments)));
+ });
+ // FIXME: jsonp?
+ result.abort = function () { if (xhr.abort) xhr.abort(); };
+ return result;
+}
+
+function jsonRpc(url, fct_name, params, settings) {
+ return genericJsonRpc(fct_name, params, function(data) {
+ return $.ajax(url, _.extend({}, settings, {
+ url: url,
+ dataType: 'json',
+ type: 'POST',
+ data: JSON.stringify(data, time.date_to_utc),
+ contentType: 'application/json'
+ }));
+ });
+}
+
+function jsonpRpc(url, fct_name, params, settings) {
+ settings = settings || {};
+ return genericJsonRpc(fct_name, params, function(data) {
+ var payload_str = JSON.stringify(data, time.date_to_utc);
+ var payload_url = $.param({r:payload_str});
+ var force2step = settings.force2step || false;
+ delete settings.force2step;
+ var session_id = settings.session_id || null;
+ delete settings.session_id;
+ if (payload_url.length < 2000 && ! force2step) {
+ return $.ajax(url, _.extend({}, settings, {
+ url: url,
+ dataType: 'jsonp',
+ jsonp: 'jsonp',
+ type: 'GET',
+ cache: false,
+ data: {r: payload_str, session_id: session_id}
+ }));
+ } else {
+ var args = {session_id: session_id, id: data.id};
+ var ifid = _.uniqueId('oe_rpc_iframe');
+ var html = "<iframe src='javascript:false;' name='" + ifid + "' id='" + ifid + "' style='display:none'></iframe>";
+ var $iframe = $(html);
+ var nurl = 'jsonp=1&' + $.param(args);
+ nurl = url.indexOf("?") !== -1 ? url + "&" + nurl : url + "?" + nurl;
+ var $form = $('<form>')
+ .attr('method', 'POST')
+ .attr('target', ifid)
+ .attr('enctype', "multipart/form-data")
+ .attr('action', nurl)
+ .append($('<input type="hidden" name="r" />').attr('value', payload_str))
+ .hide()
+ .appendTo($('body'));
+ var cleanUp = function() {
+ if ($iframe) {
+ $iframe.unbind("load").remove();
+ }
+ $form.remove();
+ };
+ var deferred = $.Deferred();
+ // the first bind is fired up when the iframe is added to the DOM
+ $iframe.bind('load', function() {
+ // the second bind is fired up when the result of the form submission is received
+ $iframe.unbind('load').bind('load', function() {
+ $.ajax({
+ url: url,
+ dataType: 'jsonp',
+ jsonp: 'jsonp',
+ type: 'GET',
+ cache: false,
+ data: {session_id: session_id, id: data.id}
+ }).always(function() {
+ cleanUp();
+ }).done(function() {
+ deferred.resolve.apply(deferred, arguments);
+ }).fail(function() {
+ deferred.reject.apply(deferred, arguments);
+ });
+ });
+ // now that the iframe can receive data, we fill and submit the form
+ $form.submit();
+ });
+ // append the iframe to the DOM (will trigger the first load)
+ $form.after($iframe);
+ if (settings.timeout) {
+ realSetTimeout(function() {
+ deferred.reject({});
+ }, settings.timeout);
+ }
+ return deferred;
+ }
+ });
+}
+
+// helper function to make a rpc with a function name hardcoded to 'call'
+function rpc(url, params, settings) {
+ return jsonRpc(url, 'call', params, settings);
+}
+
+// helper
+function realSetTimeout (fct, millis) {
+ var finished = new Date().getTime() + millis;
+ var wait = function() {
+ var current = new Date().getTime();
+ if (current < finished) {
+ setTimeout(wait, finished - current);
+ } else {
+ fct();
+ }
+ };
+ setTimeout(wait, millis);
+}
+
+function loadCSS(url) {
+ if (!$('link[href="' + url + '"]').length) {
+ $('head').append($('<link>', {
+ 'href': url,
+ 'rel': 'stylesheet',
+ 'type': 'text/css'
+ }));
+ }
+}
+
+var loadJS = (function () {
+ var urls = [];
+ var defs = [];
+
+ var load = function loadJS(url) {
+ // Check the DOM to see if a script with the specified url is already there
+ var alreadyRequired = ($('script[src="' + url + '"]').length > 0);
+
+ // If loadJS was already called with the same URL, it will have a registered deferred indicating if
+ // the script has been fully loaded. If not, the deferred has to be initialized. This is initialized
+ // as already resolved if the script was already there without the need of loadJS.
+ var index = _.indexOf(urls, url);
+ if (index < 0) {
+ urls.push(url);
+ index = defs.push(alreadyRequired ? $.when() : $.Deferred()) - 1;
+ }
+
+ // Get the script associated deferred and returns it after initializing the script if needed. The
+ // deferred is marked to be resolved on script load and rejected on script error.
+ var def = defs[index];
+ if (!alreadyRequired) {
+ var script = document.createElement('script');
+ script.type = 'text/javascript';
+ script.src = url;
+ script.onload = script.onreadystatechange = function() {
+ if ((script.readyState && script.readyState !== "loaded" && script.readyState !== "complete") || script.onload_done) {
+ return;
+ }
+ script.onload_done = true;
+ def.resolve(url);
+ };
+ script.onerror = function () {
+ console.error("Error loading file", script.src);
+ def.reject(url);
+ };
+ var head = document.head || document.getElementsByTagName('head')[0];
+ head.appendChild(script);
+ }
+ return def;
+ };
+
+ return load;
+})();
+
+
+/**
+ * Cooperative file download implementation, for ajaxy APIs.
+ *
+ * Requires that the server side implements an httprequest correctly
+ * setting the `fileToken` cookie to the value provided as the `token`
+ * parameter. The cookie *must* be set on the `/` path and *must not* be
+ * `httpOnly`.
+ *
+ * It would probably also be a good idea for the response to use a
+ * `Content-Disposition: attachment` header, especially if the MIME is a
+ * "known" type (e.g. text/plain, or for some browsers application/json
+ *
+ * @param {Object} options
+ * @param {String} [options.url] used to dynamically create a form
+ * @param {Object} [options.data] data to add to the form submission. If can be used without a form, in which case a form is created from scratch. Otherwise, added to form data
+ * @param {HTMLFormElement} [options.form] the form to submit in order to fetch the file
+ * @param {Function} [options.success] callback in case of download success
+ * @param {Function} [options.error] callback in case of request error, provided with the error body
+ * @param {Function} [options.complete] called after both ``success`` and ``error` callbacks have executed
+ */
+function get_file(options) {
+ // need to detect when the file is done downloading (not used
+ // yet, but we'll need it to fix the UI e.g. with a throbber
+ // while dump is being generated), iframe load event only fires
+ // when the iframe content loads, so we need to go smarter:
+ // http://geekswithblogs.net/GruffCode/archive/2010/10/28/detecting-the-file-download-dialog-in-the-browser.aspx
+ var timer, token = new Date().getTime(),
+ cookie_name = 'fileToken', cookie_length = cookie_name.length,
+ CHECK_INTERVAL = 1000, id = _.uniqueId('get_file_frame'),
+ remove_form = false;
+
+
+ // iOS devices doesn't allow iframe use the way we do it,
+ // opening a new window seems the best way to workaround
+ if (navigator.userAgent.match(/(iPod|iPhone|iPad)/)) {
+ var params = _.extend({}, options.data || {}, {token: token});
+ var url = options.session.url(options.url, params);
+ if (options.complete) { options.complete(); }
+
+ return window.open(url);
+ }
+
+ var $form, $form_data = $('<div>');
+
+ var complete = function () {
+ if (options.complete) { options.complete(); }
+ clearTimeout(timer);
+ $form_data.remove();
+ $target.remove();
+ if (remove_form && $form) { $form.remove(); }
+ };
+ var $target = $('<iframe style="display: none;">')
+ .attr({id: id, name: id})
+ .appendTo(document.body)
+ .load(function () {
+ try {
+ if (options.error) {
+ var body = this.contentDocument.body;
+ var nodes = body.children.length === 0 ? body.childNodes : body.children;
+ var node = nodes[1] || nodes[0];
+ options.error(JSON.parse(node.textContent));
+ }
+ } finally {
+ complete();
+ }
+ });
+
+ if (options.form) {
+ $form = $(options.form);
+ } else {
+ remove_form = true;
+ $form = $('<form>', {
+ action: options.url,
+ method: 'POST'
+ }).appendTo(document.body);
+ }
+ if (core.csrf_token) {
+ $('<input type="hidden" name="csrf_token">')
+ .val(core.csrf_token)
+ .appendTo($form_data);
+ }
+
+ var hparams = _.extend({}, options.data || {}, {token: token});
+ _.each(hparams, function (value, key) {
+ var $input = $form.find('[name=' + key +']');
+ if (!$input.length) {
+ $input = $('<input type="hidden" name="' + key + '">')
+ .appendTo($form_data);
+ }
+ $input.val(value);
+ });
+
+ $form
+ .append($form_data)
+ .attr('target', id)
+ .get(0).submit();
+
+ var waitLoop = function () {
+ var cookies = document.cookie.split(';');
+ // setup next check
+ timer = setTimeout(waitLoop, CHECK_INTERVAL);
+ for (var i=0; i<cookies.length; ++i) {
+ var cookie = cookies[i].replace(/^\s*/, '');
+ if (!cookie.indexOf(cookie_name === 0)) { continue; }
+ var cookie_val = cookie.substring(cookie_length + 1);
+ if (parseInt(cookie_val, 10) !== token) { continue; }
+
+ // clear cookie
+ document.cookie = _.str.sprintf("%s=;expires=%s;path=/",
+ cookie_name, new Date().toGMTString());
+ if (options.success) { options.success(); }
+ complete();
+ return;
+ }
+ };
+ timer = setTimeout(waitLoop, CHECK_INTERVAL);
+};
+
+function post (controller_url, data) {
+
+ var progressHandler = function (deferred) {
+ return function (state) {
+ if(state.lengthComputable) {
+ deferred.notify({
+ h_loaded: utils.human_size(state.loaded),
+ h_total : utils.human_size(state.total),
+ loaded : state.loaded,
+ total : state.total,
+ pcent : Math.round((state.loaded/state.total)*100)
+ });
+ }
+ };
+ };
+
+ var Def = $.Deferred();
+ var postData = new FormData();
+
+ $.each(data, function(i,val) {
+ postData.append(i, val);
+ });
+ if (core.csrf_token) {
+ postData.append('csrf_token', core.csrf_token);
+ }
+
+ var xhr = new XMLHttpRequest();
+ if(xhr.upload) xhr.upload.addEventListener('progress', progressHandler(Def), false);
+
+ var ajaxDef = $.ajax(controller_url, {
+ xhr: function() {return xhr;},
+ data: postData,
+ processData: false,
+ contentType: false,
+ type: 'POST'
+ }).then(function (data) {Def.resolve(data);})
+ .fail(function (data) {Def.reject(data);});
+
+ return Def;
+}
+
+var loadXML = (function () {
+ var loading = false;
+ var urls = [];
+ var qwebs = [];
+ var templates_def = $.Deferred();
+
+ var load = function loadXML(url, qweb) {
+ if (url) {
+ urls.push(url);
+ qwebs.push(qweb);
+ }
+
+ if (!loading && urls.length) {
+ if (templates_def.state() === "resolved") {
+ templates_def = $.Deferred();
+ }
+
+ loading = true;
+ qwebs.shift().add_template(urls.shift(), function () {
+ loading = false;
+ if (!urls.length) {
+ templates_def.resolve();
+ }
+ load(null);
+ });
+ }
+
+ return templates_def;
+ };
+
+ return load;
+})();
+
+return {
+ jsonRpc: jsonRpc,
+ jsonpRpc: jsonpRpc,
+ rpc: rpc,
+ loadCSS: loadCSS,
+ loadJS: loadJS,
+ loadXML: loadXML,
+ get_file: get_file,
+ post: post,
+};
+
+});
diff --git a/addons/web/static/src/js/core/bus.js b/addons/web/static/src/js/core/bus.js
new file mode 100644
index 00000000000..bf33c1fe01e
--- /dev/null
+++ b/addons/web/static/src/js/core/bus.js
@@ -0,0 +1,16 @@
+odoo.define('web.Bus', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+var mixins = require('web.mixins');
+
+/**
+ * Event Bus used to bind events scoped in the current instance
+ */
+return Class.extend(mixins.EventDispatcherMixin, {
+ init: function() {
+ mixins.EventDispatcherMixin.init.call(this, parent);
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/core/class.js b/addons/web/static/src/js/core/class.js
new file mode 100644
index 00000000000..8e53f0ba06d
--- /dev/null
+++ b/addons/web/static/src/js/core/class.js
@@ -0,0 +1,154 @@
+odoo.define('web.Class', function () {
+"use strict";
+/**
+ * Improved John Resig's inheritance, based on:
+ *
+ * Simple JavaScript Inheritance
+ * By John Resig http://ejohn.org/
+ * MIT Licensed.
+ *
+ * Adds "include()"
+ *
+ * Defines The Class object. That object can be used to define and inherit classes using
+ * the extend() method.
+ *
+ * Example:
+ *
+ * var Person = Class.extend({
+ * init: function(isDancing){
+ * this.dancing = isDancing;
+ * },
+ * dance: function(){
+ * return this.dancing;
+ * }
+ * });
+ *
+ * The init() method act as a constructor. This class can be instancied this way:
+ *
+ * var person = new Person(true);
+ * person.dance();
+ *
+ * The Person class can also be extended again:
+ *
+ * var Ninja = Person.extend({
+ * init: function(){
+ * this._super( false );
+ * },
+ * dance: function(){
+ * // Call the inherited version of dance()
+ * return this._super();
+ * },
+ * swingSword: function(){
+ * return true;
+ * }
+ * });
+ *
+ * When extending a class, each re-defined method can use this._super() to call the previous
+ * implementation of that method.
+ */
+var initializing = false;
+var fnTest = /xyz/.test(function(){xyz();}) ? /\b_super\b/ : /.*/;
+
+// The web Class implementation (does nothing)
+function OdooClass(){};
+
+/**
+ * Subclass an existing class
+ *
+ * @param {Object} prop class-level properties (class attributes and instance methods) to set on the new class
+ */
+OdooClass.extend = function() {
+ var _super = this.prototype;
+ // Support mixins arguments
+ var args = _.toArray(arguments);
+ args.unshift({});
+ var prop = _.extend.apply(_,args);
+
+ // Instantiate a web class (but only create the instance,
+ // don't run the init constructor)
+ initializing = true;
+ var This = this;
+ var prototype = new This();
+ initializing = false;
+
+ // Copy the properties over onto the new prototype
+ _.each(prop, function(val, name) {
+ // Check if we're overwriting an existing function
+ prototype[name] = typeof prop[name] == "function" &&
+ fnTest.test(prop[name]) ?
+ (function(name, fn) {
+ return function() {
+ var tmp = this._super;
+
+ // Add a new ._super() method that is the same
+ // method but on the super-class
+ this._super = _super[name];
+
+ // The method only need to be bound temporarily, so
+ // we remove it when we're done executing
+ var ret = fn.apply(this, arguments);
+ this._super = tmp;
+
+ return ret;
+ };
+ })(name, prop[name]) :
+ prop[name];
+ });
+
+ // The dummy class constructor
+ function Class() {
+ if(this.constructor !== OdooClass){
+ throw new Error("You can only instanciate objects with the 'new' operator");
+ }
+ // All construction is actually done in the init method
+ this._super = null;
+ if (!initializing && this.init) {
+ var ret = this.init.apply(this, arguments);
+ if (ret) { return ret; }
+ }
+ return this;
+ }
+ Class.include = function (properties) {
+ _.each(properties, function(val, name) {
+ if (typeof properties[name] !== 'function'
+ || !fnTest.test(properties[name])) {
+ prototype[name] = properties[name];
+ } else if (typeof prototype[name] === 'function'
+ && prototype.hasOwnProperty(name)) {
+ prototype[name] = (function (name, fn, previous) {
+ return function () {
+ var tmp = this._super;
+ this._super = previous;
+ var ret = fn.apply(this, arguments);
+ this._super = tmp;
+ return ret;
+ };
+ })(name, properties[name], prototype[name]);
+ } else if (typeof _super[name] === 'function') {
+ prototype[name] = (function (name, fn) {
+ return function () {
+ var tmp = this._super;
+ this._super = _super[name];
+ var ret = fn.apply(this, arguments);
+ this._super = tmp;
+ return ret;
+ };
+ })(name, properties[name]);
+ }
+ });
+ };
+
+ // Populate our constructed prototype object
+ Class.prototype = prototype;
+
+ // Enforce the constructor to be what we expect
+ Class.constructor = Class;
+
+ // And make this class extendable
+ Class.extend = this.extend;
+
+ return Class;
+};
+
+return OdooClass;
+});
diff --git a/addons/web/static/src/js/core/collections.js b/addons/web/static/src/js/core/collections.js
new file mode 100644
index 00000000000..bc27a9021cc
--- /dev/null
+++ b/addons/web/static/src/js/core/collections.js
@@ -0,0 +1,44 @@
+odoo.define("web.collections", function (require) {
+ "use strict";
+
+ var Class = require("web.Class");
+
+ /**
+ * Allows to build a tree representation of a data.
+ */
+ var Tree = Class.extend({
+ /**
+ * @constructor
+ * @param {*} data - the data associated to the root node
+ */
+ init: function (data) {
+ this._data = data;
+ this._children = [];
+ },
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Returns the root's associated data.
+ *
+ * @returns {*}
+ */
+ getData: function () {
+ return this._data;
+ },
+ /**
+ * Adds a child tree.
+ *
+ * @param {Tree} tree
+ */
+ addChild: function (tree) {
+ this._children.push(tree);
+ },
+ });
+
+ return {
+ Tree: Tree,
+ };
+});
diff --git a/addons/web/static/src/js/core/concurrency.js b/addons/web/static/src/js/core/concurrency.js
new file mode 100644
index 00000000000..cab9df6529e
--- /dev/null
+++ b/addons/web/static/src/js/core/concurrency.js
@@ -0,0 +1,237 @@
+odoo.define('web.concurrency', function (require) {
+"use strict";
+
+/**
+ * Concurrency Utils
+ *
+ * This file contains a short collection of useful helpers designed to help with
+ * everything concurrency related in Odoo.
+ *
+ * The basic concurrency primitives in Odoo JS are the callback, and the
+ * promises. Promises (deferred) are more composable, so we usually use them
+ * whenever possible. We use the jQuery implementation.
+ *
+ * Those functions are really nothing special, but are simply the result of how
+ * we solved some concurrency issues, when we noticed that a pattern emerged.
+ */
+
+var Class = require('web.Class');
+
+return {
+ /**
+ * The jquery implementation for $.when has a (most of the time) useful
+ * property: it is synchronous, if the deferred is resolved immediately.
+ *
+ * This means that when we execute $.when(def), then all registered
+ * callbacks will be executed before the next line is executed. This is
+ * useful quite often, but in some rare cases, we might want to force an
+ * async behavior. This is the purpose of this function, which simply adds a
+ * setTimeout before resolving the deferred.
+ *
+ * @returns {Deferred}
+ */
+ asyncWhen: function () {
+ var async = false;
+ var def = $.Deferred();
+ $.when.apply($, arguments).done(function() {
+ var args = arguments;
+ var action = function() {
+ def.resolve.apply(def, args);
+ };
+ if (async) {
+ action();
+ } else {
+ setTimeout(action, 0);
+ }
+ }).fail(function() {
+ var args = arguments;
+ var action = function() {
+ def.reject.apply(def, args);
+ };
+ if (async) {
+ action();
+ } else {
+ setTimeout(action, 0);
+ }
+ });
+ async = true;
+ return def;
+ },
+ /**
+ * Returns a deferred resolved after 'wait' milliseconds
+ *
+ * @param {int} [wait=0] the delay in ms
+ * @return {Deferred}
+ */
+ delay: function (wait) {
+ var def = $.Deferred();
+ setTimeout(def.resolve, wait);
+ return def;
+ },
+ /**
+ * The DropMisordered abstraction is useful for situations where you have
+ * a sequence of operations that you want to do, but if one of them
+ * completes after a subsequent operation, then its result is obsolete and
+ * should be ignored.
+ *
+ * Note that is is kind of similar to the DropPrevious abstraction, but
+ * subtly different. The DropMisordered operations will all resolves if
+ * they complete in the correct order.
+ */
+ DropMisordered: Class.extend({
+ /**
+ * @constructor
+ *
+ * @param {boolean} [failMisordered=false] whether mis-ordered responses
+ * should be failed or just ignored
+ */
+ init: function (failMisordered) {
+ // local sequence number, for requests sent
+ this.lsn = 0;
+ // remote sequence number, seqnum of last received request
+ this.rsn = -1;
+ this.failMisordered = failMisordered || false;
+ },
+ /**
+ * Adds a deferred (usually an async request) to the sequencer
+ *
+ * @param {Deferred} deferred to ensure add
+ * @returns {Deferred}
+ */
+ add: function (deferred) {
+ var res = $.Deferred();
+
+ var self = this, seq = this.lsn++;
+ deferred.done(function () {
+ if (seq > self.rsn) {
+ self.rsn = seq;
+ res.resolve.apply(res, arguments);
+ } else if (self.failMisordered) {
+ res.reject();
+ }
+ }).fail(function () {
+ res.reject.apply(res, arguments);
+ });
+
+ return res.promise();
+ },
+ }),
+ /**
+ * The DropPrevious abstraction is useful when you have a sequence of
+ * operations that you want to execute, but you only care of the result of
+ * the last operation.
+ *
+ * For example, let us say that we have a _fetch method on a widget which
+ * fetches data. We want to rerender the widget after. We could do this:
+ *
+ * this._fetch().then(function (result) {
+ * self.state = result;
+ * self.render();
+ * });
+ *
+ * Now, we have at least two problems:
+ * - if this code is called twice and the second _fetch completes before the
+ * first, the end state will be the result of the first _fetch, which is
+ * not what we expect
+ * - in any cases, the user interface will rerender twice, which is bad.
+ *
+ * Now, if we have a DropPrevious:
+ *
+ * this.dropPrevious = new DropPrevious();
+ *
+ * Then we can wrap the _fetch in a DropPrevious and have the expected
+ * result:
+ *
+ * this.dropPrevious
+ * .add(this._fetch())
+ * .then(function (result) {
+ * self.state = result;
+ * self.render();
+ * });
+ */
+ DropPrevious: Class.extend({
+ /**
+ * Registers a new deferred and rejects the previous one
+ *
+ * @param {Deferred} the new deferred
+ * @returns {Promise}
+ */
+ add: function (deferred) {
+ if (this.current_def) { this.current_def.reject(); }
+ var res = $.Deferred();
+ deferred.then(res.resolve, res.reject);
+ this.current_def = res;
+ return res.promise();
+ }
+ }),
+ /**
+ * A (Odoo) mutex is a primitive for serializing computations. This is
+ * useful to avoid a situation where two computations modify some shared
+ * state and cause some corrupted state.
+ *
+ * Imagine that we have a function to fetch some data _load(), which returns
+ * a deferred which resolves to something useful. Now, we have some code
+ * looking like this:
+ *
+ * return this._load().then(function (result) {
+ * this.state = result;
+ * });
+ *
+ * If this code is run twice, but the second execution ends before the
+ * first, then the final state will be the result of the first call to
+ * _load. However, if we have a mutex:
+ *
+ * this.mutex = new Mutex();
+ *
+ * and if we wrap the calls to _load in a mutex:
+ *
+ * return this.mutex.exec(function() {
+ * return this._load().then(function (result) {
+ * this.state = result;
+ * });
+ * });
+ *
+ * Then, it is guaranteed that the final state will be the result of the
+ * second execution.
+ *
+ * A Mutex has to be a class, and not a function, because we have to keep
+ * track of some internal state.
+ */
+ Mutex: Class.extend({
+ init: function () {
+ this.def = $.Deferred().resolve();
+ },
+ /**
+ * Add a computation to the queue, it will be executed as soon as the
+ * previous computations are completed.
+ *
+ * @param {function} action a function which may return a deferred
+ * @returns {Deferred}
+ */
+ exec: function (action) {
+ var current = this.def;
+ var next = this.def = $.Deferred();
+ return current.then(function() {
+ return $.when(action()).always(function() {
+ next.resolve();
+ });
+ });
+ }
+ }),
+ /**
+ * Rejects a deferred as soon as a reference deferred is either resolved or
+ * rejected
+ *
+ * @param {Deferred} [target_def] the deferred to potentially reject
+ * @param {Deferred} [reference_def] the reference target
+ * @returns {Deferred}
+ */
+ rejectAfter: function (target_def, reference_def) {
+ var res = $.Deferred();
+ target_def.then(res.resolve, res.reject);
+ reference_def.always(res.reject);
+ return res.promise();
+ }
+};
+
+});
diff --git a/addons/web/static/src/js/core/dialog.js b/addons/web/static/src/js/core/dialog.js
new file mode 100644
index 00000000000..6214c99483e
--- /dev/null
+++ b/addons/web/static/src/js/core/dialog.js
@@ -0,0 +1,208 @@
+odoo.define('web.Dialog', function (require) {
+"use strict";
+
+var core = require('web.core');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+/**
+ A useful class to handle dialogs.
+
+ Attributes:
+ - $footer: A jQuery element targeting a dom part where buttons can be added. It always exists
+ during the lifecycle of the dialog.
+*/
+var Dialog = Widget.extend({
+ /**
+ Constructor.
+
+ @param {Widget} parent
+ @param {dictionary} options
+ - title
+ - subtitle
+ - size: one of the following: 'large', 'medium', 'small'
+ - dialogClass: class to add to the modal-body
+ - buttons: It must be a list of dictionaries -> text, classes, close, click, disabled
+ -> If no buttons, a "ok" primary button is added with close = true
+ -> By default: close = false and classes = 'btn-primary' if only one button and 'btn-default' if many buttons
+ - $content: Some content to replace this.$el .
+ */
+ init: function (parent, options) {
+ this._super(parent);
+ this._opened = $.Deferred();
+
+ options = _.defaults(options || {}, {
+ title: _t('Odoo'), subtitle: '',
+ size: 'large',
+ dialogClass: '',
+ $content: false,
+ buttons: [{text: _t("Ok"), close: true}]
+ });
+
+ this.$content = options.$content;
+
+ this.title = options.title;
+ this.subtitle = options.subtitle;
+ this.$modal = $(QWeb.render('Dialog', {title: this.title, subtitle: this.subtitle}));
+
+ switch(options.size) {
+ case 'large':
+ this.$modal.find('.modal-dialog').addClass('modal-lg');
+ break;
+ case 'small':
+ this.$modal.find('.modal-dialog').addClass('modal-sm');
+ break;
+ }
+
+ this.dialogClass = options.dialogClass;
+ this.$footer = this.$modal.find(".modal-footer");
+
+ this.set_buttons(options.buttons);
+
+ this.$modal.on('hidden.bs.modal', _.bind(this.destroy, this));
+ },
+
+ renderElement: function() {
+ this._super();
+ if(this.$content) {
+ this.setElement(this.$content);
+ }
+ this.$el.addClass('modal-body ' + this.dialogClass);
+ },
+
+ set_buttons: function(buttons) {
+ var self = this;
+
+ self.$footer.empty();
+
+ _.each(buttons, function(b) {
+ var text = b.text || "";
+ var classes = b.classes || ((buttons.length === 1)? "btn-primary" : "btn-default");
+
+ var $b = $(QWeb.render('WidgetButton', {
+ widget: {
+ string: text,
+ node: {
+ attrs: {'class': classes, icon: b.icon}
+ },
+ fa_icon: true
+ }
+ }));
+ $b.prop('disabled', b.disabled);
+ $b.on('click', function(e) {
+ var click_def;
+ if(b.click) {
+ click_def = b.click.call(self, e);
+ }
+ if(b.close) {
+ $.when(click_def).always(self.close.bind(self));
+ }
+ });
+ self.$footer.append($b);
+ });
+ },
+
+ set_title: function(title, subtitle) {
+ this.title = title || "";
+ if (subtitle !== undefined) {
+ this.subtitle = subtitle || "";
+ }
+
+ var $title = this.$modal.find('.modal-title').first();
+ var $subtitle = $title.find('.o_subtitle').detach();
+ $title.html(this.title);
+ $subtitle.html(this.subtitle).appendTo($title);
+
+ return this;
+ },
+
+ opened: function(handler) {
+ return (handler)? this._opened.then(handler) : this._opened;
+ },
+
+ open: function() {
+ $('.tooltip').remove(); // remove open tooltip if any to prevent them staying when modal is opened
+
+ var self = this;
+ this.replace(this.$modal.find(".modal-body")).then(function() {
+ self.$modal.modal('show');
+ self._opened.resolve();
+ });
+
+ return self;
+ },
+
+ close: function() {
+ this.$modal.modal('hide');
+ },
+
+ destroy: function(reason) {
+ if (this.isDestroyed()) {
+ return;
+ }
+
+ this.trigger("closed", reason);
+
+ this._super();
+
+ $('.tooltip').remove(); //remove open tooltip if any to prevent them staying when modal has disappeared
+ this.$modal.modal('hide');
+ this.$modal.remove();
+
+ setTimeout(function () { // Keep class modal-open (deleted by bootstrap hide fnct) on body to allow scrolling inside the modal
+ var modals = $('body > .modal').filter(':visible');
+ if(modals.length) {
+ modals.last().focus();
+ $('body').addClass('modal-open');
+ }
+ }, 0);
+ }
+});
+
+// static method to open simple alert dialog
+Dialog.alert = function (owner, message, options) {
+ var buttons = [{
+ text: _t("Ok"),
+ close: true,
+ click: options && options.confirm_callback,
+ }];
+ return new Dialog(owner, _.extend({
+ size: 'medium',
+ buttons: buttons,
+ $content: $('<div>', {
+ text: message,
+ }),
+ title: _t("Alert"),
+ }, options)).open();
+};
+
+// static method to open simple confirm dialog
+Dialog.confirm = function (owner, message, options) {
+ var buttons = [
+ {
+ text: _t("Ok"),
+ classes: 'btn-primary',
+ close: true,
+ click: options && options.confirm_callback
+ },
+ {
+ text: _t("Cancel"),
+ close: true,
+ click: options && options.cancel_callback
+ }
+ ];
+ return new Dialog(owner, _.extend({
+ size: 'medium',
+ buttons: buttons,
+ $content: $('<div>', {
+ text: message,
+ }),
+ title: _t("Confirmation"),
+ }, options)).open();
+};
+
+return Dialog;
+
+});
diff --git a/addons/web/static/src/js/core/dom.js b/addons/web/static/src/js/core/dom.js
new file mode 100644
index 00000000000..4333061f924
--- /dev/null
+++ b/addons/web/static/src/js/core/dom.js
@@ -0,0 +1,164 @@
+odoo.define('web.dom', function (require) {
+"use strict";
+
+/**
+ * DOM Utility helpers
+ *
+ * We collect in this file some helpers to help integrate various DOM
+ * functionalities with the odoo framework. A common theme in these functions
+ * is the use of the main core.bus, which helps the framework react when
+ * something happens in the DOM.
+ */
+
+var core = require('web.core');
+
+/**
+ * Private function to notify that something has been attached in the DOM
+ * @param {htmlString or Element or Array or jQuery} [content] the content that
+ * has been attached in the DOM
+ * @params {Array} [callbacks] array of {widget: w, callback_args: args} such
+ * that on_attach_callback() will be called on each w with arguments args
+ */
+function _notify (content, callbacks) {
+ _.each(callbacks, function(c) {
+ if (c.widget && c.widget.on_attach_callback) {
+ c.widget.on_attach_callback(c.callback_args);
+ }
+ });
+ core.bus.trigger('DOM_updated', content);
+}
+
+return {
+ /**
+ * Appends content in a jQuery object and optionnally triggers an event
+ *
+ * @param {jQuery} [$target] the node where content will be appended
+ * @param {htmlString or Element or Array or jQuery} [content] DOM element,
+ * array of elements, HTML string or jQuery object to append to $target
+ * @param {Boolean} [options.in_DOM] true if $target is in the DOM
+ * @param {Array} [options.callbacks] array of objects describing the
+ * callbacks to perform (see _notify for a complete description)
+ */
+ append: function ($target, content, options) {
+ $target.append(content);
+ if (options && options.in_DOM) {
+ _notify(content, options.callbacks);
+ }
+ },
+ /**
+ * Autoresize a $textarea node, by recomputing its height when necessary
+ * @param {number} [options.min_height] by default, 50.
+ * @param {Widget} [options.parent] if set, autoresize will listen to some
+ * extra events to decide when to resize itself. This is useful for
+ * widgets that are not in the dom when the autoresize is declared.
+ */
+ autoresize: function ($textarea, options) {
+ if ($textarea.data("auto_resize")) {
+ return;
+ }
+ function resize () {
+ var heightOffset;
+ var style = window.getComputedStyle($textarea[0], null);
+ if (style.boxSizing === 'content-box') {
+ heightOffset = -(parseFloat(style.paddingTop) + parseFloat(style.paddingBottom));
+ } else {
+ heightOffset = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
+ }
+ $fixed_text_area.width($textarea.width());
+ $fixed_text_area.val($textarea.val());
+ var height = $fixed_text_area[0].scrollHeight;
+ $textarea.css({height: Math.max(height + heightOffset, min_height)});
+ }
+
+ options = options || {};
+
+ var $fixed_text_area;
+ var min_height = (options && options.min_height) || 50;
+ if (!$fixed_text_area) {
+ $fixed_text_area = $('<textarea disabled>').css({
+ position: 'absolute',
+ opacity: 0,
+ height: 10,
+ top: -10000,
+ left: -10000,
+ });
+ $fixed_text_area.addClass($textarea[0].className);
+ $fixed_text_area.insertAfter($textarea);
+ $fixed_text_area.data("auto_resize", true);
+ }
+
+ var style = window.getComputedStyle($textarea[0], null);
+ if (style.resize === 'vertical') {
+ $textarea[0].style.resize = 'none';
+ } else if (style.resize === 'both') {
+ $textarea[0].style.resize = 'horizontal';
+ }
+ resize();
+ $textarea.data("auto_resize", true);
+
+ $textarea.on('input focus', resize);
+ if (options.parent) {
+ core.bus.on('DOM_updated', options.parent, resize);
+ core.bus.on('view_shown', options.parent, resize);
+ }
+
+ },
+ /**
+ * Detaches widgets from the DOM and performs their on_detach_callback()
+ *
+ * @param {Array} [to_detach] array of {widget: w, callback_args: args} such
+ * that w.$el will be detached and w.on_detach_callback(args) will be
+ * called
+ * @param {jQuery} [options.$to_detach] if given, detached instead of
+ * widgets' $el
+ * @return {jQuery} the detached elements
+ */
+ detach: function (to_detach, options) {
+ _.each(to_detach, function(d) {
+ if (d.widget.on_detach_callback) {
+ d.widget.on_detach_callback(d.callback_args);
+ }
+ });
+ var $to_detach = options && options.$to_detach;
+ if (!$to_detach) {
+ $to_detach = $(_.map(to_detach, function(d) {
+ return d.widget.el;
+ }));
+ }
+ return $to_detach.detach();
+ },
+ /**
+ * Returns the distance between a DOM element and the top-left corner of the
+ * window
+ *
+ * @param {element} [e] the DOM element
+ * @return {Object} the left and top distances in pixels
+ */
+ getPosition: function (e) {
+ var position = {left: 0, top: 0};
+ while (e) {
+ position.left += e.offsetLeft;
+ position.top += e.offsetTop;
+ e = e.offsetParent;
+ }
+ return position;
+ },
+ /**
+ * Prepends content in a jQuery object and optionnally triggers an event
+ *
+ * @param {jQuery} [$target] the node where content will be prepended
+ * @param {htmlString or Element or Array or jQuery} [content] DOM element,
+ * array of elements, HTML string or jQuery object to prepend to $target
+ * @param {Boolean} [options.in_DOM] true if $target is in the DOM
+ * @param {Array} [options.callbacks] array of objects describing the
+ * callbacks to perform (see _notify for a complete description)
+ */
+ prepend: function ($target, content, options) {
+ $target.prepend(content);
+ if (options && options.in_DOM) {
+ _notify(content, options.callbacks);
+ }
+ },
+};
+
+});
diff --git a/addons/web/static/src/js/core/domain.js b/addons/web/static/src/js/core/domain.js
new file mode 100644
index 00000000000..11d6a59ec33
--- /dev/null
+++ b/addons/web/static/src/js/core/domain.js
@@ -0,0 +1,225 @@
+odoo.define("web.Domain", function (require) {
+"use strict";
+
+var collections = require("web.collections");
+var pyeval = require("web.pyeval");
+
+/**
+ * The Domain Class allows to work with a domain as a tree and provides tools
+ * to manipulate array and string representations of domains.
+ */
+var Domain = collections.Tree.extend({
+ /**
+ * @constructor
+ * @param {string|Array|boolean|undefined} domain
+ * - The given domain can be:
+ * * a string representation of the Python prefix-array
+ * representation of the domain.
+ * * a JS prefix-array representation of the domain.
+ * * a boolean where the "true" domain match all records and the
+ * "false" domain does not match any records.
+ * * undefined, considered as the false boolean.
+ */
+ init: function (domain) {
+ this._super.apply(this, arguments);
+ if (domain === true || domain === false || domain === undefined) {
+ this._data = domain || false;
+ } else {
+ this._parse(this.normalizeArray(_.clone(this.stringToArray(domain))));
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Evaluates the domain with a set of values.
+ *
+ * @param {Object} values - a mapping {fieldName -> fieldValue} (note: all
+ * the fields used in the domain should be given a
+ * value otherwise the computation will break)
+ * @returns {boolean}
+ */
+ compute: function (values) {
+ if (this._data === true || this._data === false) {
+ // The domain is a always-true or a always-false domain
+ return this._data;
+ } else if (_.isArray(this._data)) {
+ // The domain is a [name, operator, value] entity
+ // First check if we have the field value in the field values set
+ if (!(this._data[0] in values)) {
+ throw new Error(_.str.sprintf(
+ "Unknown field %s in domain",
+ this._data[0]
+ ));
+ }
+ var fieldValue = values[this._data[0]];
+
+ switch (this._data[1]) {
+ case "=":
+ case "==":
+ return _.isEqual(fieldValue, this._data[2]);
+ case "!=":
+ case "<>":
+ return !_.isEqual(fieldValue, this._data[2]);
+ case "<":
+ return (fieldValue < this._data[2]);
+ case ">":
+ return (fieldValue > this._data[2]);
+ case "<=":
+ return (fieldValue <= this._data[2]);
+ case ">=":
+ return (fieldValue >= this._data[2]);
+ case "in":
+ return _.contains(
+ _.isArray(this._data[2]) ? this._data[2] : [this._data[2]],
+ fieldValue
+ );
+ case "not in":
+ return !_.contains(
+ _.isArray(this._data[2]) ? this._data[2] : [this._data[2]],
+ fieldValue
+ );
+ case "like":
+ return (fieldValue.toLowerCase().indexOf(this._data[2].toLowerCase()) >= 0);
+ case "ilike":
+ return (fieldValue.indexOf(this._data[2]) >= 0);
+ default:
+ throw new Error(_.str.sprintf(
+ "Domain %s uses an unsupported operator",
+ this._data
+ ));
+ }
+ } else { // The domain is a set of [name, operator, value] entitie(s)
+ switch (this._data) {
+ case "&":
+ return _.every(this._children, function (child) {
+ return child.compute(values);
+ });
+ case "|":
+ return _.some(this._children, function (child) {
+ return child.compute(values);
+ });
+ case "!":
+ return !this._children[0].compute(values);
+ }
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Initializes the tree representation of the domain according to its given
+ * JS prefix-array representation. Note: the given array is considered
+ * already normalized.
+ *
+ * @private
+ * @param {Array} domain - normalized JS prefix-array representation of
+ * the domain
+ */
+ _parse: function (domain) {
+ this._data = (domain.length === 0 ? true : domain[0]);
+ if (domain.length <= 1) return;
+
+ var expected = 1;
+ for (var i = 1 ; i < domain.length ; i++) {
+ if (domain[i] === "&" || domain[i] === "|") {
+ expected++;
+ } else if (domain[i] !== "!") {
+ expected--;
+ }
+
+ if (!expected) {
+ i++;
+ this._addSubdomain(domain.slice(1, i));
+ this._addSubdomain(domain.slice(i));
+ break;
+ }
+ }
+ },
+
+ /**
+ * Adds a domain as a child (e.g. if the current domain is ["|", A, B],
+ * using this method with a ["&", C, D] domain will result in a
+ * ["|", "|", A, B, "&", C, D]).
+ * Note: the internal tree representation is automatically simplified.
+ *
+ * @param {Array} domain - normalized JS prefix-array representation of a
+ * domain to add
+ */
+ _addSubdomain: function (domain) {
+ if (!domain.length) return;
+ var subdomain = new Domain(domain);
+
+ if (!subdomain._children.length || subdomain._data !== this._data) {
+ this._children.push(subdomain);
+ } else {
+ var self = this;
+ _.each(subdomain._children, function (childDomain) {
+ self._children.push(childDomain);
+ });
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Static
+ //--------------------------------------------------------------------------
+
+ /**
+ * Converts JS prefix-array representation of a domain to a string
+ * representation of the Python prefix-array representation of this domain.
+ *
+ * @static
+ * @param {Array|string} domain
+ * @returns {string}
+ */
+ arrayToString: function (domain) {
+ if (_.isString(domain)) return domain;
+ return JSON.stringify(domain || [])
+ .replace(/false/g, "False")
+ .replace(/true/g, "True");
+ },
+ /**
+ * Converts a string representation of the Python prefix-array
+ * representation of a domain to a JS prefix-array representation of this
+ * domain.
+ *
+ * @static
+ * @param {string|Array} domain
+ * @returns {Array}
+ */
+ stringToArray: function (domain) {
+ if (!_.isString(domain)) return domain;
+ return pyeval.eval("domain", domain || "[]");
+ },
+ /**
+ * Makes implicit "&" operators explicit in the given JS prefix-array
+ * representation of domain (e.g [A, B] -> ["&", A, B])
+ *
+ * @static
+ * @param {Array} domain - the JS prefix-array representation of the domain
+ * to normalize (! will be normalized in-place)
+ * @returns {Array} the normalized JS prefix-array representation of the
+ * given domain
+ */
+ normalizeArray: function (domain) {
+ var expected = 1;
+ _.each(domain, function (item) {
+ if (item === "&" || item === "|") {
+ expected++;
+ } else if (item !== "!") {
+ expected--;
+ }
+ });
+ if (expected < 0) {
+ domain.unshift.apply(domain, _.times(Math.abs(expected), _.constant("&")));
+ }
+ return domain;
+ },
+});
+
+return Domain;
+});
diff --git a/addons/web/static/src/js/core/local_storage.js b/addons/web/static/src/js/core/local_storage.js
new file mode 100644
index 00000000000..6cf82e87752
--- /dev/null
+++ b/addons/web/static/src/js/core/local_storage.js
@@ -0,0 +1,34 @@
+odoo.define('web.local_storage', function (require) {
+'use strict';
+
+var Class = require('web.Class');
+
+// Compatibility with private browsing in Safari
+// Use dict in case of local storage is disabled
+var localStorage = window.localStorage;
+try {
+ var uid = new Date();
+ localStorage.setItem(uid, uid);
+ localStorage.removeItem(uid);
+} catch (exception) {
+ var storage = {};
+ var RamStorage = Class.extend({
+ setItem: function(key, value) {
+ storage[key] = value;
+ },
+ getItem: function(key) {
+ return storage[key];
+ },
+ removeItem: function(key) {
+ delete storage[key];
+ },
+ clear: function() {
+ storage = {};
+ },
+ });
+ localStorage = new RamStorage();
+}
+
+return localStorage;
+
+});
diff --git a/addons/web/static/src/js/core/misc.js b/addons/web/static/src/js/core/misc.js
new file mode 100644
index 00000000000..f65276551cf
--- /dev/null
+++ b/addons/web/static/src/js/core/misc.js
@@ -0,0 +1,182 @@
+odoo.define('web.framework', function (require) {
+"use strict";
+
+var core = require('web.core');
+var crash_manager = require('web.crash_manager');
+var ajax = require('web.ajax');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+
+var messages_by_seconds = function() {
+ return [
+ [0, _t("Loading...")],
+ [20, _t("Still loading...")],
+ [60, _t("Still loading...<br />Please be patient.")],
+ [120, _t("Don't leave yet,<br />it's still loading...")],
+ [300, _t("You may not believe it,<br />but the application is actually loading...")],
+ [420, _t("Take a minute to get a coffee,<br />because it's loading...")],
+ [3600, _t("Maybe you should consider reloading the application by pressing F5...")]
+ ];
+};
+
+var Throbber = Widget.extend({
+ template: "Throbber",
+ start: function() {
+ this.start_time = new Date().getTime();
+ this.act_message();
+ },
+ act_message: function() {
+ var self = this;
+ setTimeout(function() {
+ if (self.isDestroyed())
+ return;
+ var seconds = (new Date().getTime() - self.start_time) / 1000;
+ var mes;
+ _.each(messages_by_seconds(), function(el) {
+ if (seconds >= el[0])
+ mes = el[1];
+ });
+ self.$(".oe_throbber_message").html(mes);
+ self.act_message();
+ }, 1000);
+ },
+});
+
+/** Setup blockui */
+if ($.blockUI) {
+ $.blockUI.defaults.baseZ = 1100;
+ $.blockUI.defaults.message = '<div class="openerp oe_blockui_spin_container" style="background-color: transparent;">';
+ $.blockUI.defaults.css.border = '0';
+ $.blockUI.defaults.css["background-color"] = '';
+}
+
+
+var throbbers = [];
+
+function blockUI () {
+ var tmp = $.blockUI.apply($, arguments);
+ var throbber = new Throbber();
+ throbbers.push(throbber);
+ throbber.appendTo($(".oe_blockui_spin_container"));
+ $('body').addClass('o_ui_blocked');
+ return tmp;
+}
+
+function unblockUI () {
+ _.invoke(throbbers, 'destroy');
+ throbbers = [];
+ $('body').removeClass('o_ui_blocked');
+ return $.unblockUI.apply($, arguments);
+}
+
+/**
+ * Redirect to url by replacing window.location
+ * If wait is true, sleep 1s and wait for the server i.e. after a restart.
+ */
+function redirect (url, wait) {
+ // Dont display a dialog if some xmlhttprequest are in progress
+ crash_manager.disable();
+
+ var load = function() {
+ var old = "" + window.location;
+ var old_no_hash = old.split("#")[0];
+ var url_no_hash = url.split("#")[0];
+ location.assign(url);
+ if (old_no_hash === url_no_hash) {
+ location.reload(true);
+ }
+ };
+
+ var wait_server = function() {
+ ajax.rpc("/web/webclient/version_info", {}).done(load).fail(function() {
+ setTimeout(wait_server, 250);
+ });
+ };
+
+ if (wait) {
+ setTimeout(wait_server, 1000);
+ } else {
+ load();
+ }
+}
+
+// * Client action to reload the whole interface.
+// * If params.menu_id, it opens the given menu entry.
+// * If params.wait, reload will wait the openerp server to be reachable before reloading
+
+function Reload(parent, action) {
+ var params = action.params || {};
+ var menu_id = params.menu_id || false;
+ var l = window.location;
+
+ var sobj = $.deparam(l.search.substr(1));
+ if (params.url_search) {
+ sobj = _.extend(sobj, params.url_search);
+ }
+ var search = '?' + $.param(sobj);
+
+ var hash = l.hash;
+ if (menu_id) {
+ hash = "#menu_id=" + menu_id;
+ }
+ var url = l.protocol + "//" + l.host + l.pathname + search + hash;
+
+ redirect(url, params.wait);
+}
+
+core.action_registry.add("reload", Reload);
+
+
+/**
+ * Client action to go back home.
+ */
+function Home (parent, action) {
+ var url = '/' + (window.location.search || '');
+ redirect(url, action && action.params && action.params.wait);
+}
+core.action_registry.add("home", Home);
+
+/**
+ * Client action to go back in breadcrumb history.
+ * If can't go back in history stack, will go back to home.
+ */
+function HistoryBack (parent) {
+ parent.history_back().fail(function () {
+ Home(parent);
+ });
+}
+core.action_registry.add("history_back", HistoryBack);
+
+function login() {
+ redirect('/web/login');
+}
+core.action_registry.add("login", login);
+
+function logout() {
+ redirect('/web/session/logout');
+ return $.Deferred();
+}
+core.action_registry.add("logout", logout);
+
+/**
+ * Client action to refresh the session context (making sure
+ * HTTP requests will have the right one) then reload the
+ * whole interface.
+ */
+function ReloadContext (parent, action) {
+ // side-effect of get_session_info is to refresh the session context
+ ajax.rpc("/web/session/get_session_info", {}).then(function() {
+ Reload(parent, action);
+ });
+}
+core.action_registry.add("reload_context", ReloadContext);
+
+
+return {
+ blockUI: blockUI,
+ unblockUI: unblockUI,
+ redirect: redirect,
+};
+
+});
diff --git a/addons/web/static/src/js/core/mixins.js b/addons/web/static/src/js/core/mixins.js
new file mode 100644
index 00000000000..5caac9a2c8c
--- /dev/null
+++ b/addons/web/static/src/js/core/mixins.js
@@ -0,0 +1,472 @@
+odoo.define('web.mixins', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+var utils = require('web.utils');
+var AbstractService = require('web.AbstractService');
+
+/**
+ * Mixin to structure objects' life-cycles folowing a parent-children
+ * relationship. Each object can a have a parent and multiple children.
+ * When an object is destroyed, all its children are destroyed too releasing
+ * any resource they could have reserved before.
+ */
+var ParentedMixin = {
+ __parentedMixin : true,
+ init: function() {
+ this.__parentedDestroyed = false;
+ this.__parentedChildren = [];
+ this.__parentedParent = null;
+ },
+ /**
+ * Set the parent of the current object. When calling this method, the
+ * parent will also be informed and will return the current object
+ * when its getChildren() method is called. If the current object did
+ * already have a parent, it is unregistered before, which means the
+ * previous parent will not return the current object anymore when its
+ * getChildren() method is called.
+ */
+ setParent : function(parent) {
+ if (this.getParent()) {
+ if (this.getParent().__parentedMixin) {
+ this.getParent().__parentedChildren = _.without(this
+ .getParent().getChildren(), this);
+ }
+ }
+ this.__parentedParent = parent;
+ if (parent && parent.__parentedMixin) {
+ parent.__parentedChildren.push(this);
+ }
+ },
+ /**
+ * Return the current parent of the object (or null).
+ */
+ getParent : function() {
+ return this.__parentedParent;
+ },
+ /**
+ * Return a list of the children of the current object.
+ */
+ getChildren : function() {
+ return _.clone(this.__parentedChildren);
+ },
+ /**
+ * Returns true if destroy() was called on the current object.
+ */
+ isDestroyed : function() {
+ return this.__parentedDestroyed;
+ },
+ /**
+ Utility method to only execute asynchronous actions if the current
+ object has not been destroyed.
+
+ @param {$.Deferred} promise The promise representing the asynchronous
+ action.
+ @param {bool} [reject=false] If true, the returned promise will be
+ rejected with no arguments if the current
+ object is destroyed. If false, the
+ returned promise will never be resolved
+ or rejected.
+ @returns {$.Deferred} A promise that will mirror the given promise if
+ everything goes fine but will either be rejected
+ with no arguments or never resolved if the
+ current object is destroyed.
+ */
+ alive: function(promise, reject) {
+ var self = this;
+ return $.Deferred(function (def) {
+ promise.then(function () {
+ if (!self.isDestroyed()) {
+ def.resolve.apply(def, arguments);
+ }
+ }, function () {
+ if (!self.isDestroyed()) {
+ def.reject.apply(def, arguments);
+ }
+ }).always(function () {
+ if (reject) {
+ // noop if def already resolved or rejected
+ def.reject();
+ }
+ // otherwise leave promise in limbo
+ });
+ }).promise();
+ },
+ /**
+ * Inform the object it should destroy itself, releasing any
+ * resource it could have reserved.
+ */
+ destroy : function() {
+ _.each(this.getChildren(), function(el) {
+ el.destroy();
+ });
+ this.setParent(undefined);
+ this.__parentedDestroyed = true;
+ },
+ /**
+ * Find the closest ancestor matching predicate
+ */
+ findAncestor: function (predicate) {
+ var ancestor = this;
+ while (ancestor && !(predicate(ancestor)) && ancestor.getParent) {
+ ancestor = ancestor.getParent();
+ }
+ return ancestor;
+ },
+};
+
+function OdooEvent (target, name, data) {
+ this.target = target;
+ this.name = name;
+ this.data = Object.create(null);
+ _.extend(this.data, data);
+ this.stopped = false;
+}
+
+OdooEvent.prototype.stopPropagation = function () {
+ this.stopped = true;
+};
+
+OdooEvent.prototype.is_stopped = function () {
+ return this.stopped;
+};
+
+/**
+ * Backbone's events. Do not ever use it directly, use EventDispatcherMixin instead.
+ *
+ * This class just handle the dispatching of events, it is not meant to be extended,
+ * nor used directly. All integration with parenting and automatic unregistration of
+ * events is done in EventDispatcherMixin.
+ *
+ * Copyright notice for the following Class:
+ *
+ * (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc.
+ * Backbone may be freely distributed under the MIT license.
+ * For all details and documentation:
+ * http://backbonejs.org
+ *
+ */
+var Events = Class.extend({
+ on : function(events, callback, context) {
+ var ev;
+ events = events.split(/\s+/);
+ var calls = this._callbacks || (this._callbacks = {});
+ while ((ev = events.shift())) {
+ var list = calls[ev] || (calls[ev] = {});
+ var tail = list.tail || (list.tail = list.next = {});
+ tail.callback = callback;
+ tail.context = context;
+ list.tail = tail.next = {};
+ }
+ return this;
+ },
+
+ off : function(events, callback, context) {
+ var ev, calls, node;
+ if (!events) {
+ delete this._callbacks;
+ } else if ((calls = this._callbacks)) {
+ events = events.split(/\s+/);
+ while ((ev = events.shift())) {
+ node = calls[ev];
+ delete calls[ev];
+ if (!callback || !node)
+ continue;
+ while ((node = node.next) && node.next) {
+ if (node.callback === callback
+ && (!context || node.context === context))
+ continue;
+ this.on(ev, node.callback, node.context);
+ }
+ }
+ }
+ return this;
+ },
+
+ callbackList: function() {
+ var lst = [];
+ _.each(this._callbacks || {}, function(el, eventName) {
+ var node = el;
+ while ((node = node.next) && node.next) {
+ lst.push([eventName, node.callback, node.context]);
+ }
+ });
+ return lst;
+ },
+
+ trigger : function(events) {
+ var event, node, calls, tail, args, all, rest;
+ if (!(calls = this._callbacks))
+ return this;
+ all = calls.all;
+ (events = events.split(/\s+/)).push(null);
+ // Save references to the current heads & tails.
+ while ((event = events.shift())) {
+ if (all)
+ events.push({
+ next : all.next,
+ tail : all.tail,
+ event : event
+ });
+ if (!(node = calls[event]))
+ continue;
+ events.push({
+ next : node.next,
+ tail : node.tail
+ });
+ }
+ rest = Array.prototype.slice.call(arguments, 1);
+ while ((node = events.pop())) {
+ tail = node.tail;
+ args = node.event ? [ node.event ].concat(rest) : rest;
+ while ((node = node.next) !== tail) {
+ node.callback.apply(node.context || this, args);
+ }
+ }
+ return this;
+ }
+});
+
+/**
+ Mixin containing an event system. Events are also registered by specifying the target object
+ (the object which will receive the event when it is raised). Both the event-emitting object
+ and the target object store or reference to each other. This is used to correctly remove all
+ reference to the event handler when any of the object is destroyed (when the destroy() method
+ from ParentedMixin is called). Removing those references is necessary to avoid memory leak
+ and phantom events (events which are raised and sent to a previously destroyed object).
+*/
+var EventDispatcherMixin = _.extend({}, ParentedMixin, {
+ __eventDispatcherMixin: true,
+ init: function() {
+ ParentedMixin.init.call(this);
+ this.__edispatcherEvents = new Events();
+ this.__edispatcherRegisteredEvents = [];
+ },
+ on: function(events, dest, func) {
+ var self = this;
+ if (typeof func !== "function") {
+ throw new Error("Event handler must be a function.");
+ }
+ events = events.split(/\s+/);
+ _.each(events, function(eventName) {
+ self.__edispatcherEvents.on(eventName, func, dest);
+ if (dest && dest.__eventDispatcherMixin) {
+ dest.__edispatcherRegisteredEvents.push({name: eventName, func: func, source: self});
+ }
+ });
+ return this;
+ },
+ off: function(events, dest, func) {
+ var self = this;
+ events = events.split(/\s+/);
+ _.each(events, function(eventName) {
+ self.__edispatcherEvents.off(eventName, func, dest);
+ if (dest && dest.__eventDispatcherMixin) {
+ dest.__edispatcherRegisteredEvents = _.filter(dest.__edispatcherRegisteredEvents, function(el) {
+ return !(el.name === eventName && el.func === func && el.source === self);
+ });
+ }
+ });
+ return this;
+ },
+ once: function(events, dest, func) {
+ // similar to this.on(), but func is executed only once
+ var self = this;
+ if (typeof func !== "function") {
+ throw new Error("Event handler must be a function.");
+ }
+ self.on(events, dest, function what() {
+ func.apply(this, arguments);
+ self.off(events, dest, what);
+ });
+ },
+ trigger: function() {
+ this.__edispatcherEvents.trigger.apply(this.__edispatcherEvents, arguments);
+ return this;
+ },
+ trigger_up: function(name, info) {
+ var event = new OdooEvent(this, name, info);
+ this._trigger_up(event);
+ },
+ _trigger_up: function(event) {
+ var parent;
+ this.__edispatcherEvents.trigger(event.name, event);
+ if (!event.is_stopped() && (parent = this.getParent())) {
+ parent._trigger_up(event);
+ }
+ },
+ destroy: function() {
+ var self = this;
+ _.each(this.__edispatcherRegisteredEvents, function(event) {
+ event.source.__edispatcherEvents.off(event.name, event.func, self);
+ });
+ this.__edispatcherRegisteredEvents = [];
+ _.each(this.__edispatcherEvents.callbackList(), function(cal) {
+ this.off(cal[0], cal[2], cal[1]);
+ }, this);
+ this.__edispatcherEvents.off();
+ ParentedMixin.destroy.call(this);
+ }
+});
+
+var PropertiesMixin = _.extend({}, EventDispatcherMixin, {
+ init: function() {
+ EventDispatcherMixin.init.call(this);
+ this.__getterSetterInternalMap = {};
+ },
+ set: function(arg1, arg2, arg3) {
+ var map;
+ var options;
+ if (typeof arg1 === "string") {
+ map = {};
+ map[arg1] = arg2;
+ options = arg3 || {};
+ } else {
+ map = arg1;
+ options = arg2 || {};
+ }
+ var self = this;
+ var changed = false;
+ _.each(map, function(val, key) {
+ var tmp = self.__getterSetterInternalMap[key];
+ if (tmp === val)
+ return;
+ // seriously, why are you doing this? it is obviously a stupid design.
+ // the properties mixin should not be concerned with handling fields details.
+ // this also has the side effect of introducing a dependency on utils. Todo:
+ // remove this, or move it elsewhere. Also, learn OO programming.
+ if (key === 'value' && self.field && self.field.type === 'float' && tmp && val){
+ var digits = self.field.digits;
+ if (_.isArray(digits)) {
+ if (utils.float_is_zero(tmp - val, digits[1])) {
+ return;
+ }
+ }
+ }
+ changed = true;
+ self.__getterSetterInternalMap[key] = val;
+ if (! options.silent)
+ self.trigger("change:" + key, self, {
+ oldValue: tmp,
+ newValue: val
+ });
+ });
+ if (changed)
+ self.trigger("change", self);
+ },
+ get: function(key) {
+ return this.__getterSetterInternalMap[key];
+ }
+});
+
+var ServicesMixin = {
+ call: function(service, method) {
+ var args = Array.prototype.slice.call(arguments, 2);
+ var result;
+ this.trigger_up('call_service', {
+ service: service,
+ method: method,
+ args: args,
+ callback: function(r) {
+ result = r;
+ },
+ });
+ return result;
+ },
+ // AJAX calls
+ performRPC: function(route, args) {
+ return this.call('ajax', 'rpc', route, args);
+ },
+ performModelRPC: function(model, method, args, kwargs) {
+ return this.performRPC('/web/dataset/call_kw/' + model + '/' + method, {
+ model: model,
+ method: method,
+ args: args || [],
+ kwargs: kwargs || {},
+ });
+ },
+ loadFieldView: function(dataset, view_id, view_type, options) {
+ return this.loadViews(dataset.model, dataset.get_context(), [[view_id, view_type]], options).then(function (result) {
+ return result[view_type];
+ });
+ },
+ loadViews: function(modelName, context, views, options) {
+ var def = $.Deferred();
+ this.trigger_up('load_views', {
+ modelName: modelName,
+ context: context,
+ views: views,
+ options: options,
+ on_success: def.resolve.bind(def),
+ });
+ return def;
+ },
+ loadFilters: function(dataset, action_id) {
+ var def = $.Deferred();
+ this.trigger_up('load_filters', {
+ dataset: dataset,
+ action_id: action_id,
+ on_success: def.resolve.bind(def),
+ });
+ return def;
+ },
+ // Session stuff
+ getSession: function() {
+ var session;
+ this.trigger_up('get_session', {
+ callback: function(result) {
+ session = result;
+ }
+ });
+ return session;
+ },
+ /**
+ * Informs the action manager to do an action. This supposes that
+ * the action manager can be found amongst the ancestors of the current widget.
+ * If that's not the case this method will simply return `false`.
+ */
+ do_action: function (action, options) {
+ var def = $.Deferred();
+ options = options || {};
+ var oldOnSuccess = options.on_success;
+ options.on_success = function(result) {
+ oldOnSuccess(result);
+ def.resolve(result);
+ };
+ this.trigger_up('do_action', {'action': action, options: options});
+ return def;
+ },
+ do_notify: function(title, message, sticky) {
+ this.trigger_up('notification', {title: title, message: message, sticky: sticky});
+ },
+ do_warn: function(title, message, sticky) {
+ this.trigger_up('warning', {title: title, message: message, sticky: sticky});
+ },
+};
+
+var ServiceProvider = {
+ services: {},
+ init: function() {
+ var self = this;
+ _.each(AbstractService.prototype.Services, function(Service) {
+ var service = new Service();
+ self.services[service.name] = service;
+ });
+ this.custom_events.call_service = this._call_service.bind(this);
+ },
+ _call_service: function(event) {
+ var service = this.services[event.data.service];
+ var result = service[event.data.method].apply(service, event.data.args);
+ event.data.callback(result);
+ },
+};
+
+return {
+ ParentedMixin: ParentedMixin,
+ EventDispatcherMixin: EventDispatcherMixin,
+ PropertiesMixin: PropertiesMixin,
+ ServicesMixin: ServicesMixin,
+ ServiceProvider: ServiceProvider,
+};
+
+});
diff --git a/addons/web/static/src/js/core/pyeval.js b/addons/web/static/src/js/core/pyeval.js
new file mode 100644
index 00000000000..df56f60074a
--- /dev/null
+++ b/addons/web/static/src/js/core/pyeval.js
@@ -0,0 +1,1036 @@
+odoo.define('web.pyeval', function (require) {
+"use strict";
+
+var core = require('web.core');
+var utils = require('web.utils');
+
+var _t = core._t;
+var py = window.py; // to silence linters
+/*
+ * py.js helpers and setup
+ */
+
+var obj = function () {};
+obj.prototype = py.object;
+var asJS = function (arg) {
+ if (arg instanceof obj) {
+ return arg.toJSON();
+ }
+ return arg;
+};
+
+var datetime = py.PY_call(py.object);
+
+var zero = py.float.fromJSON(0);
+
+// Port from pypy/lib_pypy/datetime.py
+var DAYS_IN_MONTH = [null, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+var DAYS_BEFORE_MONTH = [null];
+var dbm = 0;
+
+for (var i=1; i<DAYS_IN_MONTH.length; ++i) {
+ DAYS_BEFORE_MONTH.push(dbm);
+ dbm += DAYS_IN_MONTH[i];
+}
+
+function is_leap (year) {
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
+}
+
+function days_before_year (year) {
+ var y = year - 1;
+ return y*365 + Math.floor(y/4) - Math.floor(y/100) + Math.floor(y/400);
+}
+
+function days_in_month (year, month) {
+ if (month === 2 && is_leap(year)) {
+ return 29;
+ }
+ return DAYS_IN_MONTH[month];
+}
+
+function days_before_month (year, month) {
+ var post_leap_feb = month > 2 && is_leap(year);
+ return DAYS_BEFORE_MONTH[month] + (post_leap_feb ? 1 : 0);
+}
+
+function ymd2ord (year, month, day) {
+ var dim = days_in_month(year, month);
+ if (!(1 <= day && day <= dim)) {
+ throw new Error("ValueError: day must be in 1.." + dim);
+ }
+ return days_before_year(year) +
+ days_before_month(year, month) +
+ day;
+}
+
+var DI400Y = days_before_year(401);
+var DI100Y = days_before_year(101);
+var DI4Y = days_before_year(5);
+
+function ord2ymd (n) {
+ --n;
+ var n400, n100, n4, n1, n0;
+ utils.divmod(n, DI400Y, function (_n400, n) {
+ n400 = _n400;
+ utils.divmod(n, DI100Y, function (_n100, n) {
+ n100 = _n100;
+ utils.divmod(n, DI4Y, function (_n4, n) {
+ n4 = _n4;
+ utils.divmod(n, 365, function (_n1, n) {
+ n1 = _n1;
+ n0 = n;
+ });
+ });
+ });
+ });
+
+ n = n0;
+ var year = n400 * 400 + 1 + n100 * 100 + n4 * 4 + n1;
+ if (n1 == 4 || n100 == 100) {
+ utils.assert(n0 === 0);
+ return {
+ year: year - 1,
+ month: 12,
+ day: 31
+ };
+ }
+
+ var leapyear = n1 === 3 && (n4 !== 24 || n100 == 3);
+ utils.assert(leapyear == is_leap(year));
+ var month = (n + 50) >> 5;
+ var preceding = DAYS_BEFORE_MONTH[month] + ((month > 2 && leapyear) ? 1 : 0);
+ if (preceding > n) {
+ --month;
+ preceding -= DAYS_IN_MONTH[month] + ((month === 2 && leapyear) ? 1 : 0);
+ }
+ n -= preceding;
+ return {
+ year: year,
+ month: month,
+ day: n+1
+ };
+}
+
+/**
+ * Converts the stuff passed in into a valid date, applying overflows as needed
+ */
+function tmxxx(year, month, day, hour, minute, second, microsecond) {
+ hour = hour || 0; minute = minute || 0; second = second || 0;
+ microsecond = microsecond || 0;
+
+ if (microsecond < 0 || microsecond > 999999) {
+ utils.divmod(microsecond, 1000000, function (carry, ms) {
+ microsecond = ms;
+ second += carry;
+ });
+ }
+ if (second < 0 || second > 59) {
+ utils.divmod(second, 60, function (carry, s) {
+ second = s;
+ minute += carry;
+ });
+ }
+ if (minute < 0 || minute > 59) {
+ utils.divmod(minute, 60, function (carry, m) {
+ minute = m;
+ hour += carry;
+ });
+ }
+ if (hour < 0 || hour > 23) {
+ utils.divmod(hour, 24, function (carry, h) {
+ hour = h;
+ day += carry;
+ });
+ }
+ // That was easy. Now it gets muddy: the proper range for day
+ // can't be determined without knowing the correct month and year,
+ // but if day is, e.g., plus or minus a million, the current month
+ // and year values make no sense (and may also be out of bounds
+ // themselves).
+ // Saying 12 months == 1 year should be non-controversial.
+ if (month < 1 || month > 12) {
+ utils.divmod(month-1, 12, function (carry, m) {
+ month = m + 1;
+ year += carry;
+ });
+ }
+ // Now only day can be out of bounds (year may also be out of bounds
+ // for a datetime object, but we don't care about that here).
+ // If day is out of bounds, what to do is arguable, but at least the
+ // method here is principled and explainable.
+ var dim = days_in_month(year, month);
+ if (day < 1 || day > dim) {
+ // Move day-1 days from the first of the month. First try to
+ // get off cheap if we're only one day out of range (adjustments
+ // for timezone alone can't be worse than that).
+ if (day === 0) {
+ --month;
+ if (month > 0) {
+ day = days_in_month(year, month);
+ } else {
+ --year; month=12; day=31;
+ }
+ } else if (day == dim + 1) {
+ ++month;
+ day = 1;
+ if (month > 12) {
+ month = 1;
+ ++year;
+ }
+ } else {
+ var r = ord2ymd(ymd2ord(year, month, 1) + (day - 1));
+ year = r.year;
+ month = r.month;
+ day = r.day;
+ }
+ }
+ return {
+ year: year,
+ month: month,
+ day: day,
+ hour: hour,
+ minute: minute,
+ second: second,
+ microsecond: microsecond
+ };
+}
+
+datetime.timedelta = py.type('timedelta', null, {
+ __init__: function () {
+ var args = py.PY_parseArgs(arguments, [
+ ['days', zero], ['seconds', zero], ['microseconds', zero],
+ ['milliseconds', zero], ['minutes', zero], ['hours', zero],
+ ['weeks', zero]
+ ]);
+
+ var d = 0, s = 0, m = 0;
+ var days = args.days.toJSON() + args.weeks.toJSON() * 7;
+ var seconds = args.seconds.toJSON()
+ + args.minutes.toJSON() * 60
+ + args.hours.toJSON() * 3600;
+ var microseconds = args.microseconds.toJSON()
+ + args.milliseconds.toJSON() * 1000;
+
+ // Get rid of all fractions, and normalize s and us.
+ // Take a deep breath <wink>.
+ var daysecondsfrac = utils.modf(days, function (dayfrac, days) {
+ d = days;
+ if (dayfrac) {
+ return utils.modf(dayfrac * 24 * 3600, function (dsf, dsw) {
+ s = dsw;
+ return dsf;
+ });
+ }
+ return 0;
+ });
+
+ var secondsfrac = utils.modf(seconds, function (sf, s) {
+ seconds = s;
+ return sf + daysecondsfrac;
+ });
+ utils.divmod(seconds, 24*3600, function (days, seconds) {
+ d += days;
+ s += seconds;
+ });
+ // seconds isn't referenced again before redefinition
+
+ microseconds += secondsfrac * 1e6;
+ utils.divmod(microseconds, 1000000, function (seconds, microseconds) {
+ utils.divmod(seconds, 24*3600, function (days, seconds) {
+ d += days;
+ s += seconds;
+ m += Math.round(microseconds);
+ });
+ });
+
+ // Carrying still possible here?
+
+ this.days = d;
+ this.seconds = s;
+ this.microseconds = m;
+ },
+ __str__: function () {
+ var hh, mm, ss;
+ utils.divmod(this.seconds, 60, function (m, s) {
+ utils.divmod(m, 60, function (h, m) {
+ hh = h;
+ mm = m;
+ ss = s;
+ });
+ });
+ var s = _.str.sprintf("%d:%02d:%02d", hh, mm, ss);
+ if (this.days) {
+ s = _.str.sprintf("%d day%s, %s",
+ this.days,
+ (this.days != 1 && this.days != -1) ? 's' : '',
+ s);
+ }
+ if (this.microseconds) {
+ s = _.str.sprintf("%s.%06d", s, this.microseconds);
+ }
+ return py.str.fromJSON(s);
+ },
+ __eq__: function (other) {
+ if (!py.PY_isInstance(other, datetime.timedelta)) {
+ return py.False;
+ }
+
+ return (this.days === other.days
+ && this.seconds === other.seconds
+ && this.microseconds === other.microseconds)
+ ? py.True : py.False;
+ },
+ __add__: function (other) {
+ if (!py.PY_isInstance(other, datetime.timedelta)) {
+ return py.NotImplemented;
+ }
+ return py.PY_call(datetime.timedelta, [
+ py.float.fromJSON(this.days + other.days),
+ py.float.fromJSON(this.seconds + other.seconds),
+ py.float.fromJSON(this.microseconds + other.microseconds)
+ ]);
+ },
+ __radd__: function (other) { return this.__add__(other); },
+ __sub__: function (other) {
+ if (!py.PY_isInstance(other, datetime.timedelta)) {
+ return py.NotImplemented;
+ }
+ return py.PY_call(datetime.timedelta, [
+ py.float.fromJSON(this.days - other.days),
+ py.float.fromJSON(this.seconds - other.seconds),
+ py.float.fromJSON(this.microseconds - other.microseconds)
+ ]);
+ },
+ __rsub__: function (other) {
+ if (!py.PY_isInstance(other, datetime.timedelta)) {
+ return py.NotImplemented;
+ }
+ return this.__neg__().__add__(other);
+ },
+ __neg__: function () {
+ return py.PY_call(datetime.timedelta, [
+ py.float.fromJSON(-this.days),
+ py.float.fromJSON(-this.seconds),
+ py.float.fromJSON(-this.microseconds)
+ ]);
+ },
+ __pos__: function () { return this; },
+ __mul__: function (other) {
+ if (!py.PY_isInstance(other, py.float)) {
+ return py.NotImplemented;
+ }
+ var n = other.toJSON();
+ return py.PY_call(datetime.timedelta, [
+ py.float.fromJSON(this.days * n),
+ py.float.fromJSON(this.seconds * n),
+ py.float.fromJSON(this.microseconds * n)
+ ]);
+ },
+ __rmul__: function (other) { return this.__mul__(other); },
+ __div__: function (other) {
+ if (!py.PY_isInstance(other, py.float)) {
+ return py.NotImplemented;
+ }
+ var usec = ((this.days * 24 * 3600) + this.seconds) * 1000000
+ + this.microseconds;
+ return py.PY_call(
+ datetime.timedelta, [
+ zero, zero, py.float.fromJSON(usec / other.toJSON())]);
+ },
+ __floordiv__: function (other) { return this.__div__(other); },
+ total_seconds: function () {
+ return py.float.fromJSON(
+ this.days * 86400
+ + this.seconds
+ + this.microseconds / 1000000);
+ },
+ __nonzero__: function () {
+ return (!!this.days || !!this.seconds || !!this.microseconds)
+ ? py.True
+ : py.False;
+ }
+});
+
+datetime.datetime = py.type('datetime', null, {
+ __init__: function () {
+ var zero = py.float.fromJSON(0);
+ var args = py.PY_parseArgs(arguments, [
+ 'year', 'month', 'day',
+ ['hour', zero], ['minute', zero], ['second', zero],
+ ['microsecond', zero], ['tzinfo', py.None]
+ ]);
+ for(var key in args) {
+ if (!args.hasOwnProperty(key)) { continue; }
+ this[key] = asJS(args[key]);
+ }
+ },
+ replace: function () {
+ var args = py.PY_parseArgs(arguments, [
+ ['year', py.None], ['month', py.None], ['day', py.None],
+ ['hour', py.None], ['minute', py.None], ['second', py.None],
+ ['microsecond', py.None] // FIXME: tzinfo, can't use None as valid input
+ ]);
+ var params = {};
+ for(var key in args) {
+ if (!args.hasOwnProperty(key)) { continue; }
+
+ var arg = args[key];
+ params[key] = (arg === py.None ? this[key] : asJS(arg));
+ }
+ return py.PY_call(datetime.datetime, params);
+ },
+ strftime: function () {
+ var self = this;
+ var args = py.PY_parseArgs(arguments, 'format');
+ return py.str.fromJSON(args.format.toJSON()
+ .replace(/%([A-Za-z])/g, function (m, c) {
+ switch (c) {
+ case 'Y': return _.str.sprintf('%04d', self.year);
+ case 'm': return _.str.sprintf('%02d', self.month);
+ case 'd': return _.str.sprintf('%02d', self.day);
+ case 'H': return _.str.sprintf('%02d', self.hour);
+ case 'M': return _.str.sprintf('%02d', self.minute);
+ case 'S': return _.str.sprintf('%02d', self.second);
+ }
+ throw new Error('ValueError: No known conversion for ' + m);
+ }));
+ },
+ now: py.classmethod.fromJSON(function () {
+ var d = new Date();
+ return py.PY_call(datetime.datetime, [
+ d.getFullYear(), d.getMonth() + 1, d.getDate(),
+ d.getHours(), d.getMinutes(), d.getSeconds(),
+ d.getMilliseconds() * 1000]);
+ }),
+ today: py.classmethod.fromJSON(function () {
+ var dt_class = py.PY_getAttr(datetime, 'datetime');
+ return py.PY_call(py.PY_getAttr(dt_class, 'now'));
+ }),
+ utcnow: py.classmethod.fromJSON(function () {
+ var d = new Date();
+ return py.PY_call(datetime.datetime,
+ [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(),
+ d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(),
+ d.getUTCMilliseconds() * 1000]);
+ }),
+ combine: py.classmethod.fromJSON(function () {
+ var args = py.PY_parseArgs(arguments, 'date time');
+ return py.PY_call(datetime.datetime, [
+ py.PY_getAttr(args.date, 'year'),
+ py.PY_getAttr(args.date, 'month'),
+ py.PY_getAttr(args.date, 'day'),
+ py.PY_getAttr(args.time, 'hour'),
+ py.PY_getAttr(args.time, 'minute'),
+ py.PY_getAttr(args.time, 'second')
+ ]);
+ }),
+ toJSON: function () {
+ return new Date(
+ this.year,
+ this.month - 1,
+ this.day,
+ this.hour,
+ this.minute,
+ this.second,
+ this.microsecond / 1000);
+ },
+});
+
+datetime.date = py.type('date', null, {
+ __init__: function () {
+ var args = py.PY_parseArgs(arguments, 'year month day');
+ this.year = asJS(args.year);
+ this.month = asJS(args.month);
+ this.day = asJS(args.day);
+ },
+ strftime: function () {
+ var self = this;
+ var args = py.PY_parseArgs(arguments, 'format');
+ return py.str.fromJSON(args.format.toJSON()
+ .replace(/%([A-Za-z])/g, function (m, c) {
+ switch (c) {
+ case 'Y': return self.year;
+ case 'm': return _.str.sprintf('%02d', self.month);
+ case 'd': return _.str.sprintf('%02d', self.day);
+ }
+ throw new Error('ValueError: No known conversion for ' + m);
+ }));
+ },
+ __eq__: function (other) {
+ return (this.year === other.year
+ && this.month === other.month
+ && this.day === other.day)
+ ? py.True : py.False;
+ },
+ replace: function () {
+ var args = py.PY_parseArgs(arguments, [
+ ['year', py.None], ['month', py.None], ['day', py.None]
+ ]);
+ var params = {};
+ for(var key in args) {
+ if (!args.hasOwnProperty(key)) { continue; }
+
+ var arg = args[key];
+ params[key] = (arg === py.None ? this[key] : asJS(arg));
+ }
+ return py.PY_call(datetime.date, params);
+ },
+ __add__: function (other) {
+ if (!py.PY_isInstance(other, datetime.timedelta)) {
+ return py.NotImplemented;
+ }
+ var s = tmxxx(this.year, this.month, this.day + other.days);
+ return datetime.date.fromJSON(s.year, s.month, s.day);
+ },
+ __radd__: function (other) { return this.__add__(other); },
+ __sub__: function (other) {
+ if (py.PY_isInstance(other, datetime.timedelta)) {
+ return py.PY_add(this, py.PY_negative(other));
+ }
+ if (py.PY_isInstance(other, datetime.date)) {
+ // FIXME: getattr and sub API methods
+ return py.PY_call(datetime.timedelta, [
+ py.PY_subtract(
+ py.PY_call(py.PY_getAttr(this, 'toordinal')),
+ py.PY_call(py.PY_getAttr(other, 'toordinal')))
+ ]);
+ }
+ return py.NotImplemented;
+ },
+ toordinal: function () {
+ return py.float.fromJSON(ymd2ord(this.year, this.month, this.day));
+ },
+ weekday: function () {
+ return py.float.fromJSON((this.toordinal().toJSON()+6)%7);
+ },
+ fromJSON: function (year, month, day) {
+ return py.PY_call(datetime.date, [year, month, day]);
+ },
+ today: py.classmethod.fromJSON(function () {
+ var d = new Date ();
+ return py.PY_call(datetime.date, [
+ d.getFullYear(), d.getMonth() + 1, d.getDate()]);
+ }),
+});
+/**
+ Returns the current local date, which means the date on the client (which can be different
+ compared to the date of the server).
+
+ @return {datetime.date}
+*/
+function context_today () {
+ var d = new Date();
+ return py.PY_call(
+ datetime.date, [d.getFullYear(), d.getMonth() + 1, d.getDate()]);
+}
+
+datetime.time = py.type('time', null, {
+ __init__: function () {
+ var zero = py.float.fromJSON(0);
+ var args = py.PY_parseArgs(arguments, [
+ ['hour', zero], ['minute', zero], ['second', zero], ['microsecond', zero],
+ ['tzinfo', py.None]
+ ]);
+
+ for(var k in args) {
+ if (!args.hasOwnProperty(k)) { continue; }
+ this[k] = asJS(args[k]);
+ }
+ }
+});
+
+var time = py.PY_call(py.object);
+time.strftime = py.PY_def.fromJSON(function () {
+ var args = py.PY_parseArgs(arguments, 'format');
+ var dt_class = py.PY_getAttr(datetime, 'datetime');
+ var d = py.PY_call(py.PY_getAttr(dt_class, 'utcnow'));
+ return py.PY_call(py.PY_getAttr(d, 'strftime'), [args.format]);
+});
+
+var args = _.map(('year month day '
+ + 'years months weeks days '
+ + 'weekday leapdays yearday nlyearday').split(' '), function (arg) {
+ switch (arg) {
+ case 'years':case 'months':case 'days':case 'leapdays':case 'weeks':
+ return [arg, zero];
+ case 'year':case 'month':case 'day':case 'weekday':
+ case 'yearday':case 'nlyearday':
+ return [arg, null];
+ default:
+ throw new Error("Unknown relativedelta argument " + arg);
+ }
+});
+args.unshift('*');
+
+var _utils = {
+ monthrange: function (year, month) {
+ if (month < 1 || month > 12) {
+ throw new Error("Illegal month " + month);
+ }
+
+ var day1 = this.weekday(year, month, 1);
+ var ndays = this.mdays[month] + (month == this.February && this.isleap(year));
+ return [day1, ndays];
+ },
+ weekday: function (year, month, day) {
+ var date = py.PY_call(datetime.date, [year, month, day]);
+ return py.PY_call(py.PY_getAttr(date, 'weekday'));
+ },
+ isleap: function (year) {
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
+ },
+ mdays: [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
+ January: 1,
+ February: 2
+};
+
+var relativedelta = py.type('relativedelta', null, {
+ __init__: function () {
+ this.ops = py.PY_parseArgs(arguments, args);
+ this.ops.days = py.float.fromJSON(
+ asJS(this.ops.days) + asJS(this.ops.weeks) * 7
+ );
+
+ var yday = zero;
+ if (this.ops.nlyearday) {
+ yday = this.ops.nlyearday;
+ } else if (this.ops.yearday) {
+ yday = this.ops.yearday;
+ if (asJS(this.ops.yearday) > 59) {
+ this.ops.leapdays = py.float.fromJS(-1);
+ }
+ }
+ if (py.PY_isTrue(yday)) {
+ var ydayidx = [31, 59, 90, 120, 151, 181, 212,
+ 243, 273, 304, 334, 366];
+ for(var idx=0; idx<ydayidx.length; ++idx) {
+ var ydays = ydayidx[idx];
+ if (asJS(yday) <= ydays) {
+ this.ops.month = py.float.fromJSON(idx+1);
+ if (!idx) {
+ this.ops.day = yday;
+ } else {
+ this.ops.day = py.PY_subtract(
+ yday,
+ py.float.fromJSON(ydayidx[idx-1])
+ );
+ }
+ break;
+ }
+ }
+ if (idx === ydayidx.length) {
+ throw new Error("Invalid year day (" + asJS(yday) + ")");
+ }
+ }
+ this._fix();
+ },
+ _fix: function () {
+ var months = asJS(this.ops.months);
+ if (Math.abs(months) > 11) {
+ var s = months > 0 ? 1 : -1;
+ var r = utils.divmod(months * s, 12);
+ this.ops.months = py.float.fromJSON(r.mod*s);
+ this.ops.years = py.float.fromJSON(
+ asJS(this.ops.years) + r.div*s);
+ }
+ this._has_time = 0;
+ },
+ __add__: function (other) {
+ if (!py.PY_isInstance(other, datetime.date)) {
+ return py.NotImplemented;
+ }
+ // TODO: test this whole mess
+ var year = (asJS(this.ops.year) || asJS(other.year)) + asJS(this.ops.years);
+ var month = asJS(this.ops.month) || asJS(other.month);
+ var months;
+ if (months = asJS(this.ops.months)) {
+ if (Math.abs(months) < 1 || Math.abs(months) > 12) {
+ throw new Error("Can only use relative months between -12 and +12");
+ }
+ month += months;
+ if (month > 12) {
+ year += 1;
+ month -= 12;
+ }
+ if (month < 1) {
+ year -= 1;
+ month += 12;
+ }
+ }
+
+ var day = Math.min(_utils.monthrange(year, month)[1],
+ asJS(this.ops.day) || asJS(other.day));
+
+ var repl = {
+ year: py.float.fromJSON(year),
+ month: py.float.fromJSON(month),
+ day: py.float.fromJSON(day)
+ };
+
+ var days = asJS(this.ops.days);
+ if (py.PY_isTrue(this.ops.leapdays) && month > 2 && _utils.isleap(year)) {
+ days += asJS(this.ops.leapdays);
+ }
+
+ var ret = py.PY_add(
+ py.PY_call(py.PY_getAttr(other, 'replace'), repl),
+ py.PY_call(datetime.timedelta, {
+ days: py.float.fromJSON(days)
+ })
+ );
+
+ if (this.ops.weekday) {
+ // FIXME: only handles numeric weekdays, not decorated
+ var weekday = asJS(this.ops.weekday), nth = 1;
+ var jumpdays = (Math.abs(nth) - 1) * 7;
+
+ var ret_weekday = asJS(py.PY_call(py.PY_getAttr(ret, 'weekday')));
+ if (nth > 0) {
+ jumpdays += (7-ret_weekday+weekday) % 7;
+ } else {
+ jumpdays += (ret_weekday - weekday) % 7;
+ jumpdays *= -1;
+ }
+ ret = py.PY_add(
+ ret,
+ py.PY_call(datetime.timedelta, {
+ days: py.float.fromJSON(jumpdays)
+ })
+ );
+ }
+
+ return ret;
+ },
+ __radd__: function (other) {
+ return this.__add__(other);
+ },
+ __rsub__: function (other) {
+ return this.__neg__().__radd__(other);
+ },
+ __neg__: function () {
+ return py.PY_call(relativedelta, {
+ years: py.PY_negative(this.ops.years),
+ months: py.PY_negative(this.ops.months),
+ days: py.PY_negative(this.ops.days),
+ leapdays: this.ops.leapdays,
+ year: this.ops.year,
+ month: this.ops.month,
+ day: this.ops.day,
+ weekday: this.ops.weekday
+ });
+ }
+});
+
+// recursively wraps JS objects passed into the context to attributedicts
+// which jsonify back to JS objects
+function wrap(value) {
+ if (value === null) { return py.None; }
+
+ switch (typeof value) {
+ case 'undefined': throw new Error("No conversion for undefined");
+ case 'boolean': return py.bool.fromJSON(value);
+ case 'number': return py.float.fromJSON(value);
+ case 'string': return py.str.fromJSON(value);
+ }
+
+ switch(value.constructor) {
+ case Object: return wrapping_dict.fromJSON(value);
+ case Array: return wrapping_list.fromJSON(value);
+ }
+
+ throw new Error("ValueError: unable to wrap " + value);
+}
+
+var wrapping_dict = py.type('wrapping_dict', null, {
+ __init__: function () {
+ this._store = {};
+ },
+ __getitem__: function (key) {
+ var k = key.toJSON();
+ if (!(k in this._store)) {
+ throw new Error("KeyError: '" + k + "'");
+ }
+ return wrap(this._store[k]);
+ },
+ __getattr__: function (key) {
+ return this.__getitem__(py.str.fromJSON(key));
+ },
+ __len__: function () {
+ return Object.keys(this._store).length
+ },
+ __nonzero__: function () {
+ return py.PY_size(this) > 0 ? py.True : py.False;
+ },
+ get: function () {
+ var args = py.PY_parseArgs(arguments, ['k', ['d', py.None]]);
+
+ if (!(args.k.toJSON() in this._store)) { return args.d; }
+ return this.__getitem__(args.k);
+ },
+ fromJSON: function (d) {
+ var instance = py.PY_call(wrapping_dict);
+ instance._store = d;
+ return instance;
+ },
+ toJSON: function () {
+ return this._store;
+ },
+});
+
+var wrapping_list = py.type('wrapping_list', null, {
+ __init__: function () {
+ this._store = [];
+ },
+ __getitem__: function (index) {
+ return wrap(this._store[index.toJSON()]);
+ },
+ __len__: function () {
+ return this._store.length;
+ },
+ __nonzero__: function () {
+ return py.PY_size(this) > 0 ? py.True : py.False;
+ },
+ fromJSON: function (ar) {
+ var instance = py.PY_call(wrapping_list);
+ instance._store = ar;
+ return instance;
+ },
+ toJSON: function () {
+ return this._store;
+ },
+});
+
+function wrap_context (context) {
+ for (var k in context) {
+ if (!context.hasOwnProperty(k)) { continue; }
+ var val = context[k];
+
+ if (val === null) { continue; }
+ if (val.constructor === Array) {
+ context[k] = wrapping_list.fromJSON(val);
+ } else if (val.constructor === Object
+ && !py.PY_isInstance(val, py.object)) {
+ context[k] = wrapping_dict.fromJSON(val);
+ }
+ }
+ return context;
+}
+
+function eval_contexts (contexts, evaluation_context) {
+ evaluation_context = _.extend(pycontext(), evaluation_context || {});
+ return _(contexts).reduce(function (result_context, ctx) {
+ // __eval_context evaluations can lead to some of `contexts`'s
+ // values being null, skip them as well as empty contexts
+ if (_.isEmpty(ctx)) { return result_context; }
+ if (_.isString(ctx)) {
+ // wrap raw strings in context
+ ctx = { __ref: 'context', __debug: ctx };
+ }
+ var evaluated = ctx;
+ switch(ctx.__ref) {
+ case 'context':
+ evaluation_context.context = evaluation_context;
+ evaluated = py.eval(ctx.__debug, wrap_context(evaluation_context));
+ break;
+ case 'compound_context':
+ var eval_context = eval_contexts([ctx.__eval_context]);
+ evaluated = eval_contexts(
+ ctx.__contexts, _.extend({}, evaluation_context, eval_context));
+ break;
+ }
+ // add newly evaluated context to evaluation context for following
+ // siblings
+ _.extend(evaluation_context, evaluated);
+ return _.extend(result_context, evaluated);
+ }, {});
+}
+
+function eval_domains (domains, evaluation_context) {
+ evaluation_context = _.extend(pycontext(), evaluation_context || {});
+ var result_domain = [];
+ // Normalize only if the first domain is the array ["|"] or ["!"]
+ var need_normalization = (
+ domains &&
+ domains.length > 0 &&
+ domains[0].length === 1 &&
+ (domains[0][0] === "|" || domains[0][0] === "!")
+ );
+ _(domains).each(function (domain) {
+ if (_.isString(domain)) {
+ // wrap raw strings in domain
+ domain = { __ref: 'domain', __debug: domain };
+ }
+ var domain_array_to_combine;
+ switch(domain.__ref) {
+ case 'domain':
+ evaluation_context.context = evaluation_context;
+ domain_array_to_combine = py.eval(domain.__debug, wrap_context(evaluation_context));
+ break;
+ case 'compound_domain':
+ var eval_context = eval_contexts([domain.__eval_context]);
+ domain_array_to_combine = eval_domains(
+ domain.__domains, _.extend({}, evaluation_context, eval_context)
+ );
+ break;
+ default:
+ domain_array_to_combine = domain;
+ }
+ if (need_normalization) {
+ domain_array_to_combine = get_normalized_domain(domain_array_to_combine);
+ }
+ result_domain.push.apply(result_domain, domain_array_to_combine);
+ });
+ return result_domain;
+}
+
+/**
+ * Returns a normalized copy of the given domain array. Normalization is
+ * is making the implicit "&" at the start of the domain explicit, e.g.
+ * [A, B, C] would become ["&", "&", A, B, C].
+ *
+ * @param {Array} domain_array
+ * @returns {Array} normalized copy of the given array
+ */
+function get_normalized_domain(domain_array) {
+ var expected = 1; // Holds the number of expected domain expressions
+ _.each(domain_array, function (item) {
+ if (item === "&" || item === "|") {
+ expected++;
+ } else if (item !== "!") {
+ expected--;
+ }
+ });
+ var new_explicit_ands = _.times(-expected, _.constant("&"));
+ return new_explicit_ands.concat(domain_array);
+}
+
+function eval_groupbys (contexts, evaluation_context) {
+ evaluation_context = _.extend(pycontext(), evaluation_context || {});
+ var result_group = [];
+ _(contexts).each(function (ctx) {
+ if (_.isString(ctx)) {
+ // wrap raw strings in context
+ ctx = { __ref: 'context', __debug: ctx };
+ }
+ var group;
+ var evaluated = ctx;
+ switch(ctx.__ref) {
+ case 'context':
+ evaluation_context.context = evaluation_context;
+ evaluated = py.eval(ctx.__debug, wrap_context(evaluation_context));
+ break;
+ case 'compound_context':
+ var eval_context = eval_contexts([ctx.__eval_context]);
+ evaluated = eval_contexts(
+ ctx.__contexts, _.extend({}, evaluation_context, eval_context));
+ break;
+ }
+ group = evaluated.group_by;
+ if (!group) { return; }
+ if (typeof group === 'string') {
+ result_group.push(group);
+ } else if (group instanceof Array) {
+ result_group.push.apply(result_group, group);
+ } else {
+ throw new Error('Got invalid groupby {{'
+ + JSON.stringify(group) + '}}');
+ }
+ _.extend(evaluation_context, evaluated);
+ });
+ return result_group;
+}
+
+
+function pycontext () {
+ return {
+ datetime: datetime,
+ context_today: context_today,
+ time: time,
+ relativedelta: relativedelta,
+ current_date: py.PY_call(
+ time.strftime, [py.str.fromJSON('%Y-%m-%d')]),
+ };
+}
+
+/**
+ * @param {String} type "domains", "contexts" or "groupbys"
+ * @param {Array} object domains or contexts to evaluate
+ * @param {Object} [context] evaluation context
+ */
+function pyeval (type, object, context) {
+ context = _.extend(pycontext(), context || {});
+
+ //noinspection FallthroughInSwitchStatementJS
+ switch(type) {
+ case 'context':
+ case 'contexts':
+ if (type === 'context') {
+ object = [object];
+ }
+ return eval_contexts(object, context);
+ case 'domain':
+ case 'domains':
+ if (type === 'domain')
+ object = [object];
+ return eval_domains(object, context);
+ case 'groupbys':
+ return eval_groupbys(object, context);
+ }
+ throw new Error("Unknow evaluation type " + type);
+}
+
+function eval_arg (arg) {
+ if (typeof arg !== 'object' || !arg.__ref) { return arg; }
+ switch(arg.__ref) {
+ case 'domain': case 'compound_domain':
+ return pyeval('domains', [arg]);
+ case 'context': case 'compound_context':
+ return pyeval('contexts', [arg]);
+ default:
+ throw new Error(_t("Unknown nonliteral type ") + ' ' + arg.__ref);
+ }
+}
+
+/**
+ * If args or kwargs are unevaluated contexts or domains (compound or not),
+ * evaluated them in-place.
+ *
+ * Potentially mutates both parameters.
+ *
+ * @param args
+ * @param kwargs
+ */
+function ensure_evaluated (args, kwargs) {
+ for (var i=0; i<args.length; ++i) {
+ args[i] = eval_arg(args[i]);
+ }
+ for (var k in kwargs) {
+ if (!kwargs.hasOwnProperty(k)) { continue; }
+ kwargs[k] = eval_arg(kwargs[k]);
+ }
+}
+
+function eval_domains_and_contexts (source) {
+ // see Session.eval_context in Python
+ return {
+ context: pyeval('contexts', source.contexts || [], source.eval_context),
+ domain: pyeval('domains', source.domains, source.eval_context),
+ group_by: pyeval('groupbys', source.group_by_seq || [], source.eval_context),
+ };
+}
+
+function py_eval (expr, context) {
+ return py.eval(expr, _.extend({}, context || {}, {"true": true, "false": false, "null": null}));
+}
+
+
+return {
+ context: pycontext,
+ ensure_evaluated: ensure_evaluated,
+ eval: pyeval,
+ eval_domains_and_contexts: eval_domains_and_contexts,
+ py_eval: py_eval,
+};
+
+});
diff --git a/addons/web/static/src/js/core/qweb.js b/addons/web/static/src/js/core/qweb.js
new file mode 100644
index 00000000000..50742075e05
--- /dev/null
+++ b/addons/web/static/src/js/core/qweb.js
@@ -0,0 +1,51 @@
+odoo.define('web.QWeb', function (require) {
+"use strict";
+
+var translation = require('web.translation');
+
+var _t = translation._t;
+
+function QWeb(debug, default_dict) {
+ var qweb = new QWeb2.Engine();
+ qweb.default_dict = _.extend({}, default_dict || {}, {
+ '_' : _,
+ 'JSON': JSON,
+ '_t' : translation._t,
+ '__debug__': debug,
+ 'moment': function(date) { return new moment(date); },
+ 'csrf_token': odoo.csrf_token,
+ });
+ qweb.debug = debug;
+ qweb.preprocess_node = preprocess_node;
+ return qweb;
+}
+
+function preprocess_node() {
+ // Note that 'this' is the Qweb Node
+ switch (this.node.nodeType) {
+ case Node.TEXT_NODE:
+ case Node.CDATA_SECTION_NODE:
+ // Text and CDATAs
+ var translation = this.node.parentNode.attributes['t-translation'];
+ if (translation && translation.value === 'off') {
+ return;
+ }
+ var match = /^(\s*)([\s\S]+?)(\s*)$/.exec(this.node.data);
+ if (match) {
+ this.node.data = match[1] + _t(match[2]) + match[3];
+ }
+ break;
+ case Node.ELEMENT_NODE:
+ // Element
+ var attr, attrs = ['label', 'title', 'alt', 'placeholder'];
+ while ((attr = attrs.pop())) {
+ if (this.attributes[attr]) {
+ this.attributes[attr] = _t(this.attributes[attr]);
+ }
+ }
+ }
+}
+
+return QWeb;
+
+});
diff --git a/addons/web/static/src/js/core/registry.js b/addons/web/static/src/js/core/registry.js
new file mode 100644
index 00000000000..d70c09e4ae0
--- /dev/null
+++ b/addons/web/static/src/js/core/registry.js
@@ -0,0 +1,97 @@
+odoo.define('web.Registry', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+
+/**
+ * The registry is really pretty much only a mapping from some keys to some
+ * values. The Registry class only add a few simple methods around that to make
+ * it nicer and slightly safer.
+ *
+ * Note that registries have a fundamental problem: the value that you try to
+ * get in a registry might not have been added yet, so of course, you need to
+ * make sure that your dependencies are solid. For this reason, it is a good
+ * practice to avoid using the registry if you can simply import what you need
+ * with the 'require' statement.
+ *
+ * However, on the flip side, sometimes you cannot just simply import something
+ * because we would have a dependency cycle. In that case, registries might
+ * help.
+ */
+var Registry = Class.extend({
+ /**
+ * @constructor
+ * @param {Object} [mapping] the initial data in the registry
+ */
+ init: function (mapping) {
+ this.map = Object.create(mapping || null);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a key (and a value) to the registry.
+ *
+ * @param {string} key
+ * @param {any} value
+ * @returns {Registry} can be used to chain add calls.
+ */
+ add: function (key, value) {
+ this.map[key] = value;
+ return this;
+ },
+ /**
+ * Check if the registry contains the value
+ *
+ * @param {string} key
+ * @returns {boolean}
+ */
+ contains: function (key) {
+ return (key in this.map);
+ },
+ /**
+ * Creates and returns a copy of the current mapping, with the provided
+ * mapping argument added in (replacing existing keys if needed)
+ *
+ * Parent and child remain linked, a new key in the parent (which is not
+ * overwritten by the child) will appear in the child.
+ *
+ * @param {Object} [mapping={}] a mapping of keys to object-paths
+ */
+ extend: function (mapping) {
+ var child = new Registry(this.map);
+ _.extend(child.map, mapping);
+ return child;
+ },
+ /**
+ * Returns the value associated to the given key.
+ *
+ * @param {string} key
+ * @returns {any}
+ */
+ get: function (key) {
+ return this.map[key];
+ },
+ /**
+ * Tries a number of keys, and returns the first object matching one of
+ * the keys.
+ *
+ * @param {string[]} keys a sequence of keys to fetch the object for
+ * @returns {any} the first result found matching an object
+ */
+ getAny: function (keys) {
+ for (var i=0; i<keys.length; i++) {
+ if (keys[i] in this.map) {
+ return this.map[keys[i]];
+ }
+ }
+ return null;
+ },
+});
+
+return Registry;
+
+});
+
diff --git a/addons/web/static/src/js/core/session.js b/addons/web/static/src/js/core/session.js
new file mode 100644
index 00000000000..ca56eb62439
--- /dev/null
+++ b/addons/web/static/src/js/core/session.js
@@ -0,0 +1,412 @@
+odoo.define('web.Session', function (require) {
+"use strict";
+
+var ajax = require('web.ajax');
+var concurrency = require('web.concurrency');
+var core = require('web.core');
+var utils = require('web.utils');
+
+var _t = core._t;
+var qweb = core.qweb;
+var mixins = core.mixins;
+
+// To do: refactor session. Session accomplishes several concerns (rpc,
+// configuration, currencies (wtf?), user permissions...). They should be
+// clarified and separated.
+
+var Session = core.Class.extend(mixins.EventDispatcherMixin, {
+ /**
+
+ @param parent The parent of the newly created object.
+ or `null` if the server to contact is the origin server.
+ @param {Dict} options A dictionary that can contain the following options:
+
+ * "override_session": Default to false. If true, the current session object will
+ not try to re-use a previously created session id stored in a cookie.
+ * "session_id": Default to null. If specified, the specified session_id will be used
+ by this session object. Specifying this option automatically implies that the option
+ "override_session" is set to true.
+ */
+ init: function(parent, origin, options) {
+ mixins.EventDispatcherMixin.init.call(this, parent);
+ options = options || {};
+ this.module_list = (options.modules && options.modules.slice()) || (window.odoo._modules && window.odoo._modules.slice()) || [];
+ this.server = null;
+ this.session_id = options.session_id || null;
+ this.override_session = options.override_session || !!options.session_id || false;
+ this.avoid_recursion = false;
+ this.use_cors = options.use_cors || false;
+ this.setup(origin);
+ var debug_param = $.deparam($.param.querystring()).debug;
+ this.debug = (debug_param !== undefined ? debug_param || 1 : false);
+
+ // for historic reasons, the session requires a name to properly work
+ // (see the methods get_cookie and set_cookie). We should perhaps
+ // remove it totally (but need to make sure the cookies are properly set)
+ this.name = "instance0";
+ // TODO: session store in cookie should be optional
+ this.qweb_mutex = new concurrency.Mutex();
+ this.currencies = {};
+ this._groups_def = {};
+ },
+ setup: function(origin, options) {
+ // must be able to customize server
+ var window_origin = location.protocol + "//" + location.host;
+ origin = origin ? origin.replace( /\/+$/, '') : window_origin;
+ if (!_.isUndefined(this.origin) && this.origin !== origin)
+ throw new Error('Session already bound to ' + this.origin);
+ else
+ this.origin = origin;
+ this.prefix = this.origin;
+ this.server = this.origin; // keep chs happy
+ this.origin_server = this.origin === window_origin;
+ options = options || {};
+ if ('use_cors' in options) {
+ this.use_cors = options.use_cors;
+ }
+ },
+ /**
+ * Setup a session
+ */
+ session_bind: function(origin) {
+ var self = this;
+ this.setup(origin);
+ qweb.default_dict._s = this.origin;
+ this.uid = null;
+ this.username = null;
+ this.user_context= {};
+ this.db = null;
+ this.module_loaded = {};
+ _(this.module_list).each(function (mod) {
+ self.module_loaded[mod] = true;
+ });
+ this.active_id = null;
+ return this.session_init();
+ },
+ /**
+ * Init a session, reloads from cookie, if it exists
+ */
+ session_init: function () {
+ var def = this.session_reload();
+ if (this.is_frontend) return def;
+
+ var self = this;
+ return def.then(function() {
+ var modules = self.module_list.join(',');
+ var deferred = self.load_qweb(modules);
+ if(self.session_is_valid()) {
+ return deferred.then(function() { return self.load_modules(); });
+ }
+ return $.when(
+ deferred,
+ self.rpc('/web/webclient/bootstrap_translations', {mods: self.module_list}).then(function(trans) {
+ _t.database.set_bundle(trans);
+ })
+ );
+ });
+ },
+ session_is_valid: function() {
+ var db = $.deparam.querystring().db;
+ if (db && this.db !== db) {
+ return false;
+ }
+ return !!this.uid;
+ },
+ /**
+ * The session is validated by restoration of a previous session
+ */
+ session_authenticate: function() {
+ var self = this;
+ return $.when(this._session_authenticate.apply(this, arguments)).then(function() {
+ return self.load_modules();
+ });
+ },
+ /**
+ * The session is validated either by login or by restoration of a previous session
+ */
+ _session_authenticate: function(db, login, password) {
+ var self = this;
+ var params = {db: db, login: login, password: password};
+ return this.rpc("/web/session/authenticate", params).then(function(result) {
+ if (!result.uid) {
+ return $.Deferred().reject();
+ }
+ delete result.session_id;
+ _.extend(self, result);
+ });
+ },
+ session_logout: function() {
+ $.bbq.removeState();
+ return this.rpc("/web/session/destroy", {});
+ },
+ user_has_group: function(group) {
+ if (!this.uid) {
+ return $.when(false);
+ }
+ var def = this._groups_def[group];
+ if (!def) {
+ def = this._groups_def[group] = this.rpc('/web/dataset/call_kw/res.users/has_group', {
+ "model": "res.users",
+ "method": "has_group",
+ "args": [group],
+ "kwargs": {}
+ });
+ }
+ return def;
+ },
+ get_cookie: function (name) {
+ if (!this.name) { return null; }
+ var nameEQ = this.name + '|' + name + '=';
+ var cookies = document.cookie.split(';');
+ for(var i=0; i<cookies.length; ++i) {
+ var cookie = cookies[i].replace(/^\s*/, '');
+ if(cookie.indexOf(nameEQ) === 0) {
+ try {
+ return JSON.parse(decodeURIComponent(cookie.substring(nameEQ.length)));
+ } catch(err) {
+ // wrong cookie, delete it
+ this.set_cookie(name, '', -1);
+ }
+ }
+ }
+ return null;
+ },
+ /**
+ * Create a new cookie with the provided name and value
+ *
+ * @private
+ * @param name the cookie's name
+ * @param value the cookie's value
+ * @param ttl the cookie's time to live, 1 year by default, set to -1 to delete
+ */
+ set_cookie: function (name, value, ttl) {
+ if (!this.name) { return; }
+ ttl = ttl || 24*60*60*365;
+ utils.set_cookie(this.name + '|' + name, value, ttl);
+ },
+ /**
+ * Load additional web addons of that instance and init them
+ *
+ */
+ load_modules: function() {
+ var self = this;
+ var modules = odoo._modules;
+ var all_modules = _.uniq(self.module_list.concat(modules));
+ var to_load = _.difference(modules, self.module_list).join(',');
+ this.module_list = all_modules;
+
+ var loaded = $.when(self.load_translations());
+ var locale = "/web/webclient/locale/" + self.user_context.lang || 'en_US';
+ var file_list = [ locale ];
+ if(to_load.length) {
+ loaded = $.when(
+ loaded,
+ self.rpc('/web/webclient/csslist', {mods: to_load}).done(self.load_css.bind(self)),
+ self.load_qweb(to_load),
+ self.rpc('/web/webclient/jslist', {mods: to_load}).done(function(files) {
+ file_list = file_list.concat(files);
+ })
+ );
+ }
+ return loaded.then(function () {
+ return self.load_js(file_list);
+ }).done(function() {
+ self.on_modules_loaded();
+ self.trigger('module_loaded');
+ });
+ },
+ load_translations: function() {
+ return _t.database.load_translations(this, this.module_list, this.user_context.lang);
+ },
+ load_css: function (files) {
+ var self = this;
+ _.each(files, function (file) {
+ ajax.loadCSS(self.url(file, null));
+ });
+ },
+ load_js: function(files) {
+ var self = this;
+ var d = $.Deferred();
+ if (files.length !== 0) {
+ var file = files.shift();
+ var url = self.url(file, null);
+ ajax.loadJS(url).done(d.resolve);
+ } else {
+ d.resolve();
+ }
+ return d;
+ },
+ load_qweb: function(mods) {
+ this.qweb_mutex.exec(function () {
+ return $.get('/web/webclient/qweb?mods=' + mods).then(function (doc) {
+ if (!doc) { return; }
+ qweb.add_template(doc);
+ });
+ });
+ return this.qweb_mutex.def;
+ },
+ on_modules_loaded: function() {
+ var openerp = window.openerp;
+ for(var j=0; j<this.module_list.length; j++) {
+ var mod = this.module_list[j];
+ if(this.module_loaded[mod])
+ continue;
+ openerp[mod] = {};
+ // init module mod
+ var fct = openerp._openerp[mod];
+ if(typeof(fct) === "function") {
+ openerp._openerp[mod] = {};
+ for (var k in fct) {
+ openerp._openerp[mod][k] = fct[k];
+ }
+ fct(openerp, openerp._openerp[mod]);
+ }
+ this.module_loaded[mod] = true;
+ }
+ },
+ get_currency: function(currency_id) {
+ return this.currencies[currency_id];
+ },
+ get_file: function (options) {
+ if (this.override_session){
+ options.data.session_id = this.session_id;
+ }
+ options.session = this;
+ ajax.get_file(options);
+ },
+ /**
+ * (re)loads the content of a session: db name, username, user id, session
+ * context and status of the support contract
+ *
+ * @returns {$.Deferred} deferred indicating the session is done reloading
+ */
+ session_reload: function () {
+ var result = _.extend({}, window.odoo.session_info);
+ delete result.session_id;
+ _.extend(this, result);
+ return $.when();
+ },
+ check_session_id: function() {
+ var self = this;
+ if (this.avoid_recursion)
+ return $.when();
+ if (this.session_id)
+ return $.when(); // we already have the session id
+ if (!this.use_cors && (this.override_session || ! this.origin_server)) {
+ // If we don't use the origin server we consider we should always create a new session.
+ // Even if some browsers could support cookies when using jsonp that behavior is
+ // not consistent and the browser creators are tending to removing that feature.
+ this.avoid_recursion = true;
+ return this.rpc("/gen_session_id", {}).then(function(result) {
+ self.session_id = result;
+ }).always(function() {
+ self.avoid_recursion = false;
+ });
+ } else {
+ // normal use case, just use the cookie
+ self.session_id = utils.get_cookie("session_id");
+ return $.when();
+ }
+ },
+ /**
+ * Executes an RPC call, registering the provided callbacks.
+ *
+ * Registers a default error callback if none is provided, and handles
+ * setting the correct session id and session context in the parameter
+ * objects
+ *
+ * @param {String} url RPC endpoint
+ * @param {Object} params call parameters
+ * @param {Object} options additional options for rpc call
+ * @param {Function} success_callback function to execute on RPC call success
+ * @param {Function} error_callback function to execute on RPC call failure
+ * @returns {jQuery.Deferred} jquery-provided ajax deferred
+ */
+ rpc: function(url, params, options) {
+ var self = this;
+ options = _.clone(options || {});
+ var shadow = options.shadow || false;
+ options.headers = _.extend({}, options.headers)
+ if (odoo.debug) {
+ options.headers["X-Debug-Mode"] = $.deparam($.param.querystring()).debug;
+ }
+
+ delete options.shadow;
+
+ return self.check_session_id().then(function() {
+ // TODO: remove
+ if (! _.isString(url)) {
+ _.extend(options, url);
+ url = url.url;
+ }
+ // TODO correct handling of timeouts
+ if (! shadow)
+ self.trigger('request');
+ var fct;
+ if (self.origin_server) {
+ fct = ajax.jsonRpc;
+ if (self.override_session) {
+ options.headers["X-Openerp-Session-Id"] = self.session_id || '';
+ }
+ } else if (self.use_cors) {
+ fct = ajax.jsonRpc;
+ url = self.url(url, null);
+ options.session_id = self.session_id || '';
+ if (self.override_session) {
+ options.headers["X-Openerp-Session-Id"] = self.session_id || '';
+ }
+ } else {
+ fct = ajax.jsonpRpc;
+ url = self.url(url, null);
+ options.session_id = self.session_id || '';
+ }
+ var p = fct(url, "call", params, options);
+ p = p.then(function (result) {
+ if (! shadow)
+ self.trigger('response');
+ return result;
+ }, function(type, error, textStatus, errorThrown) {
+ if (type === "server") {
+ if (! shadow)
+ self.trigger('response');
+ if (error.code === 100) {
+ self.uid = false;
+ }
+ return $.Deferred().reject(error, $.Event());
+ } else {
+ if (! shadow)
+ self.trigger('response_failed');
+ var nerror = {
+ code: -32098,
+ message: "XmlHttpRequestError " + errorThrown,
+ data: {type: "xhr"+textStatus, debug: error.responseText, objects: [error, errorThrown] }
+ };
+ return $.Deferred().reject(nerror, $.Event());
+ }
+ });
+ return p.fail(function() { // Allow deferred user to disable rpc_error call in fail
+ p.fail(function(error, event) {
+ if (!event.isDefaultPrevented()) {
+ self.trigger('error', error, event);
+ }
+ });
+ });
+ });
+ },
+ url: function(path, params) {
+ params = _.extend(params || {});
+ if (this.override_session || (! this.origin_server))
+ params.session_id = this.session_id;
+ var qs = $.param(params);
+ if (qs.length > 0)
+ qs = "?" + qs;
+ var prefix = _.any(['http://', 'https://', '//'], function(el) {
+ return path.length >= el.length && path.slice(0, el.length) === el;
+ }) ? '' : this.prefix;
+ return prefix + path + qs;
+ },
+});
+
+return Session;
+
+});
diff --git a/addons/web/static/src/js/core/time.js b/addons/web/static/src/js/core/time.js
new file mode 100644
index 00000000000..af13aa5a3b1
--- /dev/null
+++ b/addons/web/static/src/js/core/time.js
@@ -0,0 +1,299 @@
+odoo.define('web.time', function (require) {
+"use strict";
+
+var translation = require('web.translation');
+var utils = require('web.utils');
+
+var lpad = utils.lpad;
+var rpad = utils.rpad;
+var _t = translation._t;
+
+/**
+ * Replacer function for JSON.stringify, serializes Date objects to UTC
+ * datetime in the OpenERP Server format.
+ *
+ * However, if a serialized value has a toJSON method that method is called
+ * *before* the replacer is invoked. Date#toJSON exists, and thus the value
+ * passed to the replacer is a string, the original Date has to be fetched
+ * on the parent object (which is provided as the replacer's context).
+ *
+ * @param {String} k
+ * @param {Object} v
+ * @returns {Object}
+ */
+function date_to_utc (k, v) {
+ var value = this[k];
+ if (!(value instanceof Date)) { return v; }
+
+ return datetime_to_str(value);
+}
+
+/**
+ * Converts a string to a Date javascript object using OpenERP's
+ * datetime string format (exemple: '2011-12-01 15:12:35.832').
+ *
+ * The time zone is assumed to be UTC (standard for OpenERP 6.1)
+ * and will be converted to the browser's time zone.
+ *
+ * @param {String} str A string representing a datetime.
+ * @returns {Date}
+ */
+function str_to_datetime (str) {
+ if(!str) {
+ return str;
+ }
+ var regex = /^(\d\d\d\d)-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d(?:\.(\d+))?)$/;
+ var res = regex.exec(str);
+ if ( !res ) {
+ throw new Error("'" + str + "' is not a valid datetime");
+ }
+ var tmp = new Date(2000,0,1);
+ tmp.setUTCMonth(1970);
+ tmp.setUTCMonth(0);
+ tmp.setUTCDate(1);
+ tmp.setUTCFullYear(parseFloat(res[1]));
+ tmp.setUTCMonth(parseFloat(res[2]) - 1);
+ tmp.setUTCDate(parseFloat(res[3]));
+ tmp.setUTCHours(parseFloat(res[4]));
+ tmp.setUTCMinutes(parseFloat(res[5]));
+ tmp.setUTCSeconds(parseFloat(res[6]));
+ tmp.setUTCSeconds(parseFloat(res[6]));
+ tmp.setUTCMilliseconds(parseFloat(utils.rpad((res[7] || "").slice(0, 3), 3)));
+ return tmp;
+}
+
+/**
+ * Converts a string to a Date javascript object using OpenERP's
+ * date string format (exemple: '2011-12-01').
+ *
+ * As a date is not subject to time zones, we assume it should be
+ * represented as a Date javascript object at 00:00:00 in the
+ * time zone of the browser.
+ *
+ * @param {String} str A string representing a date.
+ * @returns {Date}
+ */
+function str_to_date (str) {
+ if(!str) {
+ return str;
+ }
+ var regex = /^(\d\d\d\d)-(\d\d)-(\d\d)$/;
+ var res = regex.exec(str);
+ if ( !res ) {
+ throw new Error("'" + str + "' is not a valid date");
+ }
+ var tmp = new Date(2000,0,1);
+ tmp.setFullYear(parseFloat(res[1]));
+ tmp.setMonth(parseFloat(res[2]) - 1);
+ tmp.setDate(parseFloat(res[3]));
+ tmp.setHours(0);
+ tmp.setMinutes(0);
+ tmp.setSeconds(0);
+ return tmp;
+}
+
+/**
+ * Converts a string to a Date javascript object using OpenERP's
+ * time string format (exemple: '15:12:35').
+ *
+ * The OpenERP times are supposed to always be naive times. We assume it is
+ * represented using a javascript Date with a date 1 of January 1970 and a
+ * time corresponding to the meant time in the browser's time zone.
+ *
+ * @param {String} str A string representing a time.
+ * @returns {Date}
+ */
+function str_to_time (str) {
+ if(!str) {
+ return str;
+ }
+ var regex = /^(\d\d):(\d\d):(\d\d(?:\.(\d+))?)$/;
+ var res = regex.exec(str);
+ if ( !res ) {
+ throw new Error("'" + str + "' is not a valid time");
+ }
+ var tmp = new Date();
+ tmp.setFullYear(1970);
+ tmp.setMonth(0);
+ tmp.setDate(1);
+ tmp.setHours(parseFloat(res[1]));
+ tmp.setMinutes(parseFloat(res[2]));
+ tmp.setSeconds(parseFloat(res[3]));
+ tmp.setMilliseconds(parseFloat(rpad((res[4] || "").slice(0, 3), 3)));
+ return tmp;
+}
+
+/**
+ * Converts a Date javascript object to a string using OpenERP's
+ * datetime string format (exemple: '2011-12-01 15:12:35').
+ *
+ * The time zone of the Date object is assumed to be the one of the
+ * browser and it will be converted to UTC (standard for OpenERP 6.1).
+ *
+ * @param {Date} obj
+ * @returns {String} A string representing a datetime.
+ */
+function datetime_to_str (obj) {
+ if (!obj) {
+ return false;
+ }
+ return lpad(obj.getUTCFullYear(),4) + "-" + lpad(obj.getUTCMonth() + 1,2) + "-"
+ + lpad(obj.getUTCDate(),2) + " " + lpad(obj.getUTCHours(),2) + ":"
+ + lpad(obj.getUTCMinutes(),2) + ":" + lpad(obj.getUTCSeconds(),2);
+}
+
+/**
+ * Converts a Date javascript object to a string using OpenERP's
+ * date string format (exemple: '2011-12-01').
+ *
+ * As a date is not subject to time zones, we assume it should be
+ * represented as a Date javascript object at 00:00:00 in the
+ * time zone of the browser.
+ *
+ * @param {Date} obj
+ * @returns {String} A string representing a date.
+ */
+function date_to_str (obj) {
+ if (!obj) {
+ return false;
+ }
+ return lpad(obj.getFullYear(),4) + "-" + lpad(obj.getMonth() + 1,2) + "-"
+ + lpad(obj.getDate(),2);
+}
+
+/**
+ * Converts a Date javascript object to a string using OpenERP's
+ * time string format (exemple: '15:12:35').
+ *
+ * The OpenERP times are supposed to always be naive times. We assume it is
+ * represented using a javascript Date with a date 1 of January 1970 and a
+ * time corresponding to the meant time in the browser's time zone.
+ *
+ * @param {Date} obj
+ * @returns {String} A string representing a time.
+ */
+function time_to_str (obj) {
+ if (!obj) {
+ return false;
+ }
+ return lpad(obj.getHours(),2) + ":" + lpad(obj.getMinutes(),2) + ":"
+ + lpad(obj.getSeconds(),2);
+}
+
+function auto_str_to_date (value) {
+ try {
+ return str_to_datetime(value);
+ } catch(e) {}
+ try {
+ return str_to_date(value);
+ } catch(e) {}
+ try {
+ return str_to_time(value);
+ } catch(e) {}
+ throw new Error(_.str.sprintf(_t("'%s' is not a correct date, datetime nor time"), value));
+}
+
+function auto_date_to_str (value, type) {
+ switch(type) {
+ case 'datetime':
+ return datetime_to_str(value);
+ case 'date':
+ return date_to_str(value);
+ case 'time':
+ return time_to_str(value);
+ default:
+ throw new Error(_.str.sprintf(_t("'%s' is not convertible to date, datetime nor time"), type));
+ }
+}
+
+/**
+ * Convert Python strftime to escaped moment.js format.
+ *
+ * @param {String} value original format
+ */
+function strftime_to_moment_format (value) {
+ if (_normalize_format_cache[value] === undefined) {
+ var isletter = /[a-zA-Z]/,
+ output = [],
+ inToken = false;
+
+ for (var index=0; index < value.length; ++index) {
+ var character = value[index];
+ if (character === '%' && !inToken) {
+ inToken = true;
+ continue;
+ }
+ if (isletter.test(character)) {
+ if (inToken && normalize_format_table[character] !== undefined) {
+ character = normalize_format_table[character];
+ } else {
+ character = '[' + character + ']'; // moment.js escape
+ }
+ }
+ output.push(character);
+ inToken = false;
+ }
+ _normalize_format_cache[value] = output.join('');
+ }
+ return _normalize_format_cache[value];
+}
+
+/**
+ * Convert moment.js format to python strftime
+ *
+ * @param {String} value original format
+ */
+function moment_to_strftime_format(value) {
+ var regex = /(MMMM|DDDD|dddd|YYYY|MMM|ddd|mm|ss|ww|WW|MM|YY|hh|HH|DD|A|d)/g;
+ return value.replace(regex, function(val){
+ return '%'+inverse_normalize_format_table[val];
+ });
+}
+
+var _normalize_format_cache = {};
+var normalize_format_table = {
+ // Python strftime to moment.js conversion table
+ // See openerp/addons/base/res/res_lang_view.xml
+ // for details about supported directives
+ 'a': 'ddd',
+ 'A': 'dddd',
+ 'b': 'MMM',
+ 'B': 'MMMM',
+ 'd': 'DD',
+ 'H': 'HH',
+ 'I': 'hh',
+ 'j': 'DDDD',
+ 'm': 'MM',
+ 'M': 'mm',
+ 'p': 'A',
+ 'S': 'ss',
+ 'U': 'ww',
+ 'W': 'WW',
+ 'w': 'd',
+ 'y': 'YY',
+ 'Y': 'YYYY',
+ // unsupported directives
+ 'c': 'ddd MMM D HH:mm:ss YYYY',
+ 'x': 'MM/DD/YY',
+ 'X': 'HH:mm:ss'
+};
+var inverse_normalize_format_table = _.invert(normalize_format_table);
+
+
+
+return {
+ date_to_utc: date_to_utc,
+ str_to_datetime: str_to_datetime,
+ str_to_date: str_to_date,
+ str_to_time: str_to_time,
+ datetime_to_str: datetime_to_str,
+ date_to_str: date_to_str,
+ time_to_str: time_to_str,
+ auto_str_to_date: auto_str_to_date,
+ auto_date_to_str: auto_date_to_str,
+ strftime_to_moment_format: strftime_to_moment_format,
+ moment_to_strftime_format: moment_to_strftime_format,
+};
+
+});
+
diff --git a/addons/web/static/src/js/core/translation.js b/addons/web/static/src/js/core/translation.js
new file mode 100644
index 00000000000..f2bad0db4ee
--- /dev/null
+++ b/addons/web/static/src/js/core/translation.js
@@ -0,0 +1,117 @@
+
+odoo.define('web.translation', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+
+var TranslationDataBase = Class.extend(/** @lends instance.TranslationDataBase# */{
+ init: function() {
+ this.db = {};
+ this.multi_lang = false
+ this.parameters = {"direction": 'ltr',
+ "date_format": '%m/%d/%Y',
+ "time_format": '%H:%M:%S',
+ "grouping": [],
+ "decimal_point": ".",
+ "thousands_sep": ","};
+ },
+ set_bundle: function(translation_bundle) {
+ var self = this;
+ this.multi_lang = translation_bundle.multi_lang
+ var modules = _.keys(translation_bundle.modules);
+ modules.sort();
+ if (_.include(modules, "web")) {
+ modules = ["web"].concat(_.without(modules, "web"));
+ }
+ _.each(modules, function(name) {
+ self.add_module_translation(translation_bundle.modules[name]);
+ });
+ if (translation_bundle.lang_parameters) {
+ this.parameters = translation_bundle.lang_parameters;
+ this.parameters.grouping = JSON.parse(this.parameters.grouping);
+ }
+ },
+ add_module_translation: function(mod) {
+ var self = this;
+ _.each(mod.messages, function(message) {
+ self.db[message.id] = message.string;
+ });
+ },
+ build_translation_function: function() {
+ var self = this;
+ var fcnt = function(str) {
+ var tmp = self.get(str);
+ return tmp === undefined ? str : tmp;
+ };
+ fcnt.database = this;
+ return fcnt;
+ },
+ get: function(key) {
+ return this.db[key];
+ },
+ /**
+ Loads the translations from an OpenERP server.
+
+ @param {openerp.Session} session The session object to contact the server.
+ @param {Array} [modules] The list of modules to load the translation. If not specified,
+ it will default to all the modules installed in the current database.
+ @param {Object} [lang] lang The language. If not specified it will default to the language
+ of the current user.
+ @returns {jQuery.Deferred}
+ */
+ load_translations: function(session, modules, lang) {
+ var self = this;
+ return session.rpc('/web/webclient/translations', {
+ "mods": modules || null,
+ "lang": lang || null
+ }).done(function(trans) {
+ self.set_bundle(trans);
+ });
+ }
+});
+
+var _t = new TranslationDataBase().build_translation_function();
+/**
+ * Lazy translation function, only performs the translation when actually
+ * printed (e.g. inserted into a template)
+ *
+ * Useful when defining translatable strings in code evaluated before the
+ * translation database is loaded, as class attributes or at the top-level of
+ * an OpenERP Web module
+ *
+ * @param {String} s string to translate
+ * @returns {Object} lazy translation object
+ */
+var _lt = function (s) {
+ return {toString: function () { return _t(s); }};
+};
+
+/** Setup jQuery timeago */
+/*
+ * Strings in timeago are "composed" with prefixes, words and suffixes. This
+ * makes their detection by our translating system impossible. Use all literal
+ * strings we're using with a translation mark here so the extractor can do its
+ * job.
+ */
+{
+ _t('less than a minute ago');
+ _t('about a minute ago');
+ _t('%d minutes ago');
+ _t('about an hour ago');
+ _t('%d hours ago');
+ _t('a day ago');
+ _t('%d days ago');
+ _t('about a month ago');
+ _t('%d months ago');
+ _t('about a year ago');
+ _t('%d years ago');
+}
+
+
+return {
+ _t: _t,
+ _lt: _lt,
+ TranslationDataBase: TranslationDataBase,
+};
+
+});
diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js
new file mode 100644
index 00000000000..578eb3478f1
--- /dev/null
+++ b/addons/web/static/src/js/core/utils.js
@@ -0,0 +1,436 @@
+odoo.define('web.utils', function (require) {
+"use strict";
+
+/**
+ * Utils
+ *
+ * Various generic utility functions
+ */
+
+var translation = require('web.translation');
+
+var _t = translation._t;
+var id = -1;
+
+var utils = {
+ /**
+ * Throws an error if the given condition is not true
+ *
+ * @param {any} bool
+ */
+ assert: function (bool) {
+ if (!bool) {
+ throw new Error("AssertionError");
+ }
+ },
+ /**
+ * Confines a value inside an interval
+ *
+ * @param {number} [val] the value to confine
+ * @param {number} [min] the minimum of the interval
+ * @param {number} [max] the maximum of the interval
+ * @return {number} val if val is in [min, max], min if val < min and max
+ * otherwise
+ */
+ confine: function (val, min, max) {
+ return Math.max(min, Math.min(max, val));
+ },
+ /**
+ * computes (Math.floor(a/b), a%b and passes that to the callback.
+ *
+ * returns the callback's result
+ */
+ divmod: function (a, b, fn) {
+ var mod = a%b;
+ // in python, sign(a % b) === sign(b). Not in JS. If wrong side, add a
+ // round of b
+ if (mod > 0 && b < 0 || mod < 0 && b > 0) {
+ mod += b;
+ }
+ return fn(Math.floor(a/b), mod);
+ },
+ /**
+ * @param {number} value
+ * @param {integer} decimals
+ * @returns {boolean}
+ */
+ float_is_zero: function (value, decimals) {
+ var epsilon = Math.pow(10, -decimals);
+ return Math.abs(utils.round_precision(value, epsilon)) < epsilon;
+ },
+ /**
+ * Generate a unique numerical ID
+ *
+ * @returns {integer}
+ */
+ generateID: function () {
+ return ++id;
+ },
+ /**
+ * Read the cookie described by c_name
+ *
+ * @param {string} c_name
+ * @returns {string}
+ */
+ get_cookie: function (c_name) {
+ var cookies = document.cookie ? document.cookie.split('; ') : [];
+ for (var i = 0, l = cookies.length; i < l; i++) {
+ var parts = cookies[i].split('=');
+ var name = parts.shift();
+ var cookie = parts.join('=');
+
+ if (c_name && c_name === name) {
+ return cookie;
+ }
+ }
+ return "";
+ },
+ /**
+ * Returns a human readable number
+ *
+ * @param {Number} number
+ */
+ human_number: function (number, decimals) {
+ decimals = decimals | 0;
+ number = Math.round(number);
+ var d2 = Math.pow(10, decimals);
+ var val = _t("kMGTPE");
+ var i = val.length-1, s;
+ while( i ) {
+ s = Math.pow(10,i--*3);
+ if( s <= number ) {
+ number = Math.round(number*d2/s)/d2 + val[i];
+ }
+ }
+ return number;
+ },
+ /**
+ * Returns a human readable size
+ *
+ * @param {Number} number of bytes
+ */
+ human_size: function (size) {
+ var units = _t("Bytes,Kb,Mb,Gb,Tb,Pb,Eb,Zb,Yb").split(',');
+ var i = 0;
+ while (size >= 1024) {
+ size /= 1024;
+ ++i;
+ }
+ return size.toFixed(2) + ' ' + units[i];
+ },
+ /**
+ * Insert "thousands" separators in the provided number (which is actually
+ * a string)
+ *
+ * @param {String} num
+ * @returns {String}
+ */
+ insert_thousand_seps: function (num) {
+ var negative = num[0] === '-';
+ num = (negative ? num.slice(1) : num);
+ return (negative ? '-' : '') + utils.intersperse(
+ num, _t.database.parameters.grouping, _t.database.parameters.thousands_sep);
+ },
+ /**
+ * Intersperses ``separator`` in ``str`` at the positions indicated by
+ * ``indices``.
+ *
+ * ``indices`` is an array of relative offsets (from the previous insertion
+ * position, starting from the end of the string) at which to insert
+ * ``separator``.
+ *
+ * There are two special values:
+ *
+ * ``-1``
+ * indicates the insertion should end now
+ * ``0``
+ * indicates that the previous section pattern should be repeated (until all
+ * of ``str`` is consumed)
+ *
+ * @param {String} str
+ * @param {Array<Number>} indices
+ * @param {String} separator
+ * @returns {String}
+ */
+ intersperse: function (str, indices, separator) {
+ separator = separator || '';
+ var result = [], last = str.length;
+
+ for(var i=0; i<indices.length; ++i) {
+ var section = indices[i];
+ if (section === -1 || last <= 0) {
+ // Done with string, or -1 (stops formatting string)
+ break;
+ } else if(section === 0 && i === 0) {
+ // repeats previous section, which there is none => stop
+ break;
+ } else if (section === 0) {
+ // repeat previous section forever
+ //noinspection AssignmentToForLoopParameterJS
+ section = indices[--i];
+ }
+ result.push(str.substring(last-section, last));
+ last -= section;
+ }
+
+ var s = str.substring(0, last);
+ if (s) { result.push(s); }
+ return result.reverse().join(separator);
+ },
+ /**
+ * @param {any} object
+ * @param {any} path
+ * @returns
+ */
+ into: function (object, path) {
+ if (!_(path).isArray()) {
+ path = path.split('.');
+ }
+ for (var i = 0; i < path.length; i++) {
+ object = object[path[i]];
+ }
+ return object;
+ },
+ /**
+ * @param {string} v
+ * @returns {boolean}
+ */
+ is_bin_size: function (v) {
+ return (/^\d+(\.\d*)? \w+$/).test(v);
+ },
+ /**
+ * @param {any} node
+ * @param {any} human_readable
+ * @param {any} indent
+ * @returns {string}
+ */
+ json_node_to_xml: function (node, human_readable, indent) {
+ // For debugging purpose, this function will convert a json node back to xml
+ indent = indent || 0;
+ var sindent = (human_readable ? (new Array(indent + 1).join('\t')) : ''),
+ r = sindent + '<' + node.tag,
+ cr = human_readable ? '\n' : '';
+
+ if (typeof(node) === 'string') {
+ return sindent + node.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+ } else if (typeof(node.tag) !== 'string' || !node.children instanceof Array || !node.attrs instanceof Object) {
+ throw new Error(
+ _.str.sprintf(_t("Node [%s] is not a JSONified XML node"),
+ JSON.stringify(node)));
+ }
+ for (var attr in node.attrs) {
+ var vattr = node.attrs[attr];
+ if (typeof(vattr) !== 'string') {
+ // domains, ...
+ vattr = JSON.stringify(vattr);
+ }
+ vattr = vattr.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+ if (human_readable) {
+ vattr = vattr.replace(/&quot;/g, "'");
+ }
+ r += ' ' + attr + '="' + vattr + '"';
+ }
+ if (node.children && node.children.length) {
+ r += '>' + cr;
+ var childs = [];
+ for (var i = 0, ii = node.children.length; i < ii; i++) {
+ childs.push(utils.json_node_to_xml(node.children[i], human_readable, indent + 1));
+ }
+ r += childs.join(cr);
+ r += cr + sindent + '</' + node.tag + '>';
+ return r;
+ } else {
+ return r + '/>';
+ }
+ },
+ /**
+ * Left-pad provided arg 1 with zeroes until reaching size provided by second
+ * argument.
+ *
+ * @see rpad
+ *
+ * @param {number|string} str value to pad
+ * @param {number} size size to reach on the final padded value
+ * @returns {string} padded string
+ */
+ lpad: function (str, size) {
+ str = "" + str;
+ return new Array(size - str.length + 1).join('0') + str;
+ },
+ /**
+ * Passes the fractional and integer parts of x to the callback, returns
+ * the callback's result
+ */
+ modf: function (x, fn) {
+ var mod = x%1;
+ if (mod < 0) {
+ mod += 1;
+ }
+ return fn(mod, Math.floor(x));
+ },
+ /**
+ * performs a half up rounding with a fixed amount of decimals, correcting for float loss of precision
+ * See the corresponding float_round() in server/tools/float_utils.py for more info
+ * @param {Number} the value to be rounded
+ * @param {Number} the number of decimals. eg: round_decimals(3.141592,2) -> 3.14
+ */
+ round_decimals: function (value, decimals) {
+ return utils.round_precision(value, Math.pow(10,-decimals));
+ },
+ /**
+ * performs a half up rounding with arbitrary precision, correcting for float loss of precision
+ * See the corresponding float_round() in server/tools/float_utils.py for more info
+ *
+ * @param {number} value the value to be rounded
+ * @param {number} precision a precision parameter. eg: 0.01 rounds to two digits.
+ */
+ round_precision: function (value, precision) {
+ if (!value) {
+ return 0;
+ } else if (!precision || precision < 0) {
+ precision = 1;
+ }
+ var normalized_value = value / precision;
+ var epsilon_magnitude = Math.log(Math.abs(normalized_value))/Math.log(2);
+ var epsilon = Math.pow(2, epsilon_magnitude - 53);
+ normalized_value += normalized_value >= 0 ? epsilon : -epsilon;
+
+ /**
+ * Javascript performs strictly the round half up method, which is asymmetric. However, in
+ * Python, the method is symmetric. For example:
+ * - In JS, Math.round(-0.5) is equal to -0.
+ * - In Python, round(-0.5) is equal to -1.
+ * We want to keep the Python behavior for consistency.
+ */
+ var sign = normalized_value < 0 ? -1.0 : 1.0;
+ var rounded_value = sign * Math.round(Math.abs(normalized_value));
+ return rounded_value * precision;
+ },
+ /**
+ * @see lpad
+ *
+ * @param {string} str
+ * @param {number} size
+ * @returns {string}
+ */
+ rpad: function (str, size) {
+ str = "" + str;
+ return str + new Array(size - str.length + 1).join('0');
+ },
+ /**
+ * Create a cookie
+ * @param {String} name : the name of the cookie
+ * @param {String} value : the value stored in the cookie
+ * @param {Integer} ttl : time to live of the cookie in millis. -1 to erase the cookie.
+ */
+ set_cookie: function (name, value, ttl) {
+ ttl = ttl || 24*60*60*365;
+ document.cookie = [
+ name + '=' + value,
+ 'path=/',
+ 'max-age=' + ttl,
+ 'expires=' + new Date(new Date().getTime() + ttl*1000).toGMTString()
+ ].join(';');
+ },
+ /**
+ * @param {any} array
+ * @param {any} elem1
+ * @param {any} elem2
+ */
+ swap: function (array, elem1, elem2) {
+ var i1 = array.indexOf(elem1);
+ var i2 = array.indexOf(elem2);
+ array[i2] = elem1;
+ array[i1] = elem2;
+ },
+ /**
+ * @param {any} str
+ * @param {any} elseValues
+ * @param {any} trueValues
+ * @param {any} falseValues
+ * @returns
+ */
+ toBoolElse: function (str, elseValues, trueValues, falseValues) {
+ var ret = _.str.toBool(str, trueValues, falseValues);
+ if (_.isUndefined(ret)) {
+ return elseValues;
+ }
+ return ret;
+ },
+ /**
+ * @todo: is this really the correct place?
+ *
+ * @param {any} data
+ * @param {any} f
+ */
+ traverse_records: function (data, f) {
+ if (data.type === 'record') {
+ f(data);
+ } else if (data.data) {
+ for (var i = 0; i < data.data.length; i++) {
+ utils.traverse_records(data.data[i], f);
+ }
+ }
+ },
+ /**
+ * @param {any} node
+ * @param {any} strip_whitespace
+ * @returns
+ */
+ xml_to_json: function (node, strip_whitespace) {
+ switch (node.nodeType) {
+ case 9:
+ return utils.xml_to_json(node.documentElement, strip_whitespace);
+ case 3:
+ case 4:
+ return (strip_whitespace && node.data.trim() === '') ? undefined : node.data;
+ case 1:
+ var attrs = $(node).getAttributes();
+ _.each(['domain', 'filter_domain', 'context', 'default_get'], function (key) {
+ if (attrs[key]) {
+ try {
+ attrs[key] = JSON.parse(attrs[key]);
+ } catch(e) { }
+ }
+ });
+ return {
+ tag: node.tagName.toLowerCase(),
+ attrs: attrs,
+ children: _.compact(_.map(node.childNodes, function (node) {
+ return utils.xml_to_json(node, strip_whitespace);
+ })),
+ };
+ }
+ },
+ /**
+ * @param {any} node
+ * @returns {string}
+ */
+ xml_to_str: function (node) {
+ var str = "";
+ if (window.XMLSerializer) {
+ str = (new XMLSerializer()).serializeToString(node);
+ } else if (window.ActiveXObject) {
+ str = node.xml;
+ } else {
+ throw new Error(_t("Could not serialize XML"));
+ }
+ // Browsers won't deal with self closing tags except void elements:
+ // http://www.w3.org/TR/html-markup/syntax.html
+ var void_elements = 'area base br col command embed hr img input keygen link meta param source track wbr'.split(' ');
+
+ // The following regex is a bit naive but it's ok for the xmlserializer output
+ str = str.replace(/<([a-z]+)([^<>]*)\s*\/\s*>/g, function (match, tag, attrs) {
+ if (void_elements.indexOf(tag) < 0) {
+ return "<" + tag + attrs + "></" + tag + ">";
+ } else {
+ return match;
+ }
+ });
+ return str;
+ },
+};
+
+return utils;
+
+});
diff --git a/addons/web/static/src/js/core/widget.js b/addons/web/static/src/js/core/widget.js
new file mode 100644
index 00000000000..221a127c9dd
--- /dev/null
+++ b/addons/web/static/src/js/core/widget.js
@@ -0,0 +1,397 @@
+odoo.define('web.Widget', function (require) {
+"use strict";
+
+var core = require('web.core');
+
+var mixins = core.mixins;
+
+/**
+ * Base class for all visual components. Provides a lot of functionalities helpful
+ * for the management of a part of the DOM.
+ *
+ * Widget handles:
+ * - Rendering with QWeb.
+ * - Life-cycle management and parenting (when a parent is destroyed, all its children are
+ * destroyed too).
+ * - Insertion in DOM.
+ *
+ * Guide to create implementations of the Widget class:
+ * ==============================================
+ *
+ * Here is a sample child class:
+ *
+ * MyWidget = openerp.base.Widget.extend({
+ * // the name of the QWeb template to use for rendering
+ * template: "MyQWebTemplate",
+ *
+ * init: function(parent) {
+ * this._super(parent);
+ * // stuff that you want to init before the rendering
+ * },
+ * start: function() {
+ * // stuff you want to make after the rendering, `this.$el` holds a correct value
+ * this.$(".my_button").click(/* an example of event binding * /);
+ *
+ * // if you have some asynchronous operations, it's a good idea to return
+ * // a promise in start()
+ * var promise = this.rpc(...);
+ * return promise;
+ * }
+ * });
+ *
+ * Now this class can simply be used with the following syntax:
+ *
+ * var my_widget = new MyWidget(this);
+ * my_widget.appendTo($(".some-div"));
+ *
+ * With these two lines, the MyWidget instance was inited, rendered, it was inserted into the
+ * DOM inside the ".some-div" div and its events were binded.
+ *
+ * And of course, when you don't need that widget anymore, just do:
+ *
+ * my_widget.destroy();
+ *
+ * That will kill the widget in a clean way and erase its content from the dom.
+ */
+
+
+var Widget = core.Class.extend(mixins.PropertiesMixin, mixins.ServicesMixin, {
+ // Backbone-ish API
+ tagName: 'div',
+ id: null,
+ className: null,
+ attributes: {},
+ events: {},
+ custom_events: {},
+ /**
+ * The name of the QWeb template that will be used for rendering. Must be
+ * redefined in subclasses or the default render() method can not be used.
+ *
+ * @type string
+ */
+ template: null,
+ /**
+ * Constructs the widget and sets its parent if a parent is given.
+ *
+ * @constructs openerp.Widget
+ *
+ * @param {openerp.Widget} parent Binds the current instance to the given Widget instance.
+ * When that widget is destroyed by calling destroy(), the current instance will be
+ * destroyed too. Can be null.
+ */
+ init: function (parent) {
+ mixins.PropertiesMixin.init.call(this);
+ this.setParent(parent);
+ // Bind on_/do_* methods to this
+ // We might remove this automatic binding in the future
+ for (var name in this) {
+ if(typeof(this[name]) == "function") {
+ if((/^on_|^do_/).test(name)) {
+ this[name] = _.bind(this[name], this);
+ }
+ }
+ }
+ // FIXME: this should not be
+ this.setElement(this._make_descriptive());
+ this.delegateCustomEvents();
+ },
+ /**
+ * Method called between init and start. Performs asynchronous calls required by start.
+ *
+ * This method should return a Deferred which is resolved when start can be executed.
+ *
+ * @return {jQuery.Deferred}
+ */
+ willStart: function () {
+ return $.when();
+ },
+ /**
+ * Destroys the current widget, also destroys all its children before destroying itself.
+ */
+ destroy: function () {
+ _.each(this.getChildren(), function (el) {
+ el.destroy();
+ });
+ if(this.$el) {
+ this.$el.remove();
+ }
+ mixins.PropertiesMixin.destroy.call(this);
+ },
+ /**
+ * Renders the current widget and appends it to the given jQuery object or Widget.
+ *
+ * @param target A jQuery object or a Widget instance.
+ */
+ appendTo: function (target) {
+ var self = this;
+ return this.__widgetRenderAndInsert(function (t) {
+ self.$el.appendTo(t);
+ }, target);
+ },
+ /**
+ * Renders the current widget and prepends it to the given jQuery object or Widget.
+ *
+ * @param target A jQuery object or a Widget instance.
+ */
+ prependTo: function (target) {
+ var self = this;
+ return this.__widgetRenderAndInsert(function (t) {
+ self.$el.prependTo(t);
+ }, target);
+ },
+ /**
+ * Renders the current widget and inserts it after to the given jQuery object or Widget.
+ *
+ * @param target A jQuery object or a Widget instance.
+ */
+ insertAfter: function (target) {
+ var self = this;
+ return this.__widgetRenderAndInsert(function (t) {
+ self.$el.insertAfter(t);
+ }, target);
+ },
+ /**
+ * Renders the current widget and inserts it before to the given jQuery object or Widget.
+ *
+ * @param target A jQuery object or a Widget instance.
+ */
+ insertBefore: function (target) {
+ var self = this;
+ return this.__widgetRenderAndInsert(function (t) {
+ self.$el.insertBefore(t);
+ }, target);
+ },
+ /**
+ * Attach the current widget to a dom element
+ *
+ * @param target A jQuery object or a Widget instance.
+ */
+ attachTo: function (target) {
+ var self = this;
+ this.setElement(target.$el || target);
+ return this.willStart().then(function () {
+ return self.start();
+ });
+ },
+ /**
+ * Renders the current widget and replaces the given jQuery object.
+ *
+ * @param target A jQuery object or a Widget instance.
+ */
+ replace: function (target) {
+ return this.__widgetRenderAndInsert(_.bind(function (t) {
+ this.$el.replaceAll(t);
+ }, this), target);
+ },
+ __widgetRenderAndInsert: function (insertion, target) {
+ var self = this;
+ return this.willStart().then(function () {
+ self.renderElement();
+ insertion(target);
+ return self.start();
+ });
+ },
+ /**
+ * Method called after rendering. Mostly used to bind actions, perform asynchronous
+ * calls, etc...
+ *
+ * By convention, this method should return an object that can be passed to $.when()
+ * to inform the caller when this widget has been initialized.
+ *
+ * @returns {jQuery.Deferred or any}
+ */
+ start: function () {
+ return $.when();
+ },
+ /**
+ * Renders the element. The default implementation renders the widget using QWeb,
+ * `this.template` must be defined. The context given to QWeb contains the "widget"
+ * key that references `this`.
+ */
+ renderElement: function () {
+ var $el;
+ if (this.template) {
+ $el = $(core.qweb.render(this.template, {widget: this}).trim());
+ } else {
+ $el = this._make_descriptive();
+ }
+ this.replaceElement($el);
+ },
+ /**
+ * Re-sets the widget's root element and replaces the old root element
+ * (if any) by the new one in the DOM.
+ *
+ * @param {HTMLElement | jQuery} $el
+ * @returns {*} this
+ */
+ replaceElement: function ($el) {
+ var $oldel = this.$el;
+ this.setElement($el);
+ if ($oldel && !$oldel.is(this.$el)) {
+ if ($oldel.length > 1) {
+ $oldel.wrapAll('<div/>');
+ $oldel.parent().replaceWith(this.$el);
+ } else {
+ $oldel.replaceWith(this.$el);
+ }
+ }
+ return this;
+ },
+ /**
+ * Re-sets the widget's root element (el/$el/$el).
+ *
+ * Includes:
+ * * re-delegating events
+ * * re-binding sub-elements
+ * * if the widget already had a root element, replacing the pre-existing
+ * element in the DOM
+ *
+ * @param {HTMLElement | jQuery} element new root element for the widget
+ * @return {*} this
+ */
+ setElement: function (element) {
+ // NB: completely useless, as WidgetMixin#init creates a $el
+ // always
+ if (this.$el) {
+ this.undelegateEvents();
+ }
+
+ this.$el = (element instanceof $) ? element : $(element);
+ this.el = this.$el[0];
+
+ this.delegateEvents();
+
+ return this;
+ },
+ /**
+ * Utility function to build small DOM elements.
+ *
+ * @param {String} tagName name of the DOM element to create
+ * @param {Object} [attributes] map of DOM attributes to set on the element
+ * @param {String} [content] HTML content to set on the element
+ * @return {Element}
+ */
+ make: function (tagName, attributes, content) {
+ var el = document.createElement(tagName);
+ if (!_.isEmpty(attributes)) {
+ $(el).attr(attributes);
+ }
+ if (content) {
+ $(el).html(content);
+ }
+ return el;
+ },
+ /**
+ * Makes a potential root element from the declarative builder of the
+ * widget
+ *
+ * @return {jQuery}
+ * @private
+ */
+ _make_descriptive: function () {
+ var attrs = _.extend({}, this.attributes || {});
+ if (this.id) { attrs.id = this.id; }
+ if (this.className) { attrs['class'] = this.className; }
+ return $(this.make(this.tagName, attrs));
+ },
+ delegateEvents: function () {
+ var events = this.events;
+ if (_.isEmpty(events)) { return; }
+
+ for(var key in events) {
+ if (!events.hasOwnProperty(key)) { continue; }
+
+ var method = this.proxy(events[key]);
+
+ var match = /^(\S+)(\s+(.*))?$/.exec(key);
+ var event = match[1];
+ var selector = match[3];
+
+ event += '.widget_events';
+ if (!selector) {
+ this.$el.on(event, method);
+ } else {
+ this.$el.on(event, selector, method);
+ }
+ }
+ },
+ delegateCustomEvents: function () {
+ if (_.isEmpty(this.custom_events)) { return; }
+ for (var key in this.custom_events) {
+ if (!this.custom_events.hasOwnProperty(key)) { continue; }
+
+ var method = this.proxy(this.custom_events[key]);
+ this.on(key, this, method);
+ }
+ },
+ undelegateEvents: function () {
+ this.$el.off('.widget_events');
+ },
+ /**
+ * Shortcut for ``this.$el.find(selector)``
+ *
+ * @param {String} selector CSS selector, rooted in $el
+ * @returns {jQuery} selector match
+ */
+ $: function (selector) {
+ if (selector === undefined)
+ return this.$el;
+ return this.$el.find(selector);
+ },
+ /**
+ * Displays the widget
+ */
+ do_show: function () {
+ this.$el.removeClass('o_hidden');
+ },
+ /**
+ * Hides the widget
+ */
+ do_hide: function () {
+ this.$el.addClass('o_hidden');
+ },
+ /**
+ * Displays or hides the widget
+ * @param {Boolean} [display] use true to show the widget or false to hide it
+ */
+ do_toggle: function (display) {
+ if (_.isBoolean(display)) {
+ display ? this.do_show() : this.do_hide();
+ } else {
+ this.$el.hasClass('o_hidden') ? this.do_show() : this.do_hide();
+ }
+ },
+ /**
+ * Proxies a method of the object, in order to keep the right ``this`` on
+ * method invocations.
+ *
+ * This method is similar to ``Function.prototype.bind`` or ``_.bind``, and
+ * even more so to ``jQuery.proxy`` with a fundamental difference: its
+ * resolution of the method being called is lazy, meaning it will use the
+ * method as it is when the proxy is called, not when the proxy is created.
+ *
+ * Other methods will fix the bound method to what it is when creating the
+ * binding/proxy, which is fine in most javascript code but problematic in
+ * OpenERP Web where developers may want to replace existing callbacks with
+ * theirs.
+ *
+ * The semantics of this precisely replace closing over the method call.
+ *
+ * @param {String|Function} method function or name of the method to invoke
+ * @returns {Function} proxied method
+ */
+ proxy: function (method) {
+ var self = this;
+ return function () {
+ var fn = (typeof method === 'string') ? self[method] : method;
+ if (fn === void 0) {
+ throw new Error("Couldn't find method '" + method + "' in widget " + self);
+ }
+ return fn.apply(self, arguments);
+ };
+ },
+});
+
+return Widget;
+
+});