Index: main/plugin-web/i18n/messages_en.xml
===================================================================
--- main/plugin-web/i18n/messages_en.xml	(revision 32835)
+++ main/plugin-web/i18n/messages_en.xml	(working copy)
@@ -2411,5 +2411,12 @@
     <message key="PLUGINS_WEB_DAOS_SITEMAP_NOT_FOUND">Selection error.</message>
     <message key="PLUGINS_WEB_DAOS_SITEMAP_NOT_FOUND_HINT">The sitemap does not exist.</message>
     
+    <!-- +
+         | RELATIONS
+         + -->
+    <message key="PLUGINS_WEB_RELATIONS_SETTAG_LABEL">Tags moving</message>
+    <message key="PLUGINS_WEB_RELATIONS_SETTAG_DESCRIPTION">Move action on tags to pages or contents</message>
+    <message key="PLUGINS_WEB_RELATIONS_SETTAG_ERROR">An error occured, could not assign the following tags : </message>
+
 </catalogue>
 	
\ No newline at end of file
Index: main/plugin-web/i18n/messages_fr.xml
===================================================================
--- main/plugin-web/i18n/messages_fr.xml	(revision 32835)
+++ main/plugin-web/i18n/messages_fr.xml	(working copy)
@@ -2416,5 +2416,12 @@
     <message key="PLUGINS_WEB_DAOS_SITEMAP_NOT_FOUND">Erreur de sélection.</message>
     <message key="PLUGINS_WEB_DAOS_SITEMAP_NOT_FOUND_HINT">Le plan du site n'existe plus.</message>
     
+    <!-- +
+         | RELATIONS
+         + -->
+    <message key="PLUGINS_WEB_RELATIONS_SETTAG_LABEL">Déplacement d'étiquettes</message>
+    <message key="PLUGINS_WEB_RELATIONS_SETTAG_DESCRIPTION">Action de déplacement des étiquettes vers des pages ou contenus</message>
+    <message key="PLUGINS_WEB_RELATIONS_SETTAG_ERROR">Une erreur est survenue, impossible d'assigner la ou les étiquettes suivantes : </message>
+
 </catalogue>
 	
\ No newline at end of file
Index: main/plugin-web/pages/tags/import-result-json.xsl
===================================================================
--- main/plugin-web/pages/tags/import-result-json.xsl	(revision 32835)
+++ main/plugin-web/pages/tags/import-result-json.xsl	(nonexistent)
@@ -1,43 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-   Copyright 2014 Anyware Services
-
-   Licensed under the Apache License, Version 2.0 (the "License");
-   you may not use this file except in compliance with the License.
-   You may obtain a copy of the License at
-
-       http://www.apache.org/licenses/LICENSE-2.0
-
-   Unless required by applicable law or agreed to in writing, software
-   distributed under the License is distributed on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-   See the License for the specific language governing permissions and
-   limitations under the License.
-   -->
-<xsl:stylesheet version="1.0" 
-                xmlns:i18n="http://apache.org/cocoon/i18n/2.1"
-                xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
-    
-    <xsl:template match="/ActionResult">
-        <json>
-        <xsl:choose>
-            <xsl:when test="success = 'true'">
-{
-    success: true,
-    createdCount: <xsl:value-of select="createdCount"/>,
-    updatedCount: <xsl:value-of select="updatedCount"/>,
-    errorCount: <xsl:value-of select="errorCount"/>,
-    rootId: "<xsl:value-of select="rootId"/>"
-}
-            </xsl:when>
-            <xsl:otherwise>
-{
-    success: false,
-    error: "<xsl:value-of select="error"/>"
-}
-            </xsl:otherwise>
-        </xsl:choose>
-        </json>
-    </xsl:template>
-    
-</xsl:stylesheet>
Index: main/plugin-web/plugin.xml
===================================================================
--- main/plugin-web/plugin.xml  (revision 32839)
+++ main/plugin-web/plugin.xml  (working copy)
@@ -2882,8 +2882,73 @@
                     <file>js/Ametys/plugins/web/tag/TagsTool.js</file>
                 </scripts>
             </extension>
+            
+            <extension id="org.ametys.web.tag.ImportXml"
+                       point="org.ametys.core.ui.RibbonControlsManager" 
+                       class="org.ametys.core.ui.StaticClientSideElement">
+                <class name="Ametys.ribbon.element.ui.ButtonController">
+                    <action>Ametys.plugins.cms.tag.TagActions.importXml</action>
+                    <selection-target-type>^tag(-root)?$</selection-target-type>
+                    
+                    <label type="i18n">plugin.cms:PLUGINS_CMS_UITOOL_TAGS_IMPORT_XML_LABEL</label>
+                    <default-description type="i18n">plugin.cms:PLUGINS_CMS_UITOOL_TAGS_IMPORT_XML_DESC</default-description>
+
+                    <forceProvider>org.ametys.web.tag.jcr.JCRTagProvider</forceProvider>
+
+                    <icon-small type="file" plugin="cms">img/tag/import_xml_16.png</icon-small>
+                    <icon-medium type="file" plugin="cms">img/tag/import_xml_32.png</icon-medium>
+                    <icon-large type="file" plugin="cms">img/tag/import_xml_48.png</icon-large>
+                </class>
+                <scripts>
+                    <file plugin="cms">js/Ametys/plugins/cms/tag/TagActions.js</file>
+                    <file plugin="cms">js/Ametys/plugins/cms/tag/TagsImportDialog.js</file>
+                </scripts>
+                <right>CMS_Rights_Tags_HandleTag</right>
+            </extension>
+
+            <extension id="org.ametys.web.userinterface.Tag"
+                       point="org.ametys.core.ui.RibbonControlsManager"
+                       class="org.ametys.core.ui.StaticClientSideElement">
+                <class name="Ametys.ribbon.element.ui.ButtonController">
+                    <action>Ametys.plugins.web.tag.TagActions.affect</action>
+                    <selection-target-type>^(page|content)?$</selection-target-type>
+                    
+                    <label type="i18n">PLUGINS_WEB_UITOOL_TAG_LABEL</label>
+                    <default-description type="i18n">PLUGINS_WEB_UITOOL_TAG_DESCRIPTION</default-description>
+                    <selection-enable-multiselection>false</selection-enable-multiselection>
+
+                    <icon-small type="file" plugin="cms">img/tag/tag_16.png</icon-small>
+                    <icon-medium type="file" plugin="cms">img/tag/tag_32.png</icon-medium>
+                    <icon-large type="file" plugin="cms">img/tag/tag_50.png</icon-large>
+                </class>
+                <scripts>
+                    <file plugin="cms">js/Ametys/cms/uihelper/ChooseTag.js</file>
+                    <file>js/Ametys/plugins/web/tag/TagActions.js</file>
+                </scripts>
+                <right>CMS_Rights_Tags_HandleTag</right>
+            </extension>
        </extensions>
     </feature>
+    
+    <feature name="relations.settags">
+        <extensions>
+            <extension id="org.ametys.web.relations.settags"
+                   point="org.ametys.core.ui.RelationsManager"
+                   class="org.ametys.core.ui.StaticClientSideElement">
+                <class name="Ametys.plugins.web.tag.relations.SetTagsRelationHandler">
+                    <label i18n="true">PLUGINS_WEB_RELATIONS_SETTAG_LABEL</label>
+                    <description i18n="true">PLUGINS_WEB_RELATIONS_SETTAG_DESCRIPTION</description>
+                    
+                    <icon-small file="true" plugin="cms">img/tag/add_16.png</icon-small>
+                    <icon-medium file="true" plugin="cms">img/tag/add_32.png</icon-medium>
+                    <icon-large file="true" plugin="cms">img/tag/add_50.png</icon-large>
+                </class>
+                <scripts>
+                    <file>js/Ametys/plugins/web/tag/relations/SetTagsRelationHandler.js</file>
+                </scripts>
+            </extension>
+        </extensions>
+    </feature>
 
    <!-- +
         | SITEMAP
Index: main/plugin-web/resources/js/Ametys/plugins/web/tag/TagActions.js
===================================================================
--- main/plugin-web/resources/js/Ametys/plugins/web/tag/TagActions.js	(revision 0)
+++ main/plugin-web/resources/js/Ametys/plugins/web/tag/TagActions.js	(revision 0)
@@ -0,0 +1,185 @@
+/*
+ *  Copyright 2014 Anyware Services
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+
+/**
+ * Singleton class to handle actions on tags.
+ * @private
+ */
+Ext.define('Ametys.plugins.web.tag.TagActions', {
+    singleton : true,
+
+    /**
+     * Function called to affect a tag
+     * @param {Ametys.ribbon.element.ui.ButtonController} controller The controller calling the function
+     * @param {Boolean} [config.multiple=false] `true` to allow selecting multiple tags.
+     * @param {Boolean} [config.onlyCustomTags=false] `true` to show only custom tags.
+     * @param {Boolean} [config.allowProviders=false] `true` to allow selecting providers.
+     * @param {Boolean} [config.allowCreation=false] `true` to allow the creation of new tags from the dialog box.
+     * @param {Boolean} [config.onlyTagsWithChildren=false] `true` to prevent selection of tags without child.
+     * @param {String} [config.targetType=null] The type of tags to display. Set to target value to display only target tags. Set to null to display all tags
+     * @param {String[]} [config.filterTarget=null] Filter on target type. Set to target value to disable target tags. Set to null to enable all tags.
+     */
+    affect: function(controller)
+    {
+    	var targets = Ametys.message.MessageBus.getCurrentSelectionMessage().getTargets();
+    	if (targets.length > 0)
+        {
+            var messageTargetIds = this._getTargetsId(targets);
+
+            Ametys.data.ServerComm.callMethod({
+                role: "org.ametys.cms.tag.jcr.JCRTagsDAO", 
+                methodName: 'getTags', 
+                parameters: [ messageTargetIds ],
+                waitMessage: true,
+                callback: {
+                    handler: this._getTagsCb,
+                    scope: this,
+                    arguments: [controller, targets]
+                }
+            });
+        }  
+    },
+
+    /**
+     * @private
+     * Callback for retrieving the list of tags associated with an ametys object
+     * @param  {String[]} response The list of tags names
+     * @param  {Object[]} args The arguments of the callback
+     * @param  {Ametys.ribbon.element.ui.ButtonController} args[0] The controller calling the function
+     * @param  {Ametys.message.MessageTarget[]} args[1] The message targets
+     */
+    _getTagsCb: function(response, args)
+    {
+        var controller = args[0]; 
+        var targets = args[1]; 
+
+        var filterTargets = controller.filterTarget != null && !Ext.isArray(controller.filterTarget) ? [controller.filterTarget] : controller.filterTarget;
+
+		Ametys.cms.uihelper.ChooseTag.open({
+            values: response,
+            plugin: "web",
+            url: "tags.json",
+            messageTargets: targets,
+            multiple: controller.multiple || true,
+            onlyCustomTags: controller.onlyCustomTags || false,
+            allowProviders: controller.allowProviders || false,
+            allowCreation: controller.allowCreation || false,
+            onlyTagsWithChildren: controller.onlyTagsWithChildren || false,
+            targetType: controller.targetType || null,
+            filterTarget: filterTargets || null,
+            callback: Ext.bind(this._doAffect, this, [targets], true)
+        });
+    },
+
+    /**
+     * Affect the tags that were selected by the helper
+     * @param  {String[]} tagNames The list of tags to assign
+     * @param  {Ametys.message.MessageTarget[]} targets The list of targets to assign
+     */
+    _doAffect: function(tagNames, targets)
+    {
+        var pageTargets = this._getTargetsId(Ametys.message.MessageTargetHelper.findTargets(targets, Ametys.message.MessageTarget.PAGE));
+        var contentTargets = this._getTargetsId(Ametys.message.MessageTargetHelper.findTargets(targets, Ametys.message.MessageTarget.CONTENT));
+
+        Ametys.data.ServerComm.callMethod({
+            role: "org.ametys.web.repository.page.PageDAO", 
+            methodName: 'tag', 
+            parameters: [ pageTargets, contentTargets, tagNames, Ametys.getAppParameters(), false ],
+            waitMessage: true,
+            callback: {
+                handler: this._doAffectCb,
+                scope: this
+            }
+        });
+    },
+
+    /**
+     * @private
+     * Callback for the tag function
+     * @param  {String[]} response The response from the server
+     */
+    _doAffectCb: function(response)
+    {
+        var pages = response["allright-pages"];
+        var contents = response["allright-contents"];
+
+        if (pages != null && pages.length > 0)
+        {
+            this._sendMessageModified(Ametys.message.MessageTarget.PAGE, pages);
+        }
+
+        if (contents != null && contents.length > 0)
+        {
+            this._sendMessageModified(Ametys.message.MessageTarget.CONTENT, contents);
+        }
+    },
+
+    /**
+     * Send a Modified message from a target type and a list of modified objects
+     * @param  {String} messageTargetType The type of targets
+     * @param  {Ametys.message.MessageTarget[]} modifiedTargets The list of modified targets
+     */
+    _sendMessageModified: function(messageTargetType, modifiedTargets)
+    {
+        var i;
+        var ids = [];
+
+        for (i = 0; i < modifiedTargets.length; i++) 
+        {
+            ids.push(modifiedTargets[i].id);
+        }
+
+        var targets = [];
+        for (i = 0; i < ids.length; i++)
+        {
+            var target = Ext.create('Ametys.message.MessageTarget', {
+                type: messageTargetType,
+                parameters: {id: ids[i]}
+            });
+            
+            targets.push(target);
+        }
+        
+        if (targets.length > 0)
+        {
+            Ext.create('Ametys.message.Message', {
+                type: Ametys.message.Message.MODIFIED,
+                targets: targets
+            });
+        }
+    },
+
+    /**
+     * @private
+     * Get the ids from a list of message targets
+     * @param  {Ametys.message.MessageTarget[]} messageTarget list of targets
+     * @return {String[]} A list of ids
+     */
+    _getTargetsId: function(messageTarget)
+    {
+        var messageTargetIds = [];
+        for (var i = 0; i < messageTarget.length; i++) 
+        {
+            var target = messageTarget[i];
+            if (target != null)
+            {
+                messageTargetIds.push(target.getParameters().id);
+            }
+        }
+
+        return messageTargetIds;
+    }
+});
\ No newline at end of file
Index: main/plugin-web/resources/js/Ametys/plugins/web/tag/relations/SetTagsRelationHandler.js
===================================================================
--- main/plugin-web/resources/js/Ametys/plugins/web/tag/relations/SetTagsRelationHandler.js	(revision 0)
+++ main/plugin-web/resources/js/Ametys/plugins/web/tag/relations/SetTagsRelationHandler.js	(revision 0)
@@ -0,0 +1,223 @@
+/*
+ *  Copyright 2015 Anyware Services
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+
+/**
+ * This is a relation handler between:
+ * * source : tag
+ * * destination : page or content
+ * 
+ * E.g. when you drag a tag to a content or a page
+ * @private
+ */
+Ext.define('Ametys.plugins.web.tag.relations.SetTagsRelationHandler', {
+	extend: 'Ametys.relation.RelationHandler',
+	
+	/** 
+	 * @protected
+	 * @property {RegExp} sourceTargetType The message target type (of type tag) of the source to handle
+	 */
+	sourceTargetType: /^tag$/,
+	
+	/**
+	 * @protected
+	 * @property {RegExp} destTargetType The message target type (of type content or page) of the target to handle
+	 */
+	destTargetType: /^(page|content)$/,
+
+	/**
+	 * @private
+	 * Revert the #targetType test
+	 * @param {Object/Ametys.message.MessageTarget} target The target to test
+	 * @return {Boolean} True if the target type does not match #targetType
+	 */
+	_nonMatchingSource: function(target)
+	{
+		return !this.sourceTargetType.test(target.isMessageTarget ? target.getType() : target.type)
+	},
+	
+	/**
+	 * @private
+	 * Revert the #targetType test
+	 * @param {Object/Ametys.message.MessageTarget} target The target to test
+	 * @return {Boolean} True if the target type does not match #targetType
+	 */
+	_nonMatchingDest: function(target)
+	{
+		return !this.destTargetType.test(target.isMessageTarget ? target.getType() : target.type)
+	},
+
+	supportedRelations: function(source, target)
+	{
+		var sourceContentTargets = Ametys.message.MessageTargetHelper.findTargets(source.targets, this.sourceTargetType, 1);
+		if (sourceContentTargets.length != 1 || Ametys.message.MessageTargetHelper.findTargets(source.targets, Ext.bind(this._nonMatchingSource, this)).length > 0)
+		{
+			return [];
+		}
+
+		var destContentTargets = Ametys.message.MessageTargetHelper.findTargets(target.targets, this.destTargetType, 1);
+		if (destContentTargets.length != 1 || Ametys.message.MessageTargetHelper.findTargets(target.targets, Ext.bind(this._nonMatchingDest, this)).length > 0)
+		{
+			return [];
+		}
+
+		var relations = [ 
+		    Ext.create('Ametys.relation.Relation', {
+    			type: Ametys.relation.Relation.REFERENCE,
+    			label: "<i18n:text i18n:key='PLUGINS_WEB_RELATIONS_SETTAG_LABEL'/>",
+    			description: "<i18n:text i18n:key='PLUGINS_WEB_RELATIONS_SETTAG_DESCRIPTION'/>",
+    			smallIcon: null,
+    			mediumIcon: null,
+    			largeIcon: null
+    		})
+		];
+			
+		return relations;
+	},
+
+	/**
+	 * @protected
+	 * Do the #link work but based only on targets array
+	 * The method is called to link source to target using the given relation. 
+	 * This operation can be asynchronous and will call callback at the end.
+	 * In most cases this implementation will send a Ametys.message.Message to inform the UI that the operation was done.
+	 * @param {Ametys.message.MessageTarget[]} sourceTargets The source point of the link operation. Targets are assumed to be ready.
+	 * @param {Ametys.message.MessageTarget} target The end point of the link operation. Targets are assumed to be ready.
+	 * @param {Function} callback The callback to call when operation has ended. 
+	 * @param {Boolean} callback.success True if the operation was successful
+	 */
+	_link: function(sourceTargets, target, callback)
+	{
+		var tagNames = [];
+		Ext.Array.forEach(Ametys.message.MessageTargetHelper.findTargets(sourceTargets, this.sourceTargetType, 1), function(target) {
+			tagNames.push(target.getParameters().names[0]);
+		});
+
+		var pageTargets = (target.getType() == Ametys.message.MessageTarget.PAGE ? [ target.getParameters().id ] : []);
+		var contentTargets = (target.getType() == Ametys.message.MessageTarget.CONTENT ? [ target.getParameters().id ] : []);
+		if (pageTargets.length > 0)
+		{
+			Ametys.data.ServerComm.callMethod({
+	            role: "org.ametys.web.repository.page.PageDAO", 
+	            methodName: 'tag', 
+	            parameters: [ pageTargets, tagNames, Ametys.getAppParameters(), true ],
+	            waitMessage: true,
+	            callback: {
+	                handler: this._linkCb,
+	                scope: this,
+	                arguments: [ callback, "page" ]
+	            }
+        	});
+		}
+		else if (contentTargets.length > 0)
+		{
+			Ametys.data.ServerComm.callMethod({
+	            role: "org.ametys.cms.repository.ContentDAO", 
+	            methodName: 'tag', 
+	            parameters: [ contentTargets, tagNames, Ametys.getAppParameters(), true ],
+	            waitMessage: true,
+	            callback: {
+	                handler: this._linkCb,
+	                scope: this,
+	                arguments: [ callback, "content" ]
+	            }
+	        });
+		}
+	},
+	
+	link: function(source, target, callback, relationType)
+	{
+		this._link(source.getTargets(), target.getTarget(), callback);
+	},
+	
+	/**
+	 * @private
+	 * Callback of the link operation. That will call the initial callback.
+	 * @param {Object} response The response from the server
+	 * @param {Function} callback The callback to call when operation has ended. 
+	 * @param {boolean} callback.sucess True if the operation was successful
+	 */
+	_linkCb: function(response, args)
+	{
+		if (args[1] == "page")
+		{
+	        var pages = response["allright-pages"];
+
+	        if (pages != null && pages.length > 0)
+	        {
+	            this._sendMessageModified(Ametys.message.MessageTarget.PAGE, pages);
+	        }
+		}
+		else if (args[1] == "content")
+		{
+	        var contents = response["allright-contents"];
+
+	        if (contents != null && contents.length > 0)
+	        {
+	            this._sendMessageModified(Ametys.message.MessageTarget.CONTENT, contents);
+	        }
+		}
+
+		if (response["invalid-tags"].length > 0)
+		{
+            Ext.Msg.show({
+                msg: "<i18n:text i18n:key='PLUGINS_WEB_RELATIONS_SETTAG_ERROR'/>" + response["invalid-tags"].join(', '),
+                buttons: Ext.Msg.OK,
+                icon: Ext.MessageBox.ERROR
+            });
+		}
+
+        if (Ext.isFunction(args[0]))
+        {
+			args[0](response["invalid-tags"].length > 0);
+		}
+    },
+
+    /**
+     * @private
+     * Send the target modified message
+     * @param  {String} messageTargetType The message target type
+     * @param  {Ametys.message.MessageTarget} messageTargets The message targets
+     */
+    _sendMessageModified: function(messageTargetType, messageTargets)
+    {
+        var i;
+        var ids = [];
+
+        for (i = 0; i < messageTargets.length; i++) 
+        {
+            ids.push(messageTargets[i].id);
+        }
+
+        var targets = [];
+        for (i = 0; i < ids.length; i++)
+        {
+            var target = Ext.create('Ametys.message.MessageTarget', {
+                type: messageTargetType,
+                parameters: {id: ids[i]}
+            });
+            
+            targets.push(target);
+        }
+        
+        if (targets.length > 0)
+        {
+            Ext.create('Ametys.message.Message', {
+                type: Ametys.message.Message.MODIFIED,
+                targets: targets
+            });
+        }
+    },
+});
Index: main/plugin-web/ribbon/cms-ribbon-tags.xml
===================================================================
--- main/plugin-web/ribbon/cms-ribbon-tags.xml	(revision 32835)
+++ main/plugin-web/ribbon/cms-ribbon-tags.xml	(working copy)
@@ -18,30 +18,30 @@
           xsi:noNamespaceSchemaLocation="http://ametys.org/cms/3.0/ribbon.xsd">
 
 	<tabs>
-		<tab label="plugin.web:RIBBON_TABS_TAB_TAGS_LABEL" id="org.ametys.web.TagsTab" contextualColor="5" contextualLabel="plugin.web:RIBBON_TABS_TAB_TAG_GROUP_LABEL">
+		<tab label="plugin.cms:RIBBON_TABS_TAB_TAGS_LABEL" id="org.ametys.cms.tag.Tab" contextualColor="5" contextualLabel="plugin.cms:RIBBON_TABS_TAB_TAGS_GROUP_LABEL">
 			<groups>
-				<group label="plugin.web:RIBBON_TABS_TAB_TAGS_GROUP_GROUP_TAG_LABEL" icon="">
+				<group label="plugin.cms:RIBBON_TABS_TAB_TAGS_GROUP_GROUP_ACTION_LABEL" icon="">
 					<large>
-						<control id="org.ametys.web.tag.Add"/>
+						<control id="org.ametys.cms.tag.Add"/>
 						<layout size="small">
-							<control id="org.ametys.web.tag.Edit"/>
-							<control id="org.ametys.web.tag.Delete"/>
+							<control id="org.ametys.cms.tag.Edit"/>
+							<control id="org.ametys.cms.tag.Delete"/>
 						</layout>
                         <control id="org.ametys.web.tag.ImportXml"/>
 					</large>
 					<medium>
 						<layout size="small">
-							<control id="org.ametys.web.tag.Add"/>
-							<control id="org.ametys.web.tag.Edit"/>
-							<control id="org.ametys.web.tag.Delete"/>
+							<control id="org.ametys.cms.tag.Add"/>
+							<control id="org.ametys.cms.tag.Edit"/>
+							<control id="org.ametys.cms.tag.Delete"/>
 						</layout>
                         <control id="org.ametys.web.tag.ImportXml"/>
 					</medium>
 					<small>
-						<control id="org.ametys.web.tag.Add"/>
+						<control id="org.ametys.cms.tag.Add"/>
 						<layout size="very-small">
-							<control id="org.ametys.web.tag.Edit"/>
-							<control id="org.ametys.web.tag.Delete"/>
+							<control id="org.ametys.cms.tag.Edit"/>
+							<control id="org.ametys.cms.tag.Delete"/>
 						</layout>
                         <control id="org.ametys.web.tag.ImportXml"/>
 					</small>
Index: main/plugin-web/sitemap-back.xmap
===================================================================
--- main/plugin-web/sitemap-back.xmap	(revision 32835)
+++ main/plugin-web/sitemap-back.xmap	(working copy)
@@ -106,6 +106,7 @@
             <map:action name="administrator-is-system-user" src="org.ametys.runtime.plugins.admin.IsAdminAction" logger="org.ametys.runtime.plugins.web.administrator.sitemap.action.isAdmin" />
 
             <map:action name="tag" src="org.ametys.web.repository.tag.actions.TagAction" />
+            <map:action name="get-tags" src="org.ametys.web.tags.GetTagsAction" logger="org.ametys.web.tags"/>
             <map:action name="edit-metadata" src="org.ametys.web.repository.page.actions.EditMetadataAction" />
 
             <map:action name="page-available-templates" src="org.ametys.web.repository.page.actions.GetPageTemplatesAction" />
@@ -406,6 +405,12 @@
                 </map:act>
             </map:match>
 
+            <map:match pattern="tags.json">
+                <map:act type="get-tags">
+                    <map:read type="json"/>
+                </map:act>
+            </map:match>    
+
             <map:match pattern="repository/edit-metadata">
                 <map:act type="edit-metadata">
                     <map:generate type="action-result" />
Index: main/plugin-web/src/org/ametys/web/repository/page/PageDAO.java
===================================================================
--- main/plugin-web/src/org/ametys/web/repository/page/PageDAO.java	(revision 32835)
+++ main/plugin-web/src/org/ametys/web/repository/page/PageDAO.java	(working copy)
@@ -870,10 +870,11 @@
      * @param pageIds The ids of pages to tag
      * @param tagNames The tags
      * @param contextualParameters Contextual parameters. Must contain the site name
+     * @param keepOldTags If false, erase the old tags before adding the news tags.
      * @return the result
      */
     @Callable
-    public Map<String, Object> tag (List<String> pageIds, List<String> tagNames, Map<String, Object> contextualParameters)
+    public Map<String, Object> tag (List<String> pageIds, List<String> tagNames, Map<String, Object> contextualParameters, Boolean keepOldTags)
     {
         Map<String, Object> result = new HashMap<>();
         
@@ -895,22 +896,28 @@
                 
                 // First delete old tags
                 Set<String> oldTags = mPage.getTags();
-                for (String tagName : oldTags)
+                if (!keepOldTags)
                 {
-                    mPage.untag(tagName);
+                    for (String tagName : oldTags)
+                    {
+                        mPage.untag(tagName);
+                    }
                 }
                 
                 // Then set new tags
                 for (String tagName : tagNames)
                 {
-                    if (_isTagValid(page, tagName))
-                    {
-                        mPage.tag(tagName);
-                    }
-                    else
+                    if (!keepOldTags || !oldTags.contains(tagName))
                     {
-                        List<String> invalidTags = (List<String>) result.get("invalid-tags");
-                        invalidTags.add(tagName);
+                        if (_isTagValid(page, tagName))
+                        {
+                            mPage.tag(tagName);
+                        }
+                        else
+                        {
+                            List<String> invalidTags = (List<String>) result.get("invalid-tags");
+                            invalidTags.add(tagName);
+                        }
                     }
                 }
                 
@@ -941,23 +948,31 @@
     }
     
     /**
-     * Tag a list of pages
+     * Tag a list of pages or contents
      * @param pageIds The ids of pages to tag
+     * @param contentIds the ids of contents to tag
      * @param tagNames The tags
      * @param contextualParameters Contextual parameters. Must contain the site name
+     * @param keepOldTags If false, erase the old tags before adding the news tags.
      * @return the result
      */
     @Callable
-    public Map<String, Object> tag (List<String> pageIds, List<String> contentIds, List<String> tagNames, Map<String, Object> contextualParameters)
+    public Map<String, Object> tag (List<String> pageIds, List<String> contentIds, List<String> tagNames, Map<String, Object> contextualParameters, Boolean keepOldTags)
     {
-        Map<String, Object> result = tag(pageIds, tagNames, contextualParameters);
-        result.putAll(_contentDAO.tag(contentIds, tagNames, contextualParameters));
+        Map<String, Object> result = tag(pageIds, tagNames, contextualParameters, keepOldTags);
+        result.putAll(_contentDAO.tag(contentIds, tagNames, contextualParameters, keepOldTags));
         
         // Invalid tags are ignored
         result.remove("invalid-tags");
         return result;
     }
     
+    /**
+     * Test if a tag is valid for a specific page
+     * @param page The page
+     * @param tagName The tag name
+     * @return True if the tag is valid
+     */
     public boolean _isTagValid (Page page, String tagName)
     {
         Map<String, Object> params = new HashMap<>();
Index: main/plugin-web/src/org/ametys/web/tags/GetTagsAction.java
===================================================================
--- main/plugin-web/src/org/ametys/web/tags/GetTagsAction.java	(revision 0)
+++ main/plugin-web/src/org/ametys/web/tags/GetTagsAction.java	(revision 0)
@@ -0,0 +1,69 @@
+package org.ametys.web.tags;
+
+import java.util.List;
+
+import org.ametys.cms.repository.Content;
+import org.ametys.cms.tag.Tag;
+import org.ametys.core.right.RightsManager.RightResult;
+import org.ametys.plugins.repository.AmetysObject;
+import org.ametys.plugins.repository.AmetysRepositoryException;
+import org.ametys.web.repository.page.Page;
+
+/**
+ * SAX events for tags
+ */
+public class GetTagsAction extends org.ametys.cms.tag.GetTagsAction
+{
+    @Override
+    protected boolean isUserAuthorized(Tag tag, List<String> objectTargetIds)
+    {
+        if (objectTargetIds != null)
+        {
+            for (String objectId : objectTargetIds)
+            {
+                AmetysObject ao = _resolver.resolveById(objectId);
+                
+                if (ao instanceof Content && tag.getTarget().getName().equals("CONTENT"))
+                {
+                    String context = _getContentContext((Content) ao);
+                    if (isPrivate(tag))
+                    {
+                        return _rightsManager.hasRight(_getCurrentUser(), "CMS_Rights_Content_Private_Tag", context) == RightResult.RIGHT_OK;
+                    }
+                    else
+                    {
+                        return _rightsManager.hasRight(_getCurrentUser(), "CMS_Rights_Content_Tag", context) == RightResult.RIGHT_OK
+                                || _rightsManager.hasRight(_getCurrentUser(), "CMS_Rights_Content_Private_Tag", context) == RightResult.RIGHT_OK;
+                    }
+                }
+                else if (ao instanceof Page && tag.getTarget().getName().equals("PAGE"))
+                {
+                    String context = _getPageContext((Page) ao);
+                    if (isPrivate(tag))
+                    {
+                        return _rightsManager.hasRight(_getCurrentUser(), "WEB_Rights_Page_Private_Tag", context) == RightResult.RIGHT_OK;
+                    }
+                    else
+                    {
+                        return _rightsManager.hasRight(_getCurrentUser(), "WEB_Rights_Page_Tag", context) == RightResult.RIGHT_OK
+                                || _rightsManager.hasRight(_getCurrentUser(), "WEB_Rights_Page_Private_Tag", context) == RightResult.RIGHT_OK;
+                    }
+                }
+            }
+        }
+        
+        return true;
+    }
+    
+    /**
+     * Get the page rights context.
+     * @param page the page.
+     * @return the page context.
+     * @throws AmetysRepositoryException if an error occurs.
+     */
+    protected String _getPageContext(Page page) throws AmetysRepositoryException
+    {
+        return "/pages/" + page.getSitemapName() + "/" + page.getPathInSitemap();
+    }
+    
+}
