summaryrefslogtreecommitdiff
path: root/addons/website/static/src/js/menu/seo.js
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/website/static/src/js/menu/seo.js
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/website/static/src/js/menu/seo.js')
-rw-r--r--addons/website/static/src/js/menu/seo.js902
1 files changed, 902 insertions, 0 deletions
diff --git a/addons/website/static/src/js/menu/seo.js b/addons/website/static/src/js/menu/seo.js
new file mode 100644
index 00000000..b724bc1a
--- /dev/null
+++ b/addons/website/static/src/js/menu/seo.js
@@ -0,0 +1,902 @@
+odoo.define('website.seo', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Class = require('web.Class');
+var Dialog = require('web.Dialog');
+var mixins = require('web.mixins');
+var rpc = require('web.rpc');
+var Widget = require('web.Widget');
+var weWidgets = require('wysiwyg.widgets');
+var websiteNavbarData = require('website.navbar');
+
+var _t = core._t;
+
+// This replaces \b, because accents(e.g. à, é) are not seen as word boundaries.
+// Javascript \b is not unicode aware, and words beginning or ending by accents won't match \b
+var WORD_SEPARATORS_REGEX = '([\\u2000-\\u206F\\u2E00-\\u2E7F\'!"#\\$%&\\(\\)\\*\\+,\\-\\.\\/:;<=>\\?¿¡@\\[\\]\\^_`\\{\\|\\}~\\s]+|^|$)';
+
+var Suggestion = Widget.extend({
+ template: 'website.seo_suggestion',
+ xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
+ events: {
+ 'click .o_seo_suggestion': 'select',
+ },
+
+ init: function (parent, options) {
+ this.keyword = options.keyword;
+ this._super(parent);
+ },
+ select: function () {
+ this.trigger('selected', this.keyword);
+ },
+});
+
+var SuggestionList = Widget.extend({
+ template: 'website.seo_suggestion_list',
+ xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
+
+ init: function (parent, options) {
+ this.root = options.root;
+ this.language = options.language;
+ this.htmlPage = options.htmlPage;
+ this._super(parent);
+ },
+ start: function () {
+ this.refresh();
+ },
+ refresh: function () {
+ var self = this;
+ self.$el.append(_t("Loading..."));
+ var context;
+ this.trigger_up('context_get', {
+ callback: function (ctx) {
+ context = ctx;
+ },
+ });
+ var language = self.language || context.lang.toLowerCase();
+ this._rpc({
+ route: '/website/seo_suggest',
+ params: {
+ keywords: self.root,
+ lang: language,
+ },
+ }).then(function (keyword_list) {
+ self.addSuggestions(JSON.parse(keyword_list));
+ });
+ },
+ addSuggestions: function (keywords) {
+ var self = this;
+ self.$el.empty();
+ // TODO Improve algorithm + Ajust based on custom user keywords
+ var regex = new RegExp(WORD_SEPARATORS_REGEX + self.root + WORD_SEPARATORS_REGEX, 'gi');
+ keywords = _.map(_.uniq(keywords), function (word) {
+ return word.replace(regex, '').trim();
+ });
+ // TODO Order properly ?
+ _.each(keywords, function (keyword) {
+ if (keyword) {
+ var suggestion = new Suggestion(self, {
+ keyword: keyword,
+ });
+ suggestion.on('selected', self, function (word, language) {
+ self.trigger('selected', word, language);
+ });
+ suggestion.appendTo(self.$el);
+ }
+ });
+ },
+});
+
+var Keyword = Widget.extend({
+ template: 'website.seo_keyword',
+ xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
+ events: {
+ 'click a[data-action=remove-keyword]': 'destroy',
+ },
+
+ init: function (parent, options) {
+ this.keyword = options.word;
+ this.language = options.language;
+ this.htmlPage = options.htmlPage;
+ this.used_h1 = this.htmlPage.isInHeading1(this.keyword);
+ this.used_h2 = this.htmlPage.isInHeading2(this.keyword);
+ this.used_content = this.htmlPage.isInBody(this.keyword);
+ this._super(parent);
+ },
+ start: function () {
+ var self = this;
+ this.$('.o_seo_keyword_suggestion').empty();
+ this.suggestionList = new SuggestionList(this, {
+ root: this.keyword,
+ language: this.language,
+ htmlPage: this.htmlPage,
+ });
+ this.suggestionList.on('selected', this, function (word, language) {
+ this.trigger('selected', word, language);
+ });
+ return this.suggestionList.appendTo(this.$('.o_seo_keyword_suggestion')).then(function() {
+ self.htmlPage.on('title-changed', self, self._updateTitle);
+ self.htmlPage.on('description-changed', self, self._updateDescription);
+ self._updateTitle();
+ self._updateDescription();
+ });
+ },
+ destroy: function () {
+ this.trigger('removed');
+ this._super();
+ },
+ _updateTitle: function () {
+ var $title = this.$('.js_seo_keyword_title');
+ if (this.htmlPage.isInTitle(this.keyword)) {
+ $title.css('visibility', 'visible');
+ } else {
+ $title.css('visibility', 'hidden');
+ }
+ },
+ _updateDescription: function () {
+ var $description = this.$('.js_seo_keyword_description');
+ if (this.htmlPage.isInDescription(this.keyword)) {
+ $description.css('visibility', 'visible');
+ } else {
+ $description.css('visibility', 'hidden');
+ }
+ },
+});
+
+var KeywordList = Widget.extend({
+ template: 'website.seo_list',
+ xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
+ maxKeywords: 10,
+
+ init: function (parent, options) {
+ this.htmlPage = options.htmlPage;
+ this._super(parent);
+ },
+ start: function () {
+ var self = this;
+ var existingKeywords = self.htmlPage.keywords();
+ if (existingKeywords.length > 0) {
+ _.each(existingKeywords, function (word) {
+ self.add.call(self, word);
+ });
+ }
+ },
+ keywords: function () {
+ var result = [];
+ this.$('.js_seo_keyword').each(function () {
+ result.push($(this).data('keyword'));
+ });
+ return result;
+ },
+ isFull: function () {
+ return this.keywords().length >= this.maxKeywords;
+ },
+ exists: function (word) {
+ return _.contains(this.keywords(), word);
+ },
+ add: async function (candidate, language) {
+ var self = this;
+ // TODO Refine
+ var word = candidate ? candidate.replace(/[,;.:<>]+/g, ' ').replace(/ +/g, ' ').trim().toLowerCase() : '';
+ if (word && !self.isFull() && !self.exists(word)) {
+ var keyword = new Keyword(self, {
+ word: word,
+ language: language,
+ htmlPage: this.htmlPage,
+ });
+ keyword.on('removed', self, function () {
+ self.trigger('list-not-full');
+ self.trigger('content-updated', true);
+ });
+ keyword.on('selected', self, function (word, language) {
+ self.trigger('selected', word, language);
+ });
+ await keyword.appendTo(self.$el);
+ }
+ if (self.isFull()) {
+ self.trigger('list-full');
+ }
+ self.trigger('content-updated');
+ },
+});
+
+var Preview = Widget.extend({
+ template: 'website.seo_preview',
+ xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
+
+ init: function (parent, options) {
+ this.title = options.title;
+ this.url = options.url;
+ this.description = options.description;
+ if (this.description.length > 160) {
+ this.description = this.description.substring(0, 159) + '…';
+ }
+ this._super(parent);
+ },
+});
+
+var HtmlPage = Class.extend(mixins.PropertiesMixin, {
+ init: function () {
+ mixins.PropertiesMixin.init.call(this);
+ this.initTitle = this.title();
+ this.defaultTitle = $('meta[name="default_title"]').attr('content');
+ this.initDescription = this.description();
+ },
+ url: function () {
+ return window.location.origin + window.location.pathname;
+ },
+ title: function () {
+ return $('title').text().trim();
+ },
+ changeTitle: function (title) {
+ // TODO create tag if missing
+ $('title').text(title.trim() || this.defaultTitle);
+ this.trigger('title-changed', title);
+ },
+ description: function () {
+ return ($('meta[name=description]').attr('content') || '').trim();
+ },
+ changeDescription: function (description) {
+ // TODO create tag if missing
+ $('meta[name=description]').attr('content', description);
+ this.trigger('description-changed', description);
+ },
+ keywords: function () {
+ var $keywords = $('meta[name=keywords]');
+ var parsed = ($keywords.length > 0) && $keywords.attr('content') && $keywords.attr('content').split(',');
+ return (parsed && parsed[0]) ? parsed: [];
+ },
+ changeKeywords: function (keywords) {
+ // TODO create tag if missing
+ $('meta[name=keywords]').attr('content', keywords.join(','));
+ },
+ headers: function (tag) {
+ return $('#wrap '+tag).map(function () {
+ return $(this).text();
+ });
+ },
+ getOgMeta: function () {
+ var ogImageUrl = $('meta[property="og:image"]').attr('content');
+ var title = $('meta[property="og:title"]').attr('content');
+ var description = $('meta[property="og:description"]').attr('content');
+ return {
+ ogImageUrl: ogImageUrl && ogImageUrl.replace(window.location.origin, ''),
+ metaTitle: title,
+ metaDescription: description,
+ };
+ },
+ images: function () {
+ return $('#wrap img').filter(function () {
+ return this.naturalHeight >= 200 && this.naturalWidth >= 200;
+ }).map(function () {
+ return {
+ src: this.getAttribute('src'),
+ alt: this.getAttribute('alt'),
+ };
+ });
+ },
+ company: function () {
+ return $('html').attr('data-oe-company-name');
+ },
+ bodyText: function () {
+ return $('body').children().not('.oe_seo_configuration').text();
+ },
+ heading1: function () {
+ return $('body').children().not('.oe_seo_configuration').find('h1').text();
+ },
+ heading2: function () {
+ return $('body').children().not('.oe_seo_configuration').find('h2').text();
+ },
+ isInBody: function (text) {
+ return new RegExp(WORD_SEPARATORS_REGEX + text + WORD_SEPARATORS_REGEX, 'gi').test(this.bodyText());
+ },
+ isInTitle: function (text) {
+ return new RegExp(WORD_SEPARATORS_REGEX + text + WORD_SEPARATORS_REGEX, 'gi').test(this.title());
+ },
+ isInDescription: function (text) {
+ return new RegExp(WORD_SEPARATORS_REGEX + text + WORD_SEPARATORS_REGEX, 'gi').test(this.description());
+ },
+ isInHeading1: function (text) {
+ return new RegExp(WORD_SEPARATORS_REGEX + text + WORD_SEPARATORS_REGEX, 'gi').test(this.heading1());
+ },
+ isInHeading2: function (text) {
+ return new RegExp(WORD_SEPARATORS_REGEX + text + WORD_SEPARATORS_REGEX, 'gi').test(this.heading2());
+ },
+});
+
+var MetaTitleDescription = Widget.extend({
+ // Form and preview for SEO meta title and meta description
+ //
+ // We only want to show an alert for "description too small" on those cases
+ // - at init and the description is not empty
+ // - we reached past the minimum and went back to it
+ // - focus out of the field
+ // Basically we don't want the too small alert when the field is empty and
+ // we start typing on it.
+ template: 'website.seo_meta_title_description',
+ xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
+ events: {
+ 'input input[name=website_meta_title]': '_titleChanged',
+ 'input input[name=website_seo_name]': '_seoNameChanged',
+ 'input textarea[name=website_meta_description]': '_descriptionOnInput',
+ 'change textarea[name=website_meta_description]': '_descriptionOnChange',
+ },
+ maxRecommendedDescriptionSize: 300,
+ minRecommendedDescriptionSize: 50,
+ showDescriptionTooSmall: false,
+
+ /**
+ * @override
+ */
+ init: function (parent, options) {
+ this.htmlPage = options.htmlPage;
+ this.canEditTitle = !!options.canEditTitle;
+ this.canEditDescription = !!options.canEditDescription;
+ this.canEditUrl = !!options.canEditUrl;
+ this.isIndexed = !!options.isIndexed;
+ this.seoName = options.seoName;
+ this.seoNameDefault = options.seoNameDefault;
+ this.seoNameHelp = options.seoNameHelp;
+ this.previewDescription = options.previewDescription;
+ this._super(parent, options);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$title = this.$('input[name=website_meta_title]');
+ this.$seoName = this.$('input[name=website_seo_name]');
+ this.$seoNamePre = this.$('span.seo_name_pre');
+ this.$seoNamePost = this.$('span.seo_name_post');
+ this.$description = this.$('textarea[name=website_meta_description]');
+ this.$warning = this.$('div#website_meta_description_warning');
+ this.$preview = this.$('.js_seo_preview');
+
+ if (!this.canEditTitle) {
+ this.$title.attr('disabled', true);
+ }
+ if (!this.canEditDescription) {
+ this.$description.attr('disabled', true);
+ }
+ if (this.htmlPage.title().trim() !== this.htmlPage.defaultTitle.trim()) {
+ this.$title.val(this.htmlPage.title());
+ }
+ if (this.htmlPage.description().trim() !== this.previewDescription) {
+ this.$description.val(this.htmlPage.description());
+ }
+
+ if (this.canEditUrl) {
+ this.previousSeoName = this.seoName;
+ this.$seoName.val(this.seoName);
+ this.$seoName.attr('placeholder', this.seoNameDefault);
+ // make slug editable with input group for static text
+ const splitsUrl = window.location.pathname.split(this.previousSeoName || this.seoNameDefault);
+ this.$seoNamePre.text(splitsUrl[0]);
+ this.$seoNamePost.text(splitsUrl.slice(-1)[0]); // at least the -id theorically
+ }
+ this._descriptionOnChange();
+ },
+ /**
+ * Get the current title
+ */
+ getTitle: function () {
+ return this.$title.val().trim() || this.htmlPage.defaultTitle;
+ },
+ /**
+ * Get the potential new url with custom seoName as slug.
+ I can differ after save if slug JS != slug Python, but it provide an idea for the preview
+ */
+ getUrl: function () {
+ const path = window.location.pathname.replace(
+ this.previousSeoName || this.seoNameDefault,
+ (this.$seoName.length && this.$seoName.val() ? this.$seoName.val().trim() : this.$seoName.attr('placeholder'))
+ );
+ return window.location.origin + path
+ },
+ /**
+ * Get the current description
+ */
+ getDescription: function () {
+ return this.getRealDescription() || this.previewDescription;
+ },
+ /**
+ * Get the current description chosen by the user
+ */
+ getRealDescription: function () {
+ return this.$description.val() || '';
+ },
+ /**
+ * @private
+ */
+ _titleChanged: function () {
+ var self = this;
+ self._renderPreview();
+ self.trigger('title-changed');
+ },
+ /**
+ * @private
+ */
+ _seoNameChanged: function () {
+ var self = this;
+ // don't use _, because we need to keep trailing whitespace during edition
+ const slugified = this.$seoName.val().toString().toLowerCase()
+ .replace(/\s+/g, '-') // Replace spaces with -
+ .replace(/[^\w\-]+/g, '-') // Remove all non-word chars
+ .replace(/\-\-+/g, '-'); // Replace multiple - with single -
+ this.$seoName.val(slugified);
+ self._renderPreview();
+ },
+ /**
+ * @private
+ */
+ _descriptionOnChange: function () {
+ this.showDescriptionTooSmall = true;
+ this._descriptionOnInput();
+ },
+ /**
+ * @private
+ */
+ _descriptionOnInput: function () {
+ var length = this.getDescription().length;
+
+ if (length >= this.minRecommendedDescriptionSize) {
+ this.showDescriptionTooSmall = true;
+ } else if (length === 0) {
+ this.showDescriptionTooSmall = false;
+ }
+
+ if (length > this.maxRecommendedDescriptionSize) {
+ this.$warning.text(_t('Your description looks too long.')).show();
+ } else if (this.showDescriptionTooSmall && length < this.minRecommendedDescriptionSize) {
+ this.$warning.text(_t('Your description looks too short.')).show();
+ } else {
+ this.$warning.hide();
+ }
+
+ this._renderPreview();
+ this.trigger('description-changed');
+ },
+ /**
+ * @private
+ */
+ _renderPreview: function () {
+ var indexed = this.isIndexed;
+ var preview = "";
+ if (indexed) {
+ preview = new Preview(this, {
+ title: this.getTitle(),
+ description: this.getDescription(),
+ url: this.getUrl(),
+ });
+ } else {
+ preview = new Preview(this, {
+ description: _t("You have hidden this page from search results. It won't be indexed by search engines."),
+ });
+ }
+ this.$preview.empty();
+ preview.appendTo(this.$preview);
+ },
+});
+
+var MetaKeywords = Widget.extend({
+ // Form and table for SEO meta keywords
+ template: 'website.seo_meta_keywords',
+ xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
+ events: {
+ 'keyup input[name=website_meta_keywords]': '_confirmKeyword',
+ 'click button[data-action=add]': '_addKeyword',
+ },
+
+ init: function (parent, options) {
+ this.htmlPage = options.htmlPage;
+ this._super(parent, options);
+ },
+ start: function () {
+ var self = this;
+ this.$input = this.$('input[name=website_meta_keywords]');
+ this.keywordList = new KeywordList(this, {htmlPage: this.htmlPage});
+ this.keywordList.on('list-full', this, function () {
+ self.$input.attr({
+ readonly: 'readonly',
+ placeholder: "Remove a keyword first"
+ });
+ self.$('button[data-action=add]').prop('disabled', true).addClass('disabled');
+ });
+ this.keywordList.on('list-not-full', this, function () {
+ self.$input.removeAttr('readonly').attr('placeholder', "");
+ self.$('button[data-action=add]').prop('disabled', false).removeClass('disabled');
+ });
+ this.keywordList.on('selected', this, function (word, language) {
+ self.keywordList.add(word, language);
+ });
+ this.keywordList.on('content-updated', this, function (removed) {
+ self._updateTable(removed);
+ });
+ return this.keywordList.insertAfter(this.$('.table thead')).then(function() {
+ self._getLanguages();
+ self._updateTable();
+ });
+ },
+ _addKeyword: function () {
+ var $language = this.$('select[name=seo_page_language]');
+ var keyword = this.$input.val();
+ var language = $language.val().toLowerCase();
+ this.keywordList.add(keyword, language);
+ this.$input.val('').focus();
+ },
+ _confirmKeyword: function (e) {
+ if (e.keyCode === 13) {
+ this._addKeyword();
+ }
+ },
+ _getLanguages: function () {
+ var self = this;
+ var context;
+ this.trigger_up('context_get', {
+ callback: function (ctx) {
+ context = ctx;
+ },
+ });
+ this._rpc({
+ route: '/website/get_languages',
+ }).then(function (data) {
+ self.$('#language-box').html(core.qweb.render('Configurator.language_promote', {
+ 'language': data,
+ 'def_lang': context.lang
+ }));
+ });
+ },
+ /*
+ * Show the table if there is at least one keyword. Hide it otherwise.
+ *
+ * @private
+ * @param {boolean} removed: a keyword is about to be removed,
+ * we need to exclude it from the count
+ */
+ _updateTable: function (removed) {
+ var min = removed ? 1 : 0;
+ if (this.keywordList.keywords().length > min) {
+ this.$('table').show();
+ } else {
+ this.$('table').hide();
+ }
+ },
+});
+
+var MetaImageSelector = Widget.extend({
+ template: 'website.seo_meta_image_selector',
+ xmlDependencies: ['/website/static/src/xml/website.seo.xml'],
+ events: {
+ 'click .o_meta_img_upload': '_onClickUploadImg',
+ 'click .o_meta_img': '_onClickSelectImg',
+ },
+ /**
+ * @override
+ * @param {widget} parent
+ * @param {Object} data
+ */
+ init: function (parent, data) {
+ this.metaTitle = data.title || '';
+ this.activeMetaImg = data.metaImg;
+ this.serverUrl = data.htmlpage.url();
+ const imgField = data.hasSocialDefaultImage ? 'social_default_image' : 'logo';
+ data.pageImages.unshift(_.str.sprintf('/web/image/website/%s/%s', odoo.session_info.website_id, imgField));
+ this.images = _.uniq(data.pageImages);
+ this.customImgUrl = _.contains(
+ data.pageImages.map((img)=> new URL(img, window.location.origin).pathname),
+ new URL(data.metaImg, window.location.origin).pathname)
+ ? false : data.metaImg;
+ this.previewDescription = data.previewDescription;
+ this._setDescription(this.previewDescription);
+ this._super(parent);
+ },
+ setTitle: function (title) {
+ this.metaTitle = title;
+ this._updateTemplateBody();
+ },
+ setDescription: function (description) {
+ this._setDescription(description);
+ this._updateTemplateBody();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Set the description, applying ellipsis if too long.
+ *
+ * @private
+ */
+ _setDescription: function (description) {
+ this.metaDescription = description || this.previewDescription;
+ if (this.metaDescription.length > 160) {
+ this.metaDescription = this.metaDescription.substring(0, 159) + '…';
+ }
+ },
+
+ /**
+ * Update template.
+ *
+ * @private
+ */
+ _updateTemplateBody: function () {
+ this.$el.empty();
+ this.images = _.uniq(this.images);
+ this.$el.append(core.qweb.render('website.og_image_body', {widget: this}));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a select image from list -> change the preview accordingly.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickSelectImg: function (ev) {
+ var $img = $(ev.currentTarget);
+ this.activeMetaImg = $img.find('img').attr('src');
+ this._updateTemplateBody();
+ },
+ /**
+ * Open a mediaDialog to select/upload image.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickUploadImg: function (ev) {
+ var self = this;
+ var $image = $('<img/>');
+ var mediaDialog = new weWidgets.MediaDialog(this, {
+ onlyImages: true,
+ res_model: 'ir.ui.view',
+ }, $image[0]);
+ mediaDialog.open();
+ mediaDialog.on('save', this, function (image) {
+ self.activeMetaImg = image.src;
+ self.customImgUrl = image.src;
+ self._updateTemplateBody();
+ });
+ },
+});
+
+var SeoConfigurator = Dialog.extend({
+ template: 'website.seo_configuration',
+ xmlDependencies: Dialog.prototype.xmlDependencies.concat(
+ ['/website/static/src/xml/website.seo.xml']
+ ),
+ canEditTitle: false,
+ canEditDescription: false,
+ canEditKeywords: false,
+ canEditLanguage: false,
+ canEditUrl: false,
+
+ init: function (parent, options) {
+ options = options || {};
+ _.defaults(options, {
+ title: _t('Optimize SEO'),
+ buttons: [
+ {text: _t('Save'), classes: 'btn-primary', click: this.update},
+ {text: _t('Discard'), close: true},
+ ],
+ });
+
+ this._super(parent, options);
+ },
+ start: function () {
+ var self = this;
+
+ this.$modal.addClass('oe_seo_configuration');
+
+ this.htmlPage = new HtmlPage();
+
+ this.disableUnsavableFields().then(function () {
+ // Image selector
+ self.metaImageSelector = new MetaImageSelector(self, {
+ htmlpage: self.htmlPage,
+ hasSocialDefaultImage: self.hasSocialDefaultImage,
+ title: self.htmlPage.getOgMeta().metaTitle,
+ metaImg: self.metaImg || self.htmlPage.getOgMeta().ogImageUrl,
+ pageImages: _.pluck(self.htmlPage.images().get(), 'src'),
+ previewDescription: _t('The description will be generated by social media based on page content unless you specify one.'),
+ });
+ self.metaImageSelector.appendTo(self.$('.js_seo_image'));
+
+ // title and description
+ self.metaTitleDescription = new MetaTitleDescription(self, {
+ htmlPage: self.htmlPage,
+ canEditTitle: self.canEditTitle,
+ canEditDescription: self.canEditDescription,
+ canEditUrl: self.canEditUrl,
+ isIndexed: self.isIndexed,
+ previewDescription: _t('The description will be generated by search engines based on page content unless you specify one.'),
+ seoNameHelp: _t('This value will be escaped to be compliant with all major browsers and used in url. Keep it empty to use the default name of the record.'),
+ seoName: self.seoName, // 'my-custom-display-name' or ''
+ seoNameDefault: self.seoNameDefault, // 'display-name'
+ });
+ self.metaTitleDescription.on('title-changed', self, self.titleChanged);
+ self.metaTitleDescription.on('description-changed', self, self.descriptionChanged);
+ self.metaTitleDescription.appendTo(self.$('.js_seo_meta_title_description'));
+
+ // keywords
+ self.metaKeywords = new MetaKeywords(self, {htmlPage: self.htmlPage});
+ self.metaKeywords.appendTo(self.$('.js_seo_meta_keywords'));
+ });
+ },
+ /*
+ * Reset meta tags to their initial value if not saved.
+ *
+ * @private
+ */
+ destroy: function () {
+ if (!this.savedData) {
+ this.htmlPage.changeTitle(this.htmlPage.initTitle);
+ this.htmlPage.changeDescription(this.htmlPage.initDescription);
+ }
+ this._super.apply(this, arguments);
+ },
+ disableUnsavableFields: function () {
+ var self = this;
+ return this.loadMetaData().then(function (data) {
+ // We only need a reload for COW when the copy is happening, therefore:
+ // - no reload if we are not editing a view (condition: website_id === undefined)
+ // - reload if generic page (condition: website_id === false)
+ self.reloadOnSave = data.website_id === undefined ? false : !data.website_id;
+ //If website.page, hide the google preview & tell user his page is currently unindexed
+ self.isIndexed = (data && ('website_indexed' in data)) ? data.website_indexed : true;
+ self.canEditTitle = data && ('website_meta_title' in data);
+ self.canEditDescription = data && ('website_meta_description' in data);
+ self.canEditKeywords = data && ('website_meta_keywords' in data);
+ self.metaImg = data.website_meta_og_img;
+ self.hasSocialDefaultImage = data.has_social_default_image;
+ self.canEditUrl = data && ('seo_name' in data);
+ self.seoName = self.canEditUrl && data.seo_name;
+ self.seoNameDefault = self.canEditUrl && data.seo_name_default;
+ if (!self.canEditTitle && !self.canEditDescription && !self.canEditKeywords) {
+ // disable the button to prevent an error if the current page doesn't use the mixin
+ // we make the check here instead of on the view because we don't need to check
+ // at every page load, just when the rare case someone clicks on this link
+ // TODO don't show the modal but just an alert in this case
+ self.$footer.find('button[data-action=update]').attr('disabled', true);
+ }
+ });
+ },
+ update: function () {
+ var self = this;
+ var data = {};
+ if (this.canEditTitle) {
+ data.website_meta_title = this.metaTitleDescription.$title.val();
+ }
+ if (this.canEditDescription) {
+ data.website_meta_description = this.metaTitleDescription.$description.val();
+ }
+ if (this.canEditKeywords) {
+ data.website_meta_keywords = this.metaKeywords.keywordList.keywords().join(', ');
+ }
+ if (this.canEditUrl) {
+ if (this.metaTitleDescription.$seoName.val() != this.metaTitleDescription.previousSeoName) {
+ data.seo_name = this.metaTitleDescription.$seoName.val();
+ self.reloadOnSave = true; // will force a refresh on old url and redirect to new slug
+ }
+ }
+ data.website_meta_og_img = this.metaImageSelector.activeMetaImg;
+ this.saveMetaData(data).then(function () {
+ // We want to reload if we are editing a generic page
+ // because it will become a specific page after this change (COW)
+ // and we want the user to be on the page he just created.
+ if (self.reloadOnSave) {
+ window.location.href = self.htmlPage.url();
+ } else {
+ self.htmlPage.changeKeywords(self.metaKeywords.keywordList.keywords());
+ self.savedData = true;
+ self.close();
+ }
+ });
+ },
+ getMainObject: function () {
+ var mainObject;
+ this.trigger_up('main_object_request', {
+ callback: function (value) {
+ mainObject = value;
+ },
+ });
+ return mainObject;
+ },
+ getSeoObject: function () {
+ var seoObject;
+ this.trigger_up('seo_object_request', {
+ callback: function (value) {
+ seoObject = value;
+ },
+ });
+ return seoObject;
+ },
+ loadMetaData: function () {
+ var obj = this.getSeoObject() || this.getMainObject();
+ return new Promise(function (resolve, reject) {
+ if (!obj) {
+ // return Promise.reject(new Error("No main_object was found."));
+ resolve(null);
+ } else {
+ rpc.query({
+ route: "/website/get_seo_data",
+ params: {
+ 'res_id': obj.id,
+ 'res_model': obj.model,
+ },
+ }).then(function (data) {
+ var meta = data;
+ meta.model = obj.model;
+ resolve(meta);
+ }).guardedCatch(reject);
+ }
+ });
+ },
+ saveMetaData: function (data) {
+ var obj = this.getSeoObject() || this.getMainObject();
+ if (!obj) {
+ return Promise.reject();
+ } else {
+ return this._rpc({
+ model: obj.model,
+ method: 'write',
+ args: [[obj.id], data],
+ });
+ }
+ },
+ titleChanged: function () {
+ var self = this;
+ _.defer(function () {
+ var title = self.metaTitleDescription.getTitle();
+ self.htmlPage.changeTitle(title);
+ self.metaImageSelector.setTitle(title);
+ });
+ },
+ descriptionChanged: function () {
+ var self = this;
+ _.defer(function () {
+ var description = self.metaTitleDescription.getRealDescription();
+ self.htmlPage.changeDescription(description);
+ self.metaImageSelector.setDescription(description);
+ });
+ },
+});
+
+var SeoMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({
+ actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, {
+ 'promote-current-page': '_promoteCurrentPage',
+ }),
+
+ init: function (parent, options) {
+ this._super(parent, options);
+
+ if ($.deparam.querystring().enable_seo !== undefined) {
+ this._promoteCurrentPage();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Actions
+ //--------------------------------------------------------------------------
+
+ /**
+ * Opens the SEO configurator dialog.
+ *
+ * @private
+ */
+ _promoteCurrentPage: function () {
+ new SeoConfigurator(this).open();
+ },
+});
+
+websiteNavbarData.websiteNavbarRegistry.add(SeoMenu, '#promote-menu');
+
+return {
+ SeoConfigurator: SeoConfigurator,
+ SeoMenu: SeoMenu,
+};
+});