Index: /quintagroup.seoptimizer/tags/2.1.0/MANIFEST.in
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/MANIFEST.in	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/MANIFEST.in	(revision 1346)
@@ -0,0 +1,3 @@
+recursive-include docs *
+recursive-include quintagroup *
+global-exclude *pyc
Index: /quintagroup.seoptimizer/tags/2.1.0/README.txt
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/README.txt	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/README.txt	(revision 1346)
@@ -0,0 +1,95 @@
+Quintagroup Search Engine Optimization Tool
+===========================================
+
+This product was created to enhance SE visibility of Plone sites.
+
+Copyright (c) "Quintagroup": http://quintagroup.com, 2004-2009
+  
+Product sponsored by "4webresults, SEO and Internet Marketing
+Services provider": http://4webresults.com. Plone-3.x.x compatibility, extra
+configuration features sponsored by:
+
+* "Eogogics Inc.": http://eogogics.com
+ 
+* "4webresults": http://4webresults.com
+
+* "eLawSoftware": http://www.elawsoftware.com
+
+
+Features
+--------
+
+quintagroup.seoptimizer allows per document editing of:
+
+* HTML Title tag
+
+* META description tag
+
+* META keywords tag
+
+* HTML comment into page header
+
+* META robots tag
+
+* META Disposition tag
+
+* Canonical URl (http://projects.quintagroup.com/products/wiki/qSEOptimizer#CanonicalURL)
+
+For Title, description keywords and comment you have statistics
+(total/stop/used words, field length counter).
+  
+  
+Usage
+-----
+
+* Go to Plone Control Panel, enable Plone SEO for desired content types
+
+* Go to a document (Blog entry, news item, event, etc)
+
+* Switch to *SEO Properties* tab
+
+* Select *Override* checkboxes of features you want to override
+
+* Type-in your SEO values
+
+* Save
+
+* Do this for all documents that need enhanced SEO properties
+
+
+Requirements
+------------
+
+* Plone 3.0+ 
+
+
+Note
+----
+
+* For Plone 2.0, 2.1, 2.5 versions - use Plone SEO 1.6.1  release (http://plone.org/products/plone-seo/releases/1.6.1)
+
+* For Plone 3 version and up - use Plone SEO releases starting from 2.0.2 release (http://plone.org/products/plone-seo/releases/2.0.2)
+
+
+Authors
+-------
+
+* Myroslav Opyr
+
+* Volodymyr Romaniuk
+
+* Mykola Kharechko
+
+* Vitaliy Podoba
+
+* Volodymyr Cherepanyak
+
+* Taras Melnychuk
+
+* Vitaliy Stepanov
+
+
+Links
+-----
+
+Watch Plone SEO screencast (http://quintagroup.com/cms/screencasts/plone-seo) to learn how to install and set up Plone SEO on a buildout-based Plone instance for Plone 3.2 or above.
Index: /quintagroup.seoptimizer/tags/2.1.0/docs/HISTORY.txt
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/docs/HISTORY.txt	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/docs/HISTORY.txt	(revision 1346)
@@ -0,0 +1,90 @@
+Changelog
+=========
+
+2.1.0 (unreleased)
+------------------
+
+- Added metatags order feature, which is managed
+  by metatags_order property of of configlet
+  [liebster]
+
+- Resolved metatags duplication
+  [liebster]
+
+
+2.0.9 (2009-09-10)
+------------------
+
+- Fixed bugs in metatags viewlet
+  [liebster]
+
+
+2.0.8 (2009-07-28)
+------------------
+
+- Added use of default meta tags from the control panel in metatags viewlet
+  [liebster]
+
+
+2.0.7 (2009-07-25)
+------------------
+
+- Fix url action in qseo_properties_edit_form
+  [liebster]
+
+
+2.0.6 (2009-05-19)
+------------------
+
+- Fix page title tag, now it is not duplicated in case
+  site and current page have equal titles
+  [piv]
+
+
+2.0.5 (2009-04-03)
+------------------
+
+- Fix seoptimizer intrusion before
+  installation to plone instance
+  [mylan]
+
+- Fix base url for folderish objects
+  [mylan]
+
+- Bind viewlets to specific layer and
+  set viewlets order for all skins.
+  [mylan]
+
+
+2.0.4 (2009-03-31)
+------------------
+
+* Canonical url support added
+  [mylan]
+* Custom script viewlets in html head added
+
+2.0.2 (2009-03-06)
+------------------
+
+* Uninstallation package profile title added
+* REAME.txt and INSTALL.txt changed, text format ready for PyPi
+
+
+2.0.1 (2009-02-16)
+------------------
+
+* Formatted text in docs for PyPi
+
+
+2.0.0 (2009-02-11)
+------------------
+
+* Some refactoring of product was done, some python script are moved to zope 3 views.
+  Also all main parts of html head section were moved to viewlets and now can easily be
+  reordered.
+
+
+1.5.1
+-----
+
+* Support of additional keywords added.
Index: /quintagroup.seoptimizer/tags/2.1.0/docs/INSTALL.txt
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/docs/INSTALL.txt	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/docs/INSTALL.txt	(revision 1346)
@@ -0,0 +1,52 @@
+Installation
+============
+
+To install quintagroup.seoptimizer into the global Python environment (or a workingenv), using a traditional Zope 2 instance, you can do this:
+
+* Copy ``quintagroup`` folder to the /path/to/instance/lib/python directory.
+
+* Create a file called ``quintagroup.seoptimizer-configure.zcml`` in the */path/to/instance/etc/package-includes* directory. The file should only contain this::
+
+  <include package="quintagroup.seoptimizer" file="configure.zcml" />
+
+* Create a file called ``quintagroup.seoptimizer-overrides.zcml`` in the */path/to/instance/etc/package-includes* directory. The file should only contain this::
+
+  <include package="quintagroup.seoptimizer" file="overrides.zcml" />
+
+* Restart the Zope server, for example with the following command in the terminal::
+
+   $ ./bin/zopectl restart
+
+
+Alternatively, if you are using zc.buildout and the plone.recipe.zope2instance recipe to manage your project, you can do this:
+
+* Add ``quintagroup.seoptimizer`` to the list of eggs to install, e.g.::
+
+   [buildout]
+   ....
+   eggs =
+        ...
+        quintagroup.seoptimizer
+
+* Tell the plone.recipe.zope2instance recipe to install a ZCML slug::
+
+   [instance]
+   ...
+   zcml =
+       quintagroup.seoptimizer
+       quintagroup.seoptimizer-overrides
+
+* Re-run buildout, e.g. with::
+
+   $ ./bin/buildout
+
+* Restart the Zope server, e.g with the following command in the terminal::
+
+   $ ./bin/instance restart
+
+Install quintagroup.seoptimizer via ZMI portal_setup. Select ``quintagroup.seoptimizer`` from the list of available profiles and press *Import all steps*.
+
+Uninstallation
+==============
+
+To uninstall quintagroup.seoptimizer - select ``quintagroup.seoptimizer uninstall`` profile from the list of available profiles and press *Import all steps*.
Index: /quintagroup.seoptimizer/tags/2.1.0/docs/LICENSE.GPL
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/docs/LICENSE.GPL	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/docs/LICENSE.GPL	(revision 1346)
@@ -0,0 +1,225 @@
+		    GNU GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License.  The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language.  (Hereinafter, translation is included without limitation in
+the term "modification".)  Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+  1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+  2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) You must cause the modified files to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    b) You must cause any work that you distribute or publish, that in
+    whole or in part contains or is derived from the Program or any
+    part thereof, to be licensed as a whole at no charge to all third
+    parties under the terms of this License.
+
+    c) If the modified program normally reads commands interactively
+    when run, you must cause it, when started running for such
+    interactive use in the most ordinary way, to print or display an
+    announcement including an appropriate copyright notice and a
+    notice that there is no warranty (or else, saying that you provide
+    a warranty) and that users may redistribute the program under
+    these conditions, and telling the user how to view a copy of this
+    License.  (Exception: if the Program itself is interactive but
+    does not normally print such an announcement, your work based on
+    the Program is not required to print an announcement.)
+
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+    a) Accompany it with the complete corresponding machine-readable
+    source code, which must be distributed under the terms of Sections
+    1 and 2 above on a medium customarily used for software interchange; or,
+
+    b) Accompany it with a written offer, valid for at least three
+    years, to give any third party, for a charge no more than your
+    cost of physically performing source distribution, a complete
+    machine-readable copy of the corresponding source code, to be
+    distributed under the terms of Sections 1 and 2 above on a medium
+    customarily used for software interchange; or,
+
+    c) Accompany it with the information you received as to the offer
+    to distribute corresponding source code.  (This alternative is
+    allowed only for noncommercial distribution and only if you
+    received the program in object code or executable form with such
+    an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it.  For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable.  However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+
+  4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License.  Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+  5. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Program or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+  6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+  7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+
+  8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded.  In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+  9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation.  If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+  10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission.  For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this.  Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+			    NO WARRANTY
+
+  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+		     END OF TERMS AND CONDITIONS
Index: /quintagroup.seoptimizer/tags/2.1.0/docs/LICENSE.txt
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/docs/LICENSE.txt	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/docs/LICENSE.txt	(revision 1346)
@@ -0,0 +1,16 @@
+  quintagroup.seoptimizer is copyright (c) "Quintagroup": http://quintagroup.com/ , 2009
+
+  This program is free software; you can redistribute it and/or modify
+  it under the terms of the GNU General Public License as published by
+  the Free Software Foundation; either version 2 of the License, or
+  (at your option) any later version.
+
+  This program is distributed in the hope that it will be useful,
+  but WITHOUT ANY WARRANTY; without even the implied warranty of
+  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  GNU General Public License for more details.
+
+  You should have received a copy of the GNU General Public License
+  along with this program; if not, write to the Free Software
+  Foundation, Inc., 59 Temple Place, Suite 330, Boston,
+  MA 02111-1307 USA.
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/__init__.py
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/__init__.py	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/__init__.py	(revision 1346)
@@ -0,0 +1,6 @@
+# See http://peak.telecommunity.com/DevCenter/setuptools#namespace-packages
+try:
+    __import__('pkg_resources').declare_namespace(__name__)
+except ImportError:
+    from pkgutil import extend_path
+    __path__ = extend_path(__path__, __name__)
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/Extensions/Install.py
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/Extensions/Install.py	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/Extensions/Install.py	(revision 1346)
@@ -0,0 +1,9 @@
+from Products.CMFCore.utils import getToolByName
+from quintagroup.seoptimizer.config import PROJECT_NAME
+
+
+def uninstall( portal ):
+    setup_tool = getToolByName(portal, 'portal_setup')
+    setup_tool.setBaselineContext('profile-%s:uninstall'%PROJECT_NAME)
+    setup_tool.runAllImportStepsFromProfile('profile-%s:uninstall'%PROJECT_NAME)
+    return "Ran all uninstall steps."
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/__init__.py
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/__init__.py	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/__init__.py	(revision 1346)
@@ -0,0 +1,139 @@
+from AccessControl import allow_module
+from zope.component import queryMultiAdapter
+
+from Acquisition import aq_inner
+from DateTime import DateTime
+
+from Products.CMFCore.utils import getToolByName
+
+from quintagroup.seoptimizer.interfaces import IKeywords, IMappingMetaTags
+from quintagroup.seoptimizer.util import SortedDict
+
+allow_module('quintagroup.seoptimizer.util')
+qSEO_globals = globals()
+
+
+try:
+    from Products.CMFPlone.PloneTool import PloneTool, METADATA_DCNAME, \
+        FLOOR_DATE, CEILING_DATE
+    _present = hasattr(PloneTool, "listMetaTags")
+except ImportError:
+    _present = False
+
+
+if _present:
+    old_lmt = PloneTool.listMetaTags
+
+    def listMetaTags(self, context):
+        """Lists meta tags helper.
+
+        Creates a mapping of meta tags.
+        """
+
+        from quintagroup.seoptimizer.browser.interfaces import IPloneSEOLayer
+        if not IPloneSEOLayer.providedBy(self.REQUEST):
+            return old_lmt(getToolByName(self, 'plone_utils'), context)
+
+        result = SortedDict()
+        site_props = getToolByName(self, 'portal_properties').site_properties
+        use_all = site_props.getProperty('exposeDCMetaTags', None)
+
+        seo_context = queryMultiAdapter((context, self.REQUEST), name='seo_context')
+        adapter = IMappingMetaTags(context, None)
+        mapping_metadata = adapter and adapter.getMappingMetaTags() or SortedDict()
+
+        if not use_all:
+            metadata_names = mapping_metadata.has_key('DC.description') and {'DC.description': mapping_metadata['DC.description']} or SortedDict()
+            if mapping_metadata.has_key('description'):
+                metadata_names['description'] = mapping_metadata['description']
+        else:
+            metadata_names = mapping_metadata
+
+        for key, accessor in metadata_names.items():
+            if accessor == 'seo_keywords':
+                # Set the additional matching keywords, if any
+                adapter = IKeywords(context, None)
+                if adapter is not None:
+                    keywords = adapter.listKeywords()
+                    if keywords:
+                        result['keywords'] = keywords
+                continue
+
+            method = getattr(seo_context, accessor, None)
+            if method is None:
+                method = getattr(aq_inner(context).aq_explicit, accessor, None)
+
+            if not callable(method):
+                continue
+
+            # Catch AttributeErrors raised by some AT applications
+            try:
+                value = method()
+            except AttributeError:
+                value = None
+
+            if not value:
+                # No data
+                continue
+            if accessor == 'Publisher' and value == 'No publisher':
+                # No publisher is hardcoded (TODO: still?)
+                continue
+            if isinstance(value, (list, tuple)):
+                # convert a list to a string
+                value = ', '.join(value)
+
+            # Special cases
+            if accessor == 'Description' and not metadata_names.has_key('description'):
+                result['description'] = value
+            elif accessor == 'Subject' and not metadata_names.has_key('keywords'):
+                result['keywords'] = value
+
+            if accessor not in ('Description', 'Subject'):
+                result[key] = value
+
+        if use_all:
+            created = context.CreationDate()
+
+            try:
+                effective = context.EffectiveDate()
+                if effective == 'None':
+                    effective = None
+                if effective:
+                    effective = DateTime(effective)
+            except AttributeError:
+                effective = None
+
+            try:
+                expires = context.ExpirationDate()
+                if expires == 'None':
+                    expires = None
+                if expires:
+                    expires = DateTime(expires)
+            except AttributeError:
+                expires = None
+
+            # Filter out DWIMish artifacts on effective / expiration dates
+            if effective is not None and \
+               effective > FLOOR_DATE and \
+               effective != created:
+                eff_str = effective.Date()
+            else:
+                eff_str = ''
+
+            if expires is not None and expires < CEILING_DATE:
+                exp_str = expires.Date()
+            else:
+                exp_str = ''
+
+            if exp_str or exp_str:
+                result['DC.date.valid_range'] = '%s - %s' % (eff_str, exp_str)
+
+        # add custom meta tags (added from qseo tab by user) for given context and default from configlet
+        custom_meta_tags = seo_context and seo_context.seo_customMetaTags() or []
+        for tag in custom_meta_tags:
+            if tag['meta_content']:
+                result[tag['meta_name']] = tag['meta_content']
+
+        return result
+
+    PloneTool.listMetaTags = listMetaTags
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/adapters.py
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/adapters.py	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/adapters.py	(revision 1346)
@@ -0,0 +1,61 @@
+import re, commands
+from zope.interface import implements
+from zope.component import queryMultiAdapter
+from Products.CMFCore.utils import getToolByName
+
+from quintagroup.seoptimizer.interfaces import IKeywords, IMappingMetaTags
+from quintagroup.seoptimizer.util import SortedDict
+
+
+class AdditionalKeywords(object):
+    implements(IKeywords)
+
+    def __init__(self, context):
+        self.context = context
+
+    def listKeywords(self):
+        portal_props = getToolByName(self.context, 'portal_properties')
+        seo_props = getToolByName(portal_props, 'seo_properties', None)
+
+        # now set type is not using because of it unordered behaviour
+        #original = set(self.context.qSEO_Keywords())
+        #additional = set(seo_props.additional_keywords)
+        #text = set(self.context.SearchableText().split())
+        #keywords = list(additional.intersection(text).union(original))
+
+        request = self.context.REQUEST
+        seo_context = queryMultiAdapter((self.context, request), name='seo_context')
+        if seo_context:
+            keywords = list(seo_context.seo_keywords())
+            lower_keywords = map(lambda x: x.lower(), keywords)
+            additional = seo_props.additional_keywords
+
+            is_test = self.context.REQUEST.get('qseo_without_additional_keywords', None)
+
+            if additional and is_test is None:
+                # extract words from url page using lynx browser
+                text = commands.getoutput('lynx --dump --nolist %s?qseo_without_additional_keywords=1' % self.context.absolute_url()).lower()
+                if text and text != 'sh: lynx: command not found':
+                    for keyword in additional:
+                        if keyword.lower() not in lower_keywords and re.compile(r'\b%s\b' % keyword, re.I).search(text):
+                            keywords.append(keyword)
+            return ', '.join(keywords)
+        return ''
+
+
+class MappingMetaTags(object):
+    implements(IMappingMetaTags)
+
+    def __init__(self, context):
+        self.context = context
+        self.portal_props = getToolByName(self.context, 'portal_properties')
+        self.seo_props = getToolByName(self.portal_props, 'seo_properties', None)
+
+    def getMappingMetaTags(self):
+        metadata_name = SortedDict()
+        if self.seo_props:
+            pmn = self.seo_props.getProperty('metatags_order')
+            pmn = pmn and pmn or ''
+            for mt in [mt.split(' ') for mt in pmn if len(mt.split(' '))==2]:
+                metadata_name[mt[0]] = mt[1]
+        return metadata_name
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/__init__.py
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/__init__.py	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/__init__.py	(revision 1346)
@@ -0,0 +1,1 @@
+#
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/configure.zcml
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/configure.zcml	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/configure.zcml	(revision 1346)
@@ -0,0 +1,47 @@
+<configure
+    xmlns="http://namespaces.zope.org/zope"
+    xmlns:browser="http://namespaces.zope.org/browser">
+
+    <!-- The customized title -->
+    <browser:viewlet
+        name="plone.htmlhead.title"
+        manager="plone.app.layout.viewlets.interfaces.IHtmlHead"
+        class=".viewlets.TitleCommentViewlet"
+        layer=".interfaces.IPloneSEOLayer"
+        permission="zope2.View"
+        />
+
+    <browser:viewlet
+        name="quintagroup.seoptimizer.canonicalurl"
+        manager="plone.app.layout.viewlets.interfaces.IHtmlHead"
+        class=".viewlets.CanonicalUrlViewlet"
+        layer=".interfaces.IPloneSEOLayer"
+        permission="zope2.View"
+        />
+
+    <browser:viewlet
+        name="quintagroup.seoptimizer.customscript"
+        manager="plone.app.layout.viewlets.interfaces.IHtmlHead"
+        class=".viewlets.CustomScriptViewlet"
+        layer=".interfaces.IPloneSEOLayer"
+        permission="zope2.View"
+        />
+
+    <!--Views-->
+    <browser:page
+        name="seo-controlpanel"
+        for="Products.CMFPlone.interfaces.IPloneSiteRoot"
+        class=".views.SEOControlPanel"
+        layer=".interfaces.IPloneSEOLayer"
+        permission="cmf.ManagePortal"
+        />
+
+    <browser:page
+        name="seo_context"
+        for="*"
+        class=".views.SEOContext"
+        layer=".interfaces.IPloneSEOLayer"
+        permission="zope2.View"
+        />
+
+</configure>
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/interfaces.py
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/interfaces.py	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/interfaces.py	(revision 1346)
@@ -0,0 +1,13 @@
+from zope.interface import Interface
+from plone.theme.interfaces import IDefaultPloneLayer
+
+class IPloneSEOLayer(IDefaultPloneLayer):
+    """Marker interface that defines a Zope 3 skin layer bound to a Skin
+       Selection in portal_skins.
+    """
+
+class IValidateSEOKeywordsView(Interface):
+    """ View for validating keywords on qSEO_properties_edit_form """
+
+    def validateKeywords(text):
+        """ Parse text and validate each keyword (extracted from text) for appearing on the context page """
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/keywords.py
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/keywords.py	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/keywords.py	(revision 1346)
@@ -0,0 +1,110 @@
+import urllib, re, os, commands
+from xml.dom import minidom, Node
+
+from zope.interface import implements
+from Products.Five.browser import BrowserView
+
+from Products.CMFCore.utils import getToolByName
+from Products.CMFPlone import PloneMessageFactory as _
+
+from interfaces import IValidateSEOKeywordsView
+
+class ValidateSEOKeywordsView(BrowserView):
+
+    implements(IValidateSEOKeywordsView)
+
+    def validateKeywords(self, text):
+        """ see interface """
+        # extract keywords from text
+        if not text.strip():
+            return _(u'Keywords list is empty!')
+
+        keywords = map(lambda x: x.strip(), text.lower().split('\n'))
+        if not keywords:
+            return _(u'Keywords list is empty!')
+
+        # request html page of context object
+        url = '%s?qseo_without_additional_keywords=1' % self.context.absolute_url()
+        #try:
+            #page = urllib.urlopen(url)
+        #except IOError:
+            #return _('Could not find requested page')
+
+        #page_html = page.read()
+        #if not page_html:
+            #return _('Page is empty')
+
+        # extract words from body from html page
+
+        # this block work only with valid html
+        #doc = minidom.parseString(page_html)
+        #rootNode = doc.documentElement
+        #bodies = rootNode.getElementsByTagName('body')
+        #if len(bodies) > 0:
+            #body = bodies[0]
+        #else:
+            #return _(u'Invalid page html')
+        #page_words = []
+        #self.walkTextNodes(body, page_words)
+
+        # this block work even with invalid html
+        #pattern = re.compile('<\s*body[^>]*>(.*?)<\s*/\s*body\s*>', re.S|re.M|re.I)
+        #search = pattern.search(page_html)
+        #if search:
+            #body_html = search.group(1)
+        #else:
+            #return _('Invalid html code on page')
+
+        #page_text = self.strip_tags(body_html)
+        #page_words = page_text.lower().split()
+
+        # extract words from url page using lynx browser
+        page_text = commands.getoutput('lynx --dump --nolist %s' % url).lower()
+        if page_text and page_text != 'sh: lynx: command not found':
+            #page_words = page_text.lower().split()
+            page_text = page_text
+        else:
+            return _(u'Could not find lynx browser!')
+
+        # check every keyword on appearing in body of html page
+        missing = []
+        added = {}
+        for keyword in keywords:
+            if keyword not in added.keys() and not re.compile(r'\b%s\b' % keyword, re.I).search(page_text):
+                missing.append(keyword)
+                added[keyword] = 1
+
+        # return list of missing keywords
+        if missing:
+            msg = u"""Next keywords did not appear on the page:\n%s""" % '\n'.join(missing)
+        else:
+            msg = u"""All keywords found on the page!"""
+        return _(msg)
+
+    def walkTextNodes(self, parent, page_words=[]):
+        for node in parent.childNodes:
+            if node.nodeType == Node.ELEMENT_NODE:
+                self.walkTextNodes(node, page_words)
+            elif node.nodeType == Node.TEXT_NODE:
+                value = node.nodeValue
+                if value is not None:
+                    page_words.extend(map(lambda x: x.lower(), value.split()))
+
+    def strip_tags(self, in_text):
+        s_list = list(in_text)
+        i,j = 0,0
+
+        while i < len(s_list):
+            if s_list[i] == '<':
+                while s_list[i] != '>':
+                    # pop everything from the the left-angle bracket until the right-angle bracket
+                    s_list.pop(i)
+                    
+                # pops the right-angle bracket, too
+                s_list.pop(i)
+            else:
+                i=i+1
+                
+        # convert the list back into text
+        join_char=''
+        return join_char.join(s_list)
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/templates/seo_controlpanel.pt
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/templates/seo_controlpanel.pt	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/templates/seo_controlpanel.pt	(revision 1346)
@@ -0,0 +1,120 @@
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US"
+      xmlns:metal="http://xml.zope.org/namespaces/metal"
+      xmlns:tal="http://xml.zope.org/namespaces/tal"
+      lang="en-US"
+      metal:use-macro="here/prefs_main_template/macros/master"
+      i18n:domain="quintagroup.seoptimizer">
+  <body>
+    <div metal:fill-slot="prefs_configlet_main"
+        tal:define="errors python:request.get('errors', {})"><!--TODO:implement validation and errors handling-->
+      <h1>Search Engine Optimizer configuration</h1>
+        <a href="" class="link-parent"
+           tal:attributes="href string: $portal_url/plone_control_panel"
+           i18n:translate="label_up_to_plone_setup">
+           Up to Plone Setup
+        </a>
+        <div class="documentDescription">
+           You can select what content types are
+           qSEOptimizer-enabled, and control if Dublin Core metatags
+           are exposed in the header of content pages.
+        </div>
+        <form name="edit_form" action="." method="post" enctype="multipart/form-data"
+              tal:attributes="action string:${context/absolute_url}/@@seo-controlpanel" >
+          <div class="field">
+            <input type="checkbox"
+                   id="exposeDCMetaTags"
+                   name="exposeDCMetaTags"
+                   tal:attributes="checked view/getExposeDCMetaTags"/>
+                        <label for="exposeDCMetaTags">Expose <abbr title="Dublin Core">DC</abbr> meta tags</label>
+                        <div class="formHelp" i18n:translate="description_seo_dc_metatags">
+                           Controls if <abbr title="Dublin Core">DC</abbr> metatags are exposed to page
+                           header. They include DC.description, DC.type,
+                           DC.format, DC.creator and others.
+                        </div>
+                    </div>
+
+                    <div class="field">
+                        <label for="default_custom_metatags" i18n:translate="label_default_custom_metatags">
+                            Default custom metatags.
+                        </label>
+                        <div class="formHelp" i18n:translate="help_default_custom_metatags">
+                            Fill in custom metatag names (one per line) which will appear on qseo_properties
+                            edit tab. Example: "metaname|metacontent" or "metaname".
+                        </div>
+                        <div class="widget">
+                            <textarea cols="60" id="default_custom_metatags" name="default_custom_metatags" rows="15"
+                                      tal:content="python:'\n'.join(view.getDefaultCustomMetatags())"></textarea>
+                        </div>
+                    </div>
+
+                    <div class="field">
+                        <label for="metatags_order" i18n:translate="label_metatags_order">
+                            Meta tags order in the page.
+                        </label>
+                        <div class="formHelp" i18n:translate="help_metatags_order">
+                            Fill in meta tags order (one per line) which will appear on qseo_properties
+                            edit tab. Example: "metaname accessor".
+                        </div>
+                        <div class="widget">
+                            <textarea cols="60" id="metatags_order" name="metatags_order" rows="15"
+                                      tal:content="python:'\n'.join(view.getMetaTagsOrder())"></textarea>
+                        </div>
+                    </div>
+
+                    <div class="field">
+                        <label for="additionalKeywords" i18n:translate="label_additional_keywords">
+                            Additional keywords that should be added to the content types.
+                        </label>
+                        <div class="formHelp" i18n:translate="help_additional_keywords">
+                            Use this field when you want that your content types receive
+                            additional keywords from the ones you manually specify. Enter
+                            one keyword per line.
+                        </div>
+                        <div class="widget">
+                            <textarea cols="60" id="additionalKeywords" name="additionalKeywords" rows="15"
+                                      tal:content="python:'\n'.join(view.getAdditionalKeywords())"></textarea>
+                        </div>
+                    </div>
+
+                    <fieldset class="portalTypes"
+                              tal:define="typeLists view/createMultiColumnList">
+                        <legend i18n:translate="label_content_type_title">Content Types</legend>
+
+                        <div class="formHelp" i18n:translate="description_seo_content_types">
+                            Select content types that will have SEO properties
+                            enabled.
+                        </div>
+                        <div style="float:left; margin-right: 2em;" tal:repeat="sublist typeLists">
+                            <ul class="configlets">
+                                <li tal:repeat="ptypeInfo sublist">
+                                    <tal:type define="type python:view.typeInfo(ptypeInfo);
+                                                      name type/getId;">
+
+                                        <input  type="checkbox"
+                                                name="portalTypes:list"
+                                                tal:attributes="id name;
+                                                                value name;
+                                                                checked python:view.hasSEOAction(type)"/>
+                                        <label tal:attributes="for python:type.getId()"
+                                               tal:content="type/Title"/>
+                                    </tal:type>
+                                </li>
+                            </ul>
+                        </div>
+                    </fieldset>
+
+                    <div class="formControls"
+                         i18n:domain="plone">
+                         <input class="context"
+                             tabindex=""
+                             type="submit"
+                             name="form.button.Save"
+                             value="Save"
+                             i18n:attributes="value"
+                             tal:attributes="tabindex tabindex/next;" />
+                    </div>
+                    <input type="hidden" name="form.submitted" value="1" />
+                </form>
+    </div>
+ </body>
+</html>
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/viewlets.py
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/viewlets.py	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/viewlets.py	(revision 1346)
@@ -0,0 +1,70 @@
+from cgi import escape
+from zope.component import getMultiAdapter
+from Products.CMFPlone.utils import safe_unicode
+from plone.app.layout.viewlets.common import ViewletBase
+from Products.CMFCore.utils import getToolByName
+
+
+class TitleCommentViewlet(ViewletBase):
+
+    def update(self):
+        self.portal_state = getMultiAdapter((self.context, self.request),
+                                            name=u'plone_portal_state')
+        self.context_state = getMultiAdapter((self.context, self.request),
+                                             name=u'plone_context_state')
+        self.page_title = self.context_state.object_title
+        self.portal_title = self.portal_state.portal_title
+
+        self.override_title = self.context.hasProperty('qSEO_title')
+        self.override_comments = self.context.hasProperty('qSEO_html_comment')
+
+    def std_title(self):
+        portal_title = safe_unicode(self.portal_title())
+        page_title = safe_unicode(self.page_title())
+        if page_title == portal_title:
+            return u"<title>%s</title>" % (escape(portal_title))
+        else:
+            return u"<title>%s &mdash; %s</title>" % (
+                escape(safe_unicode(page_title)),
+                escape(safe_unicode(portal_title)))
+
+    def render(self):
+        std_title = self.std_title()
+        seo_context = getMultiAdapter((self.context, self.request), name='seo_context')
+        if not self.override_title:
+            if not self.override_comments:
+                return std_title
+            else:
+                qseo_comments = u"<!--%s-->"%safe_unicode(seo_context.seo_html_comment())
+                return u"%s\n%s"%(std_title, qseo_comments)
+        else:
+            qseo_title = u"<title>%s</title>" % safe_unicode(seo_context.seo_title())
+            if not self.override_comments:
+                return qseo_title
+            else:
+                qseo_comments = u"<!--%s-->"%safe_unicode(seo_context.seo_html_comment())
+                return u"%s\n%s"%(qseo_title, qseo_comments)
+
+
+class CustomScriptViewlet( ViewletBase ):
+
+    def getCustomScript( self ):
+        context = self.context.aq_inner
+        portal_props = getToolByName(context, 'portal_properties')
+        seo_props = getToolByName(portal_props, 'seo_properties', None)
+        if seo_props is None:
+            return '' 
+        return seo_props.getProperty('custom_script', '')
+
+    def render( self ):
+        return safe_unicode("""%s"""% self.getCustomScript())
+
+
+class CanonicalUrlViewlet( ViewletBase ):
+    """
+       simple viewlet for canonical url link rendering
+    """
+
+    def render( self ):
+        seo_context = getMultiAdapter((self.context, self.request), name='seo_context')
+        return """<link rel="canonical" href="%s" />""" % seo_context.seo_canonical()
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/views.py
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/views.py	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/browser/views.py	(revision 1346)
@@ -0,0 +1,295 @@
+from Acquisition import aq_inner
+from zope.component import queryAdapter
+from plone.app.controlpanel.form import ControlPanelView
+
+from Products.Five.browser import BrowserView
+from Products.CMFCore.utils import getToolByName
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+
+SEPERATOR = '|'
+HAS_CANONICAL_PATH = True
+
+try:
+    from quintagroup.canonicalpath.interfaces import ICanonicalPath
+except ImportError:
+    HAS_CANONICAL_PATH = False
+
+class SEOContext( BrowserView ):
+    """
+    """
+    def getSEOProperty( self, property_name, accessor='' ):
+        """
+        """
+        context = aq_inner(self.context)
+
+        if context.hasProperty(property_name):
+            return context.getProperty(property_name)
+        
+        if accessor:
+            method = getattr(context, accessor, None)
+            if not callable(method):
+                return None
+
+            # Catch AttributeErrors raised by some AT applications
+            try:
+                value = method()
+            except AttributeError:
+                value = None
+        
+            return value
+
+    def seo_title( self ):
+        """
+        """
+        return self.getSEOProperty( 'qSEO_title', accessor='Title' )
+
+    def seo_robots( self ):
+        """
+        """
+        robots = self.getSEOProperty( 'qSEO_robots' )
+        return robots and robots or 'ALL'
+
+    def seo_description( self ):
+        """
+            Generate Description from SEO properties 
+        """
+        
+        return self.getSEOProperty( 'qSEO_description', accessor = 'Description')
+
+    def seo_distribution( self ):
+        """
+           Generate Description from SEO properties
+        """
+        dist = self.getSEOProperty( 'qSEO_distribution' )
+
+        return dist and dist or 'Global'
+
+    def seo_customMetaTags( self ):
+        """
+        """
+        tags = self.seo_globalCustomMetaTags()
+        loc = self.seo_localCustomMetaTags()
+
+        names = [i['meta_name'] for i in tags]
+        add_tags = []
+        for i in loc:
+            if i['meta_name'] in names:
+                for t in tags:
+                    if t['meta_name'] == i['meta_name']:
+                        t['meta_content'] = i['meta_content']
+            else:
+                add_tags.append(i)
+        tags.extend(add_tags)
+        return tags
+
+    def seo_globalWithoutLocalCustomMetaTags( self ):
+        """
+        """
+        glob = self.seo_globalCustomMetaTags()
+        loc = self.seo_localCustomMetaTags()
+        names = [i['meta_name'] for i in loc]
+        tags = []
+        for i in glob:
+            if i['meta_name'] not in names:
+                tags.append(i)
+        return tags
+
+    def seo_localCustomMetaTags( self ):
+        """
+        """
+        result = []
+        property_prefix = 'qSEO_custom_'
+        context = aq_inner(self.context)
+        for property, value in context.propertyItems():
+            if property.startswith(property_prefix) and property[len(property_prefix):]:
+                result.append({'meta_name'    : property[len(property_prefix):],
+                               'meta_content' : value})
+        return result
+
+    def seo_globalCustomMetaTags( self ):
+        """
+        """
+        result = []
+        context = aq_inner(self.context)
+        site_properties = getToolByName(context, 'portal_properties')
+        if hasattr(site_properties, 'seo_properties'):
+            custom_meta_tags = getattr(site_properties.seo_properties, 'default_custom_metatags', [])
+            for tag in custom_meta_tags:
+                name_value = tag.split(SEPERATOR)
+                if name_value[0]:
+                    result.append({'meta_name'    : name_value[0],
+                                   'meta_content' : len(name_value) == 2 and name_value[1] or ''})
+        return result
+
+    def seo_nonEmptylocalMetaTags( self ):
+        """
+        """
+        return bool(self.seo_localCustomMetaTags())
+
+    def seo_html_comment( self ):
+        """
+        """
+        html_comment = self.getSEOProperty( 'qSEO_html_comment' )
+        return html_comment and html_comment or '' 
+        
+    def seo_keywords( self ):
+        """
+           Generate Keywords from SEO properties
+        """
+
+        prop_name = 'qSEO_keywords'
+        add_keywords = 'additional_keywords'
+        accessor = 'Subject'
+        context = aq_inner(self.context)
+
+        keywords = []
+        if context.hasProperty(prop_name):
+            keywords = context.getProperty(prop_name)
+
+        pprops = getToolByName(context, 'portal_properties')
+        sheet = getattr(pprops, 'seo_properties', None)
+        if sheet and sheet.hasProperty(add_keywords):
+            keywords += sheet.getProperty(add_keywords)
+
+        if keywords:
+            return keywords
+
+        method = getattr(context, accessor, None)
+        if not callable(method):
+            return None
+
+        # Catch AttributeErrors raised by some AT applications
+        try:
+            value = method()
+        except AttributeError:
+            value = None
+
+        return value
+    
+    def seo_canonical( self ):
+        """
+           Get canonical URL
+        """
+        canonical = self.getSEOProperty( 'qSEO_canonical' )
+
+        if not canonical and HAS_CANONICAL_PATH:
+            canpath = queryAdapter(self.context, ICanonicalPath)
+            if canpath:
+                purl = getToolByName(self.context, 'portal_url')()
+                cpath = canpath.canonical_path()
+                canonical = purl + cpath
+
+        return canonical and canonical or self.context.absolute_url()
+
+
+class SEOControlPanel( ControlPanelView ):
+    """
+    """
+    template = ViewPageTemplateFile('templates/seo_controlpanel.pt')
+
+    @property
+    def portal_properties( self ):
+        """
+        """
+        context = aq_inner(self.context)
+        return getToolByName(context, 'portal_properties')
+
+    @property
+    def portal_types( self ):
+        """
+        """
+        context = aq_inner(self.context)
+        return getToolByName(context, 'portal_types')
+
+    def hasSEOAction( self, type_info ):
+        """
+        """
+        return filter(lambda x:x.id == 'seo_properties', type_info.listActions())
+
+    def test( self, condition, first, second ):
+        """
+        """
+        return condition and first or second 
+
+    def getExposeDCMetaTags( self ):
+        """
+        """
+        sp = self.portal_properties.site_properties
+        return sp.getProperty('exposeDCMetaTags')
+
+    def getDefaultCustomMetatags( self ):
+        """
+        """
+        seo = self.portal_properties.seo_properties
+        return seo.getProperty('default_custom_metatags')
+
+    def getMetaTagsOrder( self ):
+        """
+        """
+        seo = self.portal_properties.seo_properties
+        return seo.getProperty('metatags_order')
+
+    def getAdditionalKeywords( self ):
+        """
+        """
+        seo = self.portal_properties.seo_properties
+        return seo.getProperty('additional_keywords')
+
+    def createMultiColumnList( self ):
+        """
+        """
+        context = aq_inner(self.context)
+        allTypes = self.portal_types.listContentTypes()
+        try:
+            return context.createMultiColumnList(allTypes, sort_on='title_or_id')
+        except AttributeError:
+            return [slist]
+
+    def __call__( self ):
+        """
+        """
+        context = aq_inner(self.context)
+        request = self.request
+
+        portalTypes=request.get( 'portalTypes', [] )
+        exposeDCMetaTags=request.get( 'exposeDCMetaTags', None )
+        additionalKeywords=request.get('additionalKeywords', [])
+        default_custom_metatags=request.get('default_custom_metatags', [])
+        metatags_order=request.get('metatags_order', [])
+
+        site_props = getToolByName(self.portal_properties, 'site_properties')
+        seo_props = getToolByName(self.portal_properties, 'seo_properties')
+
+        form = self.request.form
+        submitted = form.get('form.submitted', False)
+
+        if submitted:
+            site_props.manage_changeProperties(exposeDCMetaTags=exposeDCMetaTags)
+            seo_props.manage_changeProperties(additional_keywords=additionalKeywords)
+            seo_props.manage_changeProperties(default_custom_metatags=default_custom_metatags)
+            seo_props.manage_changeProperties(metatags_order=metatags_order)
+
+            for ptype in self.portal_types.objectValues():
+                acts = filter(lambda x: x.id == 'seo_properties', ptype.listActions())
+                action = acts and acts[0] or None
+                if ptype.getId() in portalTypes:
+                    if action is None:
+                        ptype.addAction('seo_properties',
+                                        'SEO Properties',
+                                        'string:${object_url}/qseo_properties_edit_form',
+                                        '',
+                                        'Modify portal content',
+                                        'object',
+                                        visible=1)
+                else:
+                    if action !=None:
+                        actions = list(ptype.listActions())
+                        ptype.deleteActions([actions.index(a) for a in actions if a.getId()=='seo_properties'])
+            return request.response.redirect('%s/%s'%(self.context.absolute_url(), '@@seo-controlpanel'))
+        else:
+            return self.template(portalTypes=portalTypes, exposeDCMetaTags=exposeDCMetaTags)
+
+    def typeInfo( self, type_name ):
+        """
+        """
+        return self.portal_types.getTypeInfo( type_name )
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/config.py
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/config.py	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/config.py	(revision 1346)
@@ -0,0 +1,16 @@
+PROJECT_NAME = 'quintagroup.seoptimizer'
+SKINS_DIR = 'skins'
+
+GLOBALS = globals()
+
+PROPERTY_SHEET = 'seo_properties'
+SHEET_TITLE = 'SEO Properties'
+
+STOP_WORDS = ['a', 'an', 'amp', 'and', 'are', 'arial', 'as', 'at', 'be', 'but', 'by', 'can', 'com', 'do', 'font',
+              'for', 'from', 'gif', 'had', 'has', 'have', 'he', 'helvetica', 'her', 'his', 'how', 'href', 'i',
+              'if', 'in', 'is', 'it', 'javascript', 'jpg', 'made', 'net', 'of', 'on', 'or', 'org', 'our', 'sans',
+              'see', 'serif', 'she', 'that', 'the', 'this', 'to', 'us', 'we', 'with', 'you', 'your']
+
+FIELDS = ['seo_title', 'seo_description', 'seo_keywords']
+
+DEFAULT_CUSTOM_METATAGS = []
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/configure.zcml
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/configure.zcml	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/configure.zcml	(revision 1346)
@@ -0,0 +1,48 @@
+<configure
+    xmlns="http://namespaces.zope.org/zope"
+    xmlns:five="http://namespaces.zope.org/five"
+    xmlns:browser="http://namespaces.zope.org/browser"
+    xmlns:genericsetup="http://namespaces.zope.org/genericsetup"
+    xmlns:cmf="http://namespaces.zope.org/cmf">
+
+  <five:registerPackage package="." />
+
+  <genericsetup:registerProfile
+      name="default"
+      title="quintagroup.seoptimizer"
+      directory="profiles/default"
+      description="Install quintagroup.seoptimizer"
+      provides="Products.GenericSetup.interfaces.EXTENSION"
+      />
+
+  <genericsetup:registerProfile
+      name="uninstall"
+      title="quintagroup.seoptimizer uninstall"
+      directory="profiles/uninstall"
+      description="Uninstall quintagroup.seoptimizer"
+      provides="Products.GenericSetup.interfaces.EXTENSION"
+      />
+
+  <browser:page
+      for="Products.Archetypes.interfaces.IBaseObject"
+      attribute="validateKeywords"
+      class=".browser.keywords.ValidateSEOKeywordsView"
+      name="checkSEOKeywords"
+      permission="cmf.ModifyPortalContent"
+      />
+
+  <adapter
+      for="Products.Archetypes.public.BaseObject"
+      provides=".interfaces.IKeywords"
+      factory=".adapters.AdditionalKeywords"
+      />
+
+  <adapter
+      for="Products.Archetypes.public.BaseContent"
+      provides=".interfaces.IMappingMetaTags"
+      factory=".adapters.MappingMetaTags"
+      />
+
+  <cmf:registerDirectory name="quintagroup.seoptimizer" />
+
+</configure>
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/interfaces.py
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/interfaces.py	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/interfaces.py	(revision 1346)
@@ -0,0 +1,15 @@
+from zope.interface import Interface
+
+class IKeywords(Interface):
+    """Handle the available keywords.
+    """
+    def listKeywords():
+        """Returns all the existing keywords for the current content type.
+        """
+
+class IMappingMetaTags(Interface):
+    """
+    """
+    def getMappingMetaTags():
+        """Returns mapping {meta_name:accssesor} all the meta tags.
+        """
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/overrides.zcml
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/overrides.zcml	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/overrides.zcml	(revision 1346)
@@ -0,0 +1,7 @@
+<configure
+    xmlns="http://namespaces.zope.org/zope"
+    xmlns:zcml="http://namespaces.zope.org/zcml">
+    <include zcml:condition="installed plone.app.viewletmanager"
+             package=".browser" />
+
+</configure>
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/browserlayer.xml
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/browserlayer.xml	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/browserlayer.xml	(revision 1346)
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<layers
+   xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+   i18n:domain="seoptimizer">
+   <layer name="qSEOptimizer"
+          interface="quintagroup.seoptimizer.browser.interfaces.IPloneSEOLayer" />
+</layers>
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/controlpanel.xml
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/controlpanel.xml	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/controlpanel.xml	(revision 1346)
@@ -0,0 +1,10 @@
+<?xml version="1.0"?>
+<object name="portal_controlpanel" meta_type="Plone Control Panel Tool"
+    xmlns:i18n="http://xml.zope.org/namespaces/i18n" i18n:domain="plone">
+ <configlet title="Search Engine Optimizer" action_id="quintagroup.seoptimizer"
+    appId="quintagroup.seoptimizer" category="Products" condition_expr=""
+    url_expr="string:${portal_url}/@@seo-controlpanel"
+    visible="True" i18n:attributes="">
+  <permission>ManagePortal</permission>
+ </configlet>
+</object>
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/metadata.xml
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/metadata.xml	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/metadata.xml	(revision 1346)
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<metadata>
+  <version>2.0.5</version>
+</metadata>
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/propertiestool.xml
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/propertiestool.xml	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/propertiestool.xml	(revision 1346)
@@ -0,0 +1,91 @@
+<?xml version="1.0"?>
+<object name="portal_properties" meta_type="Plone Properties Tool">
+ <object name="seo_properties" meta_type="Plone Property Sheet">
+  <property name="title">SEO Properties</property>
+  <property name="stop_words" type="lines">
+   <element value="a"/>
+   <element value="an"/>
+   <element value="amp"/>
+   <element value="and"/>
+   <element value="are"/>
+   <element value="arial"/>
+   <element value="as"/>
+   <element value="at"/> 
+   <element value="be"/>
+   <element value="but"/>
+   <element value="by"/>
+   <element value="can"/>
+   <element value="com"/>
+   <element value="do"/>
+   <element value="font"/>
+   <element value="for"/>
+   <element value="from"/>
+   <element value="gif"/>
+   <element value="had"/>
+   <element value="has"/>
+   <element value="have"/>
+   <element value="he"/>
+   <element value="helvetica"/>
+   <element value="her"/>
+   <element value="his"/>
+   <element value="how"/>
+   <element value="href"/>
+   <element value="i"/>
+   <element value="if"/>
+   <element value="in"/>
+   <element value="is"/>
+   <element value="it"/>
+   <element value="javascript"/>
+   <element value="jpg"/>
+   <element value="made"/>
+   <element value="net"/>
+   <element value="of"/>
+   <element value="on"/>
+   <element value="or"/>
+   <element value="org"/>
+   <element value="our"/>
+   <element value="sans"/>
+   <element value="see"/>
+   <element value="serif"/>
+   <element value="she"/>
+   <element value="that"/>
+   <element value="the"/>
+   <element value="this"/>
+   <element value="to"/>
+   <element value="us"/>
+   <element value="we"/>
+   <element value="with"/>
+   <element value="you"/>
+   <element value="your"/>
+  </property>
+  <property name="fields" type="lines">
+   <element value="seo_title"/>
+   <element value="seo_description"/>
+   <element value="seo_keywords"/>
+  </property>
+  <property name="custom_script" type="text"/>
+  <property name="additional_keywords" type="lines"/>
+  <property name="default_custom_metatags" type="lines"/>
+  <property name="metatags_order" type="lines">
+   <element value="DC.publisher Publisher"/>
+   <element value="DC.description Description"/>
+   <element value="DC.contributors Contributors"/>
+   <element value="DC.creator Creator"/>
+   <element value="DC.format Format"/>
+   <element value="DC.rights Rights"/>
+   <element value="DC.language Language"/>
+   <element value="DC.date.modified ModificationDate"/>
+   <element value="DC.date.created CreationDate"/>
+   <element value="DC.type Type"/>
+   <element value="DC.subject Subject"/>
+   <element value="DC.distribution seo_distribution"/>
+   <element value="description seo_description"/>
+   <element value="keywords seo_keywords"/>
+   <element value="robots seo_robots"/>
+   <element value="distribution seo_distribution"/>
+  </property>
+ </object>
+ <object name="site_properties" meta_type="Plone Property Sheet">
+  <property name="exposeDCMetaTags" type="boolean">True</property>
+ </object>
+</object>
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/skins.xml
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/skins.xml	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/skins.xml	(revision 1346)
@@ -0,0 +1,8 @@
+<?xml version="1.0"?>
+<object name="portal_skins">
+ <object name="quintagroup.seoptimizer" meta_type="Filesystem Directory View"
+    directory="quintagroup.seoptimizer:skins/quintagroup.seoptimizer"/>
+  <skin-path name="*">
+  <layer name="quintagroup.seoptimizer" insert-after="custom"/>
+ </skin-path>
+</object>
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/types.xml
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/types.xml	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/types.xml	(revision 1346)
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<object name="portal_types" meta_type="Plone Types Tool">
+ <property name="title">Controls the available content types in your portal</property>
+ <object name="Document" meta_type="Factory-based Type Information with dynamic views"/>
+ <object name="File" meta_type="Factory-based Type Information with dynamic views"/>
+ <object name="News Item" meta_type="Factory-based Type Information with dynamic views"/>
+</object>
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/types/Document.xml
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/types/Document.xml	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/types/Document.xml	(revision 1346)
@@ -0,0 +1,9 @@
+<?xml version="1.0"?>
+<object name="Document" meta_type="Factory-based Type Information with dynamic views"
+   i18n:domain="plone" xmlns:i18n="http://xml.zope.org/namespaces/i18n">
+ <action title="SEO Properties" action_id="seo_properties" category="object" condition_expr=""
+    url_expr="string:${object_url}/qseo_properties_edit_form" visible="True"
+    i18n:attributes="">
+  <permission value="Modify portal content"/>
+ </action>
+</object>
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/types/File.xml
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/types/File.xml	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/types/File.xml	(revision 1346)
@@ -0,0 +1,9 @@
+<?xml version="1.0"?>
+<object name="File" meta_type="Factory-based Type Information with dynamic views"
+   i18n:domain="plone" xmlns:i18n="http://xml.zope.org/namespaces/i18n">
+ <action title="SEO Properties" action_id="seo_properties" category="object" condition_expr=""
+    url_expr="string:${object_url}/qseo_properties_edit_form" visible="True"
+    i18n:attributes="">
+  <permission value="Modify portal content"/>
+ </action>
+</object>
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/types/News_Item.xml
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/types/News_Item.xml	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/types/News_Item.xml	(revision 1346)
@@ -0,0 +1,9 @@
+<?xml version="1.0"?>
+<object name="News Item" meta_type="Factory-based Type Information with dynamic views"
+   i18n:domain="plone" xmlns:i18n="http://xml.zope.org/namespaces/i18n">
+ <action title="SEO Properties" action_id="seo_properties" category="object" condition_expr=""
+    url_expr="string:${object_url}/qseo_properties_edit_form" visible="True"
+    i18n:attributes="">
+  <permission value="Modify portal content"/>
+ </action>
+</object>
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/viewlets.xml
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/viewlets.xml	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/default/viewlets.xml	(revision 1346)
@@ -0,0 +1,8 @@
+<?xml version="1.0"?>
+<object>
+  <order manager="plone.htmlhead" skinname="*">
+    <viewlet name="plone.htmlhead.title" />
+    <viewlet name="quintagroup.seoptimizer.customscript" />
+    <viewlet name="plone.resourceregistries" />
+  </order>
+</object>
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/uninstall/import_steps.xml
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/uninstall/import_steps.xml	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/profiles/uninstall/import_steps.xml	(revision 1346)
@@ -0,0 +1,8 @@
+<?xml version="1.0"?>
+<import-steps>
+ <import-step id="seo_uninstall" version="20081125-01"
+              handler="quintagroup.seoptimizer.util.uninstall"
+              title="SEO uninstallation">
+  SEO uninstallation
+ </import-step>
+</import-steps>
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/checkkeywords.js.dtml
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/checkkeywords.js.dtml	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/checkkeywords.js.dtml	(revision 1346)
@@ -0,0 +1,118 @@
+/******************************************************************/
+
+var seo_keywords_url = '&dtml-absolute_url;/checkSEOKeywords';
+var KEYWORDS_IDS = ['seo_keywords',];
+// var KEYWORDS_REPORT = {};
+
+// function extractWords(data) {
+//     data = data // replace all non-word character with space
+//                 .replace(/[^a-zA-Z0-9\-\'\u2019\"\`]+/g, ' ')
+//                 // replace "-" and "'" symbols if it create groups event inside of token
+//                 .replace(/[\-\'\u2019\"\`]{2,}/g, ' ')
+//                 // replace all non-word characters and "-", "'" if it stay at word edge
+//                 .replace(/(?:^|\s+)[^a-zA-Z0-9]+|[^a-zA-Z0-9]+(?:\s+|$)/g, ' ')
+//                 // strip whitespaces
+//                 .replace(/^\s*(.*?)\s*$/, '$1');
+//     return data.split(/[^a-zA-Z0-9\-\'\u2019\"\`]+/);
+// }
+// 
+// function countTerm(node, word, id) {
+//     var contents = extractWords(node.nodeValue.toLowerCase());
+//     var term = word.toLowerCase();
+//     for (var i = 0, w; w = contents[i]; i++) {
+//         if (w == term) {
+//             KEYWORDS_REPORT[id][word] = 1;
+//             return 'found';
+//         }
+//     }
+//     return false;
+// }
+// 
+// function keywordsWalkTextNodes(node, func, data, id) {
+//     if (!node) return false;
+//     if (KEYWORDS_REPORT[id][data] == 1) return 'found';
+//     if (node.hasChildNodes) {
+//         if (node.nodeType == 3) {
+//             if (func(node, data, id) == 'found') {
+//                 return 'found';
+//             }
+//         }
+//         for (var i = 0; i < node.childNodes.length; i++) {
+//             if (keywordsWalkTextNodes(node.childNodes[i], func, data, id) == 'found') {
+//                 return 'found';
+//             }
+//         }
+//     }
+// }
+
+// function checkPageKeywords(event) {
+//     var event = event ? event:window.event;
+//     var target = event.target ? event.target : event.srcElement;
+//     if (!target) {return false;};
+//     var id = target.id.replace('_check_keywords', '')
+//     KEYWORDS_REPORT[id] = {};
+//     var area = document.getElementById(id);
+//     if (area && typeof(area.value) != 'undefined') {
+//         var terms = extractWords(area.value);
+//         for (var i = 0, term; term = terms[i]; i++) {
+//             if (KEYWORDS_REPORT[id][term] == 1) continue;
+//             KEYWORDS_REPORT[id][term] = 0;
+//             keywordsWalkTextNodes(document.body, countTerm, term, id);
+//         }
+//     }
+//     var report = '';
+//     for (var term in KEYWORDS_REPORT[id]) {
+//         if (KEYWORDS_REPORT[id][term] != 1)
+//             report += term + ' ';
+//     }
+//     KEYWORDS_REPORT[id] = {};
+//     if (report != '') {
+//         report = 'Next keywords did not appear on the page:\n' + report;
+//     } else {
+//         report = 'All keywords found on the page!';
+//     }
+//     alert(report);
+//     return false;
+// }
+
+function checkPageKeywords(event) {
+    var event = event ? event:window.event;
+    var target = event.target ? event.target : event.srcElement;
+    if (!target) {return false;};
+    var id = target.id.replace('_check_keywords', '')
+    var area = document.getElementById(id);
+    if (area && typeof(area.value) != 'undefined') {
+        var req = new XMLHttpRequest();
+        req.open("POST", seo_keywords_url, true);
+        req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
+        req.onreadystatechange = function(request) {
+            if (req.readyState == 4) {
+                if (req.status == '200') {
+                    alert(req.responseText);
+                } else {
+                    alert('Error on server!');
+                }
+            }
+        };
+        req.send("text="+area.value);
+    }
+    return false;
+}
+
+function addKeywordsButton(event) {
+    for (var i = 0, id; id = KEYWORDS_IDS[i]; i++) {
+        var area = document.getElementById(id);
+        if (!area || (typeof(area.value) == 'undefined')) continue;
+        var button = document.createElement('INPUT');
+        button.type = 'button';
+        button.value = 'Check Keywords';
+        button.name = id+'_check_keywords';
+        button.id = id+'_check_keywords';
+        button.className = 'check-keywords-button';
+        area.parentNode.insertBefore(button, area.nextSibling);
+        registerEventListener(button, 'click', checkPageKeywords);
+//         KEYWORDS_REPORT[id] = {};
+    }
+}
+
+registerPloneFunction(addKeywordsButton);
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/custommetatags.js
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/custommetatags.js	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/custommetatags.js	(revision 1346)
@@ -0,0 +1,352 @@
+customMetaTagsFunctions = new Object()
+
+customMetaTagsFunctions.getInputOrSelect = function(node) {
+    /* Get the (first) input or select form element under the given node */
+    
+    var inputs = node.getElementsByTagName("input");
+    if(inputs.length > 0) {
+        return inputs[0];
+    }
+    
+    var selects = node.getElementsByTagName("select");
+    if(selects.length > 0) {
+        return selects[0];
+    }
+
+    return null;
+}
+
+customMetaTagsFunctions.getWidgetRows = function(currnode) {
+	/* Return primary <tr>s of current node's parent DGW */
+	tbody = this.getParentElementById(currnode, "datagridwidget-tbody");
+	return this.getRows(tbody);
+}
+
+customMetaTagsFunctions.getRows = function(tbody) {
+	/* Return <tr> rows of <table> element */
+    
+	var rows = new Array()
+	
+	child = tbody.firstChild;
+	while(child != null) {
+		if(child.tagName != null) {
+			if(child.tagName.toLowerCase() == "tr") {
+				rows = rows.concat(child);
+			}
+		}
+		child = child.nextSibling;
+	}
+	              
+	return rows;   
+} 
+
+customMetaTagsFunctions.autoInsertRow = function(e) {
+    /* Add a new row when changing the last row 
+       (i.e. the infamous auto insert feature)
+    
+       Check that if this onchange event handler was
+       called from the last row. In this case,
+       add a new row for DGF.
+       
+    */
+
+    var currnode = window.event ? window.event.srcElement : e.currentTarget;
+    
+	// fetch required data structure   
+    var tbody = this.getParentElement(currnode, "TBODY");
+    var rows = this.getRows(tbody);        
+    var lastRow = rows[rows.length-1]; 
+    
+    var thisRow = this.getParentElementById(currnode, "datagridwidget-row");      
+    
+    /* Skip the very last row which is a hidden template row */
+    if(rows.length-1 ==(thisRow.rowIndex)) {
+	    // Create a new row
+	    var newtr = this.createNewRow(lastRow);
+	                                                        
+	    // Put new row to DOM tree before template row        
+		lastRow.parentNode.insertBefore(newtr, lastRow);
+		
+		// update orderindex hidden fields
+		this.updateOrderIndex(tbody);	        	    
+    }    
+}
+
+customMetaTagsFunctions.addRowAfter = function(currnode) {
+	/*
+		Creates a new row before the clicked row
+	*/
+	
+	// fetch required data structure
+    var tbody = this.getParentElementById(currnode, "datagridwidget-tbody"); 
+    var thisRow = this.getParentElementById(currnode, "datagridwidget-row"); 
+
+    var newtr = this.createNewRow(thisRow);
+        
+	thisRow.parentNode.insertBefore(newtr, thisRow);
+	
+	// update orderindex hidden fields
+	this.updateOrderIndex(tbody);	
+  
+}
+
+customMetaTagsFunctions.addRow = function(id) {
+	/* Explitcly add row for given DataGridField 
+	
+		@param id Archetypes field id for the widget	
+	*/
+	
+	// fetch required data structure
+    var tbody = document.getElementById("datagridwidget-tbody-" + id);    
+    var rows = this.getRows(tbody);    
+    var lastRow = rows[rows.length-1];
+        
+    var oldRows = rows.length;
+                  
+    // Create a new row
+    var newtr = this.createNewRow(lastRow);
+    
+    // Put new row to DOM tree before template row        
+	newNode = lastRow.parentNode.insertBefore(newtr, lastRow);
+	
+	// update orderindex hidden fields
+	this.updateOrderIndex(tbody);		
+      
+}
+
+customMetaTagsFunctions.createNewRow = function(tr) { 
+	/* Creates a new row 
+		   
+	   @param tr A row in a table where we'll be adding the new row
+	*/
+	
+    var tbody = this.getParentElementById(tr, "datagridwidget-tbody"); 
+    var rows = this.getRows(tbody);   
+    
+    // hidden template row 
+    var lastRow = rows[rows.length-1]; 
+	
+	var newtr = document.createElement("tr");
+    newtr.setAttribute("id", "datagridwidget-row");
+    newtr.setAttribute("class", "datagridwidget-row");
+    	
+	// clone template contents from the last row to the newly created row
+	// HOX HOX HOX
+	// If f****ng IE clones lastRow directly it doesn't work.
+	// lastRow is in hidden state and no matter what you do it remains hidden.
+	// i.e. overriding class doesn't bring it visible.
+	// In Firefox everything worked like a charm.
+	// So the code below is really a hack to satisfy Microsoft codeborgs.
+	// keywords: IE javascript clone clonenode hidden element render visibility visual
+	child = lastRow.firstChild;
+	while(child != null) {
+		newchild = child.cloneNode(true);
+		newtr.appendChild(newchild);
+		child = child.nextSibling;
+	}		
+	    
+    return newtr;	 
+}
+
+
+customMetaTagsFunctions.removeFieldRow = function(node) {
+    /* Remove the row in which the given node is found */
+    
+    var row = this.getParentElementById(node, 'datagridwidget-row');
+    var tbody = this.getParentElementById(node, 'datagridwidget-tbody');
+    tbody.removeChild(row);
+}
+
+customMetaTagsFunctions.moveRowDown = function(currnode){
+    /* Move the given row down one */
+           
+    var tbody = this.getParentElementById(currnode, "datagridwidget-tbody");    
+    
+    var rows = this.getWidgetRows(currnode);
+    
+    var row = this.getParentElementById(currnode, "datagridwidget-row");      
+    if(row == null) {
+    	alert("Couldn't find DataGridWidget row");
+    	return;
+    }
+    
+    var idx = null
+    
+    // We can't use nextSibling because of blank text nodes in some browsers
+    // Need to find the index of the row
+    for(var t = 0; t < rows.length; t++) {
+        if(rows[t] == row) {
+            idx = t;
+            break;
+        }
+    }
+
+    // Abort if the current row wasn't found
+    if(idx == null)
+        return;     
+        
+    // If this was the last row (before the blank row at the end used to create
+    // new rows), move to the top, else move down one.
+    if(idx + 2 == rows.length) {
+        var nextRow = rows.item[0]
+        this.shiftRow(row, nextRow)
+    } else {
+        var nextRow = rows[idx+1]
+        this.shiftRow(nextRow, row)
+    }
+    
+    this.updateOrderIndex(tbody)
+
+}
+
+customMetaTagsFunctions.moveRowUp = function(currnode){
+    /* Move the given row up one */
+    
+    var tbody = this.getParentElementById(currnode, "datagridwidget-tbody");    
+    var rows = this.getWidgetRows(currnode);
+    
+    var row = this.getParentElementById(currnode, "datagridwidget-row");      
+    if(row == null) {
+    	alert("Couldn't find DataGridWidget row");
+    	return;
+    }
+
+    var idx = null
+    
+    // We can't use nextSibling because of blank text nodes in some browsers
+    // Need to find the index of the row
+    for(var t = 0; t < rows.length; t++) {
+        if(rows[t] == row) {
+            idx = t;
+            break;
+        }
+    }
+    
+    // Abort if the current row wasn't found
+    if(idx == null)
+        return;
+        
+    // If this was the first row, move to the end (i.e. before the blank row
+    // at the end used to create new rows), else move up one
+    if(idx == 0) {
+        var previousRow = rows[rows.length - 1]
+        this.shiftRow(row, previousRow);
+    } else {
+        var previousRow = rows[idx-1];
+        this.shiftRow(row, previousRow);
+    }
+    
+    this.updateOrderIndex(tbody);
+}
+
+customMetaTagsFunctions.shiftRow = function(bottom, top){
+    /* Put node top before node bottom */
+    
+    bottom.parentNode.insertBefore(bottom, top)   
+}
+
+customMetaTagsFunctions.updateOrderIndex = function (tbody) {
+
+    /* Update the hidden orderindex fields to be in the right order */
+    
+    var xre = new RegExp(/^orderindex__/)
+    var idx = 0;
+    var cell;
+    
+    var rows = this.getRows(tbody); 
+    
+    /* Make sure that updateOrderIndex doesn't touch 
+       the template (last) row */
+    for(var i=0; i<rows.length-1; i++) {
+    
+	    for (var c = 0; (cell = rows[i].getElementsByTagName('INPUT').item(c)); c++) {
+	            
+	        if (cell.getAttribute('id')) {
+	            if (xre.exec(cell.id)) {
+	                cell.value = idx;
+	            }
+	        }           
+	        this.updateRadioButtonGroupName(this.getParentElement(cell, "TR"), idx);        
+	        idx++;
+	    }      
+	}
+}
+
+
+customMetaTagsFunctions.updateRadioButtonGroupName = function (row, newIndex) {
+	/* Adjust radio button group names after reordering 
+	
+	   Why we do this, see RadioColumn class comments
+	   
+	   TODO: If chain onchange -> updateOrderIndex -> updaterRadioButtonGroupName
+	   is triggered on Firefox, the value of checked radio button is put to the
+	   newly generated row instead of clicked row.
+	*/
+
+   var cell;
+   var xre = new RegExp(/^radio/)
+   var xre2 = new RegExp(/^checkbox/)
+   
+    for (var c = 0; (cell = row.getElementsByTagName('INPUT').item(c)); c++) {
+   	    	    
+   	   	if(cell.getAttribute('type')) {
+   	   		var type = cell.getAttribute('type');
+             if (xre.exec(type) || xre2.exec(type)) {          
+            	
+				var name = cell.getAttribute("NAME")
+				if(name == null) continue;
+
+				// save fieldId + columnId part
+				var baseLabel = name.substring(0, name.lastIndexOf("."));				
+				// update per row running id
+				cell.setAttribute("NAME", baseLabel + "." + newIndex);
+			}
+   	    }               
+	}
+}
+
+customMetaTagsFunctions.getParentElement = function(currnode, tagname) {
+    /* Find the first parent node with the given tag name */
+
+    tagname = tagname.toUpperCase();
+    var parent = currnode.parentNode;
+
+    while(parent.tagName.toUpperCase() != tagname) {
+        parent = parent.parentNode;
+        // Next line is a safety belt
+        if(parent.tagName.toUpperCase() == "BODY") 
+            return null;
+    }
+
+    return parent;
+}
+
+customMetaTagsFunctions.getParentElementById = function(currnode, id) {
+    /* Find the first parent node with the given id 
+    
+    	Id is partially matched: the beginning of
+    	an element id matches parameter id string.
+    
+    	Currnode: Node where ascending in DOM tree beings
+    	Id: Id string to look for. 
+    	    	
+    */
+    
+    id = id.toLowerCase();
+    var parent = currnode.parentNode;
+
+    while(true) {
+       
+    	var parentId = parent.getAttribute("id");
+    	if(parentId != null) {    	
+    		 if(parentId.toLowerCase().substring(0, id.length) == id) break;
+    	}
+    	    
+        parent = parent.parentNode;
+        // Next line is a safety belt
+        if(parent.tagName.toUpperCase() == "BODY") 
+            return null;
+    }
+
+    return parent;
+}
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/qseo_properties_edit.cpy
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/qseo_properties_edit.cpy	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/qseo_properties_edit.cpy	(revision 1346)
@@ -0,0 +1,77 @@
+## Controller Python Script "qseo_properties_edit"
+##bind container=container
+##bind context=context
+##bind namespace=
+##bind script=script
+##bind state=state
+##bind subpath=traverse_subpath
+##title=Update SEO Properties
+##parameters=title=None,description=None,canonical=None,keywords=None,html_comment=None,robots=None,distribution=None,title_override=0,description_override=0,canonical_override=0,keywords_override=0,html_comment_override=0,robots_override=0,distribution_override=0,custommetatags=[],custommetatags_override=0
+
+from Products.CMFCore.utils import getToolByName
+SEPERATOR = '|'
+
+def setProperty(context, property, value, type='string'):
+    if context.hasProperty(property):
+        context.manage_changeProperties({property: value})
+    else:
+        context.manage_addProperty(property, value, type)
+
+delete_list = []
+
+# update custom meta tags
+property_prefix = 'qSEO_custom_'
+custom_existing = []
+for property, value in context.propertyItems():
+    if property.find(property_prefix) == 0 and len(property) > len(property_prefix):
+        custom_existing.append(property)
+
+if not custommetatags_override: custommetatags=[]
+
+globalCustomMetaTags = []
+site_properties = getToolByName(context, 'portal_properties')
+if hasattr(site_properties, 'seo_properties'):
+    custom_meta_tags = getattr(site_properties.seo_properties, 'default_custom_metatags', [])
+    for tag in custom_meta_tags:
+        name_value = tag.split(SEPERATOR)
+        if name_value[0]:
+            globalCustomMetaTags.append({'meta_name'    : name_value[0],
+                                         'meta_content' : len(name_value) == 1 and '' or name_value[1]})
+
+custom_updated = []
+for tag in custommetatags:
+    meta_name, meta_content = tag['meta_name'], tag['meta_content']
+    if meta_name:
+        if not [gmt for gmt in globalCustomMetaTags if (gmt['meta_name']==meta_name and gmt['meta_content']==meta_content)]:
+            setProperty(context, '%s%s' % (property_prefix, meta_name), meta_content)
+        custom_updated.append('%s%s' % (property_prefix, meta_name))
+
+#add not updated custom metatags to delete list
+for tag in custom_existing:
+    if tag not in custom_updated:
+        delete_list.append(tag)
+
+setProperty(context, 'qSEO_title', title)
+setProperty(context, 'qSEO_description', description)
+setProperty(context, 'qSEO_keywords', keywords, 'lines')
+setProperty(context, 'qSEO_html_comment', html_comment)
+setProperty(context, 'qSEO_robots', robots)
+setProperty(context, 'qSEO_distribution', distribution)
+setProperty(context, 'qSEO_canonical', canonical)
+
+if not title_override:        delete_list.append('qSEO_title')
+if not description_override:  delete_list.append('qSEO_description')
+if not keywords_override:     delete_list.append('qSEO_keywords')
+if not html_comment_override: delete_list.append('qSEO_html_comment')
+if not robots_override:       delete_list.append('qSEO_robots')
+if not distribution_override: delete_list.append('qSEO_distribution')
+if not canonical_override:    delete_list.append('qSEO_canonical')
+
+if delete_list: context.manage_delProperties(delete_list)
+
+msg ='Content SEO properties have been saved.'
+try:
+    context.plone_utils.addPortalMessage(msg)
+    return state
+except AttributeError:
+    return state.set(context=context, portal_status_message=msg)
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/qseo_properties_edit.cpy.metadata
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/qseo_properties_edit.cpy.metadata	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/qseo_properties_edit.cpy.metadata	(revision 1346)
@@ -0,0 +1,6 @@
+[validators]
+validators = 
+
+[actions]
+action.failure=redirect_to:string:qseo_properties_edit_form
+action.success=redirect_to_action:string:view
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/qseo_properties_edit_form.cpt
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/qseo_properties_edit_form.cpt	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/qseo_properties_edit_form.cpt	(revision 1346)
@@ -0,0 +1,403 @@
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"
+      lang="en"
+      metal:use-macro="here/main_template/macros/master"
+      i18n:domain="qseoptimizer">
+  <metal:border fill-slot="top_slot">
+    <tal:border define="dummy python:request.set('enable_border',1)" />
+  </metal:border>
+  <metal:style_head fill-slot="style_slot">
+      <style type="text/css">
+          tr.datagridwidget-empty-row {
+              display:none;
+          }
+      </style>
+  </metal:style_head>
+  <metal:javascript_head fill-slot="javascript_head_slot">
+      <script type="text/javascript"
+              tal:condition="python:exists('portal/statistics.js')"
+              tal:attributes="src string:${here/absolute_url}/statistics.js">
+      </script>
+      <script type="text/javascript"
+              tal:condition="python:exists('portal/checkkeywords.js')"
+              tal:attributes="src string:${here/absolute_url}/checkkeywords.js">
+      </script>
+      <script type="text/javascript"
+              tal:condition="python:exists('portal/custommetatags.js')"
+              tal:attributes="src string:${here/absolute_url}/custommetatags.js">
+      </script>
+  </metal:javascript_head>
+  <body>
+    <div metal:fill-slot="main"
+         tal:define="errors options/state/getErrors;">
+      <h1 i18n:translate="heading_edit_seo_properties">Edit SEO Properties</h1>
+      <p i18n:translate="description_edit_seo_properties">
+        This form allows you to edit html header meta tags that are produced by 
+        Plone CMS to incrase Search Engine visibility. The values generated by
+        Plone are present as default values in input fields. To override default
+        value, check corresponding checkbox and input value itself in the 
+        corresponding field.
+      </p>
+      <form name="edit_form"
+            action=""
+            method="post"
+            tal:attributes="action string:${here/absolute_url}/${template/getId}">
+        <fieldset>
+          <legend i18n:domain="plone" i18n:translate="legend_item_properties">
+            Item Properties
+          </legend>
+          <div class="field">
+            <label i18n:translate="label_page_title_tag" for="title">
+              Page Title Tag
+            </label>
+            <div class="formHelp" i18n:translate="help_seo_title">
+              Text to be present in &lt;TITLE&gt; tag. It is displayed in 
+              browser title bar. Search engines display it as a title of the 
+              document.
+            </div>
+            <input type="checkbox"
+                   id="title_override"
+                   name="title_override"
+                   tabindex=""
+                   tal:attributes="checked python:test(here.hasProperty('qSEO_title'), 'on', None);
+                                   tabindex tabindex/next;"
+                   />
+            <label i18n:translate="label_override" for="title_override">
+              Override
+            </label>
+            <input type="text"
+                   id="seo_title"
+                   name="title"
+                   size="40"
+                   value=""
+                   tabindex=""
+                   tal:attributes="value request/title | here/@@seo_context/seo_title;
+                                   tabindex tabindex/next;"
+                   />
+          </div>
+          <div class="field">
+            <label i18n:translate="label_meta_description_tag" for="description">
+              Meta Description Tag
+            </label>
+            <div class="formHelp" i18n:translate="help_seo_description">
+              Description of the document to be indexed by Search Engines. This text will be present in meta description tag in page HTML source. 
+            </div>
+            <input type="checkbox"
+                   id="description_override"
+                   name="description_override"
+                   tabindex=""
+                   tal:attributes="checked python:test(here.hasProperty('qSEO_description'), 'on', None);
+                                   tabindex tabindex/next;"
+                   />
+            <label i18n:translate="label_override" for="description_override">
+              Override
+            </label>
+            <textarea 
+                      type="text"
+                      id="seo_description"
+                      name="description"
+                      rows="5"
+                      cols="15"
+                      tabindex=""
+                      tal:attributes="tabindex tabindex/next;"
+                      tal:content="request/description | here/@@seo_context/seo_description"
+                      ></textarea>
+
+          </div>
+            
+          <div class="field">
+            <label i18n:translate="label_meta_keywords_tag" for="keywords">
+              Meta Keywords Tag
+            </label>
+            <div class="formHelp" i18n:translate="help_seo_keywords">
+              Keywords, the page will be indexed with. 
+              <strong>Enter each keyword in separate line, please.</strong>
+              Though the relevance of listing meta keywords is of 
+              questionable value now, it is useful to set meta keywords for
+              pages - for furture reference.
+            </div>
+            <input type="checkbox"
+                   id="keywords_override"
+                   name="keywords_override"
+                   tabindex=""
+                   tal:attributes="checked python:test(here.hasProperty('qSEO_keywords'), 'on', None);
+                                   tabindex tabindex/next;"
+                   />
+            <label i18n:translate="label_override" for="keywords_override">
+              Override
+            </label>
+            <textarea 
+                      id="seo_keywords"
+                      name="keywords:lines"
+                      rows="11"
+                      cols="15"
+                      wrap="off"
+                      tabindex=""
+                      tal:attributes="tabindex tabindex/next;"
+                      tal:define="keywords request/keywords | here/@@seo_context/seo_keywords"
+                      tal:content="python:'\n'.join(keywords)"
+                      ></textarea>
+          </div>
+          <div class="field">
+            <label i18n:translate="label_html_comment" for="html_commment">
+              HTML Comment
+            </label>
+            <div class="formHelp" i18n:translate="help_seo_html_comment">
+              Comments embedded into HTML header.
+            </div>
+            <input type="checkbox"
+                   id="html_comment_override"
+                   name="html_comment_override"
+                   tabindex=""
+                   tal:attributes="checked python:test(here.hasProperty('qSEO_html_comment'), 'on', None);
+                                   tabindex tabindex/next;"
+                   />
+            <label i18n:translate="label_override" for="html_comment_override">
+              Override
+            </label>
+            <br />
+            <textarea 
+                      id="seo_html_comment"
+                      name="html_comment"
+                      rows="5"
+                      cols="15"
+                      tabindex=""
+                      tal:attributes="tabindex tabindex/next;"
+                      tal:content="request/html_comment | here/@@seo_context/seo_html_comment"
+                      ></textarea>
+          </div>
+          <div class="field">
+            <label i18n:translate="label_robot_tag" for="robots">
+              Robot Tag
+            </label>
+            <div class="formHelp" i18n:translate="help_seo_robots">
+              Robots meta tag value. Allows or denies indexing, following 
+              links, etc. for spiders indexing your content.
+            </div>
+            <input type="checkbox"
+                   id="robots_override"
+                   name="robots_override"
+                   tabindex=""
+                   tal:attributes="checked python:test(here.hasProperty('qSEO_robots'), 'on', None);
+                                   tabindex tabindex/next;"
+                   />
+            <label i18n:translate="label_override" for="robots_override">
+              Override
+            </label><br />
+            <input type="text"
+                   id="seo_robots"
+                   name="robots"
+                   size="25"
+                   value=""
+                   tabindex=""
+                   tal:attributes="value request/robots | here/@@seo_context/seo_robots;
+                                   tabindex tabindex/next;"
+                   />
+          </div>
+          <div class="field">
+            <label i18n:translate="label_distribution_tag" for="distribution">
+              Distribution Tag
+            </label>
+            <div class="formHelp" i18n:translate="help_seo_distribution">
+              Distribution meta tag to designate time and area disribution
+              of the document.
+            </div>
+            <input type="checkbox"
+                   id="distribution_override"
+                   name="distribution_override"
+                   tabindex=""
+                   tal:attributes="checked python:test(here.hasProperty('qSEO_distribution'), 'on', None);
+                                   tabindex tabindex/next;"
+                   />
+            <label i18n:translate="label_override" for="distribution_override">
+              Override
+            </label><br />
+            <input type="text"
+                   id="seo_distribution"
+                   name="distribution"
+                   size="25"
+                   value=""
+                   tabindex=""
+                   tal:attributes="value request/distribution | here/@@seo_context/seo_distribution;
+                                   tabindex tabindex/next;"
+                   />
+          </div>
+          <div class="field"
+               tal:define="error errors/canonical|nothing;"
+               tal:attributes="class python:test(error, 'field error', 'field')">
+            <label i18n:translate="label_canonical_tag" for="canonical">
+              Canonical URL
+            </label>
+            <div class="formHelp" i18n:translate="help_seo_canonical">
+              Specify your canonical URL.
+              If your site has identical or vastly similar content that's accessible through multiple URLs,
+              this format provides you with more control over the URL returned in search results.
+            </div>
+            <input type="checkbox"
+                   id="canonical_override"
+                   name="canonical_override"
+                   tabindex=""
+                   tal:attributes="checked python:test(here.hasProperty('qSEO_canonical'), 'on', None);
+                                   tabindex tabindex/next;"
+                   />
+            <label i18n:translate="label_override" for="canonical_override">
+              Override
+            </label><br />
+            <div tal:content="error">Validation error output</div>
+            <input type="text"
+                   id="seo_canonical"
+                   name="canonical"
+                   size="55"
+                   value=""
+                   tabindex=""
+                   tal:attributes="value request/canonical | here/@@seo_context/seo_canonical;
+                                   tabindex tabindex/next;"
+                   />
+          </div>
+          <div class="field"
+               tal:define="fieldName string:custommetatags;
+                           nonEmptylocalMetaTags here/@@seo_context/seo_nonEmptylocalMetaTags;">
+            <label i18n:translate="label_custom_metatags" for="custom_metatags">
+              Custom Meta Tags
+            </label>
+            <div class="formHelp" i18n:translate="help_seo_custommetatags">
+              Here you can manage your custom meta tags. Specify local Meta Tags parameters to be added to the global Meta Tags.  
+              If metacontent field is empty, then a meta tag will not be present on this page.
+
+            </div>
+            <input type="checkbox"
+                   id="custommetatags_override"
+                   name="custommetatags_override"
+                   tabindex=""
+                   tal:attributes="checked python:test(nonEmptylocalMetaTags, 'on', None);
+                                   tabindex tabindex/next;"
+                   />
+            <label i18n:translate="label_override" for="custommetatags_override">
+              Override
+            </label><br />
+            <table class="custom-metatags-table"
+                   id="datagridwidget-table-custommetatags"
+                   style="width: 100%"
+                   tal:define="columnNames python:['meta name', 'meta content'];
+                               columnIds   python:['meta_name', 'meta_content'];
+                               globalMT    here/@@seo_context/seo_globalWithoutLocalCustomMetaTags;
+                               localMT    here/@@seo_context/seo_localCustomMetaTags;">
+                <thead>
+                    <tr>
+                        <th class="discreet" style="text-align: left"
+                            tal:repeat="columnName columnNames"
+                            tal:content="columnName"
+                            i18n:translate="">Column name</th>
+                        <th />
+                        <th />
+                    </tr>
+                </thead>
+                <tbody id="datagridwidget-tbody-custommetatags">
+                    <tal:row-loop tal:repeat="row globalMT">
+                        <tr id="datagridwidget-row">
+                            <tal:vars define="fieldId string:${fieldName}_${repeat/row/number}">
+                                <td class="datagridwidget-cell"
+                                    tal:repeat="column columnIds">
+                                    <tal:cell tal:define="cell_value python:row.get(column);">
+                                        <input type="text"
+                                               id="columnId_fieldId"
+                                               value=""
+                                               name="fieldId.columnId:records"
+                                               style="width: 100%;"
+                                               tal:attributes="name string:${fieldName}.${column}:records;
+                                                               id string:${column}_${fieldId};
+                                                               value cell_value"/>
+                                    </tal:cell>
+                                </td>
+                            </tal:vars>
+                        </tr>
+                    </tal:row-loop>
+                    <tal:row-loop tal:repeat="row localMT">
+                        <tr id="datagridwidget-row">
+                            <tal:vars define="fieldId string:${fieldName}_${repeat/row/number}">
+                                <td class="datagridwidget-cell"
+                                    tal:repeat="column columnIds">
+                                    <tal:cell tal:define="cell_value python:row.get(column);">
+                                        <input type="text"
+                                               id="columnId_fieldId"
+                                               value=""
+                                               name="fieldId.columnId:records"
+                                               style="width: 100%;"
+                                               tal:attributes="name string:${fieldName}.${column}:records;
+                                                               id string:${column}_${fieldId};
+                                                               value cell_value"/>
+                                    </tal:cell>
+                                </td>
+                                <td  class="datagridwidget-manipulator">
+                                    <img alt="Delete row"
+                                         onclick="customMetaTagsFunctions.removeFieldRow(this);return false"
+                                         tal:attributes="src string:${portal_url}/delete_icon.gif;" />
+                                </td>
+                                <td class="datagridwidget-hidden-data">
+                                    <input type="hidden"
+                                           tal:attributes="name string:${fieldName}.orderindex_:records;
+                                                           id string:orderindex__${fieldId};
+                                                           value repeat/row/number;" />
+                                </td>
+                            </tal:vars>
+                        </tr>
+                    </tal:row-loop>
+                    <tr id="datagridwidget-empty-row"
+                        class="datagridwidget-empty-row"
+                        tal:define="fieldId string:${fieldName}_new">
+                        <td class="datagridwidget-cell"
+                            tal:repeat="column columnIds">
+                            <span>
+                                <input type="text"
+                                       id="columnId_fieldName_new"
+                                       value=""
+                                       name="fieldName.columnId:records"
+                                       style="width: 100%;"
+                                       tal:attributes="id string:${column}_${fieldName}_new;
+                                                       name string:${fieldName}.${column}:records;" />
+                           </span>
+                        </td>
+                        <td  class="datagridwidget-manipulator">
+                            <img alt="Delete row"
+                                 onclick="customMetaTagsFunctions.removeFieldRow(this);return false"
+                                 tal:attributes="src string:${portal_url}/delete_icon.gif;" />
+                        </td>
+                        <td class="datagridwidget-hidden-data">
+                            <input type="hidden"
+                                   value="template_row_marker"
+                                   tal:attributes="name string:${fieldName}.orderindex_:records;
+                                                  id string:orderindex__${fieldId};" />
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+            <input type="button"
+                   class="datagridwidget-add-button"
+                   id="datagridwidget-add-button"
+                   value="Add new row"
+                   tal:attributes="onclick string:customMetaTagsFunctions.addRow('${fieldName}')" />
+          </div>
+          <div class="formControls"
+               tal:define="process_creation request/process_creation|nothing;"
+               i18n:domain="plone">
+            <input class="context"
+                   tabindex=""
+                   type="submit"
+                   name="form.button.Save"
+                   value="Save"
+                   i18n:attributes="value"
+                   tal:attributes="tabindex tabindex/next;" />
+            <input class="standalone"
+                   tabindex=""
+                   type="submit"
+                   name="form.button.Cancel"
+                   value="Cancel"
+                   i18n:attributes="value"
+                   tal:attributes="tabindex tabindex/next;" />
+          </div>
+        </fieldset>
+        <input type="hidden" name="form.submitted" value="1" />
+      </form>
+    </div>
+  </body>
+</html>
+
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/qseo_properties_edit_form.cpt.metadata
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/qseo_properties_edit_form.cpt.metadata	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/qseo_properties_edit_form.cpt.metadata	(revision 1346)
@@ -0,0 +1,14 @@
+[default]
+title=Edit SEO Properties
+
+[security]
+View=0:Authenticated
+
+[validators]
+validators..Save=validate_qseo_properties
+validators..Cancel=
+
+[actions]
+action.success..Save=traverse_to:string:qseo_properties_edit
+action.success..Cancel=redirect_to_action:string:view
+action.failure=traverse_to:string:qseo_properties_edit_form
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/statistics.js.dtml
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/statistics.js.dtml	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/statistics.js.dtml	(revision 1346)
@@ -0,0 +1,105 @@
+/******************************************************************
+<dtml-with portal_properties>
+
+Word Wrapping Rules:
+
+    1. multiple spaces == one space 1
+    2. word can include "-" and "'" // [\-\']
+    3. token that not contain a-zA-Z0-9 symbols isn't a word
+    4. non-word characters inside (or on the edge of) word divide it in smaller words
+
+HTML code for text statistics: // for else fields than title in html code title will change accordingly to the field's id
+
+  <div id="title-statistics" class="statistics">
+      <span class="total-words">Total Words: <span class="value">5</span></span>
+      <span class="stop-words">Stop Words: <span class="value">2</span></span>
+      <span class="used-words">Used Words: <span class="value">3</span></span>
+      <span class="length-words">Length: <span class="value">24</span></span>
+  </div>
+
+*******************************************************************/
+
+<dtml-try>
+var stop_words = <dtml-var expr="list(seo_properties.stop_words)">;
+<dtml-except>
+var stop_words = [];
+</dtml-try>
+
+<dtml-try>
+var ids = <dtml-var expr="list(seo_properties.fields)">;
+<dtml-except>
+var ids = [];
+</dtml-try>
+
+var template = '<span class="total-words">Total Words: <span class="value">total_row</span></span> <span class="stop-words">Stop Words: <span class="value">stop_row</span></span> <span class="used-words">Used Words: <span class="value">used_row</span></span> <span class="length-words">Length: <span class="value">length_row</span></span>';
+
+var stop_dict = {};
+
+for (var j=0; word=stop_words[j]; j++) {stop_dict[word] = 1;};
+
+function countWords(data) {
+    var total = 0;
+    var stop  = 0;
+    var used  = 0;
+    var len = data.length || 0;
+    if (len != 0) {
+        data = data // replace all non-word character with space
+                   .replace(/[^a-zA-Z0-9\-\'\u2019\"\`]+/g, ' ')
+                    // replace "-" and "'" symbols if it create groups event inside of token
+                   .replace(/[\-\'\u2019\"\`]{2,}/g, ' ')
+                    // replace all non-word characters and "-", "'" if it stay at word edge
+                   .replace(/(?:^|\s+)[^a-zA-Z0-9]+|[^a-zA-Z0-9]+(?:\s+|$)/g, ' ')
+                   // strip whitespaces
+                   .replace(/^\s*(.*?)\s*$/, '$1');
+        var data_list = data.split(/[^a-zA-Z0-9\-\'\u2019\"\`]+/);
+        for (var i=0; word=data_list[i]; i++) {
+            stop += stop_dict[word.toLowerCase()] ? 1:0;
+        };
+        total = data_list.length;
+        used = total - stop;
+    };
+    return {'total': total, 'stop': stop, 'used': used, 'length': len};
+};
+
+function getHTML(source) {
+    var stats = countWords(source);
+    var html = template;
+    for (var p in stats) {
+        html = html.replace(p+'_row', stats[p]);
+    };
+    return html;
+};
+
+function listenField(event) {
+    var event = event ? event:window.event;
+    var target = null;
+    if (event.target) {
+        target = event.target;
+    } else if (event.srcElement) {
+        target = event.srcElement;
+    };
+    if (!target) {return false;};
+    var update = document.getElementById(target.id+'-statistics');
+    if (update) {
+        update.innerHTML = getHTML(target.value||'');
+    } else {
+        window.status = "Couldn\'t find element with id = \'"+target.id+"-statistics\' on this page";
+    };
+};
+
+function loadStatistics(event){
+    for (var i=0; id=ids[i]; i++) {
+        var el = document.getElementById(id);
+        if (el && (typeof(el.value) != 'undefined')) {
+            var div = document.createElement('DIV');
+            div.id = id+'-statistics';
+            div.className = 'statistics';
+            div.innerHTML = getHTML(el.value||'');
+            el.parentNode.insertBefore(div, el);
+            registerEventListener(el, 'keyup', listenField);
+        };
+    };
+};
+
+registerPloneFunction(loadStatistics);
+/*</dtml-with> Register onload function */
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/validate_qseo_properties.vpy
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/validate_qseo_properties.vpy	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/skins/quintagroup.seoptimizer/validate_qseo_properties.vpy	(revision 1346)
@@ -0,0 +1,31 @@
+## Controller Python Script "qseo_properties_edit"
+##bind container=container
+##bind context=context
+##bind namespace=
+##bind script=script
+##bind state=state
+##bind subpath=traverse_subpath
+##title=validates SEO Properties
+##parameters=title=None,description=None,canonical=None,keywords=None,html_comment=None,robots=None,distribution=None,title_override=0,description_override=0,canonical_override=0,keywords_override=0,html_comment_override=0,robots_override=0,distribution_override=0,custommetatags=[]
+
+from Products.CMFCore.utils import getToolByName
+from Products.CMFPlone import MessageFactory
+_ = MessageFactory('seoptimizer')
+
+def getMainDomain(url):
+    url = url.split('//')[-1]
+    dompath = url.split(':')[0]
+    dom = dompath.split('/')[0]
+    return '.'.join(dom.split('.')[-2:])
+
+purl = getToolByName(context, 'portal_url')()
+if canonical:
+    pdomain = getMainDomain(purl)
+    if not pdomain == getMainDomain(canonical):
+        state.setError('canonical', _(u'Canonical URL mast be in "%s" domain' % pdomain), "canonical")
+
+if state.getErrors():
+    context.plone_utils.addPortalMessage(_(u'Please correct the indicated errors.'))
+    return state.set(status='failure')
+else:
+    return state
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/tests/__init__.py
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/tests/__init__.py	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/tests/__init__.py	(revision 1346)
@@ -0,0 +1,1 @@
+# This file intentionally left blank
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/tests/testQSEOptimizer.py
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/tests/testQSEOptimizer.py	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/tests/testQSEOptimizer.py	(revision 1346)
@@ -0,0 +1,564 @@
+import re
+import string
+import urllib
+from zope.component import getMultiAdapter
+from Products.Five import zcml, fiveconfigure
+from Testing.ZopeTestCase import installPackage, hasPackage
+from Products.PloneTestCase import PloneTestCase
+from Products.CMFCore.utils import getToolByName
+from Products.CMFCore.permissions import ManagePortal
+from Products.CMFQuickInstallerTool.InstalledProduct import InstalledProduct
+from AccessControl.SecurityManagement import newSecurityManager, noSecurityManager
+
+import quintagroup.seoptimizer
+from quintagroup.seoptimizer.config import *
+
+zcml.load_site()
+zcml.load_config('overrides.zcml', quintagroup.seoptimizer)
+zcml.load_config('configure.zcml', quintagroup.seoptimizer)
+PRODUCT = 'quintagroup.seoptimizer'
+installPackage(PRODUCT)
+props = {'stop_words':STOP_WORDS, 'fields':FIELDS, 'additional_keywords': []}
+
+custom_metatags = [{'meta_name'    : 'metatag1',
+                    'meta_content' : 'metatag1value'},
+                   {'meta_name'    : 'metatag2',
+                    'meta_content' : 'metatag2value'},
+                   {'meta_name'    : 'metatag3',
+                    'meta_content' : ''}
+                  ]
+view_metatags = ['DC.creator', 'DC.format', 'DC.date.modified', 'DC.date.created', 'DC.type',
+                   'DC.distribution', 'description', 'keywords', 'robots', 'distribution']
+
+global_custom_metatags = {'default_custom_metatags':'metatag1|global_metatag1value\nmetatag4|global_metatag4value'}
+
+configlets = ({'id':'qSEOptimizer',
+    'name':'Search Engine Optimizer',
+    'action':'string:${portal_url}/seo-controlpanel',
+    'condition':'',
+    'category':'Products',
+    'visible':1,
+    'appId':'qSEOptimizer',
+    'permission':ManagePortal},)
+
+qSEO_CONTENT = ['File','Document','News Item']
+qSEO_FOLDER  = []
+qSEO_TYPES   = qSEO_CONTENT + qSEO_FOLDER
+
+
+PloneTestCase.setupPloneSite()
+
+class TestBeforeInstall(PloneTestCase.FunctionalTestCase):
+
+    def afterSetUp(self):
+        self.basic_auth = 'mgr:mgrpw'
+        self.portal_path = '/%s' % self.portal.absolute_url(1)
+
+    def testAccessPortalRootAnonymous(self):
+        response = self.publish(self.portal_path)
+        self.assertEqual(response.getStatus(), 200)
+
+    def testAccessPortalRootAuthenticated(self):
+        response = self.publish(self.portal_path, self.basic_auth)
+        self.assertEqual(response.getStatus(), 200)
+
+
+class TestInstallation(PloneTestCase.PloneTestCase):
+
+    def afterSetUp(self):
+        self.properties = getToolByName(self.portal, 'portal_properties')
+        self.qi = self.portal.portal_quickinstaller
+        self.qi.installProduct(PRODUCT)
+
+    def testAddingPropertySheet(self):
+        """ Test adding property sheet to portal_properties tool """
+        self.failUnless(hasattr(self.properties.aq_base, PROPERTY_SHEET))
+
+    def testAddingPropertyFields(self):
+        """ Test adding property field to portal_properties.maps_properties sheet """
+        map_sheet = self.properties[PROPERTY_SHEET]
+        for key, value in props.items():
+            self.failUnless(map_sheet.hasProperty(key) and list(map_sheet.getProperty(key)) == value)
+
+    def test_configlet_install(self):
+        configTool = getToolByName(self.portal, 'portal_controlpanel', None)
+        self.assert_(PRODUCT in [a.getId() for a in configTool.listActions()], 'Configlet not found')
+
+    def test_actions_install(self):
+        portal_types = getToolByName(self.portal, 'portal_types')
+        for ptype in portal_types.objectValues():
+            try:
+                #for Plone-2.5 and higher
+                acts = filter(lambda x: x.id == 'seo_properties', ptype.listActions())
+                action = acts and acts[0] or None
+            except AttributeError:
+                action = ptype.getActionById('seo_properties', default=None )
+
+            if ptype.getId() in qSEO_TYPES:
+                self.assert_(action, 'Action for %s not found' % ptype.getId())
+            else:
+                self.assert_(not action, 'Action found for %s' % ptype.getId())
+
+    def test_skins_install(self):
+        skinstool=getToolByName(self.portal, 'portal_skins')
+
+        for skin in skinstool.getSkinSelections():
+            path = skinstool.getSkinPath(skin)
+            path = map( string.strip, string.split( path,',' ) )
+            self.assert_(PRODUCT in path, 'qSEOptimizer layer not found in %s' %skin)
+
+    def test_versionedskin_install(self):
+        skinstool=getToolByName(self.portal, 'portal_skins')
+        mtool = getToolByName(self.portal, 'portal_migration')
+        plone_version = mtool.getFileSystemVersion()
+        if plone_version < "3":
+            for skin in skinstool.getSkinSelections():
+                path = skinstool.getSkinPath(skin)
+                path = map( string.strip, string.split( path,',' ) )
+                self.assert_(PRODUCT+'/%s' % plone_version in path, 'qSEOptimizer versioned layer not found in %s' %skin)
+
+    def test_actions_uninstall(self):
+        self.qi.uninstallProducts([PRODUCT])
+        self.assertNotEqual(self.qi.isProductInstalled(PRODUCT), True,'qSEOptimizer is already installed')
+        portal_types = getToolByName(self.portal, 'portal_types')
+        for ptype in portal_types.objectValues():
+            try:
+                #for Plone-2.5 and higher
+                acts = filter(lambda x: x.id == 'seo_properties', ptype.listActions())
+                action = acts and acts[0] or None
+            except AttributeError:
+                action = ptype.getActionById('seo_properties', default=None )
+
+            self.assert_(not action, 'Action for %s found after uninstallation' % ptype.getId())
+
+    def test_skins_uninstall(self):
+        self.qi.uninstallProducts([PRODUCT])
+        self.assertNotEqual(self.qi.isProductInstalled(PRODUCT), True,'qSEOptimizer is already installed')
+        skinstool=getToolByName(self.portal, 'portal_skins')
+
+        for skin in skinstool.getSkinSelections():
+            path = skinstool.getSkinPath(skin)
+            path = map( string.strip, string.split( path,',' ) )
+            self.assert_(not PRODUCT in path, 'qSEOptimizer layer found in %s after uninstallation' %skin)
+
+    def test_versionedskin_uninstall(self):
+        self.qi.uninstallProducts([PRODUCT])
+        self.assertNotEqual(self.qi.isProductInstalled(PRODUCT), True,'qSEOptimizer is already installed')
+        skinstool=getToolByName(self.portal, 'portal_skins')
+        mtool = getToolByName(self.portal, 'portal_migration')
+        plone_version = mtool.getFileSystemVersion()
+
+        for skin in skinstool.getSkinSelections():
+            path = skinstool.getSkinPath(skin)
+            path = map( string.strip, string.split( path,',' ) )
+            self.assert_(not PRODUCT+'/%s' % plone_version in path, 'qSEOptimizer versioned layer found in %s after uninstallation' %skin)
+
+    def test_configlet_uninstall(self):
+        self.qi.uninstallProducts([PRODUCT])
+        self.assertNotEqual(self.qi.isProductInstalled(PRODUCT), True,'qSEOptimizer is already installed')
+
+        configTool = getToolByName(self.portal, 'portal_controlpanel', None)
+        self.assert_(not PRODUCT in [a.getId() for a in configTool.listActions()], 'Configlet found after uninstallation')
+
+
+class TestResponse(PloneTestCase.FunctionalTestCase):
+
+    def afterSetUp(self):
+        self.qi = self.portal.portal_quickinstaller
+        self.qi.installProduct(PRODUCT)
+        #self.portal.changeSkin('Plone Default')
+
+        self.basic_auth = 'mgr:mgrpw'
+        self.loginAsPortalOwner()
+
+        '''Preparation for functional testing'''
+        my_doc = self.portal.invokeFactory('Document', id='my_doc')
+        my_doc = self.portal['my_doc']
+        self.canonurl = 'http://test.site.com/test.html'
+        self.sp = self.portal.portal_properties.seo_properties
+        self.sp.manage_changeProperties(**global_custom_metatags)
+
+        my_doc.qseo_properties_edit(title='hello world', title_override=1,
+                                    description='it is description', description_override=1,
+                                    keywords='my1|key2', keywords_override=1,
+                                    html_comment='no comments', html_comment_override=1,
+                                    robots='ALL', robots_override=1,
+                                    distribution='Global', distribution_override=1,
+                                    canonical=self.canonurl, canonical_override=1,
+                                    custommetatags=custom_metatags, custommetatags_override=1)
+
+        wf_tool = self.portal.portal_workflow
+        wf_tool.doActionFor(my_doc, 'publish')
+
+        abs_path = "/%s" % my_doc.absolute_url(1)
+        self.abs_path = abs_path
+        self.my_doc = my_doc
+        self.html = self.publish(abs_path, self.basic_auth).getBody()
+
+        # now setup page with title equal to plone site's title
+        my_doc2 = self.portal.invokeFactory('Document', id='my_doc2')
+        my_doc2 = self.portal['my_doc2']
+        my_doc2.update(title=self.portal.Title())
+        wf_tool.doActionFor(my_doc2, 'publish')
+        abs_path2 = "/%s" % my_doc2.absolute_url(1)
+        self.html2 = self.publish(abs_path2, self.basic_auth).getBody()
+
+    def testTitle(self):
+        m = re.match('.*<title>\\s*hello world\\s*</title>', self.html, re.S|re.M)
+        self.assert_(m, 'Title not set in')
+
+    def testTitleDuplication(self):
+        """If we are not overriding page title and current page title equals title of the plone site
+        then there should be no concatenation of both titles. Only one should be displayed.
+        """
+        m = re.match('.*<title>\\s*%s\\s*</title>' % self.portal.Title(), self.html2, re.S|re.M)
+        self.assert_(m, 'Title is not set correctly, perhaps it is duplicated with plone site title')
+
+    def testDescription(self):
+        m = re.match('.*<meta name="description" content="it is description" />', self.html, re.S|re.M)
+        if not m:
+            m = re.match('.*<meta content="it is description" name="description" />', self.html, re.S|re.M)
+        self.assert_(m, 'Description not set in')
+
+    def testKeywords(self):
+        m = re.match('.*<meta name="keywords" content="my1|key2" />', self.html, re.S|re.M)
+        if not m:
+             m = re.match('.*<meta content="my1|key2" name="keywords" />', self.html, re.S|re.M)
+        self.assert_(m, 'Keywords not set in')
+
+    def testRobots(self):
+        m = re.match('.*<meta name="robots" content="ALL" />', self.html, re.S|re.M)
+        if not m:
+            m = re.match('.*<meta content="ALL" name="robots" />', self.html, re.S|re.M)
+        self.assert_(m, 'Robots not set in')
+
+    def testDistribution(self):
+        m = re.match('.*<meta name="distribution" content="Global" />', self.html, re.S|re.M)
+        if not m:
+            m = re.match('.*<meta content="Global" name="distribution" />', self.html, re.S|re.M)
+        self.assert_(m, 'Distribution not set in')
+
+    def testHTMLComments(self):
+        m = re.match('.*<!--\\s*no comments\\s*-->', self.html, re.S|re.M)
+        self.assert_(m, 'Comments not set in')
+
+    def testTagsOrder(self):
+        mtop = self.sp.getProperty('metatags_order')
+        metatags_order = [t.split(' ')[0] for t in mtop if len(t.split(' '))==2 and t.split(' ')[0] in view_metatags]
+        m = re.search('.*'.join(['<meta.*name="%s".*/>' %t for t in metatags_order]), self.html, re.S|re.M)
+        self.assert_(m, "Meta tags order not supported.")
+
+        mtop = list(mtop)
+        mtop.reverse()
+        metatags_order = [t.split(' ')[0] for t in mtop if len(t.split(' '))==2 and t.split(' ')[0] in view_metatags]
+        m = re.search('.*'.join(['<meta.*name="%s".*/>' %t for t in metatags_order]), self.html, re.S|re.M)
+        self.assertFalse(m, "Meta tags order not supported.")
+
+        self.sp.manage_changeProperties(**{'metatags_order':tuple(mtop)})
+        html = self.publish(self.abs_path, self.basic_auth).getBody()
+        m = re.search('.*'.join(['<meta.*name="%s".*/>' %t for t in metatags_order]), self.html, re.S|re.M)
+        self.assertFalse(m, "Meta tags order not supported.")
+
+        m = re.search('.*'.join(['<meta.*name="%s".*/>' %t for t in metatags_order]), html, re.S|re.M)
+        self.assert_(m, "Meta tags order not supported.")
+
+
+    def testCustomMetaTags(self):
+        for tag in custom_metatags:
+            m = re.search('<meta name="%(meta_name)s" content="%(meta_content)s" />' % tag, self.html, re.S|re.M)
+            if not m:
+                m = re.search('<meta content="%(meta_content)s" name="%(meta_name)s" />' % tag, self.html, re.S|re.M)
+            if tag['meta_content']:
+                self.assert_(m, "Custom meta tag %s not applied." % tag['meta_name'])
+            else:
+                self.assert_(not m, "Meta tag %s has no content, but is present in the page." % tag['meta_name'])
+        m = re.search('<meta name="metatag4" content="global_metatag4value" />' , self.html, re.S|re.M)
+        if not m:
+            m = re.search('<meta content="global_metatag4value" name="metatag4" />' , self.html, re.S|re.M)
+        self.assert_(m, "Global custom meta tag %s not applied." % 'metatag4')
+
+    def testDeleteCustomMetaTags(self):
+        self.sp.manage_changeProperties(**{'default_custom_metatags':'metatag1|global_metatag1value'})
+        my_doc = self.my_doc
+        my_doc.qseo_properties_edit(custommetatags=custom_metatags, custommetatags_override=0)
+        html = self.publish(self.abs_path, self.basic_auth).getBody()
+        m = re.search('<meta name="metatag4" content="global_metatag4value" />' , html, re.S|re.M)
+        if not m:
+            m = re.search('<meta content="global_metatag4value" name="metatag4" />' , html, re.S|re.M)
+        self.assert_(not m, "Global custom meta tag %s is prosent in the page." % 'metatag4')
+        m = re.search('<meta name="metatag1" content="global_metatag1value" />' , html, re.S|re.M)
+        if not m:
+            m = re.search('<meta content="global_metatag1value" name="metatag1" />' , html, re.S|re.M)
+        self.assert_(m, "Global custom meta tag %s is prosent in the page." % 'metatag4')
+
+    def testCanonical(self):
+        m = re.match('.*<link rel="canonical" href="%s" />' % self.canonurl, self.html, re.S|re.M)
+        self.assert_(m, self.canonurl)
+
+    def testDefaultCanonical(self):
+        """Default canonical url mast add document absolute_url
+        """
+        # Delete custom canonical url
+        my_doc = self.portal['my_doc']
+        my_doc._delProperty(id='qSEO_canonical')
+        # Get document without customized canonical url
+        abs_path = "/%s" % my_doc.absolute_url(1)
+        self.html = self.publish(abs_path, self.basic_auth).getBody()
+
+        my_url = my_doc.absolute_url()
+        m = re.match('.*<link rel="canonical" href="%s" />' % my_url, self.html, re.S|re.M)
+        self.assert_(m, my_url)
+
+class TestAdditionalKeywords(PloneTestCase.FunctionalTestCase):
+
+    def afterSetUp(self):
+        self.qi = self.portal.portal_quickinstaller
+        self.qi.installProduct(PRODUCT)
+        self.sp = self.portal.portal_properties.seo_properties
+        self.pu = self.portal.plone_utils
+        #self.portal.changeSkin('Plone Default')
+
+        self.basic_auth = 'portal_manager:secret'
+        uf = self.app.acl_users
+        uf.userFolderAddUser('portal_manager', 'secret', ['Manager'], [])
+        user = uf.getUserById('portal_manager')
+        if not hasattr(user, 'aq_base'):
+            user = user.__of__(uf)
+        newSecurityManager(None, user)
+
+        '''Preparation for functional testing'''
+        self.my_doc = self.portal.invokeFactory('Document', id='my_doc')
+        self.my_doc = self.portal['my_doc']
+
+    def test_additional_keywords_in_configlet(self):
+        quoted_keywords = urllib.quote('foo\nbar')
+        path = self.portal.id+'/@@seo-controlpanel?additionalKeywords:lines=%s&form.submitted=1'%quoted_keywords
+        self.publish(path, self.basic_auth)
+        self.assertEqual(self.sp.additional_keywords, ('foo', 'bar'))
+        self.publish(self.portal.id+'/@@seo-controlpanel?form.submitted=1', self.basic_auth)
+        self.assertEqual(self.sp.additional_keywords, ())
+
+    def test_listMetaTags_empty(self):
+        metatags = self.pu.listMetaTags(self.my_doc)
+        self.assert_('keywords' not in metatags)
+
+    def test_listMetaTags_one(self):        
+        self.my_doc.manage_addProperty('qSEO_keywords', ('foo',), 'lines')
+        self.html = str(self.publish(self.portal.id+'/my_doc', self.basic_auth))
+        m = re.match('.*<meta\ content="foo"\ name="keywords"\ />', self.html, re.S|re.M)
+        if not m:
+            m = re.match('.*<meta\ name="keywords"\ content="foo"\ />', self.html, re.S|re.M)
+        self.assert_(m, "No 'foo' keyword find")
+
+    def test_listMetaTags_two(self):        
+        self.my_doc.manage_addProperty('qSEO_keywords', ('foo', 'bar'), 'lines')
+        self.html = str(self.publish(self.portal.id+'/my_doc', self.basic_auth))
+        m = re.match('.*<meta\ content="foo, bar"\ name="keywords"\ />', self.html, re.S|re.M)
+        if not m:
+            m = re.match('.*<meta\ name="keywords"\ content="foo, bar"\ />', self.html, re.S|re.M)
+        self.assert_(m, "No 'foo, bar' keyword find")
+
+    def test_additional_keywords_in_listMetaTags_empty(self):        
+        self.sp.additional_keywords = ('foo',)
+        metatags = self.pu.listMetaTags(self.my_doc)
+        self.assert_('keywords' not in metatags)
+
+    def test_additional_keywords_in_listMetaTags_one(self):
+        self.my_doc.setText('<p>foo</p>')
+        self.sp.additional_keywords = ('foo',)
+        self.html = str(self.publish(self.portal.id+'/my_doc', self.basic_auth))
+        m = re.match('.*<meta\ content="foo"\ name="keywords"\ />', self.html, re.S|re.M)
+        if not m:
+            m = re.match('.*<meta\ name="keywords"\ content="foo"\ />', self.html, re.S|re.M)
+        self.assert_(m, "No 'foo' keyword find")
+
+    def test_additional_keywords_in_listMetaTags_two(self):
+        self.my_doc.setText('<p>foo bar</p>')
+        self.sp.additional_keywords = ('foo', 'bar')
+        self.html = str(self.publish(self.portal.id+'/my_doc', self.basic_auth))
+        m = re.match('.*<meta\ content="foo, bar"\ name="keywords"\ />', self.html, re.S|re.M)
+        if not m:
+            m = re.match('.*<meta\ name="keywords"\ content="foo, bar"\ />', self.html, re.S|re.M)
+        self.assert_(m, "No 'foo, bar' keyword find")
+
+    def test_additional_keywords_in_listMetaTags_merge(self):
+        self.my_doc.setText('<p>foo bar</p>')
+        self.sp.additional_keywords = ('foo', 'bar')
+        self.my_doc.manage_addProperty('qSEO_keywords', ('baz',), 'lines')
+        self.html = str(self.publish(self.portal.id+'/my_doc', self.basic_auth))
+        m = re.match('.*<meta\ content="baz,\ foo,\ bar"\ name="keywords"\ />', self.html, re.S|re.M)
+        if not m:
+            m = re.match('.*<meta\ name="keywords"\ content="baz,\ foo,\ bar"\ />', self.html, re.S|re.M)
+        self.assert_(m, "No 'foo, bar, baz' keyword find")
+
+
+class TestExposeDCMetaTags(PloneTestCase.FunctionalTestCase):
+
+    def afterSetUp(self):
+        self.qi = self.portal.portal_quickinstaller
+        self.sp = self.portal.portal_properties.site_properties
+        self.qi.installProduct(PRODUCT)
+        #self.portal.changeSkin('Plone Default')
+
+        self.basic_auth = 'portal_manager:secret'
+        uf = self.app.acl_users
+        uf.userFolderAddUser('portal_manager', 'secret', ['Manager'], [])
+        user = uf.getUserById('portal_manager')
+        if not hasattr(user, 'aq_base'):
+            user = user.__of__(uf)
+        newSecurityManager(None, user)
+
+        '''Preparation for functional testing'''
+        self.my_doc = self.portal.invokeFactory('Document', id='my_doc')
+        self.my_doc = self.portal['my_doc']
+
+    def test_exposeDCMetaTags_in_configletOn(self):
+        path = self.portal.id+'/@@seo-controlpanel?exposeDCMetaTags=True&form.submitted=1'
+        self.publish(path, self.basic_auth)
+        self.assert_(self.sp.exposeDCMetaTags)
+
+    def test_exposeDCMetaTags_in_configletOff(self):
+        self.publish(self.portal.id+'/@@seo-controlpanel?form.submitted=1', self.basic_auth)
+        self.assert_(not self.sp.exposeDCMetaTags)
+
+    def test_exposeDCMetaTagsPropertyOff(self):
+        self.sp.manage_changeProperties(exposeDCMetaTags = False)
+
+        self.my_doc.qseo_properties_edit()
+        self.html = str(self.publish(self.portal.id+'/my_doc', self.basic_auth))
+        m1 = re.match('.*<meta\ name="DC.format"\ content=".*?"\ />', self.html, re.S|re.M)
+        if not m1:
+            m1 = re.match('.*<meta content=".*?" name="DC.format" />', self.html, re.S|re.M)
+        m2 = re.match('.*<meta name="DC.distribution" content=".*?" />', self.html, re.S|re.M)
+        if not m2:
+            m2 = re.match('.*<meta content=".*?" name="DC.distribution" />', self.html, re.S|re.M)
+        m = m1 or m2
+        self.assert_(not m, 'DC meta tags avaliable when exposeDCMetaTags=False')
+
+    def test_exposeDCMetaTagsPropertyOn(self):
+        self.sp.manage_changeProperties(exposeDCMetaTags = True)
+        self.my_doc.qseo_properties_edit()
+        self.html = str(self.publish(self.portal.id+'/my_doc', self.basic_auth))
+        m1 = re.match('.*<meta\ content=".*?"\ name="DC.format"\ />', self.html, re.S|re.M)
+        if not m1:
+            m1 = re.match('.*<meta\ name="DC.format"\ content=".*?"\ />', self.html, re.S|re.M)
+        m2 = re.match('.*<meta\ content=".*?"\ name="DC.type"\ />', self.html, re.S|re.M)
+        if not m2:
+            m2 = re.match('.*<meta\ name="DC.type"\ content=".*?"\ />', self.html, re.S|re.M)
+        m = m1 and m2
+        self.assert_(m, 'DC meta tags not avaliable when createManager=True')
+
+
+class TestMetaTagsDuplication(PloneTestCase.FunctionalTestCase):
+
+    def afterSetUp(self):
+        self.qi = self.portal.portal_quickinstaller
+        self.basic_auth = 'portal_manager:secret'
+        uf = self.app.acl_users
+        uf.userFolderAddUser('portal_manager', 'secret', ['Manager'], [])
+        user = uf.getUserById('portal_manager')
+        if not hasattr(user, 'aq_base'):
+            user = user.__of__(uf)
+        newSecurityManager(None, user)
+
+        '''Preparation for functional testing'''
+        self.my_doc = self.portal.invokeFactory('Document', id='my_doc')
+        self.my_doc = self.portal['my_doc']
+        self.my_doc.update(description="Document description")
+
+    def test_GeneratorMeta(self):
+        # Get document without customized canonical url
+        abs_path = "/%s" % self.my_doc.absolute_url(1)
+        regen = re.compile('<meta\s+[^>]*name=\"generator\"[^>]*>', re.S|re.M)
+
+        # Before product installation
+        html = self.publish(abs_path, self.basic_auth).getBody()
+        lengen = len(regen.findall(html))
+        self.assert_(lengen==1, "There is %d generator meta tag(s) " \
+           "before seoptimizer installation" % lengen)
+
+#         # After PRODUCT installation
+#         self.qi.installProduct(PRODUCT)
+#         html = self.publish(abs_path, self.basic_auth).getBody()
+#         lengen = len(regen.findall(html))
+#         self.assert_(lengen==1, "There is %d generator meta tag(s) " \
+#            "after seoptimizer installation" % lengen)
+
+    def test_DescriptionMeta(self):
+        # Get document without customized canonical url
+        abs_path = "/%s" % self.my_doc.absolute_url(1)
+        regen = re.compile('<meta\s+[^>]*name=\"description\"[^>]*>', re.S|re.M)
+
+        # Before product installation
+        html = self.publish(abs_path, self.basic_auth).getBody()
+        lendesc = len(regen.findall(html))
+        self.assert_(lendesc==1, "There is %d DESCRIPTION meta tag(s) " \
+           "before seoptimizer installation" % lendesc)
+
+#         # After PRODUCT installation
+#         self.qi.installProduct(PRODUCT)
+#         html = self.publish(abs_path, self.basic_auth).getBody()
+#         lendesc = len(regen.findall(html))
+#         self.assert_(lendesc==1, "There is %d DESCRIPTION meta tag(s) " \
+#            "after seoptimizer installation" % lendesc)
+
+class TestBaseURL(PloneTestCase.FunctionalTestCase):
+
+    def afterSetUp(self):
+        self.qi = self.portal.portal_quickinstaller
+        self.qi.installProduct(PRODUCT)
+        #self.portal.changeSkin('Plone Default')
+
+        self.basic_auth = 'portal_manager:secret'
+        uf = self.app.acl_users
+        uf.userFolderAddUser('portal_manager', 'secret', ['Manager'], [])
+        user = uf.getUserById('portal_manager')
+        if not hasattr(user, 'aq_base'):
+            user = user.__of__(uf)
+        newSecurityManager(None, user)
+
+    def test_notFolderBaseURL(self):
+        my_doc = self.portal.invokeFactory('Document', id='my_doc')
+        my_doc = self.portal['my_doc']
+        regen = re.compile('<base\s+[^>]*href=\"([^\"]*)\"[^>]*>', re.S|re.M)
+        
+        path = "/%s" % my_doc.absolute_url(1)
+        html = self.publish(path, self.basic_auth).getBody()
+        burls = regen.findall(html)
+        
+        mydocurl = my_doc.absolute_url()
+        self.assert_(not [1 for burl in burls if not burl==mydocurl],
+           "Wrong BASE URL for document: %s, all must be: %s" % (burls, mydocurl))
+
+    def test_folderBaseURL(self):
+        my_fldr = self.portal.invokeFactory('Folder', id='my_fldr')
+        my_fldr = self.portal['my_fldr']
+        regen = re.compile('<base\s+[^>]*href=\"([^\"]*)\"[^>]*>', re.S|re.M)
+        
+        path = "/%s" % my_fldr.absolute_url(1)
+        html = self.publish(path, self.basic_auth).getBody()
+        burls = regen.findall(html)
+
+        myfldrurl = my_fldr.absolute_url() + '/'
+        self.assert_(not [1 for burl in burls if not burl==myfldrurl],
+           "Wrong BASE URL for folder: %s , all must be : %s" % (burls, myfldrurl))
+
+TESTS = [TestBeforeInstall,
+         TestInstallation,
+         TestResponse,
+         TestExposeDCMetaTags,
+         TestAdditionalKeywords,
+         TestMetaTagsDuplication,
+         TestBaseURL,
+        ]
+
+def test_suite():
+    from unittest import TestSuite, makeSuite
+    suite = TestSuite()
+    for suite_class in TESTS:
+        suite.addTest(makeSuite(suite_class))
+
+    return suite
+
+if __name__ == '__main__':
+    framework()
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/util.py
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/util.py	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/util.py	(revision 1346)
@@ -0,0 +1,84 @@
+from Globals import InitializeClass
+from AccessControl import ClassSecurityInfo
+from Products.CMFCore.utils import getToolByName
+
+def createMultiColumnList(self,slist, numCols, sort_on='title_or_id'):
+    try:
+        mcl = self.createMultiColumnList(slist, numCols, sort_on=sort_on)
+        return mcl
+    except AttributeError:
+        return [slist]
+
+class SortedDict(dict):
+    security = ClassSecurityInfo()
+
+    security.declarePublic('items')
+    def items(self):
+        primary_metatags = self.pmt
+        lst = [(name,self[name]) for name in primary_metatags                    \
+                                                 if name in self.keys()] +       \
+              [(name, self[name]) for name in self.keys()                        \
+                                                 if name not in primary_metatags]
+        return lst
+
+
+    security.declarePublic('__init__')
+    def __init__(self, *args, **kwargs):
+        super(SortedDict,self).__init__(*args, **kwargs)
+        self.pmt = []
+
+
+    security.declarePublic('__setitem__')
+    def __setitem__(self, i, y):
+        super(SortedDict,self).__setitem__(i, y)
+        if i not in self.pmt:
+            self.pmt.append(i)
+
+    security.declarePublic('pop')
+    def pop(self, k, *args, **kwargs):
+        super(SortedDict,self).pop(k, *args, **kwargs)
+        if k in self.pmt:
+            self.pmt.remove(k)
+
+try:
+    InitializeClass(SortedDict)
+except:
+    pass
+
+def removeSkin(self, layer):
+    """Remove layers"""
+    skinstool = getToolByName(self, 'portal_skins')
+    for skinName in skinstool.getSkinSelections():
+        original_path = skinstool.getSkinPath(skinName)
+        original_path = [l.strip() for l in original_path.split(',')]
+        new_path= []
+        for l in original_path:
+            if (l == layer) or (l.startswith(layer+'/')):
+                continue
+            new_path.append(l)
+        skinstool.addSkinSelection(skinName, ','.join(new_path))
+
+def removeActions(self):
+    tool = getToolByName(self, 'portal_types')
+    for ptype in tool.objectValues():
+        if ptype.getId() in ['File','Document','News Item']:
+            acts = filter(lambda x: x.id == 'seo_properties', ptype.listActions())
+            action = acts and acts[0] or None
+            if action != None:
+                acts = list(ptype.listActions())
+                ptype.deleteActions([acts.index(a) for a in acts if a.getId()=='seo_properties'])
+
+def remove_configlets( context, conf_ids ):
+    configTool = getToolByName(context, 'portal_controlpanel', None)
+    if configTool:
+        for id in conf_ids:
+            configTool.unregisterConfiglet(id)
+
+def uninstall( context ):
+    if context.readDataFile('seo_uninstall.txt') is None:
+        return
+    site = context.getSite()
+    removeSkin( site, 'quintagroup.seoptimizer' )
+    removeActions( site )
+    remove_configlets( site, ('quintagroup.seoptimizer',))
+
Index: /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/version.txt
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/version.txt	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/quintagroup/seoptimizer/version.txt	(revision 1346)
@@ -0,0 +1,1 @@
+2.1.0
Index: /quintagroup.seoptimizer/tags/2.1.0/setup.cfg
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/setup.cfg	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/setup.cfg	(revision 1346)
@@ -0,0 +1,3 @@
+[egg_info]
+tag_build = dev
+tag_svn_revision = true
Index: /quintagroup.seoptimizer/tags/2.1.0/setup.py
===================================================================
--- /quintagroup.seoptimizer/tags/2.1.0/setup.py	(revision 1346)
+++ /quintagroup.seoptimizer/tags/2.1.0/setup.py	(revision 1346)
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+"""
+This module contains the tool of quintagroup.seoptimizer
+"""
+import os
+from setuptools import setup, find_packages
+
+
+version = '2.1.0'
+
+setup(name='quintagroup.seoptimizer',
+      version=version,
+      description="Quintagroup Search Engine Optimization Tool",
+      long_description=open("README.txt").read() + "\n" +
+                       open(os.path.join("docs", "INSTALL.txt")).read() + "\n" +
+                       open(os.path.join("docs", "HISTORY.txt")).read(),
+
+      # Get more strings from http://www.python.org/pypi?%3Aaction=list_classifiers
+      classifiers=[
+        "Framework :: Plone",
+        "Framework :: Zope2",
+        "Framework :: Zope3",
+        "Programming Language :: Python",
+        "Topic :: Software Development :: Libraries :: Python Modules",
+        ],
+      keywords='',
+      author='Myroslav Opyr, Volodymyr Romaniuk, Mykola Kharechko, Vitaliy Podoba, Volodymyr Cherepanyak, Taras Melnychuk, Vitaliy Stepanov',
+      author_email='support@quintagroup.com',
+      url='http://quintagroup.com/services/plone-development/products/qSEOptimizer/',
+      license='GPL',
+      packages=find_packages(exclude=['ez_setup']),
+      namespace_packages=['quintagroup'],
+      include_package_data=True,
+      zip_safe=False,
+      install_requires=[
+          'setuptools',
+          # -*- Extra requirements: -*-
+      ],
+      entry_points="""
+      # -*- Entry points: -*-
+      """,
+      )
