[FIX] website: SEO js
[odoo/odoo.git] / addons / website / static / src / js / website.seo.js
1 (function () {
2     'use strict';
3
4     var website = openerp.website;
5     website.add_template_file('/website/static/src/xml/website.seo.xml');
6
7     website.EditorBar.include({
8         events: _.extend({}, website.EditorBar.prototype.events, {
9             'click a[data-action=promote-current-page]': 'launchSeo',
10         }),
11         launchSeo: function () {
12             (new website.seo.Configurator(this)).appendTo($(document.body));
13         },
14     });
15
16     website.seo = {};
17
18     function analyzeKeyword(htmlPage, keyword) {
19         return  htmlPage.isInTitle(keyword) ? {
20                     title: 'label label-primary',
21                     description: "This keyword is used in the page title",
22                 } : htmlPage.isInDescription(keyword) ? {
23                     title: 'label label-info',
24                     description: "This keyword is used in the page description",
25                 } : htmlPage.isInBody(keyword) ? {
26                     title: 'label label-info',
27                     description: "This keyword is used in the page content."
28                 } : {
29                     title: 'label label-default',
30                     description: "This keyword is not used anywhere on the page."
31                 };
32     }
33
34     website.seo.Suggestion = openerp.Widget.extend({
35         template: 'website.seo_suggestion',
36         events: {
37             'click .js_seo_suggestion': 'select',
38         },
39         init: function (parent, options) {
40             this.root = options.root;
41             this.keyword = options.keyword;
42             this.htmlPage = options.page;
43             this._super(parent);
44         },
45         start: function () {
46             this.htmlPage.on('title-changed', this, this.renderElement);
47             this.htmlPage.on('description-changed', this, this.renderElement);
48         },
49         analyze: function () {
50             return analyzeKeyword(this.htmlPage, this.keyword);
51         },
52         highlight: function () {
53             return this.analyze().title;
54         },
55         tooltip: function () {
56             return this.analyze().description;
57         },
58         select: function () {
59             this.trigger('selected', this.keyword);
60         },
61     });
62
63     website.seo.SuggestionList = openerp.Widget.extend({
64         template: 'website.seo_suggestion_list',
65         init: function (parent, options) {
66             this.root = options.root;
67             this.htmlPage = options.page;
68             this._super(parent);
69         },
70         start: function () {
71             this.refresh();
72         },
73         refresh: function () {
74             var self = this;
75             self.$el.append("Loading...");
76             function addSuggestions (list) {
77                 self.$el.empty();
78                 // TODO Improve algorithm + Ajust based on custom user keywords
79                 var regex = new RegExp(self.root, "gi");
80                 var cleanList = _.map(list, function (word) {
81                     return word.replace(regex, "").trim();
82                 });
83                 // TODO Order properly ?
84                 _.each(_.uniq(cleanList), function (keyword) {
85                     if (keyword) {
86                         var suggestion = new website.seo.Suggestion(self, {
87                             root: self.root,
88                             keyword: keyword,
89                             page: self.htmlPage,
90                         });
91                         suggestion.on('selected', self, function (word) {
92                             self.trigger('selected', word);
93                         });
94                         suggestion.appendTo(self.$el);
95                     }
96                 });
97             }
98             $.getJSON("http://suggest.hp.af.cm/suggest/"+encodeURIComponent(this.root + " "), addSuggestions);
99         },
100     });
101
102     website.seo.Keyword = openerp.Widget.extend({
103         template: 'website.seo_keyword',
104         events: {
105             'click a[data-action=remove-keyword]': 'destroy',
106         },
107         maxWordsPerKeyword: 4, // TODO Check
108         init: function (parent, options) {
109             this.keyword = options.word;
110             this.htmlPage = options.page;
111             this._super(parent);
112         },
113         start: function () {
114             this.htmlPage.on('title-changed', this, this.updateLabel);
115             this.htmlPage.on('description-changed', this, this.updateLabel);
116             this.suggestionList = new website.seo.SuggestionList(this, {
117                 root: this.keyword,
118                 page: this.htmlPage,
119             });
120             this.suggestionList.on('selected', this, function (word) {
121                 this.trigger('selected', word);
122             });
123             this.suggestionList.appendTo(this.$('.js_seo_keyword_suggestion'));
124         },
125         analyze: function () {
126             return analyzeKeyword(this.htmlPage, this.keyword);
127         },
128         highlight: function () {
129             return this.analyze().title;
130         },
131         tooltip: function () {
132             return this.analyze().description;
133         },
134         updateLabel: function () {
135             var cssClass = "oe_seo_keyword js_seo_keyword " + this.highlight();
136             this.$(".js_seo_keyword").attr('class', cssClass);
137             this.$(".js_seo_keyword").attr('title', this.tooltip());
138         },
139         destroy: function () {
140             this.trigger('removed');
141             this._super();
142         },
143     });
144
145     website.seo.KeywordList = openerp.Widget.extend({
146         template: 'website.seo_list',
147         maxKeywords: 10,
148         init: function (parent, options) {
149             this.htmlPage = options.page;
150             this._super(parent);
151         },
152         start: function () {
153             var self = this;
154             var existingKeywords = self.htmlPage.keywords();
155             if (existingKeywords.length > 0) {
156                 _.each(existingKeywords, function (word) {
157                     self.add.call(self, word);
158                 });
159             } else {
160                 var companyName = self.htmlPage.company().toLowerCase();
161                 if (companyName != 'yourcompany') {
162                     self.add(companyName);
163                 }
164             }
165         },
166         keywords: function () {
167             var result = [];
168             this.$('.js_seo_keyword').each(function () {
169                 result.push($(this).data('keyword'));
170             });
171             return result;
172         },
173         isFull: function () {
174             return this.keywords().length >= this.maxKeywords;
175         },
176         exists: function (word) {
177             return _.contains(this.keywords(), word);
178         },
179         add: function (candidate) {
180             var self = this;
181             // TODO Refine
182             var word = candidate ? candidate.replace(/[,;.:<>]+/g, " ").replace(/ +/g, " ").trim().toLowerCase() : "";
183             if (word && !self.isFull() && !self.exists(word)) {
184                 var keyword = new website.seo.Keyword(self, {
185                     word: word,
186                     page: this.htmlPage,
187                 });
188                 keyword.on('removed', self, function () {
189                    self.trigger('list-not-full');
190                    self.trigger('removed', word);
191                 });
192                 keyword.on('selected', self, function (word) {
193                     self.trigger('selected', word);
194                 });
195                 keyword.appendTo(self.$el);
196             }
197             if (self.isFull()) {
198                 self.trigger('list-full');
199             }
200         },
201     });
202
203     website.seo.Image = openerp.Widget.extend({
204         template: 'website.seo_image',
205         init: function (parent, options) {
206             this.src = options.src;
207             this.alt = options.alt;
208             this._super(parent);
209         },
210     });
211
212
213     website.seo.ImageList = openerp.Widget.extend({
214         init: function (parent, options) {
215             this.htmlPage = options.page;
216             this._super(parent);
217         },
218         start: function () {
219             var self = this;
220             this.htmlPage.images().each(function (index, image) {
221                 new website.seo.Image(self, image).appendTo(self.$el);
222             });
223         },
224         images: function () {
225             var result = [];
226             this.$('input').each(function () {
227                var $input = $(this);
228                result.push({
229                    src: $input.attr('src'),
230                    alt: $input.val(),
231                });
232             });
233             return result;
234         },
235         add: function (image) {
236             new website.seo.Image(this, image).appendTo(this.$el);
237         },
238     });
239
240     website.seo.Preview = openerp.Widget.extend({
241         template: 'website.seo_preview',
242         init: function (parent, options) {
243             this.title = options.title;
244             this.url = options.url;
245             this.description = options.description || "[ The description will be generated by google unless you specify one ]";
246             this._super(parent);
247         },
248     });
249
250     website.seo.HtmlPage = openerp.Class.extend(openerp.PropertiesMixin, {
251         url: function () {
252             var url = window.location.href;
253             var hashIndex = url.indexOf('#');
254             return hashIndex >= 0 ? url.substring(0, hashIndex) : url;
255         },
256         title: function () {
257             var $title = $('title');
258             return ($title.length > 0) && $title.text() && $title.text().trim();
259         },
260         changeTitle: function (title) {
261             // TODO create tag if missing
262             $('title').text(title);
263             this.trigger('title-changed', title);
264         },
265         description: function () {
266             var $description = $('meta[name=description]');
267             return ($description.length > 0) && ($description.attr('content') && $description.attr('content').trim());
268         },
269         changeDescription: function (description) {
270             // TODO create tag if missing
271             $('meta[name=description]').attr('content', description);
272             this.trigger('description-changed', description);
273         },
274         keywords: function () {
275             var $keywords = $('meta[name=keywords]');
276             var parsed = ($keywords.length > 0) && $keywords.attr('content') && $keywords.attr('content').split(",");
277             return (parsed && parsed[0]) ? parsed: [];
278         },
279         changeKeywords: function (keywords) {
280             // TODO create tag if missing
281             $('meta[name=keywords]').attr('content', keywords.join(","));
282             this.trigger('keywords-changed', keywords);
283         },
284         headers: function (tag) {
285             return $('#wrap '+tag).map(function () {
286                 return $(this).text();
287             });
288         },
289         images: function () {
290             return $('#wrap img').map(function () {
291                 var $img = $(this);
292                 return  {
293                     src: $img.attr('src'),
294                     alt: $img.attr('alt'),
295                 };
296             });
297         },
298         company: function () {
299             return $('html').attr('data-oe-company-name');
300         },
301         bodyText: function () {
302             return $('body').children().not('.js_seo_configuration').text();
303         },
304         isInBody: function (text) {
305             return new RegExp("\\b"+text+"\\b", "gi").test(this.bodyText());
306         },
307         isInTitle: function (text) {
308             return new RegExp("\\b"+text+"\\b", "gi").test(this.title());
309         },
310         isInDescription: function (text) {
311             return new RegExp("\\b"+text+"\\b", "gi").test(this.description());
312         },
313     });
314
315     website.seo.Tip = openerp.Widget.extend({
316         template: 'website.seo_tip',
317         events: {
318             'closed.bs.alert': 'destroy',
319         },
320         init: function (parent, options) {
321             this.message = options.message;
322             // cf. http://getbootstrap.com/components/#alerts
323             // success, info, warning or danger
324             this.type = options.type || 'info';
325             this._super(parent);
326         },
327     });
328
329     website.seo.Configurator = openerp.Widget.extend({
330         template: 'website.seo_configuration',
331         events: {
332             'keyup input[name=seo_page_keywords]': 'confirmKeyword',
333             'keyup input[name=seo_page_title]': 'titleChanged',
334             'keyup textarea[name=seo_page_description]': 'descriptionChanged',
335             'click button[data-action=add]': 'addKeyword',
336             'click button[data-action=update]': 'update',
337             'hidden.bs.modal': 'destroy',
338         },
339         canEditTitle: false,
340         canEditDescription: false,
341         canEditKeywords: false,
342         maxTitleSize: 65,
343         maxDescriptionSize: 150,
344         start: function () {
345             var self = this;
346             var $modal = self.$el;
347             var htmlPage = this.htmlPage = new website.seo.HtmlPage();
348             $modal.find('.js_seo_page_url').text(htmlPage.url());
349             $modal.find('input[name=seo_page_title]').val(htmlPage.title());
350             $modal.find('textarea[name=seo_page_description]').val(htmlPage.description());
351             // self.suggestImprovements();
352             // self.imageList = new website.seo.ImageList(self, { page: htmlPage });
353             // if (htmlPage.images().length === 0) {
354             //     $modal.find('.js_image_section').remove();
355             // } else {
356             //     self.imageList.appendTo($modal.find('.js_seo_image_list'));
357             // }
358             self.keywordList = new website.seo.KeywordList(self, { page: htmlPage });
359             self.keywordList.on('list-full', self, function () {
360                 $modal.find('input[name=seo_page_keywords]')
361                     .attr('readonly', "readonly")
362                     .attr('placeholder', "Remove a keyword first");
363                 $modal.find('button[data-action=add]')
364                     .prop('disabled', true).addClass('disabled');
365             });
366             self.keywordList.on('list-not-full', self, function () {
367                 $modal.find('input[name=seo_page_keywords]')
368                     .removeAttr('readonly').attr('placeholder', "");
369                 $modal.find('button[data-action=add]')
370                     .prop('disabled', false).removeClass('disabled');
371             });
372             self.keywordList.on('selected', self, function (word) {
373                 self.keywordList.add(word);
374             });
375             self.keywordList.appendTo($modal.find('.js_seo_keywords_list'));
376             self.disableUnsavableFields();
377             self.renderPreview();
378             $modal.modal();
379         },
380         disableUnsavableFields: function () {
381             var self = this;
382             var $modal = self.$el;
383             self.loadMetaData().then(function (data) {
384                 self.canEditTitle = data && ('website_meta_title' in data);
385                 self.canEditDescription = data && ('website_meta_description' in data);
386                 self.canEditKeywords = data && ('website_meta_keywords' in data);
387                 if (!self.canEditTitle) {
388                     $modal.find('input[name=seo_page_title]').attr('disabled', true);
389                 }
390                 if (!self.canEditDescription) {
391                     $modal.find('textarea[name=seo_page_description]').attr('disabled', true);
392                 }
393                 if (!self.canEditTitle && !self.canEditDescription && !self.canEditKeywords) {
394                     $modal.find('button[data-action=update]').attr('disabled', true);
395                 }
396             });
397         },
398         suggestImprovements: function () {
399             var tips = [];
400             var self = this;
401             function displayTip(message, type) {
402                 new website.seo.Tip(self, {
403                    message: message,
404                    type: type,
405                 }).appendTo(self.$('.js_seo_tips'));
406             }
407             var htmlPage = this.htmlPage;
408
409             // Add message suggestions at the top of the dialog
410             // if necessary....
411             // if (htmlPage.headers('h1').length === 0) {
412             //     tips.push({
413             //         type: 'warning',
414             //         message: "This page seems to be missing a title.",
415             //     });
416             // }
417
418             if (tips.length > 0) {
419                 _.each(tips, function (tip) {
420                     displayTip(tip.message, tip.type);
421                 });
422             }
423         },
424         confirmKeyword: function (e) {
425             if (e.keyCode == 13) {
426                 this.addKeyword();
427             }
428         },
429         addKeyword: function (word) {
430             var $input = this.$('input[name=seo_page_keywords]');
431             var keyword = _.isString(word) ? word : $input.val();
432             this.keywordList.add(keyword);
433             $input.val("");
434         },
435         update: function () {
436             var self = this;
437             var data = {};
438             if (self.canEditTitle) {
439                 data.website_meta_title = self.htmlPage.title();
440             }
441             if (self.canEditDescription) {
442                 data.website_meta_description = self.htmlPage.description();
443             }
444             if (self.canEditKeywords) {
445                 data.website_meta_keywords = self.keywordList.keywords().join(", ");
446             }
447             self.saveMetaData(data).then(function () {
448                self.$el.modal('hide');
449             });
450         },
451         getMainObject: function () {
452             var repr = $('html').data('main-object');
453             var m = repr.match(/.+\((.+), (\d+)\)/);
454             if (!m) {
455                 return null;
456             } else {
457                 return {
458                     model: m[1],
459                     id: m[2]|0
460                 };
461             }
462         },
463         loadMetaData: function () {
464             var self = this;
465             var obj = this.getMainObject();
466             var def = $.Deferred();
467             if (!obj) {
468                 // return $.Deferred().reject(new Error("No main_object was found."));
469                 def.resolve(null);
470             } else {
471                 var fields = ['website_meta_title', 'website_meta_description', 'website_meta_keywords'];
472                 var model = website.session.model(obj.model);
473                 model.call('read', [[obj.id], fields, website.get_context()]).then(function (data) {
474                     if (data.length) {
475                         var meta = data[0];
476                         meta.model = obj.model;
477                         def.resolve(meta);
478                     } else {
479                         def.resolve(null);
480                     }
481                 }).fail(function () {
482                     def.reject();
483                 });
484             }
485             return def;
486         },
487         saveMetaData: function (data) {
488             var obj = this.getMainObject();
489             if (!obj) {
490                 return $.Deferred().reject();
491             } else {
492                 var model = website.session.model(obj.model);
493                 return model.call('write', [[obj.id], data, website.get_context()]);
494             }
495         },
496         titleChanged: function () {
497             var self = this;
498             setTimeout(function () {
499                 var title = self.$('input[name=seo_page_title]').val();
500                 self.htmlPage.changeTitle(title);
501                 self.renderPreview();
502             }, 0);
503         },
504         descriptionChanged: function () {
505             var self = this;
506             setTimeout(function () {
507                 var description = self.$('textarea[name=seo_page_description]').attr('value');
508                 self.htmlPage.changeDescription(description);
509                 self.renderPreview();
510             }, 0);
511         },
512         renderPreview: function () {
513             var preview = new website.seo.Preview(this, {
514                 title: this.htmlPage.title(),
515                 description: this.htmlPage.description(),
516                 url: this.htmlPage.url(),
517             });
518             var $preview = this.$('.js_seo_preview');
519             $preview.empty();
520             preview.appendTo($preview);
521         },
522         destroy: function () {
523             this.htmlPage.changeKeywords(this.keywordList.keywords());
524             this._super();
525         },
526     });
527 })();