Index: /quintagroup.seoptimizer/tags/4.0.4/MANIFEST.in
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/MANIFEST.in	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/MANIFEST.in	(revision 3016)
@@ -0,0 +1,3 @@
+recursive-include docs *
+recursive-include quintagroup *
+global-exclude *pyc
Index: /quintagroup.seoptimizer/tags/4.0.4/README.txt
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/README.txt	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/README.txt	(revision 3016)
@@ -0,0 +1,116 @@
+Introduction
+============
+
+Quintagroup Search Engine Optimization Tool (quintagroup.seoptimizer)
+was created to enhance SE visibility of Plone sites.
+
+
+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).
+
+IMPORTANT
+---------
+
+**Starting from 3.0 release - quintagroup.seoptimizer package does not need 'overrides.zcml' file. So please remove 'quintagroup.seoptimizer-overrides' line from your buildout's ZCML area.**
+  
+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 changes
+
+* Do this for all documents that need enhanced SEO properties
+
+
+Requirements
+------------
+
+* Plone 3.x, Plone 4 
+
+quintagroup.seoptimizer requires plone.browserlayer package to be installed in your site. plone.browserlayer package is shipped with Plone >= 3.1 and thus you don't need anything extra when you have that version of Plone.
+
+But for Plone 3.0.x < 3.1 the process looks like this:
+
+    * if you are creating a new Plone site and want it to support Quintagroup Search Engine Optimization Tool, just select 2 extension profiles Local browser layer support and quintagroup.seoptimizer profile in 'Extension Profiles' when adding a new Plone site;
+    * if you want to add quintagroup.seoptimizer to already-existing Plone site, you need to apply Local browser layer support extension profile and then quintagroup.seoptimizer profile. You can do it either in  portal_setup/Import or in portal_quickinstaller by simple installation procedure.
+
+In Plone 3.1 you can simply install quintagroup.seoptimizer profile in portal_quickinstaller without need of prior installation of Local browser layer support (that is not available for installation anyway, since it is a part of core system).
+
+IMPORTANT! For Plone 3.0.x you should use plone.browserlayer 1.0.rc3. Be sure to define the right version of plone.browserlayer in your buildout.cfg. For Plone 3.1.x just use the version you have.
+
+
+Notes
+-----
+
+* For Plone 4 versions - use Plone SEO 4.0 release and up http://plone.org/products/plone-seo/releases/4.0. In your buildout.cfg file's egg section set product version::
+
+   [buildout]
+   ....
+   eggs =
+        ...
+        quintagroup.seoptimizer >=4.0
+
+* For Plone 3 versions - use the latest Plone SEO 3.x release http://plone.org/products/plone-seo/releases/3.0.5. In your buildout.cfg file's egg section set product version::
+
+   [buildout]
+   ....
+   eggs =
+        ...
+        quintagroup.seoptimizer >3.0,<4.0
+
+
+* For Plone 2.x versions - use Plone SEO 1.7.1  release http://plone.org/products/plone-seo/releases/1.7.1
+
+
+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.
+
+
+Authors
+-------
+
+* Myroslav Opyr
+
+* Andriy Mylenkyy
+
+* Volodymyr Cherepanyak
+
+* Vitaliy Podoba
+
+* Taras Melnychuk
+
+* Mykola Kharechko
+
+* Vitaliy Stepanov
+
+* Volodymyr Romaniuk
+
Index: /quintagroup.seoptimizer/tags/4.0.4/docs/HISTORY.txt
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/docs/HISTORY.txt	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/docs/HISTORY.txt	(revision 3016)
@@ -0,0 +1,199 @@
+Changelog
+=========
+
+4.0.4 (2010-11-05)
+------------------
+
+* Fixed "Cancel" button processing by SEO Properties
+  form on the context. Thanks Vitaliy Podoba for report.
+  [mylan]
+
+
+4.0.3 (2010-11-05)
+------------------
+
+* Merged fixes from v3.0.7,
+  [mylan]
+* Fixed plone-3.X compatibility,
+  [mylan]
+
+
+3.0.7 (2010-10-20)
+------------------
+
+* Removed lynx dependency,
+  fixed http://plone.org/products/plone-seo/issues/28 issue.
+  [mylan]
+* Added 'External keywords test' option to SEO configlet.
+  Keyword validation can occur against HTML produced by Plone
+  itself or against one processed externally with Deliverance/xdv.
+  [mylan]
+
+
+4.0.1 (2010-08-04)
+------------------
+
+* Removed tabindex form tags attributes
+  from seo-context view
+  [mylan]
+
+
+4.0 (2010-06-02)
+----------------
+
+* Release 4.0 version for Plone-4.X
+
+
+3.0.5 (2010-05-15)
+------------------
+
+* Fix version issue in metadata.xml [crchemist]
+
+
+3.0.4 (2010-05-05)
+------------------
+
+* Added overrides.zcml for BBB http://plone.org/products/plone-seo/issues/19,
+  http://plone.org/products/plone-seo/issues/23 [mylan]
+* Prevent view breakage for context, which canonical url can't be calculated for
+  http://plone.org/products/plone-seo/issues/22 [mylan]
+* Fixed security issue for SEO Property action and view
+  http://plone.org/products/plone-seo/issues/24
+  [crchemist, mylan]
+
+
+3.0.1 (2010-04-29)
+------------------
+
+* Bad title tag definition fixed http://plone.org/products/plone-seo/issues/20 [crchemist]
+
+3.0 (2010-04-20)
+----------------
+
+* Drop global keywords functionality [mylan]
+* Fix performance issues [mylan]
+* Reviewed, updated tests [mylan]
+* Moved all metatags list into htmlhead viewletmanager [mylan]
+* Added advanced tab to seo configlet [mylan]
+* Rewrite seo configlet - use standard plone configlet approach [mylan]
+* Use collective.monkeypatcher package for listMetatags patch [liebster]
+* Killed accessors usage in 'metatags order' property [liebster]
+* Changed management of 'SEO' tab visiblity for different portal types [liebster]
+* Implemented migration to new version [liebster]
+* Cleanup code [mylan]
+* Extend package's requirements, register in z3c.autoinclude entry-point [mylan]
+* Translations rebuilt [mylan]
+* Typo in fields description of seo_configlet fixed [mylan]
+* Ukrainian translations updated [mylan]
+
+2.3.4 (2010-03-30)
+------------------
+
+* Fixed bugs with using domain by custom content type.
+* Missing icon added
+
+
+2.3.3 (2010-02-22)
+------------------
+
+* Fixed content types translation
+
+
+2.3.2 (2010-02-16)
+------------------
+
+* Removed the broken keywords filtering code (fixed the issue #10 decode error) [chervol]
+
+
+2.3.0 (2010-02-08)
+------------------
+
+* Added condition install or reinstall package [liebster]
+* Added filter keywords by their availability in content [liebster]
+* Added settings to control Plone categories and global SEO keywords behaviour [liebster]
+* Added settings to control Plone global SEO keywords vs local SEO keywords behaviour [liebster]
+
+
+2.2.0 (2010-01-06)
+------------------
+
+* Refactor seo_context_properties cpy script to browser view [liebster]
+* Added search numbers of keywords at page [liebster]
+
+
+2.1.1 (2009-11-09)
+------------------
+
+* Fixed bug of meta-tags absence for folderish objects [mylan]
+
+
+2.1.0 (2009-10-21)
+------------------
+
+* 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/4.0.4/docs/INSTALL.txt
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/docs/INSTALL.txt	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/docs/INSTALL.txt	(revision 3016)
@@ -0,0 +1,78 @@
+Installation
+============
+
+Buildout
+--------
+
+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.:
+
+  For Plone 4 - in your buildout.cfg file write::
+
+   [buildout]
+   ....
+   eggs =
+        ...
+        quintagroup.seoptimizer >=4.0
+
+
+  For Plone 3 - in your buildout.cfg file write::
+
+   [buildout]
+   ....
+   eggs =
+        ...
+        quintagroup.seoptimizer >3.0,<4.0
+
+
+* Tell the plone.recipe.zope2instance recipe to install a ZCML slug::
+
+   [instance]
+   ...
+   zcml =
+       quintagroup.seoptimizer
+
+* Re-run buildout, e.g. with::
+
+   $ ./bin/buildout
+
+* Restart the Zope server, e.g with the following command in the terminal::
+
+   $ ./bin/instance restart
+
+
+Traditional Zope 2 Instance
+---------------------------
+
+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 your instance's */lib/python* directory.
+
+* Create a file called ``quintagroup.seoptimizer-configure.zcml`` in your instance's */etc/package-includes* directory. The file should only contain this::
+
+  <include package="quintagroup.seoptimizer" file="configure.zcml" />
+
+* Restart the Zope server, for example with the following command in the terminal::
+
+   $ ./bin/zopectl restart
+
+
+Install quintagroup.seoptimizer via ZMI portal_setup  -> *Import* tab. Select ``quintagroup.seoptimizer`` from the list of available profiles and press *Import all steps*.
+
+Uninstallation
+==============
+
+To uninstall quintagroup.seoptimizer - go to ZMI portal_setup  -> *Import* tab. Select ``quintagroup.seoptimizer uninstall`` profile from the list of available profiles and press *Import all steps*.
+
+Package Upgrade / Reinstall
+===========================
+
+In case you want to upgrate quintagroup.seoptimizer to a newer version in your buildout:
+
+* Remove ``quintagroup.seoptimizer-overrides`` from ``buildout.cfg`` file's ZCML area if it is there (starting from 3.0 release - quintagroup.seoptimizer package does not use overrides.zcml file any longer)
+
+* Upgrade quintagroup.seoptimizer to the newer version using buildout (rerun the buildout to replace old package with a new one in your instance)
+
+* Run the reinstall procedure: visit "Site Setup" -> "Add-ons" control panel in your Plone site, where you have to press quintagroup.seoptimizer button next to Upgrade. (The same can be done with quickinstaller via ZMI: at /portal_quickinstaller/manage_installProductsForm: check the seoptimizer box and press Reinstall button.) This is an essential step as new version of product introduces new persistent settings and ways to migrate settings/content from old to new version. 
+
Index: /quintagroup.seoptimizer/tags/4.0.4/docs/LICENSE.GPL
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/docs/LICENSE.GPL	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/docs/LICENSE.GPL	(revision 3016)
@@ -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/4.0.4/docs/LICENSE.txt
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/docs/LICENSE.txt	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/docs/LICENSE.txt	(revision 3016)
@@ -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/4.0.4/quintagroup/__init__.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/__init__.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/__init__.py	(revision 3016)
@@ -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/4.0.4/quintagroup/seoptimizer/Extensions/Install.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/Extensions/Install.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/Extensions/Install.py	(revision 3016)
@@ -0,0 +1,49 @@
+import logging
+from Products.CMFCore.utils import getToolByName
+from Products.GenericSetup.upgrade import _upgrade_registry
+
+from quintagroup.seoptimizer.config import PROJECT_NAME
+
+logger = logging.getLogger('quintagroup.seoptimizer')
+
+# our GenericSetup profile names
+INSTALL = 'profile-%s:default' % PROJECT_NAME
+UNINSTALL = 'profile-%s:uninstall' % PROJECT_NAME
+
+def install(portal, reinstall=False):
+    """ (Re)Install this product.
+
+        This external method is need, because portal_quickinstaller doens't know
+        what GenericProfile profile to apply when reinstalling a product.
+    """
+    setup_tool = getToolByName(portal, 'portal_setup')
+    if reinstall:
+        step = None
+        profile_id = 'quintagroup.seoptimizer:default'
+        steps_to_run = [s['id'] for s in setup_tool.listUpgrades(profile_id, show_old=False)]
+        for step_id in steps_to_run:
+            step = _upgrade_registry.getUpgradeStep(profile_id, step_id)
+            step.doStep(setup_tool)
+            msg = "Ran upgrade step %s for profile %s" % (step.title, profile_id)
+            logger.log(logging.INFO, msg)
+        # We update the profile version to the last one we have reached
+        # with running an upgrade step.
+        if step and step.dest is not None and step.checker is None:
+           setup_tool.setLastVersionForProfile(profile_id, step.dest)
+        return "Ran all reinstall steps."
+    else:
+        setup_tool.runAllImportStepsFromProfile(INSTALL)
+        return "Ran all install steps."
+
+def uninstall(portal, reinstall=False):
+    """ Uninstall this product.
+
+        This external method is need, because portal_quickinstaller doens't know
+        what GenericProfile profile to apply when uninstalling a product.
+    """
+    setup_tool = getToolByName(portal, 'portal_setup')
+    if reinstall:
+        return "Ran all reinstall steps."
+    else:
+        setup_tool.runAllImportStepsFromProfile(UNINSTALL)
+        return "Ran all uninstall steps."
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/README.txt
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/README.txt	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/README.txt	(revision 3016)
@@ -0,0 +1,72 @@
+Quintagroup Search Engine Optimization Tool was created to enhance SE visibility of Plone sites.
+
+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 changes
+
+  * Do this for all documents that need enhanced SEO properties
+
+Note
+
+  * For Plone 2.x versions - use Plone SEO 1.7.1  release (http://plone.org/products/plone-seo/releases/1.7.1)
+
+  * For Plone 3.x, 4.x versions - use Plone SEO releases starting from 4.0 (http://plone.org/products/plone-seo/releases/4.0)
+
+  * For Plone 3.0.x you must also add plone.browserlayer 1.0.rc3 into your zope instance.
+    Be sure to define the right version of plone.browserlayer in your buildout.cfg.
+    For Plone 3.1.x and upper Plone versions just use the version you have.
+
+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.
+
+Authors
+
+  * Myroslav Opyr
+
+  * Andriy Mylenkyy
+
+  * Volodymyr Cherepanyak
+
+  * Vitaliy Podoba
+
+  * Taras Melnychuk
+
+  * Mykola Kharechko
+
+  * Vitaliy Stepanov
+
+  * Volodymyr Romaniuk
+
+Copyright (c) "Quintagroup": http://quintagroup.com, 2004-2010
+
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/__init__.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/__init__.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/__init__.py	(revision 3016)
@@ -0,0 +1,6 @@
+from AccessControl import allow_module
+from zope.i18nmessageid import MessageFactory
+
+SeoptimizerMessageFactory = MessageFactory('quintagroup.seoptimizer')
+
+allow_module('quintagroup.seoptimizer.util')
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/adapters.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/adapters.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/adapters.py	(revision 3016)
@@ -0,0 +1,63 @@
+import re
+
+from zope.interface import implements
+from zope.component import queryAdapter
+from zope.component import queryMultiAdapter
+
+from quintagroup.seoptimizer.util import SortedDict
+from quintagroup.seoptimizer.interfaces import IMetaKeywords, IMappingMetaTags
+from quintagroup.seoptimizer.browser.seo_configlet import ISEOConfigletSchema
+
+METADATA_MAPS = dict([
+    ("DC.publisher", "Publisher"),
+    ("DC.description", "Description"),
+    ("DC.contributors", "Contributors"),
+    ("DC.creator", "Creator"),
+    ("DC.format", "Format"),
+    ("DC.rights", "Rights"),
+    ("DC.language", "Language"),
+    ("DC.date.modified", "ModificationDate"),
+    ("DC.date.created", "CreationDate"),
+    ("DC.type", "Type"),
+    ("DC.subject", "Subject"),
+    ("DC.distribution", "seo_distribution"),
+    ("description", "seo_description"),
+    ("keywords", "meta_keywords"),
+    ("robots", "seo_robots"),
+    ("distribution", "seo_distribution")])
+
+class MetaKeywordsAdapter(object):
+    implements(IMetaKeywords)
+
+    def __init__(self, context):
+        self.context = context
+
+    def getMetaKeywords(self):
+        """ See interface.
+        """
+        meta_keywords = []
+        seo_context = queryMultiAdapter((self.context, self.context.REQUEST),
+                                        name='seo_context')
+        if seo_context:
+            meta_keywords = list(seo_context['meta_keywords'])
+        return ', '.join(meta_keywords)
+
+
+class MappingMetaTags(object):
+    implements(IMappingMetaTags)
+
+    def __init__(self, context):
+        self.context = context
+        pps = queryMultiAdapter((self.context, self.context.REQUEST),
+                                name="plone_portal_state")
+        self.gseo = queryAdapter(pps.portal(), ISEOConfigletSchema)
+
+    def getMappingMetaTags(self):
+        """ See interface.
+        """
+        metadata_name = SortedDict()
+        if self.gseo:
+            for mt in self.gseo.metatags_order:
+                if METADATA_MAPS.has_key(mt):
+                    metadata_name[mt] = METADATA_MAPS[mt]
+        return metadata_name
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/adapters.zcml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/adapters.zcml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/adapters.zcml	(revision 3016)
@@ -0,0 +1,17 @@
+<configure
+    xmlns="http://namespaces.zope.org/zope"
+    i18n_domain="quintagroup.seoptimizer">
+
+  <adapter
+      for="Products.Archetypes.public.BaseObject"
+      provides=".interfaces.IMetaKeywords"
+      factory=".adapters.MetaKeywordsAdapter"
+      />
+
+  <adapter
+      for="Products.Archetypes.public.BaseObject"
+      provides=".interfaces.IMappingMetaTags"
+      factory=".adapters.MappingMetaTags"
+      />
+
+</configure>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/__init__.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/__init__.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/__init__.py	(revision 3016)
@@ -0,0 +1,1 @@
+#
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/configure.zcml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/configure.zcml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/configure.zcml	(revision 3016)
@@ -0,0 +1,92 @@
+<configure
+    xmlns="http://namespaces.zope.org/zope"
+    xmlns:browser="http://namespaces.zope.org/browser"
+    i18n_domain="quintagroup.seoptimizer">
+
+    <browser:page
+        for="Products.Archetypes.interfaces.IBaseObject"
+        attribute="validateKeywords"
+        class=".keywords.ValidateSEOKeywordsView"
+        name="checkSEOKeywords"
+        permission="cmf.ModifyPortalContent"
+        />
+
+
+    <!-- MOVED from overrides.zcml :
+         BECAUSE all views, viewlets registered for
+         own, .interfaces.IPloneSEOLayer layer      -->
+
+    <!-- Registries for configlet -->
+    <adapter factory=".seo_configlet.SEOConfigletAdapter" />
+
+    <browser:page
+        name="seo-controlpanel"
+        for="Products.CMFPlone.interfaces.IPloneSiteRoot"
+        class=".seo_configlet.SEOConfiglet"
+        layer=".interfaces.IPloneSEOLayer"
+        permission="cmf.ManagePortal"
+        />
+
+    <!-- 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"
+        />
+
+    <browser:viewlet
+        name="quintagroup.seoptimizer.seotags"
+        manager="plone.app.layout.viewlets.interfaces.IHtmlHead"
+        class=".viewlets.SEOTagsViewlet"
+        layer=".interfaces.IPloneSEOLayer"
+        permission="zope2.View"
+        />
+
+    <!--Views-->
+
+    <browser:page
+        name="seo_context"
+        for="*"
+        class=".views.SEOContext"
+        layer=".interfaces.IPloneSEOLayer"
+        permission="zope2.View"
+        />
+
+    <browser:page
+        name="seo-context-properties"
+        for="*"
+        class=".views.SEOContextPropertiesView"
+        layer=".interfaces.IPloneSEOLayer"
+        permission="cmf.ModifyPortalContent"
+        />
+
+    <browser:page
+        name="seotab-visibility"
+        for="*"
+        class=".views.VisibilityCheckerView"
+        attribute="checkVisibilitySEOAction"
+        layer=".interfaces.IPloneSEOLayer"
+        permission="zope.Public"
+        />
+
+    <browser:resourceDirectory name="quintagroup.seoptimizer" directory="./resources" />
+
+</configure>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/interfaces.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/interfaces.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/interfaces.py	(revision 3016)
@@ -0,0 +1,15 @@
+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():
+        """ 1. Get *text* parameter from request
+            2. Parse *text* and validate each extracted keyword
+               for appearing on the context page """
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/keywords.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/keywords.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/keywords.py	(revision 3016)
@@ -0,0 +1,78 @@
+import re, sys, urllib2
+from xml.dom import Node
+
+from zope.interface import implements
+from zope.component import getUtility
+from zope.component import queryAdapter
+
+from Acquisition import aq_acquire
+from Products.Five.browser import BrowserView
+
+from Products.CMFCore.utils import getToolByName
+from Products.CMFPlone.utils import safe_unicode, getSiteEncoding
+from Products.PortalTransforms.interfaces import IPortalTransformsTool
+
+from interfaces import IValidateSEOKeywordsView
+from quintagroup.seoptimizer import SeoptimizerMessageFactory as _
+from quintagroup.seoptimizer.browser.seo_configlet import ISEOConfigletSchema
+
+class ValidateSEOKeywordsView(BrowserView):
+
+    implements(IValidateSEOKeywordsView)
+
+    def validateKeywords(self):
+        """ see interface """
+        text = self.request.get('text')
+        ts = getToolByName(self.context, 'translation_service')
+        transforms = getUtility(IPortalTransformsTool)
+        portal = getToolByName(self.context, 'portal_url').getPortalObject()
+        isExternal = queryAdapter(portal, ISEOConfigletSchema).external_keywords_test
+        # extract keywords from text
+        enc = getSiteEncoding(self.context)
+        if text.lower().strip():
+            keywords = filter(None, map(lambda x: safe_unicode(x.strip(), enc),
+                                         text.lower().strip().split('\n')))
+        else:
+            return ts.utranslate(domain='quintagroup.seoptimizer',
+                                 msgid=_(u'Keywords list is empty!'),
+                                 context=self.context)
+        # Get html page internally or with external request
+        error_url = ""
+        if isExternal:
+            # Not pass timeout option because:
+            # 1. its value get from the global default timeout settings.
+            # 2. timeout option added in python 2.6 (so acceptable only in plone4+)
+            try:
+                resp = urllib2.urlopen(self.context.absolute_url())
+                try:
+                    html = resp.read()
+                finally:
+                    resp.close()
+            except (urllib2.URLError, urllib2.HTTPError), e:
+                # In case of exceed timeout period or other URL connection errors.
+                # Get nearest to context error_log object (stolen from Zope2/App/startup.py)
+                html = None
+                info = sys.exc_info()
+                elog = getToolByName(self.context, "error_log")
+                error_url = elog.raising(info)
+        else:
+            html = unicode(self.context()).encode(enc)
+
+        # If no html - information about problem with page retrieval should be returned
+        result = []
+        if html is None:
+            result.append("Problem with page retrieval.")
+            if error_url:
+                result.append("Details at %s." % error_url)
+        else:
+            page_text = transforms.convert("html_to_text", html).getData()
+            # check every keyword on appearing in body of html page
+            for keyword in keywords:
+                keyword_on_page = unicode(len(re.findall(u'\\b%s\\b' % keyword, page_text, re.I|re.U)))
+                result.append(' - '.join((keyword, keyword_on_page)))
+
+        return ts.utranslate(domain='quintagroup.seoptimizer',
+                             msgid=_(u'number_keywords',
+                               default=u'Number of keywords at page:\n${result}',
+                               mapping={'result':'\n'.join(result)}),
+                             context=self.context)
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/resources/seo_context_properties.js
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/resources/seo_context_properties.js	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/resources/seo_context_properties.js	(revision 3016)
@@ -0,0 +1,504 @@
+/******************************************************************
+
+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>
+
+*******************************************************************/
+
+// stop_words and ids defined in the browser/templates/seo_context_properties.pt
+// page template and here we use already calculated variables
+
+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);
+
+
+/******************************************************************
+
+Adding 'Check Keywords' button to check whether words you entered in
+this block as SEO keywords are present in content.
+
+*******************************************************************/
+
+var seo_keywords_url = document.baseURI + '/@@checkSEOKeywords';
+var KEYWORDS_IDS = ['seo_keywords',];
+
+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 addSeoKeywordsButton(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.nextSiblin);
+        registerEventListener(button, 'click', checkPageKeywords);
+    }
+}
+
+registerPloneFunction(addSeoKeywordsButton);
+
+/******************************************************************
+
+Using for manage your custom meta tags, to be added to the global Meta Tags.
+
+*******************************************************************/
+
+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/4.0.4/quintagroup/seoptimizer/browser/seo_configlet.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/seo_configlet.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/seo_configlet.py	(revision 3016)
@@ -0,0 +1,199 @@
+import re
+from zope.interface import Interface
+from zope.interface import implements
+from zope.component import adapts
+from zope.schema import Bool, Choice, Tuple, List
+from zope.schema import SourceText
+
+from zope.app.component.hooks import getSite
+from zope.app.form.browser import TextAreaWidget
+
+from plone.fieldsets.fieldsets import FormFieldsets
+from plone.app.controlpanel.form import ControlPanelForm
+from plone.app.controlpanel.widgets import MultiCheckBoxThreeColumnWidget
+
+from Products.CMFCore.utils import getToolByName
+from Products.CMFPlone.utils import safe_unicode
+from Products.CMFDefault.formlib.schema import ProxyFieldProperty
+from Products.CMFDefault.formlib.schema import SchemaAdapterBase
+from Products.CMFPlone.interfaces import IPloneSiteRoot
+
+from quintagroup.seoptimizer import SeoptimizerMessageFactory as _
+
+
+# Configlet schemas
+class ISEOConfigletBaseSchema(Interface):
+
+    exposeDCMetaTags = Bool(
+        title=_("label_exposeDCMetaTags",
+                default='Expose <abbr title="Dublin Core">DC</abbr> meta tags'),
+        description=_("description_seo_dc_metatags",
+                default='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.'),
+        default=True,
+        required=False)
+
+    metatags_order = List(
+        title=_("label_metatags_order",
+                default='Meta tags order in the page.'),
+        description=_("help_metatags_order",
+                default='Fill in meta tags (one per line) in the order in which'
+                    ' they will appear on site source pages. Example: '
+                    '"metaname accessor".'),
+        required=False)
+
+    types_seo_enabled = Tuple(
+        title=_("label_content_type_title", default='Content Types'),
+        description=_("description_seo_content_types",
+            default='Select content types that will have SEO properties '
+                'enabled.'),
+        required=False,
+        missing_value=tuple(),
+        value_type=Choice(
+            vocabulary="plone.app.vocabularies.ReallyUserFriendlyTypes"))
+
+    default_custom_metatags = List(
+        title=_("label_default_custom_metatags", default='Default custom metatags.'),
+        description=_("help_default_custom_metatags",
+                default='Fill in custom metatag names (one per line) which will '
+                    'appear on qseo_properties edit tab. Example: '
+                    '"metaname|metacontent" or "metaname".'),
+        required=False)
+
+
+
+class ISEOConfigletAdvancedSchema(Interface):
+    custom_script = SourceText(
+        title=_("label_custom_script", default=u'Header JavaScript'),
+        description=_("help_custom_script",
+                default=u"This JavaScript code will be included in "
+                         "the rendered HTML as entered in the page header."),
+        default=u'',
+        required=False)
+
+    fields = List(
+        title=_("label_fields", default='Fields for keywords statistic calculation.'),
+        description=_("help_fields",
+                default='Fill in filds (one per line) which statistics of keywords usage '
+                    'should be calculated for.'),
+        required=False)
+
+    stop_words = List(
+        title=_("label_stop_words", default='Stop words.'),
+        description=_("help_stop_words",
+                default='Fill in stop words (one per line) which will '
+                    'be excluded from kewords statistics calculation.'),
+        required=False)
+
+    external_keywords_test = Bool(
+        title=_("label_external_keywords_test",
+                default='External keywords check'),
+        description=_("description_external_keywords_test",
+                default='Make keywords test by opening context url as '
+                    'external resource with urllib2.openurl(). This is '
+                    'useful when xdv/Deliverance transformation is used '
+                    'on the site.'),
+        default=False,
+        required=False)
+
+
+class ISEOConfigletSchema(ISEOConfigletBaseSchema,
+                          ISEOConfigletAdvancedSchema):
+    """Combined schema for the adapter lookup.
+    """
+
+
+class SEOConfigletAdapter(SchemaAdapterBase):
+
+    adapts(IPloneSiteRoot)
+    implements(ISEOConfigletSchema)
+
+    def __init__(self, context):
+        super(SEOConfigletAdapter, self).__init__(context)
+        self.portal = getSite()
+        pprop = getToolByName(self.portal, 'portal_properties')
+        self.context = pprop.seo_properties
+        self.siteprops = pprop.site_properties
+        self.ttool = getToolByName(context, 'portal_types')
+        self.encoding = pprop.site_properties.default_charset
+
+
+    def getExposeDC(self):
+        return self.siteprops.getProperty('exposeDCMetaTags')
+
+    def setExposeDC(self, value):
+        return self.siteprops._updateProperty('exposeDCMetaTags', bool(value))
+
+    def getTypesSEOEnabled(self):
+        ct_with_seo = self.context.content_types_with_seoproperties
+        return [t for t in self.ttool.listContentTypes() if t in ct_with_seo]
+
+    def setTypesSEOEnabled(self, value):
+        value = [t for t in self.ttool.listContentTypes() if t in value]
+        self.context._updateProperty('content_types_with_seoproperties', value)
+
+    def getCustomScript(self):
+        description = getattr(self.context, 'custom_script', u'')
+        return safe_unicode(description)
+
+    def setCustomScript(self, value):
+        if value is not None:
+            self.context.custom_script = value.encode(self.encoding)
+        else:
+            self.context.custom_script = ''
+
+    exposeDCMetaTags = property(getExposeDC, setExposeDC)
+    default_custom_metatags = ProxyFieldProperty(ISEOConfigletSchema['default_custom_metatags'])
+    metatags_order = ProxyFieldProperty(ISEOConfigletSchema['metatags_order'])
+    types_seo_enabled = property(getTypesSEOEnabled, setTypesSEOEnabled)
+    custom_script = property(getCustomScript, setCustomScript)
+    fields = ProxyFieldProperty(ISEOConfigletSchema['fields'])
+    stop_words = ProxyFieldProperty(ISEOConfigletSchema['stop_words'])
+    external_keywords_test = ProxyFieldProperty(ISEOConfigletSchema['external_keywords_test'])
+
+
+class Text2ListWidget(TextAreaWidget):
+    height = 5
+    splitter = re.compile(u'\\r?\\n', re.S|re.U)
+
+    def _toFieldValue(self, input):
+        if input == self._missing:
+            return self.context._type()
+        else:
+            return self.context._type(filter(None, self.splitter.split(input)))
+
+    def _toFormValue(self, value):
+        if value == self.context.missing_value or value == self.context._type():
+            return self._missing
+        else:
+            return u'\r\n'.join(list(value))
+
+
+# Fieldset configurations
+baseset = FormFieldsets(ISEOConfigletBaseSchema)
+baseset.id = 'seobase'
+baseset.label = _(u'label_seobase', default=u'Base')
+
+advancedset = FormFieldsets(ISEOConfigletAdvancedSchema)
+advancedset.id = 'seoadvanced'
+advancedset.label = _(u'label_seoadvanced', default=u'Advanced')
+
+class SEOConfiglet(ControlPanelForm):
+
+    form_fields = FormFieldsets(baseset, advancedset)
+
+    form_fields['default_custom_metatags'].custom_widget = Text2ListWidget
+    form_fields['metatags_order'].custom_widget = Text2ListWidget
+    form_fields['types_seo_enabled'].custom_widget = MultiCheckBoxThreeColumnWidget
+    form_fields['types_seo_enabled'].custom_widget.cssClass='label'
+    form_fields['fields'].custom_widget = Text2ListWidget
+    form_fields['stop_words'].custom_widget = Text2ListWidget
+
+    label = _("Search Engine Optimizer configuration")
+    description = _("seo_configlet_description", default="You can select what "
+                    "content types are qSEOptimizer-enabled, and control if "
+                    "Dublin Core metatags are exposed in the header of content "
+                    "pages.")
+    form_name = _("")
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/templates/seo_context_properties.pt
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/templates/seo_context_properties.pt	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/templates/seo_context_properties.pt	(revision 3016)
@@ -0,0 +1,369 @@
+<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/main_template/macros/master"
+      xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+      i18n:domain="quintagroup.seoptimizer">
+  <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"
+      tal:define="portal context/@@plone_portal_state/portal;">
+    <!-- // Define variables used by seo_context_properties.js -->
+    <script type="text/javascript" tal:content="string:var stop_words = ${view/getPropertyStopWords}"></script>
+    <script type="text/javascript" tal:content="string:var ids = ${view/getPropertyFields}"></script>
+    <script tal:attributes="src string:${here/absolute_url}/++resource++quintagroup.seoptimizer/seo_context_properties.js"></script>
+  </metal:javascript_head>
+
+  <body>
+    <div metal:fill-slot="main"
+        tal:define="errors python:request.get('errors', {});
+		    seo_context here/@@seo_context"><!--TODO:implement validation and errors handling-->
+      <h1 i18n:translate="heading_edit_seo_properties">Edit SEO Properties</h1>
+      <div class="documentDescription" 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.
+        </div>
+      <form name="edit_form" action="." method="post" enctype="multipart/form-data"
+              tal:attributes="action string:${context/absolute_url}/@@seo-context-properties" >
+
+        <fieldset>
+          <legend i18n:domain="plone" i18n:translate="legend_item_properties">
+            Item Properties
+          </legend>
+
+          <div class="field">
+            <label i18n:translate="label_page_title_tag" for="seo_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="seo_title_override"
+                   name="seo_title_override"
+                   tal:attributes="checked python:view.test(seo_context['has_seo_title'], 'on', None);"
+                   />
+            <label i18n:translate="label_override" for="seo_title_override">
+              Override
+            </label>
+            <br />
+            <input type="text"
+                   id="seo_title"
+                   name="seo_title"
+                   size="40"
+                   value=""
+                   tal:attributes="value request/seo_title | seo_context/seo_title;"
+                   />
+          </div>
+          <div class="field">
+            <label i18n:translate="label_meta_description_tag" for="seo_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="seo_description_override"
+                   name="seo_description_override"
+                   tal:attributes="checked python:view.test(seo_context['has_seo_description'], 'on', None);"
+                   />
+            <label i18n:translate="label_override" for="seo_description_override">
+              Override
+            </label>
+            <br />
+            <textarea type="text"
+                      id="seo_description"
+                      name="seo_description"
+                      rows="5"
+                      cols="15"
+                      tal:content="request/seo_description | seo_context/seo_description"
+                      />
+          </div>
+          <div class="field">
+            <label i18n:translate="label_meta_keywords_tag" for="seo_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 future reference.<br/>Use 'Check Keywords' button to check whether
+              words you entered in this block as SEO keywords are present in content.
+            </div>
+            <input type="checkbox"
+                   id="seo_keywords_override"
+                   name="seo_keywords_override"
+                   tal:attributes="checked python:view.test(seo_context['has_seo_keywords'], 'on', None);"
+                   />
+            <label i18n:translate="label_override" for="seo_keywords_override">
+              Override
+            </label>
+            <br />
+            <textarea id="seo_keywords"
+                      name="seo_keywords:lines"
+                      rows="11"
+                      cols="15"
+                      wrap="off"
+                      tal:define="keywords request/seo_keywords | seo_context/seo_keywords"
+                      tal:content="python:'\n'.join(keywords)"
+                      />
+            <br />
+          </div>
+          <div class="field">
+            <label i18n:translate="label_html_comment" for="seo_html_commment">
+              HTML Comment
+            </label>
+            <div class="formHelp" i18n:translate="help_seo_html_comment">
+              Comments embedded into HTML header.
+            </div>
+            <input type="checkbox"
+                   id="seo_html_comment_override"
+                   name="seo_html_comment_override"
+                   tal:attributes="checked python:view.test(seo_context['has_html_comment'], 'on', None);"
+                   />
+            <label i18n:translate="label_override" for="seo_html_comment_override">
+              Override
+            </label>
+            <br />
+            <textarea id="seo_html_comment"
+                      name="seo_html_comment"
+                      rows="5"
+                      cols="15"
+                      tal:content="request/seo_html_comment | seo_context/seo_html_comment"
+                      />
+          </div>
+          <div class="field">
+            <label i18n:translate="label_robot_tag" for="seo_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="seo_robots_override"
+                   name="seo_robots_override"
+                   tal:attributes="checked python:view.test(seo_context['has_seo_robots'], 'on', None);"
+                   />
+            <label i18n:translate="label_override" for="seo_robots_override">
+              Override
+            </label>
+            <br />
+            <input type="text"
+                   id="seo_robots"
+                   name="seo_robots"
+                   size="25"
+                   value=""
+                   tal:attributes="value request/seo_robots | seo_context/seo_robots;"
+                   />
+          </div>
+          <div class="field">
+            <label i18n:translate="label_distribution_tag" for="seo_distribution">
+              Distribution Tag
+            </label>
+            <div class="formHelp" i18n:translate="help_seo_distribution">
+              Distribution meta tag to designate time and area distribution
+              of the document.
+            </div>
+            <input type="checkbox"
+                   id="seo_distribution_override"
+                   name="seo_distribution_override"
+                   tal:attributes="checked python:view.test(seo_context['has_seo_distribution'], 'on', None);"
+                   />
+            <label i18n:translate="label_override" for="seo_distribution_override">
+              Override
+            </label>
+            <br />
+            <input type="text"
+                   id="seo_distribution"
+                   name="seo_distribution"
+                   size="25"
+                   value=""
+                   tal:attributes="value request/seo_distribution | seo_context/seo_distribution;"
+                   />
+          </div>
+          <div class="field"
+               tal:define="error errors/seo_canonical|nothing;"
+               tal:attributes="class python:view.test(error, 'field error', 'field')">
+            <label i18n:translate="label_canonical_tag" for="seo_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="seo_canonical_override"
+                   name="seo_canonical_override"
+                   tal:attributes="checked python:view.test(seo_context['has_seo_canonical'], 'on', None);"
+                   />
+            <label i18n:translate="label_override" for="seo_canonical_override">
+              Override
+            </label>
+            <br />
+            <div tal:content="error">Validation error output</div>
+            <input type="text"
+                   id="seo_canonical"
+                   name="seo_canonical"
+                   size="55"
+                   value=""
+                   tal:attributes="value request/seo_canonical | seo_context/seo_canonical;"
+                   />
+          </div>
+          <div class="field"
+               tal:define="fieldName string:seo_custommetatags;">
+            <label i18n:translate="label_custom_metatags" for="seo_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="seo_custommetatags_override"
+                   name="seo_custommetatags_override"
+                   tal:attributes="checked python:view.test(seo_context.seo_localCustomMetaTags(), 'on', None);"
+                   />
+            <label i18n:translate="label_override" for="seo_custommetatags_override">
+              Override
+            </label>
+            <br />
+            <table class="custom-metatags-table"
+                   id="datagridwidget-table-seo_custommetatags"
+                   style="width: 100%"
+                   tal:define="columnNames python:['meta name', 'meta content'];
+                               columnIds   python:['meta_name', 'meta_content'];
+                               globalMT    seo_context/seo_globalWithoutLocalCustomMetaTags;
+                               localMT    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-seo_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"
+                   type="submit"
+                   name="form.button.Save"
+                   value="Save"
+                   i18n:attributes="value" />
+            <input class="standalone"
+                   type="submit"
+                   name="form.button.Cancel"
+                   value="Cancel"
+                   i18n:attributes="value" />
+          </div>
+        </fieldset>
+        <input type="hidden" name="form.submitted" value="1" />
+      </form>
+    </div>
+  </body>
+</html>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/viewlets.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/viewlets.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/viewlets.py	(revision 3016)
@@ -0,0 +1,211 @@
+from cgi import escape
+from DateTime import DateTime
+from Acquisition import aq_inner
+
+from zope.component import queryAdapter
+from zope.component import queryMultiAdapter
+from zope.component import getMultiAdapter
+from plone.app.layout.viewlets.common import ViewletBase
+
+from Products.CMFPlone.utils import safe_unicode, getSiteEncoding
+
+from quintagroup.seoptimizer.util import SortedDict
+from quintagroup.seoptimizer.interfaces import IMetaKeywords
+from quintagroup.seoptimizer.interfaces import IMappingMetaTags
+from quintagroup.seoptimizer.browser.seo_configlet import ISEOConfigletSchema
+
+from Products.CMFPlone.PloneTool import *
+
+class SEOTagsViewlet( ViewletBase ):
+    """ Simple viewlet for custom title rendering.
+    """
+
+    def render(self):
+        TEMPLATE = '<meta name="%s" content="%s"/>'
+        enc = getSiteEncoding(self.context)
+        sfuncd = lambda x, enc=enc:safe_unicode(x, enc)
+        return u'\n'.join([TEMPLATE % tuple(map(sfuncd, (k,v))) \
+                           for k,v in self.listMetaTags().items()])
+
+    def listMetaTags(self):
+        """Calculate list metatags"""
+
+        result = SortedDict()
+        pps = queryMultiAdapter((self.context, self.request), name="plone_portal_state")
+        seo_global = queryAdapter(pps.portal(), ISEOConfigletSchema)
+        seo_context = queryMultiAdapter((self.context, self.request), name='seo_context')
+
+        use_all = seo_global.exposeDCMetaTags
+        adapter = IMappingMetaTags(self.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 == 'meta_keywords':
+                # Render all the existing keywords for the current content type
+                adapter = IMetaKeywords(self.context, None)
+                if adapter is not None:
+                    keywords = adapter.getMetaKeywords()
+                    if keywords:
+                        result['keywords'] = keywords
+                continue
+
+            if seo_context._seotags.has_key(accessor):
+                value = seo_context._seotags.get(accessor, None)
+            else:
+                method = getattr(seo_context, accessor, None)
+                if method is None:
+                    method = getattr(aq_inner(self.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 = self.context.CreationDate()
+
+            try:
+                effective = self.context.EffectiveDate()
+                if effective == 'None':
+                    effective = None
+                if effective:
+                    effective = DateTime(effective)
+            except AttributeError:
+                effective = None
+
+            try:
+                expires = self.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
+
+
+
+class TitleCommentViewlet(ViewletBase):
+    """ Simple viewlet for custom title rendering.
+    """
+
+    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.seo_context = getMultiAdapter((self.context, self.request),
+                                             name=u'seo_context')
+
+        self.override_title = self.seo_context['has_seo_title']
+        self.override_comments = self.seo_context['has_html_comment']
+
+    def std_title(self):
+        page_title = safe_unicode(self.context_state.object_title())
+        portal_title = safe_unicode(self.portal_state.portal_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):
+        if not self.override_title:
+            std_title = self.std_title()
+            if not self.override_comments:
+                return std_title
+            else:
+                qseo_comments = u"<!--%s-->" % safe_unicode(
+                    self.seo_context["seo_html_comment"])
+                return u"%s\n%s"%(std_title, qseo_comments)
+        else:
+            qseo_title = u"<title>%s</title>" % safe_unicode(
+                self.seo_context["seo_title"])
+            if not self.override_comments:
+                return qseo_title
+            else:
+                qseo_comments = u"<!--%s-->" % safe_unicode(
+                    self.seo_context["seo_html_comment"])
+                return u"%s\n%s"%(qseo_title, qseo_comments)
+
+
+class CustomScriptViewlet( ViewletBase ):
+    """ Simple viewlet for custom script rendering.
+    """
+    def getCustomScript( self ):
+        pps = queryMultiAdapter((self.context, self.request),
+                                name="plone_portal_state")
+        gseo = queryAdapter(pps.portal(), ISEOConfigletSchema)
+        if gseo:
+            return gseo.custom_script
+        return ''
+
+    def render( self ):
+        return safe_unicode("""%s"""% self.getCustomScript())
+
+
+class CanonicalUrlViewlet( ViewletBase ):
+    """ Simple viewlet for canonical url link rendering.
+    """
+    def render( self ):
+        seoc = getMultiAdapter((self.context, self.request), name=u'seo_context')
+        if seoc['seo_canonical']:
+            return """<link rel="canonical" href="%s" />""" % seoc['seo_canonical']
+        return ""
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/views.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/views.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/browser/views.py	(revision 3016)
@@ -0,0 +1,328 @@
+from time import time
+from DateTime import DateTime
+from Acquisition import aq_inner
+from zope.component import queryAdapter
+from zope.component import queryMultiAdapter
+from zope.schema.interfaces import InvalidValue
+
+from plone.memoize import view, ram
+
+from Products.Five.browser import BrowserView
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+from Products.CMFPlone.utils import getSiteEncoding
+
+from quintagroup.canonicalpath.interfaces import ICanonicalLink
+from quintagroup.canonicalpath.adapters import PROPERTY_LINK as CANONICAL_PROPERTY
+
+from quintagroup.seoptimizer.browser.seo_configlet import ISEOConfigletSchema
+from quintagroup.seoptimizer import SeoptimizerMessageFactory as _
+
+SEPERATOR = '|'
+SEO_PREFIX = 'seo_'
+PROP_PREFIX = 'qSEO_'
+SUFFIX = '_override'
+PROP_CUSTOM_PREFIX = 'qSEO_custom_'
+
+# Ram cache function, which depends on plone instance and time
+def plone_instance_time(method, self, *args, **kwargs):
+    return (self.pps.portal(), time() // (60 * 60))
+
+class SEOContext( BrowserView ):
+    """ This class contains methods that allows to edit html header meta tags.
+    """
+
+    def __init__(self, *args, **kwargs):
+        super(SEOContext, self).__init__(*args, **kwargs)
+        self.pps = queryMultiAdapter((self.context, self.request), name="plone_portal_state")
+        self.pcs = queryMultiAdapter((self.context, self.request), name="plone_context_state")
+        self.gseo = queryAdapter(self.pps.portal(), ISEOConfigletSchema)
+        self._seotags = self._getSEOTags()
+
+    def __getitem__(self, key):
+        return self._seotags.get(key, '')
+
+    @view.memoize
+    def _getSEOTags(self):
+        seotags = {
+            "seo_title": self.getSEOProperty( 'qSEO_title', default=self.pcs.object_title() ),
+            "seo_robots": self.getSEOProperty( 'qSEO_robots', default='ALL'),
+            "seo_description": self.getSEOProperty( 'qSEO_description', accessor='Description' ),
+            "seo_distribution": self.getSEOProperty( 'qSEO_distribution', default="Global"),
+            "seo_customMetaTags": self.seo_customMetaTags(),
+            # "seo_localCustomMetaTags": self.seo_localCustomMetaTags(),
+            # "seo_globalCustomMetaTags": self.seo_globalCustomMetaTags(),
+            "seo_html_comment": self.getSEOProperty( 'qSEO_html_comment', default='' ),
+            "meta_keywords": self.getSEOProperty('qSEO_keywords', 'Subject', ()),
+            "seo_keywords": self.getSEOProperty('qSEO_keywords', default=()),
+            "seo_canonical": self.getCanonical(),
+            # Add test properties
+            "has_seo_title": self.context.hasProperty('qSEO_title'),
+            "has_seo_robots": self.context.hasProperty('qSEO_robots'),
+            "has_seo_description": self.context.hasProperty( 'qSEO_description'),
+            "has_seo_distribution": self.context.hasProperty( 'qSEO_distribution'),
+            "has_html_comment": self.context.hasProperty('qSEO_html_comment'),
+            "has_seo_keywords": self.context.hasProperty('qSEO_keywords'),
+            "has_seo_canonical": self.context.hasProperty(CANONICAL_PROPERTY),
+            }
+        #seotags["seo_nonEmptylocalMetaTags"] = bool(seotags["seo_localCustomMetaTags"])
+        return seotags
+
+    def getSEOProperty( self, property_name, accessor='', default=None ):
+        """ Get value from seo property by property name.
+        """
+        context = aq_inner(self.context)
+
+        if context.hasProperty(property_name):
+            return context.getProperty(property_name, default)
+
+        if accessor:
+            method = getattr(context, accessor, default)
+            if not callable(method):
+                return default
+
+            # Catch AttributeErrors raised by some AT applications
+            try:
+                value = method()
+            except AttributeError:
+                value = default
+
+            return value
+        return default
+
+    def seo_customMetaTags( self ):
+        """Returned seo custom metatags from default_custom_metatags property in seo_properties
+           (global seo custom metatags) with update from seo custom metatags properties
+           in context (local seo custom metatags).
+        """
+        glob, loc = self.seo_globalCustomMetaTags(), self.seo_localCustomMetaTags()
+        gnames = set(map(lambda x: x['meta_name'], glob))
+        lnames = set(map(lambda x: x['meta_name'], loc))
+        # Get untouch global, override global in custom and new custom meta tags
+        untouchglob = [t for t in glob if t['meta_name'] in list(gnames - lnames)]
+        return untouchglob + loc
+
+    def seo_globalWithoutLocalCustomMetaTags( self ):
+        """Returned seo custom metatags from default_custom_metatags property in seo_properties
+           (global seo custom metatags) without seo custom metatags from properties
+           in context (local seo custom metatags).
+        """
+        glob, loc = self.seo_globalCustomMetaTags(), self.seo_localCustomMetaTags()
+        gnames = set(map(lambda x: x['meta_name'], glob))
+        lnames = set(map(lambda x: x['meta_name'], loc))
+        return [t for t in glob if t['meta_name'] in list(gnames - lnames)]
+
+    def seo_localCustomMetaTags( self ):
+        """ Returned seo custom metatags from properties in context (local seo custom metatags).
+        """
+        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
+
+    @ram.cache(plone_instance_time)
+    def seo_globalCustomMetaTags( self ):
+        """ Returned seo custom metatags from default_custom_metatags property in seo_properties.
+        """
+        result = []
+        if self.gseo:
+            for tag in self.gseo.default_custom_metatags:
+                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 getCanonical(self):
+        canonical = queryAdapter(self.context, ICanonicalLink)
+        return canonical and canonical.canonical_link or ""
+
+
+class SEOContextPropertiesView( BrowserView ):
+    """ This class contains methods that allows to manage seo properties.
+    """
+    template = ViewPageTemplateFile('templates/seo_context_properties.pt')
+
+    def __init__(self, *args, **kwargs):
+        super(SEOContextPropertiesView, self).__init__(*args, **kwargs)
+        self.pps = queryMultiAdapter((self.context, self.request),
+                                     name="plone_portal_state")
+        self.gseo = queryAdapter(self.pps.portal(), ISEOConfigletSchema)
+
+
+    def test( self, condition, first, second ):
+        """
+        """
+        return condition and first or second
+
+    def validateSEOProperty(self, property, value):
+        """ Validate a seo property.
+        """
+        return ''
+
+    def setProperty(self, property, value, type='string'):
+        """ Add a new property.
+
+            Sets a new property with the given id, value and type or changes it.
+        """
+        context = aq_inner(self.context)
+        state = self.validateSEOProperty(property, value)
+        if not state:
+            if context.hasProperty(property):
+                context.manage_changeProperties({property: value})
+            else:
+                context.manage_addProperty(property, value, type)
+        return state
+
+    def manageSEOProps(self, **kw):
+        """ Manage seo properties.
+        """
+        context = aq_inner(self.context)
+        state = ''
+        delete_list, seo_overrides_keys, seo_keys = [], [], []
+        seo_items = dict([(k[len(SEO_PREFIX):],v) for k,v in kw.items() if k.startswith(SEO_PREFIX)])
+        for key in seo_items.keys():
+            if key.endswith(SUFFIX):
+                seo_overrides_keys.append(key[:-len(SUFFIX)])
+            else:
+                seo_keys.append(key)
+        for seo_key in seo_keys:
+            if seo_key == 'custommetatags':
+                self.manageSEOCustomMetaTagsProperties(**kw)
+            else:
+                if seo_key in seo_overrides_keys and seo_items.get(seo_key+SUFFIX):
+                    seo_value = seo_items[seo_key]
+                    if seo_key == 'canonical':
+                        try:
+                            ICanonicalLink(self.context).canonical_link = seo_value
+                        except InvalidValue, e:
+                            state = "'%s' - wrong canonical url" % str(e)
+                    else:
+                        t_value = 'string'
+                        if type(seo_value)==type([]) or type(seo_value)==type(()): t_value = 'lines'
+                        state = self.setProperty(PROP_PREFIX+seo_key, seo_value, type=t_value)
+                    if state:
+                        return state
+                elif seo_key == 'canonical':
+                    del ICanonicalLink(self.context).canonical_link
+                elif context.hasProperty(PROP_PREFIX+seo_key):
+                    delete_list.append(PROP_PREFIX+seo_key)
+        if delete_list:
+            context.manage_delProperties(delete_list)
+        return state
+
+    def setSEOCustomMetaTags(self, custommetatags):
+        """ Set seo custom metatags properties.
+        """
+        context = aq_inner(self.context)
+        for tag in custommetatags:
+            self.setProperty('%s%s' % (PROP_CUSTOM_PREFIX, tag['meta_name']), tag['meta_content'])
+
+    def delAllSEOCustomMetaTagsProperties(self):
+        """ Delete all seo custom metatags properties.
+        """
+        context = aq_inner(self.context)
+        delete_list = []
+        for property, value in context.propertyItems():
+            if property.startswith(PROP_CUSTOM_PREFIX)  and not property == PROP_CUSTOM_PREFIX:
+                delete_list.append(property)
+        if delete_list:
+            context.manage_delProperties(delete_list)
+
+    def updateSEOCustomMetaTagsProperties(self, custommetatags):
+        """ Update seo custom metatags properties.
+        """
+        globalCustomMetaTags = []
+        if self.gseo:
+            custom_meta_tags = self.gseo.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 name_value[1] or ''})
+        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)]:
+                    self.setProperty('%s%s' % (PROP_CUSTOM_PREFIX, meta_name), meta_content)
+
+    def manageSEOCustomMetaTagsProperties(self, **kw):
+        """ Update seo custom metatags properties, if enabled checkbox override or delete properties.
+
+            Change object properties by passing either a mapping object
+            of name:value pairs {'foo':6} or passing name=value parameters.
+        """
+        context = aq_inner(self.context)
+        self.delAllSEOCustomMetaTagsProperties()
+        if kw.get('seo_custommetatags_override'):
+            custommetatags = kw.get('seo_custommetatags', {})
+            self.updateSEOCustomMetaTagsProperties(custommetatags)
+
+    def getPropertyStopWords(self):
+        """ Get property 'stop_words' from SEO Properties tool.
+        """
+        enc = getSiteEncoding(self.context)
+        # self.gseo.stop_words return list of unicode objects,
+        # and may contains stop words in different languages.
+        # So we must return encoded strings.
+        sw = map(lambda x:unicode.encode(x, enc), self.gseo.stop_words)
+        return str(sw)
+
+    def getPropertyFields(self):
+        """ Get property 'fields' from SEO Properties tool.
+        """
+        # self.gseo.fields return list of unicode objects,
+        # so *str* use as encoding function from unicode to latin-1 string.
+        fields_id = map(str, self.gseo.fields)
+        return str(fields_id)
+
+    def __call__( self ):
+        """ Perform the update seo properties and redirect if necessary,
+            or render the page Call method.
+        """
+        context = aq_inner(self.context)
+        request = self.request
+        form = self.request.form
+        submitted = form.get('form.submitted', False)
+        #import pdb;pdb.set_trace()
+        if submitted:
+            msgtype = "info"
+            save = form.get('form.button.Save', False)
+            if save:
+                msg = self.manageSEOProps(**form)
+                if not msg:
+                    msg = _('seoproperties_saved',
+                            default=u'Content SEO properties have been saved.')
+                    kwargs = {'modification_date' : DateTime()} 
+                    context.plone_utils.contentEdit(context, **kwargs)
+                else:
+                    msgtype = "error"
+            else:
+                # Cancel
+                msg = _('seoproperties_canceled',
+                        default=u'No content SEO properties have been changed.')
+
+            context.plone_utils.addPortalMessage(msg, msgtype)
+            if msgtype == "info":
+                return request.response.redirect(self.context.absolute_url())
+
+        return self.template()
+
+
+class VisibilityCheckerView( BrowserView ):
+    """ This class contains methods that visibility checker.
+    """
+
+    def checkVisibilitySEOAction(self):
+        """ Checks visibility 'SEO Properties' action for content
+        """
+        context = aq_inner(self.context)
+        plone = queryMultiAdapter((self, self.request),name="plone_portal_state").portal()
+        adapter = ISEOConfigletSchema(plone)
+        return bool(self.context.portal_type in adapter.types_seo_enabled)
+
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/config.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/config.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/config.py	(revision 3016)
@@ -0,0 +1,7 @@
+PROJECT_NAME = 'quintagroup.seoptimizer'
+
+SUPPORT_BLAYER = True
+try:
+    from plone import browserlayer
+except ImportError:
+    SUPPORT_BLAYER = False
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/configure.zcml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/configure.zcml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/configure.zcml	(revision 3016)
@@ -0,0 +1,23 @@
+<configure
+    xmlns="http://namespaces.zope.org/zope"
+    xmlns:five="http://namespaces.zope.org/five"
+    xmlns:i18n="http://namespaces.zope.org/i18n"
+    i18n_domain="quintagroup.seoptimizer">
+
+  <!-- include dependent packages -->
+  <include package="plone.browserlayer" />
+  <include package="quintagroup.canonicalpath" />
+
+  <!-- This need for QI for possibility to find "uninstall"
+       method in then ./Extensions/install.py external method -->
+  <five:registerPackage package="." />
+
+  <i18n:registerTranslations directory="locales" />
+
+  <include file="patches.zcml" />
+  <include file="profiles.zcml" />
+  <include file="adapters.zcml" />
+  <include package=".browser" />
+
+
+</configure>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/interfaces.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/interfaces.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/interfaces.py	(revision 3016)
@@ -0,0 +1,15 @@
+from zope.interface import Interface
+
+class IMetaKeywords(Interface):
+    """Handle the available keywords.
+    """
+    def getMetaKeywords():
+        """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/4.0.4/quintagroup/seoptimizer/listMetaTags.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/listMetaTags.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/listMetaTags.py	(revision 3016)
@@ -0,0 +1,16 @@
+from Products.CMFPlone.PloneTool import PloneTool
+
+originalListMetaTags = PloneTool.listMetaTags
+
+def qsListMetaTags(self, context):
+    """ Custom listMetaTags method
+    """
+    from quintagroup.seoptimizer.browser.interfaces import IPloneSEOLayer
+    if not IPloneSEOLayer.providedBy(self.REQUEST):
+        return originalListMetaTags(self, context)
+    return {}
+
+def qsListMetaTagsOriginal(self, context):
+    """ Returned original method listMetaTags
+    """
+    return originalListMetaTags(self, context)
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/locales/quintagroup.seoptimizer.pot
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/locales/quintagroup.seoptimizer.pot	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/locales/quintagroup.seoptimizer.pot	(revision 3016)
@@ -0,0 +1,235 @@
+# --- PLEASE EDIT THE LINES BELOW CORRECTLY ---
+# SOME DESCRIPTIVE TITLE.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"POT-Creation-Date: 2010-04-16 10:55+0000\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI +ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=1; plural=0\n"
+"Language-Code: en\n"
+"Language-Name: English\n"
+"Preferred-Encodings: utf-8 latin1\n"
+"Domain: quintagroup.seoptimizer\n"
+
+#: ./browser/seo_configlet.py:188
+msgid ""
+msgstr ""
+
+#: ./browser/keywords.py:40
+msgid "Could not find lynx browser!"
+msgstr ""
+
+#: ./browser/keywords.py:28
+msgid "Keywords list is empty!"
+msgstr ""
+
+#: ./profiles/default/actions.xml
+#: ./profiles/upgrade_2_to_3/actions.xml
+msgid "SEO Properties"
+msgstr ""
+
+#: ./profiles/default/controlpanel.xml
+#: ./profiles/upgrade_2_to_3/controlpanel.xml
+msgid "Search Engine Optimizer"
+msgstr ""
+
+#: ./browser/seo_configlet.py:183
+msgid "Search Engine Optimizer configuration"
+msgstr ""
+
+#. Default: "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."
+#: ./browser/templates/seo_context_properties.pt:34
+msgid "description_edit_seo_properties"
+msgstr ""
+
+#. Default: "Select content types that will have SEO properties enabled."
+#: ./browser/seo_configlet.py:49
+msgid "description_seo_content_types"
+msgstr ""
+
+#. Default: "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."
+#: ./browser/seo_configlet.py:30
+msgid "description_seo_dc_metatags"
+msgstr ""
+
+#. Default: "Edit SEO Properties"
+#: ./browser/templates/seo_context_properties.pt:33
+msgid "heading_edit_seo_properties"
+msgstr ""
+
+#. Default: "This JavaScript code will be included in the rendered HTML as entered in the page header."
+#: ./browser/seo_configlet.py:70
+msgid "help_custom_script"
+msgstr ""
+
+#. Default: "Fill in custom metatag names (one per line) which will appear on qseo_properties edit tab. Example: \"metaname|metacontent\" or \"metaname\"."
+#: ./browser/seo_configlet.py:59
+msgid "help_default_custom_metatags"
+msgstr ""
+
+#. Default: "Fill in filds (one per line) which statistics of keywords usage should be calculated for."
+#: ./browser/seo_configlet.py:78
+msgid "help_fields"
+msgstr ""
+
+#. Default: "Fill in meta tags (one per line) in the order in which they will appear on site source pages. Example: \"metaname accessor\"."
+#: ./browser/seo_configlet.py:41
+msgid "help_metatags_order"
+msgstr ""
+
+#. Default: "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."
+#: ./browser/templates/seo_context_properties.pt:236
+msgid "help_seo_canonical"
+msgstr ""
+
+#. Default: "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."
+#: ./browser/templates/seo_context_properties.pt:268
+msgid "help_seo_custommetatags"
+msgstr ""
+
+#. Default: "Description of the document to be indexed by Search Engines. This text will be present in meta description tag in page HTML source."
+#: ./browser/templates/seo_context_properties.pt:86
+msgid "help_seo_description"
+msgstr ""
+
+#. Default: "Distribution meta tag to designate time and area distribution of the document."
+#: ./browser/templates/seo_context_properties.pt:205
+msgid "help_seo_distribution"
+msgstr ""
+
+#. Default: "Comments embedded into HTML header."
+#: ./browser/templates/seo_context_properties.pt:149
+msgid "help_seo_html_comment"
+msgstr ""
+
+#. Default: "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 future reference.<br />Use 'Check Keywords' button to check whether words you entered in this block as SEO keywords are present in content."
+#: ./browser/templates/seo_context_properties.pt:114
+msgid "help_seo_keywords"
+msgstr ""
+
+#. Default: "Robots meta tag value. Allows or denies indexing, following links, etc. for spiders indexing your content."
+#: ./browser/templates/seo_context_properties.pt:176
+msgid "help_seo_robots"
+msgstr ""
+
+#. Default: "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."
+#: ./browser/templates/seo_context_properties.pt:56
+msgid "help_seo_title"
+msgstr ""
+
+#. Default: "Fill in stop words (one per line) which will be excluded from kewords statistics calculation."
+#: ./browser/seo_configlet.py:85
+msgid "help_stop_words"
+msgstr ""
+
+#. Default: "Canonical URL"
+#: ./browser/templates/seo_context_properties.pt:233
+msgid "label_canonical_tag"
+msgstr ""
+
+#. Default: "Content Types"
+#: ./browser/seo_configlet.py:48
+msgid "label_content_type_title"
+msgstr ""
+
+#. Default: "Custom Meta Tags"
+#: ./browser/templates/seo_context_properties.pt:265
+msgid "label_custom_metatags"
+msgstr ""
+
+#. Default: "Header JavaScript"
+#: ./browser/seo_configlet.py:69
+msgid "label_custom_script"
+msgstr ""
+
+#. Default: "Default custom metatags."
+#: ./browser/seo_configlet.py:58
+msgid "label_default_custom_metatags"
+msgstr ""
+
+#. Default: "Distribution Tag"
+#: ./browser/templates/seo_context_properties.pt:202
+msgid "label_distribution_tag"
+msgstr ""
+
+#. Default: "Expose <abbr title=\"Dublin Core\">DC</abbr> meta tags"
+#: ./browser/seo_configlet.py:28
+msgid "label_exposeDCMetaTags"
+msgstr ""
+
+#. Default: "Fields for keywords statistic calculation."
+#: ./browser/seo_configlet.py:77
+msgid "label_fields"
+msgstr ""
+
+#. Default: "HTML Comment"
+#: ./browser/templates/seo_context_properties.pt:146
+msgid "label_html_comment"
+msgstr ""
+
+#. Default: "Meta Description Tag"
+#: ./browser/templates/seo_context_properties.pt:83
+msgid "label_meta_description_tag"
+msgstr ""
+
+#. Default: "Meta Keywords Tag"
+#: ./browser/templates/seo_context_properties.pt:111
+msgid "label_meta_keywords_tag"
+msgstr ""
+
+#. Default: "Meta tags order in the page."
+#: ./browser/seo_configlet.py:39
+msgid "label_metatags_order"
+msgstr ""
+
+#. Default: "Override"
+#: ./browser/templates/seo_context_properties.pt:68
+msgid "label_override"
+msgstr ""
+
+#. Default: "Page Title Tag"
+#: ./browser/templates/seo_context_properties.pt:53
+msgid "label_page_title_tag"
+msgstr ""
+
+#. Default: "Robot Tag"
+#: ./browser/templates/seo_context_properties.pt:173
+msgid "label_robot_tag"
+msgstr ""
+
+#. Default: "Advanced"
+#: ./browser/seo_configlet.py:170
+msgid "label_seoadvanced"
+msgstr ""
+
+#. Default: "Base"
+#: ./browser/seo_configlet.py:166
+msgid "label_seobase"
+msgstr ""
+
+#. Default: "Stop words."
+#: ./browser/seo_configlet.py:84
+msgid "label_stop_words"
+msgstr ""
+
+#. Default: "Number of keywords at page:\n${result}"
+#: ./browser/keywords.py:49
+msgid "number_keywords"
+msgstr ""
+
+#. Default: "You can select what content types are qSEOptimizer-enabled, and control if Dublin Core metatags are exposed in the header of content pages."
+#: ./browser/seo_configlet.py:184
+msgid "seo_configlet_description"
+msgstr ""
+
+#. Default: "Content SEO properties have been saved."
+#: ./browser/views.py:291
+msgid "seoproperties_saved"
+msgstr ""
+
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/locales/uk/LC_MESSAGES/quintagroup.seoptimizer.po
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/locales/uk/LC_MESSAGES/quintagroup.seoptimizer.po	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/locales/uk/LC_MESSAGES/quintagroup.seoptimizer.po	(revision 3016)
@@ -0,0 +1,235 @@
+# --- PLEASE EDIT THE LINES BELOW CORRECTLY ---
+# , 2010.
+msgid ""
+msgstr ""
+"Project-Id-Version: quintagroup.seoptimizer\n"
+"POT-Creation-Date: YEAR-MO-DA HO:MI +ZONE\n"
+"PO-Revision-Date: 2010-04-19 11:09+0300\n"
+"Last-Translator: Olha <olha.pelishok@gmail.com>\n"
+"Language-Team: American English <kde-i18n-doc@kde.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=1; plural=0\n"
+"Language-Code: uk\n"
+"Language-Name: Українська\n"
+"Preferred-Encodings: utf-8 latin1\n"
+"Domain: quintagroup.seoptimizer\n"
+"X-Generator: Lokalize 1.0\n"
+
+#: ./browser/keywords.py:40
+msgid "Could not find lynx browser!"
+msgstr "Неможливо знайти переглядач lynx!"
+
+#: ./browser/keywords.py:28
+msgid "Keywords list is empty!"
+msgstr "Список ключових слів порожній!"
+
+#. Default: "SEO Properties"
+#: ./profiles/default/actions.xml
+#: ./profiles/upgrade_2_to_3/actions.xml
+msgid "SEO Properties"
+msgstr "SEO налаштування"
+
+#. Default: "Search Engine Optimizer"
+#: ./profiles/default/controlpanel.xml
+#: ./profiles/upgrade_2_to_3/controlpanel.xml
+msgid "Search Engine Optimizer"
+msgstr "Оптимізатор Пошукових Систем"
+
+#: ./browser/seo_configlet.py:183
+msgid "Search Engine Optimizer configuration"
+msgstr "Конфігурація Оптимізатора Пошукових Систем"
+
+#. Default: "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."
+#: ./browser/templates/seo_context_properties.pt:34
+msgid "description_edit_seo_properties"
+msgstr "Ця форма дозволяє редагувати мета теґи, які генерує СКВ Plone, з метою збільшення видимості сайту для пошукових систем. Значення, які генерує Plone по замовчуванню, введено у відповідних полях. Щоб перекрити значення за замовчуванням, поставте галочку біля відповідного поля та введіть потрібне значення."
+
+#. Default: "Select content types that will have SEO properties enabled."
+#: ./browser/seo_configlet.py:49
+msgid "description_seo_content_types"
+msgstr "Виберіть типи вмісту, для яких буде можливо прописувати SEO властивості."
+
+#. Default: "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."
+#: ./browser/seo_configlet.py:30
+msgid "description_seo_dc_metatags"
+msgstr "Чи показувати <abbr title=\"Dublin Core\">DC</abbr> мета теґи в коді сторінки, включаючи такі мета теґи,  як DC.description, DC.type, DC.format, DC.creator тощо."
+
+#. Default: "Edit SEO Properties"
+#: ./browser/templates/seo_context_properties.pt:33
+msgid "heading_edit_seo_properties"
+msgstr "Редагувати властивості SEO"
+
+#. Default: "This JavaScript code will be included in the rendered HTML as entered in the page header."
+#: ./browser/seo_configlet.py:70
+msgid "help_custom_script"
+msgstr "Цей JavaScript код один до одного буде включено у HTML заголовок."
+
+#. Default: "Fill in custom metatag names (one per line) which will appear on qseo_properties edit tab. Example: \"metaname|metacontent\" or \"metaname\"."
+#: ./browser/seo_configlet.py:59
+msgid "help_default_custom_metatags"
+msgstr "Введіть назви додаткових мета теґів (по 1 в кожному рядку), які з'являться при редагуванні  SEO властивостей об'єктів сайту. Наприклад: \"metaname | metacontent \" або \"metaname \"."
+
+#. Default: "Fill in filds (one per line) which statistics of keywords usage should be calculated for."
+#: ./browser/seo_configlet.py:78
+msgid "help_fields"
+msgstr "Уведіть список полів (одне на лінію), по яким ви бажаєте обраховувати статистику використання ключових слів."
+
+#. Default: "Fill in meta tags (one per line) in the order in which they will appear on site source pages. Example: \"metaname accessor\"."
+#: ./browser/seo_configlet.py:41
+msgid "help_metatags_order"
+msgstr "Ввведіть по 1 мета теґу в рядок у тому порядку, в якому вони будуть з'являтися при перегляді коду сторінки. Наприклад: \"metaname accessor\"."
+
+#. Default: "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."
+#: ./browser/templates/seo_context_properties.pt:236
+msgid "help_seo_canonical"
+msgstr "Вкажіть canonical URL. Якщо на Вашому сайті є ідентичні або дуже схожі за вмістом сторінки, доступні за кількома URL-адресами, це поле забезпечить Вам більший контроль над тією URL-адресою, яка відображатиметься  в результатах пошуку."
+
+#. Default: "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."
+#: ./browser/templates/seo_context_properties.pt:268
+msgid "help_seo_custommetatags"
+msgstr "Тут ви можете управляти додатковими мета теґами. Вкажіть параметри локальних мета теґів, які буде додано до глобальних мета-теґів. Якщо поле metacontent - пусте, то такий мета теґ не буде показано на цій сторінці."
+
+#. Default: "Description of the document to be indexed by Search Engines. This text will be present in meta description tag in page HTML source."
+#: ./browser/templates/seo_context_properties.pt:86
+msgid "help_seo_description"
+msgstr "Опис документа, який будуть використовувати пошукові системи. Цей текст буде присутній в мета тезі description в коді сторінки."
+
+#. Default: "Distribution meta tag to designate time and area distribution of the document."
+#: ./browser/templates/seo_context_properties.pt:205
+msgid "help_seo_distribution"
+msgstr "Мета теґ Distribution використовується для позначення часу та місця розповсюдження документа."
+
+#. Default: "Comments embedded into HTML header."
+#: ./browser/templates/seo_context_properties.pt:149
+msgid "help_seo_html_comment"
+msgstr "Коментарі, додані в HTML заголовок."
+
+#. Default: "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 future reference.<br />Use 'Check Keywords' button to check whether words you entered in this block as SEO keywords are present in content."
+#: ./browser/templates/seo_context_properties.pt:114
+msgid "help_seo_keywords"
+msgstr "Ключові слова, по яких сторінку буде проіндексовано. <strong>Буль-ласка, введіть кожне ключове слово в окремому рядку.</strong> <br /> Використовуйте кнопку 'Check Keywords', щоб перевірити чи слова, які ви вказали в цьому блоці, є наявні на сторінці."
+
+#. Default: "Robots meta tag value. Allows or denies indexing, following links, etc. for spiders indexing your content."
+#: ./browser/templates/seo_context_properties.pt:176
+msgid "help_seo_robots"
+msgstr "Значення мета теґу Robots. Дозволяє або забороняє індексування, перехід за посиланнями тощо."
+
+#. Default: "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."
+#: ./browser/templates/seo_context_properties.pt:56
+msgid "help_seo_title"
+msgstr "Текст, який буде показано в тезі  &lt;TITLE&gt;. Його буде видно у заголовку переглядача. Пошукові системи відображатимуть цей текст як назву документа."
+
+#. Default: "Fill in stop words (one per line) which will be excluded from kewords statistics calculation."
+#: ./browser/seo_configlet.py:85
+msgid "help_stop_words"
+msgstr "Уведіть список небажаних слів (одне на лінію), які повинні бути виключені із обрахунку статистики використання ключових слів."
+
+#. Default: "Canonical URL"
+#: ./browser/templates/seo_context_properties.pt:233
+msgid "label_canonical_tag"
+msgstr "Канонічна URL адреса"
+
+#. Default: "Content Types"
+#: ./browser/seo_configlet.py:48
+msgid "label_content_type_title"
+msgstr "Типи вмісту"
+
+#. Default: "Custom Meta Tags"
+#: ./browser/templates/seo_context_properties.pt:265
+msgid "label_custom_metatags"
+msgstr "Додаткові мета теґи"
+
+#. Default: "Header JavaScript"
+#: ./browser/seo_configlet.py:69
+msgid "label_custom_script"
+msgstr "Додатковий JavaScript"
+
+#. Default: "Default custom metatags."
+#: ./browser/seo_configlet.py:58
+msgid "label_default_custom_metatags"
+msgstr "Додаткові мета теґи"
+
+#. Default: "Distribution Tag"
+#: ./browser/templates/seo_context_properties.pt:202
+msgid "label_distribution_tag"
+msgstr "Мета теґ Distribution"
+
+#. Default: "Expose <abbr title=\"Dublin Core\">DC</abbr> meta tags"
+#: ./browser/seo_configlet.py:28
+msgid "label_exposeDCMetaTags"
+msgstr "Показувати мета теґи <abbr title=\"Dublin Core\">DC</abbr>"
+
+#. Default: "Fields for keywords statistic calculation."
+#: ./browser/seo_configlet.py:77
+msgid "label_fields"
+msgstr "Поля для статистичних обрахунків"
+
+#. Default: "HTML Comment"
+#: ./browser/templates/seo_context_properties.pt:146
+msgid "label_html_comment"
+msgstr "Коментар HTML"
+
+#. Default: "Meta Description Tag"
+#: ./browser/templates/seo_context_properties.pt:83
+msgid "label_meta_description_tag"
+msgstr "Мета теґ Description"
+
+#. Default: "Meta Keywords Tag"
+#: ./browser/templates/seo_context_properties.pt:111
+msgid "label_meta_keywords_tag"
+msgstr "Мета теґ Keywords"
+
+#. Default: "Meta tags order in the page."
+#: ./browser/seo_configlet.py:39
+msgid "label_metatags_order"
+msgstr "Порядок мета теґів на сторінці."
+
+#. Default: "Override"
+#: ./browser/templates/seo_context_properties.pt:68
+msgid "label_override"
+msgstr "Перекрити"
+
+#. Default: "Page Title Tag"
+#: ./browser/templates/seo_context_properties.pt:53
+msgid "label_page_title_tag"
+msgstr "Мета теґ Title"
+
+#. Default: "Robot Tag"
+#: ./browser/templates/seo_context_properties.pt:173
+msgid "label_robot_tag"
+msgstr "Мета теґ Robot"
+
+#. Default: "Advanced"
+#: ./browser/seo_configlet.py:170
+msgid "label_seoadvanced"
+msgstr "Розширені налаштування"
+
+#. Default: "Base"
+#: ./browser/seo_configlet.py:166
+msgid "label_seobase"
+msgstr "Базові налаштування"
+
+#. Default: "Stop words."
+#: ./browser/seo_configlet.py:84
+msgid "label_stop_words"
+msgstr "Небажані слова"
+
+#. Default: "Number of keywords at page:\n${result}"
+#: ./browser/keywords.py:49
+msgid "number_keywords"
+msgstr ""
+"Кількість знайдених ключових слів на сторінці:\n"
+"${result}"
+
+#. Default: "You can select what content types are qSEOptimizer-enabled, and control if Dublin Core metatags are exposed in the header of content pages."
+#: ./browser/seo_configlet.py:184
+msgid "seo_configlet_description"
+msgstr "Тут Ви можете вибрати, які типи вмісту матимуть можливіть налаштування SEO властивостей, а також налаштувати інші SEO параметри на Вашому сайті."
+
+#. Default: "Content SEO properties have been saved."
+#: ./browser/views.py:291
+msgid "seoproperties_saved"
+msgstr "SEO властивості збереженно."
+
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/overrides.zcml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/overrides.zcml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/overrides.zcml	(revision 3016)
@@ -0,0 +1,6 @@
+<configure xmlns="http://namespaces.zope.org/zope">
+  <!-- BBB:  Not needed any more.
+       Present Only for prevent instance breakage after
+       upgrading to quintagroup.seoptimizer >= v.3.0
+  -->
+</configure>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/patches.zcml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/patches.zcml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/patches.zcml	(revision 3016)
@@ -0,0 +1,25 @@
+<configure
+    xmlns="http://namespaces.zope.org/zope"
+    xmlns:monkey="http://namespaces.plone.org/monkey"
+    i18n_domain="quintagroup.seoptimizer">
+
+    <include package="collective.monkeypatcher" file="meta.zcml" />
+
+    <monkey:patch
+        description="listMetaTags method is now available as listMetaTagsOriginal"
+        class="Products.CMFPlone.PloneTool.PloneTool"
+        original="listMetaTagsOriginal"
+        ignoreOriginal="True"
+        replacement=".listMetaTags.qsListMetaTagsOriginal"
+        docstringWarning="true"
+        />
+
+    <monkey:patch
+        description="listMetaTags method now support advanced features"
+        class="Products.CMFPlone.PloneTool.PloneTool"
+        original="listMetaTags"
+        replacement=".listMetaTags.qsListMetaTags"
+        docstringWarning="true"
+        />
+
+</configure>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles.zcml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles.zcml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles.zcml	(revision 3016)
@@ -0,0 +1,40 @@
+<configure
+    xmlns="http://namespaces.zope.org/zope"
+    xmlns:genericsetup="http://namespaces.zope.org/genericsetup"
+    i18n_domain="quintagroup.seoptimizer">
+
+  <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"
+      />
+
+  <genericsetup:registerProfile
+      name="upgrade_2_to_3"
+      title="Upgrade quintagroup.seoptimizer 2.x.x to 3.0.0"
+      directory="profiles/upgrade_2_to_3"
+      description="Upgrades the quintagroup.seoptimizer from version 2.x.x to 3.0.0."
+      for="Products.CMFPlone.interfaces.IMigratingPloneSiteRoot"
+      provides="Products.GenericSetup.interfaces.EXTENSION"
+      />
+
+  <genericsetup:upgradeStep
+      title="Upgrade quintagroup.seoptimizer"
+      description="Upgrade quintagroup.seoptimizer 2.x.x to 3.0.0"
+      source="*"
+      destination="3.0.0"
+      handler=".upgrades.upgrade_2_to_3"
+      sortkey="1"
+      profile="quintagroup.seoptimizer:default" />
+
+</configure>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/actionicons.xml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/actionicons.xml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/actionicons.xml	(revision 3016)
@@ -0,0 +1,8 @@
+<?xml version="1.0"?>
+<action-icons>
+ <action-icon category="controlpanel"
+              action_id="quintagroup.seoptimizer"
+              title="Search Engine Optimizer"
+              priority="0"
+              icon_expr="++resource++quintagroup.seoptimizer/seo_icon.gif" />
+</action-icons>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/actions.xml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/actions.xml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/actions.xml	(revision 3016)
@@ -0,0 +1,19 @@
+<?xml version="1.0"?>
+<object name="portal_actions" meta_type="Plone Actions Tool"
+   xmlns:i18n="http://xml.zope.org/namespaces/i18n">
+ <object name="object" meta_type="CMF Action Category">
+  <property name="title"></property>
+  <object name="SEOProperties" meta_type="CMF Action" i18n:domain="quintagroup.seoptimizer">
+   <property name="title" i18n:translate="">SEO Properties</property>
+   <property name="description" i18n:translate=""></property>
+   <property
+      name="url_expr">string:${object_url}/@@seo-context-properties</property>
+   <property name="icon_expr"></property>
+   <property name="available_expr">object/@@seotab-visibility</property>
+   <property name="permissions">
+    <element value="Modify portal content"/>
+   </property>
+   <property name="visible">True</property>
+  </object>
+ </object>
+</object>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/browserlayer.xml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/browserlayer.xml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/browserlayer.xml	(revision 3016)
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<!-- Register the package-specific browser layer, so that it will be activated
+when this product is installed. -->
+<layers>
+   <layer name="qSEOptimizer"
+          interface="quintagroup.seoptimizer.browser.interfaces.IPloneSEOLayer" />
+</layers>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/controlpanel.xml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/controlpanel.xml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/controlpanel.xml	(revision 3016)
@@ -0,0 +1,11 @@
+<?xml version="1.0"?>
+<object name="portal_controlpanel" meta_type="Plone Control Panel Tool"
+    xmlns:i18n="http://xml.zope.org/namespaces/i18n" i18n:domain="quintagroup.seoptimizer">
+ <configlet title="Search Engine Optimizer" action_id="quintagroup.seoptimizer"
+    appId="quintagroup.seoptimizer" category="Products" condition_expr=""
+    icon_expr="string:${portal_url}/++resource++quintagroup.seoptimizer/seo_icon.gif"
+    url_expr="string:${portal_url}/@@seo-controlpanel"
+    visible="True" i18n:attributes="title">
+  <permission>ManagePortal</permission>
+ </configlet>
+</object>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/metadata.xml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/metadata.xml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/metadata.xml	(revision 3016)
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<metadata>
+  <version>4.0.3</version>
+</metadata>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/propertiestool.xml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/propertiestool.xml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/propertiestool.xml	(revision 3016)
@@ -0,0 +1,96 @@
+<?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" purge='False'>
+   <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" purge='False'>
+   <element value="seo_title"/>
+   <element value="seo_description"/>
+   <element value="seo_keywords"/>
+  </property>
+  <property name="custom_script" type="text" purge='False'/>
+  <property name="default_custom_metatags" type="lines" purge='False'/>
+  <property name="metatags_order" type="lines" purge='False'>
+   <element value="DC.publisher"/>
+   <element value="DC.description"/>
+   <element value="DC.contributors"/>
+   <element value="DC.creator"/>
+   <element value="DC.format"/>
+   <element value="DC.rights"/>
+   <element value="DC.language"/>
+   <element value="DC.date.modified"/>
+   <element value="DC.date.created"/>
+   <element value="DC.type"/>
+   <element value="DC.subject"/>
+   <element value="DC.distribution"/>
+   <element value="description"/>
+   <element value="keywords"/>
+   <element value="robots"/>
+   <element value="distribution"/>
+  </property>
+  <property name="content_types_with_seoproperties" type="lines">
+   <element value="Document"/>
+   <element value="File"/>
+   <element value="News Item"/>
+  </property>
+  <property name="external_keywords_test" type="boolean">False</property>
+ </object>
+ <object name="site_properties" meta_type="Plone Property Sheet">
+  <property name="exposeDCMetaTags" type="boolean">True</property>
+ </object>
+</object>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/viewlets.xml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/viewlets.xml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/default/viewlets.xml	(revision 3016)
@@ -0,0 +1,9 @@
+<?xml version="1.0"?>
+<object>
+  <order manager="plone.htmlhead" skinname="*">
+    <viewlet name="plone.htmlhead.title" />
+    <viewlet name="quintagroup.seoptimizer.customscript" />
+    <viewlet name="plone.resourceregistries" />
+    <viewlet name="quintagroup.seoptimizer.seotags" />
+  </order>
+</object>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/uninstall/import_steps.xml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/uninstall/import_steps.xml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/uninstall/import_steps.xml	(revision 3016)
@@ -0,0 +1,8 @@
+<?xml version="1.0"?>
+<import-steps>
+ <import-step id="seo_uninstall" version="20081125-01"
+              handler="quintagroup.seoptimizer.setuphandlers.uninstall"
+              title="SEO uninstallation">
+  SEO uninstallation
+ </import-step>
+</import-steps>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/actionicons.xml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/actionicons.xml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/actionicons.xml	(revision 3016)
@@ -0,0 +1,8 @@
+<?xml version="1.0"?>
+<action-icons>
+ <action-icon category="controlpanel"
+              action_id="quintagroup.seoptimizer"
+              title="Search Engine Optimizer"
+              priority="0"
+              icon_expr="++resource++quintagroup.seoptimizer/seo_icon.gif" />
+</action-icons>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/actions.xml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/actions.xml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/actions.xml	(revision 3016)
@@ -0,0 +1,19 @@
+<?xml version="1.0"?>
+<object name="portal_actions" meta_type="Plone Actions Tool"
+   xmlns:i18n="http://xml.zope.org/namespaces/i18n">
+ <object name="object" meta_type="CMF Action Category">
+  <property name="title"></property>
+  <object name="SEOProperties" meta_type="CMF Action" i18n:domain="quintagroup.seoptimizer">
+   <property name="title" i18n:translate="">SEO Properties</property>
+   <property name="description" i18n:translate=""></property>
+   <property
+      name="url_expr">string:${object_url}/@@seo-context-properties</property>
+   <property name="icon_expr"></property>
+   <property name="available_expr">object/@@seotab-visibility</property>
+   <property name="permissions">
+    <element value="Modify portal content"/>
+   </property>
+   <property name="visible">True</property>
+  </object>
+ </object>
+</object>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/browserlayer.xml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/browserlayer.xml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/browserlayer.xml	(revision 3016)
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<!-- Register the package-specific browser layer, so that it will be activated
+when this product is installed. -->
+<layers>
+   <layer name="qSEOptimizer"
+          interface="quintagroup.seoptimizer.browser.interfaces.IPloneSEOLayer" />
+</layers>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/controlpanel.xml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/controlpanel.xml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/controlpanel.xml	(revision 3016)
@@ -0,0 +1,11 @@
+<?xml version="1.0"?>
+<object name="portal_controlpanel" meta_type="Plone Control Panel Tool"
+    xmlns:i18n="http://xml.zope.org/namespaces/i18n" i18n:domain="quintagroup.seoptimizer">
+ <configlet title="Search Engine Optimizer" action_id="quintagroup.seoptimizer"
+    appId="quintagroup.seoptimizer" category="Products" condition_expr=""
+    icon_expr="string:${portal_url}/++resource++quintagroup.seoptimizer/seo_icon.gif"
+    url_expr="string:${portal_url}/@@seo-controlpanel"
+    visible="True" i18n:attributes="title">
+  <permission>ManagePortal</permission>
+ </configlet>
+</object>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/metadata.xml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/metadata.xml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/metadata.xml	(revision 3016)
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<metadata>
+  <version>3.0.0</version>
+</metadata>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/propertiestool.xml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/propertiestool.xml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/propertiestool.xml	(revision 3016)
@@ -0,0 +1,6 @@
+<?xml version="1.0"?>
+<object name="portal_properties" meta_type="Plone Properties Tool">
+ <object name="seo_properties" meta_type="Plone Property Sheet">
+  <property name="content_types_with_seoproperties" type="lines" purge='False'/>
+ </object>
+</object>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/viewlets.xml
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/viewlets.xml	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/profiles/upgrade_2_to_3/viewlets.xml	(revision 3016)
@@ -0,0 +1,9 @@
+<?xml version="1.0"?>
+<object>
+  <order manager="plone.htmlhead" skinname="*">
+    <viewlet name="plone.htmlhead.title" />
+    <viewlet name="quintagroup.seoptimizer.customscript" />
+    <viewlet name="plone.resourceregistries" />
+    <viewlet name="quintagroup.seoptimizer.seotags" />
+  </order>
+</object>
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/setuphandlers.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/setuphandlers.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/setuphandlers.py	(revision 3016)
@@ -0,0 +1,56 @@
+import logging
+
+from zope.component import getSiteManager
+
+from config import SUPPORT_BLAYER
+    
+from Products.CMFCore.utils import getToolByName
+
+logger = logging.getLogger('quintagroup.seoptimizer')
+
+def removeActions(site):
+    """ Remove actions.
+    """
+    types_tool = getToolByName(site, 'portal_types')
+    for ptype in types_tool.objectValues():
+        idxs = [idx_act[0] for idx_act in enumerate(ptype.listActions()) \
+                           if idx_act[1].id == 'seo_properties']
+        if idxs:
+            ptype.deleteActions(idxs)
+            logger.log(logging.INFO, "Deleted \"SEO Properties\" action for %s type." % ptype.id)
+
+def removeConfiglet(site):
+    """ Remove configlet.
+    """
+    conf_id = 'quintagroup.seoptimizer'
+    controlpanel_tool = getToolByName(site, 'portal_controlpanel')
+    if controlpanel_tool:
+        controlpanel_tool.unregisterConfiglet(conf_id)
+        logger.log(logging.INFO, "Unregistered \"%s\" configlet." % conf_id)
+
+def removeBrowserLayer(site):
+    """ Remove browser layer.
+    """
+    if not SUPPORT_BLAYER:
+        return
+
+    from plone.browserlayer.utils import unregister_layer
+    from plone.browserlayer.interfaces import ILocalBrowserLayerType
+
+    name="qSEOptimizer"
+    site = getSiteManager(site)
+    registeredLayers = [r.name for r in site.registeredUtilities()
+                        if r.provided == ILocalBrowserLayerType]
+    if name in registeredLayers:
+        unregister_layer(name, site_manager=site)
+        logger.log(logging.INFO, "Unregistered \"%s\" browser layer." % name)
+
+def uninstall(context):
+    """ Do customized uninstallation.
+    """
+    if context.readDataFile('seo_uninstall.txt') is None:
+        return
+    site = context.getSite()
+    removeActions(site)
+    removeConfiglet(site)
+    removeBrowserLayer(site)
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/__init__.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/__init__.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/__init__.py	(revision 3016)
@@ -0,0 +1,1 @@
+# This file intentionally left blank
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/base.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/base.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/base.py	(revision 3016)
@@ -0,0 +1,115 @@
+"""Test setup for integration and functional tests.
+
+When we import PloneTestCase and then call setupPloneSite(), all of
+Plone's products are loaded, and a Plone site will be created. This
+happens at module level, which makes it faster to run each test, but
+slows down test runner startup.
+"""
+import re
+import transaction
+from zope.component import getUtility
+
+# Starting from plone.memoize v1.1a4 (used in plone4), global ram cache
+# utility provides other IRAMCache interface, than before
+try:
+    # In plone4 provides
+    from zope.ramcache.interfaces.ram import IRAMCache
+except ImportError:
+    # In plone3 provides
+    from zope.app.cache.interfaces.ram import IRAMCache
+
+from Products.Five import zcml
+from Products.Five import fiveconfigure
+
+from Testing import ZopeTestCase as ztc
+
+from Products.CMFCore.utils import getToolByName
+
+from Products.PloneTestCase.layer import PloneSite
+from Products.PloneTestCase import PloneTestCase as ptc
+from Products.PloneTestCase import setup as ptc_setup
+
+from Products.PloneTestCase.PloneTestCase import portal_owner
+from Products.PloneTestCase.PloneTestCase import default_password
+
+from quintagroup.seoptimizer.config import PROJECT_NAME
+from quintagroup.seoptimizer.config import SUPPORT_BLAYER
+
+ptc.setupPloneSite()
+
+class NotInstalled(PloneSite):
+    """ Only package register, without installation into portal
+    """
+
+    @classmethod
+    def setUp(cls):
+        fiveconfigure.debug_mode = True
+        import quintagroup.seoptimizer
+        zcml.load_config('configure.zcml', quintagroup.seoptimizer)
+        fiveconfigure.debug_mode = False
+        ztc.installPackage(PROJECT_NAME)
+
+
+class Installed(NotInstalled):
+    """ Install product into the portal
+    """
+    @classmethod
+    def setUp(cls):
+        app = ztc.app()
+        portal = app[ptc_setup.portal_name]
+
+        # Sets the local site/manager
+        ptc_setup._placefulSetUp(portal)
+        # Install PROJECT
+        qi = getattr(portal, 'portal_quickinstaller', None)
+        if not ptc.PLONE31:
+            qi.installProduct("plone.browserlayer")
+        qi.installProduct(PROJECT_NAME)
+        transaction.commit()
+
+    @classmethod
+    def tearDown(cls):
+        ptc_setup._placefulTearDown()
+        
+
+class MixinTestCase:
+
+    def _getauth(self):
+        # Fix authenticator for the form
+        import re
+        try:
+            authenticator = self.portal.restrictedTraverse("@@authenticator")
+        except:
+            handle  = ""
+        else:
+            html = authenticator.authenticator()
+            handle = re.search('value="(.*)"', html).groups()[0]
+        return handle
+
+    def beforeTearDown(self):
+        getUtility(IRAMCache).invalidateAll()
+
+    def installBrowserLayer(self):
+        if not ptc.PLONE31:
+            qi = getattr(self.portal, 'portal_quickinstaller', None)
+            qi.installProduct("plone.browserlayer")
+
+
+class TestCase(MixinTestCase, ptc.PloneTestCase):
+    layer = Installed
+
+class TestCaseNotInstalled(MixinTestCase, ptc.PloneTestCase):
+    layer = NotInstalled
+
+
+class FunctionalTestCase(MixinTestCase, ptc.FunctionalTestCase):
+    layer = Installed
+
+    def afterSetUp(self):
+        self.installBrowserLayer()
+
+class FunctionalTestCaseNotInstalled(MixinTestCase, ptc.FunctionalTestCase):
+    layer = NotInstalled
+
+    def afterSetUp(self):
+        self.installBrowserLayer()
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/seo_migration.txt
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/seo_migration.txt	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/seo_migration.txt	(revision 3016)
@@ -0,0 +1,137 @@
+Introduction
+============
+
+This is a full-blown functional test. The emphasis here is on testing what
+the user may input and see, and the system is largely tested as a black box.
+We use PloneTestCase to set up this test as well, so we have a full Plone site
+to play with. We *can* inspect the state of the portal, e.g. using 
+self.portal and self.folder, but it is often frowned upon since you are not
+treating the system as a black box. Also, if you, for example, log in or set
+roles using calls like self.setRoles(), these are not reflected in the test
+browser, which runs as a separate session.
+
+Being a doctest, we can tell a story here.
+
+First, we must perform some setup. We use the testbrowser that is shipped
+with Five, as this provides proper Zope 2 integration. Most of the 
+documentation, though, is in the underlying zope.testbrower package.
+
+    >>> from Products.Five.testbrowser import Browser
+    >>> browser = Browser()
+    >>> portal_url = self.portal.absolute_url()
+
+The following is useful when writing and debugging testbrowser tests. It lets
+us see all error messages in the error_log.
+
+    >>> self.portal.error_log._ignored_exceptions = ()
+
+With that in place, we can go to the portal front page and log in. We will
+do this using the default user from PloneTestCase:
+
+    >>> from Products.PloneTestCase.setup import portal_owner, default_password
+    >>> browser.open(portal_url)
+
+We have the login portlet, so let's use that.
+
+    >>> browser.open('http://nohost/plone/login_form')
+    >>> browser.getLink('Log in').click()
+    >>> browser.url
+    'http://nohost/plone/login_form'
+    >>> browser.getControl('Login Name').value = portal_owner
+    >>> browser.getControl('Password').value = default_password
+    >>> browser.getControl('Log in').click()
+    >>> "You are now logged in" in browser.contents
+    True
+    >>> "Login failed" in browser.contents
+    False
+    >>> browser.url
+    'http://nohost/plone/login_form'
+
+
+-*- extra stuff goes here -*-
+Test 'SEO Properties' action
+===============================
+
+Define some general objects:
+
+    >>> qi = self.portal.portal_quickinstaller
+    >>> object_tabs = self.portal.portal_actions.object
+    >>> ps = self.portal.portal_setup
+
+Test is 'SEO Properties' action present for a Page before
+seoptimizer installed
+
+    >>> browser.open('http://nohost/plone/front-page')
+    >>> "SEO Properties" in browser.contents
+    False
+
+Now install the product for following testing, and add
+
+    >>> qi.installProduct(PROJECT_NAME)
+    'Ran all install steps.'
+    >>> sp = self.portal.portal_properties.seo_properties
+
+And check is SEOProperties added to object tabs
+ 
+   >>> object_tabs.get('SEOProperties', None) is not None
+    True
+
+Test visibility 'SEO Properties' action after installation
+
+    >>> sp.content_types_with_seoproperties = ('Document',)
+    >>> browser.open('http://nohost/plone/front-page')
+    >>> "SEO Properties" in browser.contents
+    True
+    >>> browser.getLink('SEO Properties').url
+    'http://nohost/plone/front-page/@@seo-context-properties'
+
+Test invisibility 'SEO Properties' action after installation
+
+    >>> sp.content_types_with_seoproperties = ('File',)
+    >>> browser.open('http://nohost/plone/front-page')
+    >>> "SEO Properties" in browser.contents
+    False
+
+Test reinstallation 'SEO Properties' action
+For perform all upgrade steps on re-installation, earlier version of profile must be set.
+
+    >>> browser.open('http://nohost/plone/front-page')
+    >>> "SEO Properties" in browser.contents
+    False
+    >>> ps.setLastVersionForProfile('quintagroup.seoptimizer:default', '2.0.0')
+    >>> qi.reinstallProducts((PROJECT_NAME,))
+    >>> self.portal.portal_actions.object.get('SEOProperties', None) is not None
+    True
+
+Test visibility 'SEO Properties' action after reinstallation
+
+    >>> sp.content_types_with_seoproperties = ('Document',)
+    >>> browser.open('http://nohost/plone/front-page')
+    >>> "SEO Properties" in browser.contents
+    True
+    >>> browser.getLink('SEO Properties').url
+    'http://nohost/plone/front-page/@@seo-context-properties'
+
+Test invisibility 'SEO Properties' action after reinstallation
+
+    >>> sp.content_types_with_seoproperties = ('File',)
+    >>> browser.open('http://nohost/plone/front-page')
+    >>> "SEO Properties" in browser.contents
+    False
+
+Test uninstallation 'SEO Properties' action
+
+    >>> sp.content_types_with_seoproperties = ('Document',)
+    >>> browser.open('http://nohost/plone/front-page')
+    >>> "SEO Properties" in browser.contents
+    True
+    >>> qi.uninstallProducts((PROJECT_NAME,))
+    >>> object_tabs.get('SEOProperties', None) is not None
+    False
+
+Test visibility 'SEO Properties' action after uninstallation
+
+    >>> sp.content_types_with_seoproperties = ('Document',)
+    >>> browser.open('http://nohost/plone/front-page')
+    >>> "SEO Properties" in browser.contents
+    False
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/seo_properties.txt
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/seo_properties.txt	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/seo_properties.txt	(revision 3016)
@@ -0,0 +1,112 @@
+Introduction
+============
+
+This is a full-blown functional test. The emphasis here is on testing what
+the user may input and see, and the system is largely tested as a black box.
+We use PloneTestCase to set up this test as well, so we have a full Plone site
+to play with. We *can* inspect the state of the portal, e.g. using 
+self.portal and self.folder, but it is often frowned upon since you are not
+treating the system as a black box. Also, if you, for example, log in or set
+roles using calls like self.setRoles(), these are not reflected in the test
+browser, which runs as a separate session.
+
+Being a doctest, we can tell a story here.
+
+First, we must perform some setup. We use the testbrowser that is shipped
+with Five, as this provides proper Zope 2 integration. Most of the 
+documentation, though, is in the underlying zope.testbrower package.
+
+    >>> from Products.Five.testbrowser import Browser
+    >>> browser = Browser()
+    >>> portal_url = self.portal.absolute_url()
+
+The following is useful when writing and debugging testbrowser tests. It lets
+us see all error messages in the error_log.
+
+    >>> self.portal.error_log._ignored_exceptions = ()
+
+With that in place, we can go to the portal front page and log in. We will
+do this using the default user from PloneTestCase:
+
+    >>> from Products.PloneTestCase.setup import portal_owner, default_password
+    >>> browser.open(portal_url)
+
+We have the login portlet, so let's use that.
+
+    >>> browser.open('http://nohost/plone/login_form')
+    >>> browser.getLink('Log in').click()
+    >>> browser.url
+    'http://nohost/plone/login_form'
+    >>> browser.getControl('Login Name').value = portal_owner
+    >>> browser.getControl('Password').value = default_password
+    >>> browser.getControl('Log in').click()
+    >>> "You are now logged in" in browser.contents
+    True
+    >>> "Login failed" in browser.contents
+    False
+    >>> browser.url
+    'http://nohost/plone/login_form'
+
+
+-*- extra stuff goes here -*-
+Test 'SEO Properties' in action
+===============================
+
+Let's define some helpfull variables:
+
+    >>> page = self.portal["front-page"]
+    >>> page_url = page.absolute_url()
+    >>> seoprops_url = page_url + "/@@seo-context-properties"
+
+Test wheter 'SEO Properties' tab is visible on front page or not.
+
+    >>> browser.open(page_url)
+    >>> "SEO Properties" in browser.contents
+    True
+
+Now, let's go to the "SEO Properties" page, make some changes
+and click on "Cansel" button.
+
+    >>> browser.open(seoprops_url)
+    >>> browser.getControl(name="seo_description_override").value = True
+    >>> descr_ctrl = browser.getControl(name="seo_description")
+    >>> descr_ctrl.value = "Test change of SEO description"
+    >>> browser.getControl("Cancel").click()
+    >>> cancel_page = browser.contents
+    >>> file("/tmp/seo_props.cancel.html","wb").write(cancel_page)
+
+Clicking on "Cancel" button should lead us to the front-page view.
+
+    >>> browser.url == page_url
+    True
+
+No changes must be applied.
+
+    >>> not "Test change of SEO description" in cancel_page
+    True
+
+Check whether status message is correct or not?
+
+    >>> "No content SEO properties have been changed." in cancel_page
+    True
+
+
+Let's review other situation. Go to the "SEO Properties" page, make some changes
+and click on "Save" button.
+
+    >>> browser.open(seoprops_url)
+    >>> browser.getControl(name="seo_description_override").value = True
+    >>> descr_ctrl = browser.getControl(name="seo_description")
+    >>> descr_ctrl.value = "Test change of SEO description"
+    >>> browser.getControl("Save").click()
+    >>> save_page = browser.contents
+
+No changes must be applied.
+
+    >>> "Test change of SEO description" in save_page
+    True
+
+Check whether status message is correct or not?
+
+    >>> "Content SEO properties have been saved" in save_page
+    True
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testBugs.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testBugs.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testBugs.py	(revision 3016)
@@ -0,0 +1,199 @@
+import urllib
+from cStringIO import StringIO
+
+from OFS.interfaces import ITraversable
+
+from zope.component import providedBy
+from zope.component import getGlobalSiteManager
+from zope.component import queryAdapter, getMultiAdapter
+from zope.interface import directlyProvides
+from zope.viewlet.interfaces import IViewlet, IViewletManager
+from zope.publisher.browser import TestRequest
+
+from quintagroup.seoptimizer.browser.interfaces import IPloneSEOLayer
+from quintagroup.seoptimizer.browser.views import PROP_CUSTOM_PREFIX
+from quintagroup.seoptimizer.browser.seo_configlet import ISEOConfigletSchema
+from quintagroup.canonicalpath.interfaces import ICanonicalLink
+from quintagroup.canonicalpath.adapters import DefaultCanonicalLinkAdapter
+from base import *
+
+
+class TestBugs(FunctionalTestCase):
+
+    def afterSetUp(self):
+        self.basic_auth = ':'.join((portal_owner,default_password))
+        self.loginAsPortalOwner()
+        # prepare test document
+        my_doc = self.portal.invokeFactory('Document', id='my_doc')
+        self.my_doc = self.portal['my_doc']
+        self.mydoc_path = "/%s" % self.my_doc.absolute_url(1)
+
+    def test_modification_date(self):
+        """ Modification date changing on SEO properties edit """
+        form_data = {'seo_title': 'New Title',
+                     'seo_title_override:int': 1,
+                     'form.button.Save': "Save",
+                     'form.submitted:int': 1}
+
+        md_before = self.my_doc.modification_date
+        self.publish(path=self.mydoc_path+'/@@seo-context-properties',
+                     basic=self.basic_auth, request_method='POST',
+                     stdin=StringIO(urllib.urlencode(form_data)))
+        md_after = self.my_doc.modification_date
+
+        self.assertNotEqual(md_before, md_after)
+
+    def test_bug_20_at_plone_org(self):
+        portal = self.portal
+        fp = portal['front-page']
+        request = portal.REQUEST
+        view = portal.restrictedTraverse('@@plone')
+
+        manager = getMultiAdapter((fp, request, view), IViewletManager,
+                        name=u'plone.htmlhead')
+        viewlet = getMultiAdapter((fp, request, view, manager), IViewlet,
+                        name=u'plone.htmlhead.title')
+        viewlet.update()
+        old_title = viewlet.render()
+
+        # add IPloneSEOLayer
+        directlyProvides(request, IPloneSEOLayer)
+
+        viewlet = getMultiAdapter((fp, request, view, manager), IViewlet,
+                        name=u'plone.htmlhead.title')
+        viewlet.update()
+        new_title = viewlet.render()
+
+        self.assertEqual(old_title, new_title)
+
+    def test_bug_22_at_plone_org(self):
+        """If ICanonicalLink adapter is not found for the context object
+           - page rendering should not break, but only canonical link
+           should disappear.
+        """
+        curl = re.compile('<link\srel\s*=\s*"canonical"\s+' \
+                         '[^>]*href\s*=\s*\"([^\"]*)\"[^>]*>', re.S|re.M)
+        # When adapter registered for the object - canoncal link present on the page
+        self.assertNotEqual( queryAdapter(self.my_doc, ICanonicalLink), None)
+
+        res = self.publish(path=self.mydoc_path, basic=self.basic_auth)
+        self.assertNotEqual(curl.search(res.getBody()), None)
+
+        # Now remove adapter from the registry -> this should :
+        #     - not break page on rendering;
+        #     - canonical link will be absent on the page
+        gsm = getGlobalSiteManager()
+        gsm.unregisterAdapter(DefaultCanonicalLinkAdapter, [ITraversable,],
+                              ICanonicalLink)
+        self.assertEqual( queryAdapter(self.my_doc, ICanonicalLink), None)
+
+        res = self.publish(path=self.mydoc_path, basic=self.basic_auth)
+        self.assertEqual(curl.search(res.getBody()), None)
+
+        # register adapter back in the global site manager
+        gsm.registerAdapter(DefaultCanonicalLinkAdapter, [ITraversable,],
+                            ICanonicalLink)
+
+    def test_bug_19_23_at_plone_org(self):
+        """overrides.zcml should present in the root of the package"""
+        import quintagroup.seoptimizer
+        try:
+            zcml.load_config('overrides.zcml', quintagroup.seoptimizer)
+        except IOError:
+            self.fail("overrides.zcml removed from the package root")
+
+    def test_bug_custom_metatags_update(self):
+        # Prepare a page for the test
+        page = self.portal["front-page"]
+        request = self.portal.REQUEST
+        directlyProvides(request, IPloneSEOLayer)
+        seo_context_props = getMultiAdapter((page, request), name="seo-context-properties")
+        # Set default custom meta tag without default value (tag name only)
+        self.gseo = queryAdapter(self.portal, ISEOConfigletSchema)
+        self.gseo.default_custom_metatags = ["test_tag",]
+        try:
+            # Breakage on updating custom metatag with seo-context-properties view
+            seo_context_props.updateSEOCustomMetaTagsProperties([])
+        except IndexError:
+            self.fail("Error in calculating of default tag value, when only tag name set "\
+                      "in default_custom_metatags property of the configlet.")
+
+
+class TestBug24AtPloneOrg(FunctionalTestCase):
+
+    def afterSetUp(self):
+        # Add test users: member, editor
+        member_id = 'test_member'
+        editor_id = 'test_editor'
+        test_pswd = 'pswd'
+        uf = self.portal.acl_users
+        uf.userFolderAddUser(member_id, test_pswd,
+                        ['Member'], [])
+        uf.userFolderAddUser(editor_id, test_pswd,
+                        ['Member','Editor'], [])
+
+        self.member_auth = '%s:%s'%(member_id, test_pswd)
+        self.editor_auth = '%s:%s'%(editor_id, test_pswd)
+        self.portal_url = '/'.join(self.portal.getPhysicalPath())
+
+    def test_not_break(self):
+        """Default portal page should not breaks for any user"""
+        # Anonymous
+        resp = self.publish(path=self.portal_url)
+        self.assertEqual(resp.getStatus(), 200)
+        # Member
+        resp = self.publish(path=self.portal_url, basic=self.member_auth)
+        self.assertEqual(resp.getStatus(), 200)
+        # Editor: this fails, althought must pass
+        resp = self.publish(path=self.portal_url, basic=self.editor_auth)
+        self.assertEqual(resp.getStatus(), 200)
+
+    def test_tab_visibility(self):
+        """Only Editor can view seo tab"""
+        rexp = re.compile('<a\s+[^>]*' \
+               'href="[a-zA-Z0-9\:\/_-]*/@@seo-context-properties"[^>]*>'\
+               '\s*SEO Properties\s*</a>', re.I|re.S)
+        # Anonymous: NO SEO Properties link
+        res = self.publish(path=self.portal_url).getBody()
+        self.assertEqual(rexp.search(res), None)
+        # Member: NO 'SEO Properties' link
+        res = self.publish(path=self.portal_url, basic=self.member_auth).getBody()
+        self.assertEqual(rexp.search(res), None)
+        # Editor: PRESENT 'SEO Properties' link
+        res = self.publish(path=self.portal_url, basic=self.editor_auth).getBody()
+        self.assertNotEqual(rexp.search(res), None)
+
+    def test_tab_access(self):
+        """Only Editor can access 'SEO Properties' tab"""
+        test_url = self.portal_url + '/front-page/@@seo-context-properties'
+        # Anonymous: can NOT ACCESS
+        headers = self.publish(path=test_url).headers
+        self.assertEqual( headers.get('bobo-exception-type',""), 'Unauthorized',
+            "No 'Unauthorized' exception rised for Anonymous on '@@seo-context-properties' view")
+        # Member: can NOT ACCESS
+        status = self.publish(path=test_url, basic=self.member_auth).headers
+        self.assertEqual( headers.get('bobo-exception-type',""), 'Unauthorized',
+            "No 'Unauthorized' exception rised for Member on '@@seo-context-properties' view")
+        # Editor: CAN Access
+        res = self.publish(path=test_url, basic=self.editor_auth)
+        self.assertEqual(res.status, 200)
+
+
+    def test_tab_edit(self):
+        """Editor can change SEO Properties"""
+        test_url = self.portal_url + '/front-page/@@seo-context-properties'
+        form_data = {'seo_title': 'New Title',
+                     'seo_title_override:int': 1,
+                     'form.submitted:int': 1}
+        res = self.publish(path=test_url, basic=self.editor_auth,
+                  request_method='POST', stdin=StringIO(urllib.urlencode(form_data)))
+        self.assertNotEqual(res.status, 200)
+
+
+def test_suite():
+    from unittest import TestSuite, makeSuite
+    suite = TestSuite()
+    suite.addTest(makeSuite(TestBugs))
+    suite.addTest(makeSuite(TestBug24AtPloneOrg))
+    return suite
+
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testCanonicalURL.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testCanonicalURL.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testCanonicalURL.py	(revision 3016)
@@ -0,0 +1,100 @@
+from quintagroup.canonicalpath.interfaces import ICanonicalLink
+from base import *
+
+class TestCanonicalURL(FunctionalTestCase):
+
+    def afterSetUp(self):
+        self.basic_auth = ':'.join((portal_owner,default_password))
+        self.loginAsPortalOwner()
+        # Preparation for functional testing
+        self.portal.invokeFactory('Document', id='mydoc')
+        self.mydoc = self.portal['mydoc']
+        self.mydoc_path = "/%s" % self.mydoc.absolute_url(1)
+        self.curl = re.compile('<link\srel\s*=\s*"canonical"\s+' \
+            '[^>]*href\s*=\s*\"([^\"]*)\"[^>]*>', re.S|re.M)
+
+    def test_CanonicalURL(self):
+        html = self.publish(self.mydoc_path, self.basic_auth).getBody()
+        foundcurls = self.curl.findall(html)
+        mydoc_url = self.mydoc.absolute_url()
+
+        self.assertTrue([1 for curl in foundcurls if curl==mydoc_url],
+           "Wrong CANONICAL URL for document: %s, all must be: %s" % (
+           foundcurls, mydoc_url))
+
+    def test_updateCanonicalURL(self):
+        mydoc_url_new = self.mydoc.absolute_url() + '.new'
+        # Update canonical url property
+        self.publish(self.mydoc_path + '/@@seo-context-properties?' \
+           'seo_canonical_override=checked&seo_canonical=%s&' \
+           'form.submitted=1&form.button.Save=Save' % mydoc_url_new, self.basic_auth)
+        # Test updated canonical url
+        html = self.publish(self.mydoc_path, self.basic_auth).getBody()
+        foundcurls = self.curl.findall(html)
+
+        qseo_url = ICanonicalLink(self.mydoc).canonical_link
+        self.assertTrue(qseo_url == mydoc_url_new,
+           "Not set 'qSEO_canonical' property")
+        self.assertTrue([1 for curl in foundcurls if curl==mydoc_url_new],
+           "Wrong CANONICAL URL for document: %s, all must be: %s" % (
+           foundcurls, mydoc_url_new))
+
+    def test_defaultCanonical(self):
+        expect = self.mydoc.absolute_url()
+        cpath = ICanonicalLink(self.mydoc).canonical_link
+        self.assertTrue(cpath == expect,
+            "Default canonical link adapter return: '%s', must be: '%s'" % (
+             cpath, expect))
+
+    def testCatalogUpdated(self):
+        purl = getToolByName(self.portal, 'portal_url')
+        catalog = getToolByName(self.portal, 'portal_catalog')
+        catalog.addColumn('canonical_link')
+
+        # get catalog data before update
+        mydoc_catalog_canonical = catalog(id="mydoc")[0].canonical_link
+        self.assertTrue(not mydoc_catalog_canonical)
+
+        # Update canonical url property
+        mydoc_url_new = self.mydoc.absolute_url() + '.new'
+        self.publish(self.mydoc_path + '/@@seo-context-properties?' \
+            'seo_canonical_override=checked&seo_canonical=%s' \
+            '&form.submitted=1&form.button.Save=Save' % mydoc_url_new,
+             self.basic_auth)
+
+        newcpath = ICanonicalLink(self.mydoc).canonical_link
+        mydoc_catalog_canonical = catalog(id="mydoc")[0].canonical_link
+        self.assertTrue(newcpath == mydoc_catalog_canonical,
+            "canonical path get by adapter: '%s' not equals to cataloged one: '%s'" % (
+             newcpath, mydoc_catalog_canonical))
+
+    def test_canonicalValidation(self):
+        wrong_canonical = 'wrong   canonical'
+        # Update canonical url property
+        html = self.publish(self.mydoc_path + '/@@seo-context-properties?' \
+               'seo_canonical_override=checked&seo_canonical=%s&' \
+               'form.submitted=1&form.button.Save=Save' % wrong_canonical, self.basic_auth).getBody()
+        self.assertTrue("wrong canonical url" in html,
+                        "Canonical url not validated")
+
+    def test_delCanonical(self):
+        newcanonical = '/new_canonical'
+        ICanonicalLink(self.mydoc).canonical_link = newcanonical
+
+        assert ICanonicalLink(self.mydoc).canonical_link == newcanonical
+
+        # remove canonical url customization
+        self.publish(self.mydoc_path + '/@@seo-context-properties?' \
+           'seo_canonical=%s&seo_canonical_override=&form.submitted=1&form.button.Save=Save' % newcanonical,
+            self.basic_auth)
+
+        mydoc_canonical = ICanonicalLink(self.mydoc).canonical_link
+        self.assertTrue(mydoc_canonical == self.mydoc.absolute_url(),
+            "Steel customized canonical url after remove customization")
+
+
+def test_suite():
+    from unittest import TestSuite, makeSuite
+    suite = TestSuite()
+    suite.addTest(makeSuite(TestCanonicalURL))
+    return suite
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testConfiglet.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testConfiglet.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testConfiglet.py	(revision 3016)
@@ -0,0 +1,166 @@
+from base import *
+
+from zope.formlib.form import FormFields
+from zope.schema.interfaces import IBool
+from quintagroup.seoptimizer.browser.seo_configlet import ISEOConfigletSchema
+
+class TestConfiglet(FunctionalTestCase):
+
+    def afterSetUp(self):
+        self.sp = self.portal.portal_properties.site_properties
+        self.seo = self.portal.portal_properties.seo_properties
+        self.basic_auth = ':'.join((portal_owner,default_password))
+        self.loginAsPortalOwner()
+        self.save_url = self.portal.id+'/@@seo-controlpanel?form.actions.save=1' \
+            '&_authenticator=%s' % self._getauth()
+
+    def test_exposeDCMetaTags_On(self):
+        self.publish(self.save_url + '&form.exposeDCMetaTags=on',
+                     self.basic_auth)
+        self.assert_(self.sp.exposeDCMetaTags)
+
+    def test_exposeDCMetaTags_Off(self):
+        self.publish(self.save_url + '&form.exposeDCMetaTags=',
+             self.basic_auth)
+        self.assertTrue(not self.sp.exposeDCMetaTags)
+
+    def test_defaultCustomMetatags_On(self):
+        expect = ('test', 'custom', 'metatags')
+        formdata = "\n".join(expect)
+        self.publish(self.save_url + '&form.default_custom_metatags=%s'%formdata,
+             self.basic_auth)
+
+        dcm = self.seo.getProperty("default_custom_metatags", ())
+        self.assertTrue(dcm == expect, '"default_custom_metatags" property ' \
+            'contains: "%s", must: "%s"' % (dcm, expect))
+
+    def test_defaultCustomMetatags_Off(self):
+        data = ('test', 'custom', 'metatags')
+        self.seo._updateProperty("default_custom_metatags", data)
+        self.publish(self.save_url + '&form.default_custom_metatags=',
+             self.basic_auth)
+
+        dcm = self.seo.getProperty("default_custom_metatags", ())
+        self.assertTrue(dcm == (), '"default_custom_metatags" property ' \
+            'contains: "%s", must be empty"' % str(dcm))
+
+    def test_metatagsOrder_On(self):
+        expect = ('test', 'metatags', 'order')
+        formdata = "\n".join(expect)
+        self.publish(self.save_url + '&form.metatags_order=%s'%formdata,
+             self.basic_auth)
+
+        mo = self.seo.getProperty("metatags_order", ())
+        self.assertTrue(mo == expect, '"metatags_order" property ' \
+            'contains: "%s", must: "%s"' % (mo, expect))
+
+    def test_metatagsOrder_Off(self):
+        data = ('test', 'metatags', 'order')
+        self.seo._updateProperty("metatags_order", data)
+        self.publish(self.save_url + '&form.metatags_order=',
+             self.basic_auth)
+
+        mo = self.seo.getProperty("metatags_order", ())
+        self.assertTrue(mo == (), '"metatags_order" property ' \
+            'contains: "%s", must be empty"' % str(mo))
+
+    def test_typesSEOEnabled_On(self):
+        expect = 'Event'
+        self.publish(self.save_url + "&form.types_seo_enabled=%s" % expect, self.basic_auth)
+        tse = self.seo.getProperty("content_types_with_seoproperties", ())
+        self.assertTrue(tse == (expect,),
+            '"content_types_with_seoproperties" property contains: ' \
+            '"%s", must: "%s"' % (tse, expect))
+
+    def test_typesSEOEnabled_Off(self):
+        self.publish(self.save_url + '&form.types_seo_enabled-empty-marker=1',
+             self.basic_auth)
+
+        tse = self.seo.getProperty("content_types_with_seoproperties", ())
+        self.assertTrue(tse == (), '"content_types_with_seoproperties" property ' \
+            'contains: "%s", must be empty"' % str(tse))
+
+    def test_CustomScriptAdd(self):
+        expect = "<script>\n<!-- Test custom script -->\n</script>"
+
+        self.publish(self.save_url + '&form.custom_script=%s' % expect,
+             self.basic_auth)
+
+        cs = self.seo.getProperty("custom_script", "")
+        self.assertTrue(cs == expect, '"custom_script" property ' \
+            'contains: "%s", but "%s" must be"' % (cs, expect))
+
+    def test_CustomScriptDel(self):
+        self.publish(self.save_url + '&form.custom_script=',
+             self.basic_auth)
+
+        cs = self.seo.getProperty("custom_script", "")
+        self.assertTrue(cs == "", '"custom_script" property ' \
+            'contains: "%s", must be empty"' % cs)
+
+    def test_fieldsAdd(self):
+        expect = ('field1', 'field2')
+        formdata = "\n".join(expect)
+        self.publish(self.save_url + '&form.fields=%s'%formdata,
+             self.basic_auth)
+
+        f = self.seo.getProperty("fields", ())
+        self.assertTrue(f == expect, '"fields" property ' \
+            'contains: "%s", must: "%s"' % (f, expect))
+
+    def test_fieldsDel(self):
+        data = ('field1', 'field2')
+        self.seo._updateProperty("fields", data)
+        self.publish(self.save_url + '&form.fields=',
+             self.basic_auth)
+
+        f = self.seo.getProperty("fields", ())
+        self.assertTrue(f == (), '"fields" property ' \
+            'contains: "%s", must be empty"' % str(f))
+
+    def test_stopWordsAdd(self):
+        expect = ('sw1', 'sw2', 'sw3')
+        formdata = "\n".join(expect)
+        self.publish(self.save_url + '&form.stop_words=%s'%formdata,
+             self.basic_auth)
+
+        f = self.seo.getProperty("stop_words", ())
+        self.assertTrue(f == expect, '"stop_words" property ' \
+            'contains: "%s", must: "%s"' % (f, expect))
+
+    def test_stopWordsDel(self):
+        data = ('sw1', 'sw2', 'sw3')
+        self.seo._updateProperty("stop_words", data)
+        self.publish(self.save_url + '&form.stop_words=',
+             self.basic_auth)
+
+        f = self.seo.getProperty("stop_words", ())
+        self.assertTrue(f == (), '"stop_words" property ' \
+            'contains: "%s", must be empty"' % str(f))
+
+    def test_externalKeywordTest(self):
+        fields = FormFields(ISEOConfigletSchema)
+        ffield = fields.get("external_keywords_test")
+        self.assertTrue(ffield is not None, 'Not found "external_keywords_test" '\
+            'field in ISEOConfigletSchema interface')
+        self.assertTrue(IBool.providedBy(ffield.field),
+            '"external_keywords_test" is not boolean type field')
+        self.assertTrue(ffield.field.default == False,
+            '"external_keywords_test" field default value is not set to False')
+
+    def test_externalKeyword_On(self):
+        self.publish(self.save_url + '&form.external_keywords_test=on',
+                     self.basic_auth)
+        self.assert_(self.seo.external_keywords_test)
+
+    def test_externalKeyword_Off(self):
+        self.publish(self.save_url + '&form.external_keywords_test=',
+             self.basic_auth)
+        self.assertTrue(not self.seo.external_keywords_test)
+
+
+def test_suite():
+    from unittest import TestSuite, makeSuite
+    suite = TestSuite()
+    suite.addTest(makeSuite(TestConfiglet))
+    return suite
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testContextForm.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testContextForm.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testContextForm.py	(revision 3016)
@@ -0,0 +1,160 @@
+import urllib
+from cStringIO import StringIO
+from base import *
+
+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'
+]
+
+FORM = {
+    'seo_robots': 'ALL',
+    'form.submitted:int': 1,
+    'form.button.Save': "Save",
+    'seo_title': 'hello world',
+    'seo_title_override:int': 1,
+    'seo_robots_override:int': 1,
+    'seo_distribution': 'Global',
+    'seo_keywords_override:int': 1,
+    'seo_keywords:list': 'keyword1',
+    'seo_description_override:int': 1,
+    'seo_html_comment': 'no comments',
+    'seo_html_comment_override:int': 1,
+    'seo_distribution_override:int': 1,
+    'seo_custommetatags_override:int': 1,
+    'seo_description': 'it is description, test keyword1',
+}
+
+METATAG = '.*(<meta\s+(?:(?:name="%s"\s*)|(?:content="%s"\s*)){2}/>)'
+
+class TestContextForm(FunctionalTestCase):
+
+    def afterSetUp(self):
+        self.sp = self.portal.portal_properties.seo_properties
+        self.pu = self.portal.plone_utils
+        self.wf = self.portal.portal_workflow
+
+        self.basic_auth = ':'.join((portal_owner,default_password))
+        self.loginAsPortalOwner()
+
+        # Preparation for functional testing'
+        # create document for test
+        my_doc = self.portal.invokeFactory('Document', id='my_doc')
+        my_doc = self.portal['my_doc']
+        self.abs_path = "/%s" % my_doc.absolute_url(1)
+        # prepare seo context form data
+        self.sp.manage_changeProperties(
+            default_custom_metatags = 'metatag1|global_metatag1value\n' \
+                                      'metatag4|global_metatag4value')
+        st = ''
+        for d in CUSTOM_METATAGS:
+            st += '&seo_custommetatags.meta_name:records=%s' % d['meta_name']
+            st += '&seo_custommetatags.meta_content:records=%s' % d['meta_content']
+        # update seo properties for the test document and publish it
+        self.publish(path=self.abs_path+'/@@seo-context-properties',
+                     basic=self.basic_auth, request_method='POST',
+                     stdin=StringIO(urllib.urlencode(FORM)+st))
+        self.wf.doActionFor(my_doc, 'publish')
+        # get html view of test document
+        self.html = self.publish(self.abs_path, 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.
+        """
+        # setup page with title equal to plone site's title
+        my_doc2 = self.portal.invokeFactory('Document', id='my_doc2',
+                                            title=self.portal.Title())
+        my_doc2 = self.portal['my_doc2']
+        self.wf.doActionFor(my_doc2, 'publish')
+        html2 = self.publish('/'+my_doc2.absolute_url(1), self.basic_auth).getBody()
+
+        m = re.match('.*<title>\\s*%s\\s*</title>' % self.portal.Title(), 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(METATAG % ("description", FORM['seo_description']), self.html, re.S|re.M)
+        self.assert_(m, 'Description not set in')
+
+    def testRobots(self):
+        m = re.match(METATAG % ("robots", FORM['seo_robots']), self.html, re.S|re.M)
+        self.assert_(m, 'Robots not set in')
+
+    def testDistribution(self):
+        m = re.match(METATAG % ("distribution", FORM['seo_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):
+        def is_match(html, mtorder):
+            return re.search('.*'.join(['<meta.*name="%s".*/>' %t \
+                                        for t in mtorder]), html, re.S|re.M)
+
+        metatags_order = [t for t in self.sp.getProperty('metatags_order') if t in VIEW_METATAGS]
+        self.assert_(is_match(self.html, metatags_order), "Meta tags order not supported.")
+
+        metatags_order.reverse()
+        self.assertFalse(is_match(self.html, metatags_order), "Meta tags order not supported.")
+
+        self.sp.manage_changeProperties(metatags_order = metatags_order)
+        self.assertFalse(is_match(self.html, metatags_order), "Meta tags order not supported.")
+
+        html = self.publish(self.abs_path, self.basic_auth).getBody()
+        self.assert_(is_match(html, metatags_order), "Meta tags order not supported.")
+
+
+    def testCustomMetaTags(self):
+        for tag in CUSTOM_METATAGS:
+            m = re.match(METATAG % (tag['meta_name'], tag['meta_content']),
+                         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.match(METATAG % ("metatag4", "global_metatag4value"), 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')
+        form_data = {'seo_custommetatags': CUSTOM_METATAGS,
+                     'seo_custommetatags_override:int': 0,
+                     'form.button.Save': "Save",
+                     'form.submitted:int': 1}
+
+        # Clean-up ram cache
+        getUtility(IRAMCache).invalidateAll()
+
+        self.publish(path=self.abs_path+'/@@seo-context-properties',
+                     basic=self.basic_auth, request_method='POST',
+                     stdin=StringIO(urllib.urlencode(form_data)))
+        html = self.publish(self.abs_path, self.basic_auth).getBody()
+
+        m = re.match(METATAG % ("metatag4", "global_metatag4value"), html, re.S|re.M)
+        self.assert_(not m, "Global custom meta tag %s is prosent in the page." % 'metatag4')
+
+        m = re.match(METATAG % ("metatag1", "global_metatag1value"), html, re.S|re.M)
+        self.assert_(m, "Global custom meta tag %s not applied." % 'metatag1')
+
+def test_suite():
+    from unittest import TestSuite, makeSuite
+    suite = TestSuite()
+    suite.addTest(makeSuite(TestContextForm))
+    return suite
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testExposeDCMetaTags.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testExposeDCMetaTags.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testExposeDCMetaTags.py	(revision 3016)
@@ -0,0 +1,62 @@
+import string
+from DateTime import DateTime
+from base import *
+
+METATAG = '.*(<meta\s+(?:(?:name="%s"\s*)|(?:content="(?P<tagcontent>.*?)"\s*)){2}/>)'
+
+class TestExposeDCMetaTags(FunctionalTestCase):
+
+    def afterSetUp(self):
+        self.sp = self.portal.portal_properties.site_properties
+        self.basic_auth = ':'.join((portal_owner,default_password))
+        self.loginAsPortalOwner()
+        # Preparation for functional testing
+        self.my_doc = self.portal.invokeFactory('Document', id='my_doc')
+        self.my_doc = self.portal['my_doc']
+
+    def test_propertyOff(self):
+        self.sp.manage_changeProperties(exposeDCMetaTags = False)
+        self.html = str(self.publish(self.portal.id+'/my_doc', self.basic_auth))
+        m1 = re.match(METATAG % "DC.format", self.html, re.S|re.M)
+        m2 = re.match(METATAG % "DC.distribution", self.html, re.S|re.M)
+        self.assert_(not (m1 or m2), 'DC meta tags avaliable when exposeDCMetaTags=False')
+
+    def test_propertyOn(self):
+        self.sp.manage_changeProperties(exposeDCMetaTags = True)
+        self.html = str(self.publish(self.portal.id+'/my_doc', self.basic_auth))
+        m1 = re.match(METATAG % "DC.format", self.html, re.S|re.M)
+        m2 = re.match(METATAG % "DC.type", self.html, re.S|re.M)
+        self.assert_(m1 and m2, 'DC meta tags not avaliable when createManager=True')
+
+    def test_descriptionInPropertyOff(self):
+        self.sp.manage_changeProperties(exposeDCMetaTags = False)
+        self.my_doc.setDescription("My document description")
+        self.html = str(self.publish(self.portal.id+'/my_doc', self.basic_auth))
+        m = re.match(METATAG % "description", self.html, re.S|re.M)
+        self.assert_(m, 'No "description" meta tag when expose DC meta tags is Off')
+
+    def test_descriptionInPropertyOn(self):
+        self.sp.manage_changeProperties(exposeDCMetaTags = True)
+        self.my_doc.setDescription("My document description")
+        self.html = str(self.publish(self.portal.id+'/my_doc', self.basic_auth))
+        m = re.match(METATAG % "description", self.html, re.S|re.M)
+        self.assert_(m, 'No "description" meta tag when expose DC meta tags is On')
+
+    def test_dateValidRange(self):
+        self.sp.manage_changeProperties(exposeDCMetaTags = True)
+        EFFDSTR, EXPDSTR =  "2009/12/23", "2010/03/10"
+        self.my_doc.setExpirationDate(EXPDSTR)
+        self.my_doc.setEffectiveDate(EFFDSTR)
+        self.html = str(self.publish(self.portal.id+'/my_doc', self.basic_auth))
+        m = re.match(METATAG % "DC.date.valid_range", self.html, re.S|re.M)
+        content = m and m.group("tagcontent")
+        fact = content and map(DateTime, content.split("-"))
+        expect = map(DateTime, [EFFDSTR, EXPDSTR])
+        self.assert_( fact == expect, '"DC.date.valid_range" meta tags content="%s", ' \
+                      'but "%s" must be' % ( fact, expect ))
+
+def test_suite():
+    from unittest import TestSuite, makeSuite
+    suite = TestSuite()
+    suite.addTest(makeSuite(TestExposeDCMetaTags))
+    return suite
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testInstallation.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testInstallation.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testInstallation.py	(revision 3016)
@@ -0,0 +1,257 @@
+#
+# Test product's installation
+#
+import string
+from zope.interface import alsoProvides
+from zope.component import queryMultiAdapter
+from zope.viewlet.interfaces import IViewletManager
+
+from quintagroup.canonicalpath.adapters import PROPERTY_LINK
+from quintagroup.seoptimizer.browser.interfaces import IPloneSEOLayer
+from base import *
+
+PROPERTY_SHEET = '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']
+
+PROPS = {'stop_words': STOP_WORDS,
+         'fields': ['seo_title', 'seo_description', 'seo_keywords']}
+
+DEFAULT_METATAGS_ORDER = [
+    'DC.contributors', 'DC.creator', 'DC.date.created',
+    'DC.date.modified','DC.description', 'DC.distribution',
+    'DC.format', 'DC.language', 'DC.publisher', 'DC.rights',
+    'DC.subject', 'DC.type', 'description', 'distribution',
+    'keywords', 'robots']
+DEFAULT_METATAGS_ORDER.sort()
+
+SEO_CONTENT = ['File', 'Document', 'News Item']
+CONTENTTYPES_WITH_SEOACTION = ['File', 'Document', 'News Item', 'Folder', 'Event']
+CONTENTTYPES_WITH_SEOACTION.sort()
+
+
+class TestBeforeInstallation(FunctionalTestCaseNotInstalled):
+
+    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(TestCase):
+
+    def afterSetUp(self):
+        self.properties = getToolByName(self.portal, 'portal_properties')
+
+    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_(PROJECT_NAME in [a.getId() for a in configTool.listActions()], 'Configlet not found')
+
+    def test_viewlets_install(self):
+        VIEWLETS = ['plone.htmlhead.title',
+                    'plone.resourceregistries',
+                    'quintagroup.seoptimizer.seotags',
+                    'quintagroup.seoptimizer.customscript']
+        request = self.app.REQUEST
+        # mark request with our browser layer
+        alsoProvides(request, IPloneSEOLayer)
+        view = queryMultiAdapter((self.portal, request), name="plone")
+        manager = queryMultiAdapter( (self.portal['front-page'], request, view),
+                                     IViewletManager, name='plone.htmlhead')
+        for p in VIEWLETS:
+            self.assert_(manager.get(p) is not None, "Not registered '%s' viewlet" % p)
+
+    def test_browser_layer(self):
+        if not SUPPORT_BLAYER:
+            return
+
+        from plone.browserlayer import utils
+        self.assert_(IPloneSEOLayer in utils.registered_layers(),
+                     "Not registered 'IPloneSEOLayer' browser layer")
+
+    def test_action_install(self):
+        atool=getToolByName(self.portal, 'portal_actions')
+        action_ids = [a.id for a in atool.listActions()]
+        self.assert_("SEOProperties" in action_ids,
+                     "Not added 'SEOProperties' action")
+
+class TestUninstallation(TestCase):
+
+    def afterSetUp(self):
+        self.qi = self.portal.portal_quickinstaller
+        self.qi.uninstallProducts([PROJECT_NAME])
+
+    def test_propertysheet_uninstall(self):
+        properties = getToolByName(self.portal, 'portal_properties')
+        self.assert_(hasattr(properties.aq_base, PROPERTY_SHEET),
+            "'%s' property sheet not uninstalled" % PROPERTY_SHEET)
+
+    def test_configlet_uninstall(self):
+        self.assertNotEqual(self.qi.isProductInstalled(PROJECT_NAME), True,
+            'qSEOptimizer is already installed')
+
+        configTool = getToolByName(self.portal, 'portal_controlpanel', None)
+        self.assertEqual(PROJECT_NAME in [a.getId() for a in configTool.listActions()], False,
+            'Configlet found after uninstallation')
+
+    def test_viewlets_uninstall(self):
+        VIEWLETS = ['quintagroup.seoptimizer.seotags',
+                    'quintagroup.seoptimizer.customscript']
+        request = self.app.REQUEST
+        view = queryMultiAdapter((self.portal, request), name="plone")
+        manager = queryMultiAdapter( (self.portal['front-page'], request, view),
+                                     IViewletManager, name='plone.htmlhead')
+        for p in VIEWLETS:
+            self.assertEqual(manager.get(p) is None, True,
+                "'%s' viewlet present after uninstallation" % p)
+
+    def test_browserlayer_uninstall(self):
+        if not SUPPORT_BLAYER:
+            return
+
+        from plone.browserlayer import utils
+        self.assertEqual(IPloneSEOLayer in utils.registered_layers(), False,
+            "Still registered 'IPloneSEOLayer' browser layer")
+
+    def test_action_uninstall(self):
+        atool=getToolByName(self.portal, 'portal_actions')
+        action_ids = [a.id for a in atool.listActions()]
+        self.assertEqual("SEOProperties" in action_ids, False,
+            "'SEOProperties' action not removed from portal_actions " \
+            "on uninstallation")
+
+class TestReinstallation(TestCase):
+
+    def afterSetUp(self):
+        self.qi = self.portal.portal_quickinstaller
+        self.types_tool = getToolByName(self.portal, 'portal_types')
+        self.setup_tool = getToolByName(self.portal, 'portal_setup')
+        self.pprops_tool = getToolByName(self.portal, 'portal_properties')
+        self.seoprops_tool = getToolByName(self.pprops_tool, 'seo_properties', None)
+        # Set earlier version profile (2.0.0) for using upgrade steps 
+        self.setup_tool.setLastVersionForProfile('%s:default' % PROJECT_NAME, '2.0.0')
+
+    def testChangeDomain(self):
+        # Test changed of content type's domain from 'quintagroup.seoptimizer' to 'plone'
+        for type in SEO_CONTENT:
+            self.types_tool.getTypeInfo(type).i18n_domain = 'quintagroup.seoptimizer'
+        self.qi.reinstallProducts([PROJECT_NAME])
+        for type in SEO_CONTENT:
+            self.assertEqual(self.types_tool.getTypeInfo(type).i18n_domain, 'plone',
+                "Not changed of %s content type's domain to 'plone'" % type)
+
+    def testCutItemsMetatagsOrderList(self):
+        # Test changed format metatags order list from "metaname accessor" to "metaname"
+        value, expect_mto = ['name1 accessor1', 'name2 accessor2'], ['name1','name2']
+        self.seoprops_tool.manage_changeProperties(metatags_order=value)
+        self.qi.reinstallProducts([PROJECT_NAME])
+        mto = list(self.seoprops_tool.getProperty('metatags_order'))
+        mto.sort()
+        self.assertEqual(mto, expect_mto,
+                    "Not changed format metatags order list from \"metaname accessor\" to"\
+                    " \"metaname\". %s != %s" %(mto, expect_mto))
+
+    def testAddMetatagsOrderList(self):
+        # Test added metatags order list if it was not there before
+        self.seoprops_tool.manage_delProperties(['metatags_order'])
+        self.qi.reinstallProducts([PROJECT_NAME])
+        mto = list(self.seoprops_tool.getProperty('metatags_order'))
+        mto.sort()
+        self.assertEqual(mto, DEFAULT_METATAGS_ORDER,
+                    "Not added metatags order list with default values."\
+                    "%s != %s" %(mto, DEFAULT_METATAGS_ORDER))
+
+    def testMigrationActions(self):
+        # Test migrated actions from portal_types action to seoproperties tool
+        self.seoprops_tool.content_types_with_seoproperties = ()
+
+        # Add seoaction to content type for testing
+        for type in CONTENTTYPES_WITH_SEOACTION:
+            self.types_tool.getTypeInfo(type).addAction(id='seo_properties',
+                                                   name='SEO Properties',
+                                                   action=None,
+                                                   condition=None,
+                                                   permission=(u'Modify portal content',),
+                                                   category='object',
+                                                  )
+            # Check presence seoaction in content type
+            seoaction = [act.id for act in self.types_tool.getTypeInfo(type).listActions()
+                                          if act.id == 'seo_properties']
+            self.assertEqual(bool(seoaction), True,
+                    "Not added seoaction to content type %s for testing" % type)
+
+        self.qi.reinstallProducts([PROJECT_NAME])
+
+        # Check presence seoaction in content type
+        for type in CONTENTTYPES_WITH_SEOACTION:
+            seoaction = [act.id for act in self.types_tool.getTypeInfo(type).listActions()
+                                          if act.id == 'seo_properties']
+            self.assertEqual(bool(seoaction), False,
+                "Not removed seoaction in content type %s" % type)
+
+        # Check added content type names in seo properties tool if content types have seoaction
+        ctws = list(self.seoprops_tool.content_types_with_seoproperties)
+        ctws.sort()
+        self.assertEqual(ctws, CONTENTTYPES_WITH_SEOACTION,
+            "Not added content type names in seo properties tool if content types have seoaction."\
+            " %s != %s" %(ctws, CONTENTTYPES_WITH_SEOACTION))
+
+    def testRemoveSkin(self):
+        # Test remove layers
+        layer = 'quintagroup.seoptimizer'
+        skinstool = getToolByName(self.portal, 'portal_skins')
+        for skin in skinstool.getSkinSelections():
+            paths  = ','.join((skinstool.getSkinPath(skin), layer))
+            skinstool.addSkinSelection(skin, paths)
+        self.qi.reinstallProducts([PROJECT_NAME])
+        for skin in skinstool.getSkinSelections():
+            path = skinstool.getSkinPath(skin)
+            path = map(string.strip, string.split(path, ','))
+            self.assertEqual(layer in path, False,
+                '%s layer found in %s after uninstallation' %(layer, skin))
+
+    def testMigrateCanonical(self):
+        """ Test Migrate qSEO_canonical property into PROPERTY_LINK
+            for all portal objects, which use SEO
+        """
+        doc = self.portal.get('front-page')
+        doc.manage_addProperty('qSEO_canonical', 'val', 'string')
+        value = doc.getProperty('qSEO_canonical')
+        assert doc.getProperty('qSEO_canonical') == 'val' 
+
+        self.qi.reinstallProducts([PROJECT_NAME])
+        value = doc.getProperty(PROPERTY_LINK)
+        has_prop = bool(doc.hasProperty('qSEO_canonical'))
+        self.assertEqual(has_prop, False, "Property 'qSEO_canonical' is not deleted.")
+        self.assertEqual(value == 'val', True,
+                "Property not migrated from 'qSEO_canonical' to '%s'." % PROPERTY_LINK)
+
+def test_suite():
+    from unittest import TestSuite, makeSuite
+    suite = TestSuite()
+    suite.addTest(makeSuite(TestBeforeInstallation))
+    suite.addTest(makeSuite(TestInstallation))
+    suite.addTest(makeSuite(TestUninstallation))
+    suite.addTest(makeSuite(TestReinstallation))
+    return suite
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testMetaTagsDuplication.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testMetaTagsDuplication.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testMetaTagsDuplication.py	(revision 3016)
@@ -0,0 +1,61 @@
+from base import *
+
+GENERATOR = re.compile('.*(<meta\s+(?:(?:name="generator"\s*)|' \
+                       '(?:content=".*?"\s*)){2}/>)', re.S|re.M)
+DESCRIPTION = re.compile('.*(<meta\s+(?:(?:name="description"\s*)|' \
+                         '(?:content=".*?"\s*)){2}/>)', re.S|re.M)
+
+class InstallMixin:
+
+    def prepare(self):
+        # Preparation for functional testing
+        self.loginAsPortalOwner()
+        self.my_doc = self.portal.invokeFactory('Document', id='my_doc')
+        self.my_doc = self.portal['my_doc']
+        self.my_doc.update(description="Document description")
+        self.portal.portal_workflow.doActionFor(self.my_doc, 'publish')
+        self.logout()
+        # Get document without customized canonical url
+        self.abs_path = "/%s" % self.my_doc.absolute_url(1)
+        self.html = self.publish(self.abs_path).getBody()
+
+
+class TestTagsDuplicationInstalled(InstallMixin, FunctionalTestCase):
+
+    def afterSetUp(self):
+        self.prepare()
+
+    def test_GeneratorMetaSEOInstalled(self):
+        lengen = len(GENERATOR.findall(self.html))
+        self.assert_(lengen==1, "There is %d generator meta tag(s) " \
+           "when seoptimizer installed" % lengen)
+ 
+    def test_DescriptionMetaSEOInstalled(self):
+        lendesc = len(DESCRIPTION.findall(self.html))
+        self.assert_(lendesc==1, "There is %d DESCRIPTION meta tag(s) " \
+           "when seoptimizer installed" % lendesc)
+
+
+class TestTagsDuplicationNotInstalled(InstallMixin,
+                                      FunctionalTestCaseNotInstalled):
+
+    def afterSetUp(self):
+        self.prepare()
+
+    def test_GeneratorMetaSEOUninstalled(self):
+        lengen = len(GENERATOR.findall(self.html))
+        self.assert_(lengen<=1, "There is %d generator meta tag(s) " \
+            "when seoptimizer uninstalled" % lengen)
+
+    def test_DescriptionMetaSEOUninstalled(self):
+        lendesc = len(DESCRIPTION.findall(self.html))
+        self.assert_(lendesc==1, "There is %d DESCRIPTION meta tag(s) " \
+           "when seoptimizer uninstalled" % lendesc)
+
+
+def test_suite():
+    from unittest import TestSuite, makeSuite
+    suite = TestSuite()
+    suite.addTest(makeSuite(TestTagsDuplicationInstalled))
+    suite.addTest(makeSuite(TestTagsDuplicationNotInstalled))
+    return suite
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testUsageKeywords.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testUsageKeywords.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/testUsageKeywords.py	(revision 3016)
@@ -0,0 +1,165 @@
+from base import *
+import urllib2
+from StringIO import StringIO
+from zope.component import queryMultiAdapter
+from zope.interface import alsoProvides
+from quintagroup.seoptimizer.browser.interfaces import IPloneSEOLayer
+
+KWSTMPL = '.*(<meta\s+(?:(?:name="keywords"\s*)|(?:content="%s"\s*)){2}/>)'
+
+class TestUsageKeywords(FunctionalTestCase):
+
+    def afterSetUp(self):
+        self.sp = self.portal.portal_properties.seo_properties
+        self.pu = self.portal.plone_utils
+        self.basic_auth = ':'.join((portal_owner,default_password))
+        self.loginAsPortalOwner()
+        #Preparation for functional testing
+        self.my_doc = self.portal.invokeFactory('Document', id='my_doc')
+        self.my_doc = self.portal['my_doc']
+
+    def test_noDefaultKeywords(self):
+        """No keywords added for the content by default"""
+        metatags = self.pu.listMetaTags(self.my_doc)
+        self.assert_('keywords' not in metatags)
+
+    def testrender_SEOKeywords(self):
+        """ """
+        self.my_doc.setText('<p>foo bar</p>')
+        self.my_doc.manage_addProperty('qSEO_keywords', [], 'lines')
+
+        for seokws in [('foo',), ('foo', 'bar')]:
+            self.my_doc._updateProperty('qSEO_keywords', seokws)
+            html = str(self.publish(self.portal.id+'/my_doc', self.basic_auth))
+            expect = ',\s*'.join(seokws)
+            open('/tmp/testrender_SEOKeywords','w').write(html)
+            self.assert_(re.match(KWSTMPL % expect, html, re.S|re.M),
+                         "No '%s' keyword found" % str(seokws))
+
+    def testbehave_NoSEOKeywordsOnlySubject(self):
+        self.my_doc.setText('<p>local subject</p>')
+        self.my_doc.setSubject('subject')
+        html = str(self.publish(self.portal.id+'/my_doc', self.basic_auth))
+
+        expect = "subject"
+        self.assert_(re.match(KWSTMPL % expect, html, re.S|re.M),
+                     "No '%s' keyword find" % expect)
+
+    def testbehave_SEOKeywordsOverrideSubject(self):
+        SEOKWS = ('local',)
+        self.my_doc.setText('<p>local subject</p>')
+        self.my_doc.setSubject('subject')
+        self.my_doc.manage_addProperty('qSEO_keywords', SEOKWS, 'lines')
+        html = str(self.publish(self.portal.id+'/my_doc', self.basic_auth))
+
+        expect = ',\s*'.join(SEOKWS)
+        self.assert_(re.match(KWSTMPL % expect, html, re.S|re.M),
+                     "No '%s' keywords find" % SEOKWS)
+
+    def testbehave_noSEOKeywordsNoSubject(self):
+        """Nor seo keywords not subject added"""
+        html = str(self.publish(self.portal.id+'/my_doc', self.basic_auth))
+        self.assertFalse(re.match('.*(<meta\s[^\>]*name="keywords"[^\>]*>)',
+                                  html, re.S|re.M), "'keyword' meta tag found")
+
+
+class TestCalcKeywords(FunctionalTestCase):
+
+    def afterSetUp(self):
+        self.loginAsPortalOwner()
+        self.seo = self.portal.portal_properties.seo_properties
+        #Preparation for functional testing
+        self.key = "SEO_KEYWORD "
+        self.portal.invokeFactory('Document', id='my_doc')
+        self.my_doc = getattr(self.portal, 'my_doc')
+        self.my_doc.setText(self.key * 2)
+        # Emulate JS request
+        self.app.REQUEST.set("text", self.key)
+        # Mark request with IPloneSEOLayer browser layer interface
+        alsoProvides(self.app.REQUEST, IPloneSEOLayer)
+        # Get checkSEOKeywords view
+        self.chckView = queryMultiAdapter((self.my_doc, self.app.REQUEST),
+            name="checkSEOKeywords")
+        
+    def patchURLLib(self, fnc):
+        self.orig_urlopen = urllib2.urlopen
+        self.urlfd = StringIO()
+        urllib2.urlopen = fnc
+    
+    def unpatchURLLib(self):
+        urllib2.urlopen = self.orig_urlopen
+        self.urlfd.close()
+
+    def test_InternalPageRendering(self):
+        self.assertTrue(not self.seo.external_keywords_test)
+        # Only keywords from content must present in check view
+        self.assertTrue('2' in self.chckView())
+
+    def test_ExternalPageRendering(self):
+        def patch_urlopen(*args, **kwargs):
+            if args[0] == self.my_doc.absolute_url():
+                self.urlfd.write(unicode(self.my_doc() + self.key).encode("utf-8"))
+                self.urlfd.seek(0)
+                return self.urlfd
+            else:
+                return self.orig_urlopen(*args, **kwargs)
+        self.seo._updateProperty("external_keywords_test", True)
+        self.patchURLLib(fnc=patch_urlopen)
+        self.assertTrue(self.seo.external_keywords_test)
+        # 1. Extra keyword must present in check view
+        self.assertTrue('3' in self.chckView())
+        # 2. Opened urllib file descriptor must be closed
+        self.assertTrue(self.urlfd.closed, "Opened file descriptor was not closed.")
+        self.unpatchURLLib()
+        
+    def test_ExternalURLError(self):
+        def patch_urlopen(*args, **kwargs):
+            if args[0] == self.my_doc.absolute_url():
+                raise urllib2.URLError("Some URL Error occured")
+            else:
+                return self.orig_urlopen(*args, **kwargs)
+        self.seo._updateProperty("external_keywords_test", True)
+        self.patchURLLib(fnc=patch_urlopen)
+        self.assertTrue(self.seo.external_keywords_test)
+        # 1. Information about problem must present in check view
+        msg = self.chckView()
+        rematch = re.match(
+            ".*Problem with page retrieval.*error_log/showEntry\?id=",
+             msg, re.S)
+        self.assertTrue(rematch, "Return message has incomplete information "
+            "about problem with page retrieval: %s" % msg)
+        # 2. Opened urllib file descriptor should not be closed because
+        #    it even not returned to the view
+        self.assertFalse(self.urlfd.closed, "Opened file descriptor was closed.")
+        self.unpatchURLLib()
+    
+    def test_ExternalIOError(self):
+        def patch_urlopen(*args, **kwargs):
+            if args[0] == self.my_doc.absolute_url():
+                self.urlfd.write(unicode(self.my_doc() + self.key).encode("utf-8"))
+                self.urlfd.seek(0)
+                return self.urlfd
+            else:
+                return self.orig_urlopen(*args, **kwargs)
+        def patch_read(*args, **kwargs):
+            raise Exception("General exception")
+        # Patch urllib2.urlopen to emulate external url retrieval
+        self.patchURLLib(fnc=patch_urlopen)
+        # Patch opened by urllib2 file descriptor to emulate IOError during reading
+        self.urlfd.read = patch_read
+        self.seo._updateProperty("external_keywords_test", True)
+        self.assertTrue(self.seo.external_keywords_test)
+        # 1. General exception must be raised.
+        self.assertRaises(Exception, self.chckView)
+        # 2. Opened urllib file descriptor must be closed
+        self.assertTrue(self.urlfd.closed, "Opened file descriptor was not closed.")
+        self.unpatchURLLib()
+        
+
+def test_suite():
+    from unittest import TestSuite, makeSuite
+    suite = TestSuite()
+    suite.addTest(makeSuite(TestUsageKeywords))
+    suite.addTest(makeSuite(TestCalcKeywords))
+    return suite
+
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/test_doctest.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/test_doctest.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/tests/test_doctest.py	(revision 3016)
@@ -0,0 +1,25 @@
+import unittest
+import doctest
+
+from base import *
+
+def test_suite():
+    return unittest.TestSuite([
+
+        # Demonstrate the main content types
+        ztc.FunctionalDocFileSuite(
+            'seo_migration.txt', package='quintagroup.seoptimizer.tests',
+            test_class=FunctionalTestCaseNotInstalled, globs=globals(),
+            optionflags=doctest.REPORT_ONLY_FIRST_FAILURE |
+                doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS),
+
+        ztc.FunctionalDocFileSuite(
+            'seo_properties.txt', package='quintagroup.seoptimizer.tests',
+            test_class=FunctionalTestCase, globs=globals(),
+            optionflags=doctest.REPORT_ONLY_FIRST_FAILURE |
+                doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS),
+
+        ])
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='test_suite')
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/upgrades.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/upgrades.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/upgrades.py	(revision 3016)
@@ -0,0 +1,130 @@
+import logging
+from zope.component import queryMultiAdapter
+
+from quintagroup.canonicalpath.interfaces  import ICanonicalLink
+from quintagroup.canonicalpath.adapters import PROPERTY_LINK
+
+logger = logging.getLogger('quintagroup.seoptimizer')
+FIX_PTYPES_DOMAIN = ['Document', 'File', 'News Item']
+REMOVE_SEOPROPERTIES = ['additional_keywords', 'settings_use_keywords_sg',
+                        'settings_use_keywords_lg', 'filter_keywords_by_content',
+                       ]
+
+def changeDomain(plone_tools):
+    """ Fix i18n_domain bug for some portal_types,
+        which present in earlier versions of the package.
+    """
+    types_tool = plone_tools.types()
+    for ptype in [ptypes for ptypes in types_tool.objectValues()
+                         if ptypes.id in FIX_PTYPES_DOMAIN]:
+        if ptype.i18n_domain == 'quintagroup.seoptimizer':
+            ptype.i18n_domain = 'plone'
+            logger.log(logging.INFO, "I18n Domain of the type \'%s\' "
+                       "changed to \'plone\'." % ptype.id)
+
+def changeMetatagsOrderList(plone_tools):
+    """ Change format metatags order list from "metaname accessor" to "metaname".
+    """
+    types_tool = plone_tools.types()
+    setup_tool = plone_tools.url().getPortalObject().portal_setup
+    seoprops_tool = plone_tools.properties().seo_properties
+    if seoprops_tool.hasProperty('metatags_order'):
+        mto = seoprops_tool.getProperty('metatags_order', [])
+        mto_new = [line.split(' ')[0].strip() for line in mto]
+        if not list(mto) == mto_new:
+            logger.log(logging.INFO, "Changed format metatags order list in "
+                       "configlet from \"metaname accessor\" to \"metaname\".")
+            seoprops_tool.manage_changeProperties(metatags_order=mto_new)
+    else:
+        setup_tool.runImportStepFromProfile('profile-quintagroup.seoptimizer:default',
+                                            'propertiestool')
+
+
+def migrationActions(plone_tools):
+    """ Migration actions from portal_types action to seoproperties tool
+        (for seoaction in portal_actions).
+    """
+    types_tool = plone_tools.types()
+    seoprops_tool = plone_tools.properties().seo_properties
+    ctws = list(seoprops_tool.getProperty('content_types_with_seoproperties', []))
+    flag = False
+    for ptype in types_tool.objectValues():
+        idxs = [idx_act[0] for idx_act in enumerate(ptype.listActions())
+                           if idx_act[1].id == 'seo_properties']
+        if idxs:
+            if ptype.id not in ctws:
+                ctws.append(ptype.id)
+                flag = True
+            ptype.deleteActions(idxs)
+            logger.log(logging.INFO, "Moved \"SEO Properties\" action from %s "
+                       "type in portal actions." % ptype.id)
+    if flag:
+        seoprops_tool.manage_changeProperties(content_types_with_seoproperties=ctws)
+
+def removeNonUseSeoProperties(plone_tools):
+    """ Revome properties used in earlier versions of the package
+    """
+    seoprops_tool = plone_tools.properties().seo_properties
+    remove_properties = []
+    for pr in REMOVE_SEOPROPERTIES:
+      if seoprops_tool.hasProperty(pr):
+          remove_properties.append(pr)
+          logger.log(logging.INFO, "Removed %s property in seoproperties tool." % pr)
+    if not remove_properties:
+        seoprops_tool.manage_delProperties(remove_properties)
+
+def removeSkin(plone_tools):
+    """ Remove skin layers.
+    """
+    layer = 'quintagroup.seoptimizer'
+    skins_tool = plone_tools.url().getPortalObject().portal_skins
+    for skinName in skins_tool.getSkinSelections():
+        skin_paths = skins_tool.getSkinPath(skinName).split(',') 
+        paths = [l.strip() for l in skin_paths if not (l == layer or l.startswith(layer+'/'))]
+        logger.log(logging.INFO, "Removed layers from %s skin." % skinName)
+        skins_tool.addSkinSelection(skinName, ','.join(paths))
+
+def migrateCanonical(plone_tools):
+    """ Rename qSEO_canonical property into PROPERTY_LINK
+        for all portal objects, which use SEO
+    """
+    types = plone_tools.types()
+    portal = plone_tools.url().getPortalObject()
+    allCTTypes = types.listContentTypes()
+    obj_metatypes =  [m.content_meta_type for m in types.objectValues() \
+                      if m.getId() in allCTTypes]
+    portal.ZopeFindAndApply(
+                            portal,
+                            obj_metatypes=','.join(obj_metatypes),
+                            apply_func=renameProperty
+                            )
+
+def renameProperty(obj, path):
+    """ Rename qSEO_canonical property into PROPERTY_LINK
+        for obj, which use SEO
+    """
+    if obj.hasProperty('qSEO_canonical'):
+        value = obj.getProperty('qSEO_canonical')
+
+        level, msg = logging.INFO, "For %(url)s object 'qSEO_canonical' "\
+                     "property renamed to '%(name)s'."
+        try:
+            ICanonicalLink(obj).canonical_link = value
+        except Exception, e:
+            level, msg = logging.ERROR, "%s on renaming 'qSEO_canonical' " \
+                         "property for %%(url)s object" % str(e)
+
+        logger.log(level, msg % {'url':obj.absolute_url(), 'name':PROPERTY_LINK} )
+        obj.manage_delProperties(['qSEO_canonical'])
+
+def upgrade_2_to_3(setuptool):
+    """ Upgrade quintagroup.seoptimizer from version 2.x.x to 3.0.0.
+    """
+    plone_tools = queryMultiAdapter((setuptool, setuptool.REQUEST), name="plone_tools")
+    setuptool.runAllImportStepsFromProfile('profile-quintagroup.seoptimizer:upgrade_2_to_3')
+    migrationActions(plone_tools)
+    changeDomain(plone_tools)
+    changeMetatagsOrderList(plone_tools)
+    removeNonUseSeoProperties(plone_tools)
+    removeSkin(plone_tools)
+    migrateCanonical(plone_tools)
Index: /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/util.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/util.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/quintagroup/seoptimizer/util.py	(revision 3016)
@@ -0,0 +1,45 @@
+from AccessControl import ClassSecurityInfo
+
+try:
+    from App.class_init import InitializeClass
+except ImportError:
+    from Globals import InitializeClass
+
+
+class SortedDict(dict):
+    """ A sorted dictionary.
+    """
+    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
Index: /quintagroup.seoptimizer/tags/4.0.4/setup.cfg
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/setup.cfg	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/setup.cfg	(revision 3016)
@@ -0,0 +1,3 @@
+[egg_info]
+# tag_build = dev
+# tag_svn_revision = true
Index: /quintagroup.seoptimizer/tags/4.0.4/setup.py
===================================================================
--- /quintagroup.seoptimizer/tags/4.0.4/setup.py	(revision 3016)
+++ /quintagroup.seoptimizer/tags/4.0.4/setup.py	(revision 3016)
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+"""
+This module contains the tool of quintagroup.seoptimizer
+"""
+import os
+from setuptools import setup, find_packages
+
+version = '4.0.4'
+
+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, Andriy Mylenkyy',
+      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',
+          'plone.browserlayer',
+          'quintagroup.canonicalpath>=0.6',
+          'collective.monkeypatcher',
+          #'Plone >= 4.0a',
+          # -*- Extra requirements: -*-
+      ],
+      entry_points="""
+      # -*- Entry points: -*-
+      [z3c.autoinclude.plugin]
+      target = plone
+      """,
+      )
