[Products.Silva][Sylvain Viollon] Add Ghost Asset. Refactor Ghos...

[email protected] Fri, 08 Nov 2013 16:36:53 +0100
Newsgroups gmane.comp.web.zope.silva.cvs
Message-ID <[email protected]>
author:    Sylvain Viollon
date:      Fri Nov 08 16:36:46 2013 +0100
revision:  11875:13f06e1c78b7 in Products.Silva
branch:    2.4
details:   https://hg.infrae.com/Products.Silva?cmd=changeset;node=13f06e1c78b7
modified:  Products/Silva/Asset.py Products/Silva/EmailMessageService.py Products/Silva/ExtensionService_templates/configureextensions.cpt Products/Silva/File/content.py Products/Silva/File/smi_templates/infoportlet.cpt Products/Silva/File/views.py Products/Silva/Folder/management.py Products/Silva/Ghost.py Products/Silva/Ghost/__init__.py Products/Silva/Ghost/base.py Products/Silva/Ghost/content.py Products/Silva/Ghost/haunted.py Products/Silva/Ghost/metadata.py Products/Silva/Ghost/smi.py Products/Silva/Ghost/views.py Products/Silva/GhostAsset/__init__.py Products/Silva/GhostAsset/content.py Products/Silva/GhostAsset/smi.py Products/Silva/GhostAsset/views.py Products/Silva/GhostFolder.py Products/Silva/GhostFolder/__init__.py Products/Silva/GhostFolder/addables.py Products/Silva/GhostFol
 der/content.py Products/Silva/GhostFolder/smi.py Products/Silva/Image/content.py Products/Silva/Image/views.py Products/Silva/adapters/haunted.py Products/Silva/install.py Products/Silva/sil
 vaxml/xmlexport.py Products/Silva/silvaxml/xmlimport.py Products/Silva/tests/data/test_export_ghost_asset.silvaxml Products/Silva/tests/data/test_export_ghost_folder.silvaxml Products/Silva/tests/data/test_export_ghostfolder.silvaxml Products/Silva/tests/data/test_import_ghost_asset.zip Products/Silva/tests/mockers.py Products/Silva/tests/test_extension.py Products/Silva/tests/test_file.py Products/Silva/tests/test_folder_addables.py Products/Silva/tests/test_folder_quota.py Products/Silva/tests/test_ghost_asset.py Products/Silva/tests/test_ghost_folder.py Products/Silva/tests/test_image.py Products/Silva/tests/test_xml_export.py Products/Silva/tests/test_xml_import.py
added:     Products/Silva/Ghost/__init__.py Products/Silva/Ghost/base.py Products/Silva/Ghost/content.py Products/Silva/Ghost/haunted.py Products/Silva/Ghost/metadata.py Products/Silva/Ghost/smi.py Products/Silva/Ghost/views.py Products/Silva/GhostAsset/__init__.py Products/Silva/GhostAsset/content.py Products/Silva/GhostAsset/smi.py Products/Silva/GhostAsset/views.py Products/Silva/GhostFolder/__init__.py Products/Silva/GhostFolder/addables.py Products/Silva/GhostFolder/content.py Products/Silva/GhostFolder/smi.py Products/Silva/tests/data/test_export_ghost_asset.silvaxml Products/Silva/tests/data/test_export_ghost_folder.silvaxml Products/Silva/tests/data/test_import_ghost_asset.zip Products/Silva/tests/test_ghost_asset.py
removed:   Products/Silva/Ghost.py Products/Silva/GhostFolder.py Products/Silva/adapters/haunted.py Products/Silva/tests/data/test_export_ghostfolder.silvaxml
log:       Add Ghost Asset. Refactor Ghost code.


diffstat:

 Products/Silva/Asset.py                                           |    3 +-
 Products/Silva/EmailMessageService.py                             |    4 +-
 Products/Silva/ExtensionService_templates/configureextensions.cpt |    1 +
 Products/Silva/File/content.py                                    |   59 +-
 Products/Silva/File/smi_templates/infoportlet.cpt                 |   33 +-
 Products/Silva/File/views.py                                      |    3 +-
 Products/Silva/Folder/management.py                               |   11 +-
 Products/Silva/Ghost.py                                           |  356 --------
 Products/Silva/Ghost/__init__.py                                  |   27 +
 Products/Silva/Ghost/base.py                                      |  185 ++++
 Products/Silva/Ghost/content.py                                   |  148 +++
 Products/Silva/Ghost/haunted.py                                   |   36 +
 Products/Silva/Ghost/metadata.py                                  |   22 +
 Products/Silva/Ghost/smi.py                                       |   78 +
 Products/Silva/Ghost/views.py                                     |   38 +
 Products/Silva/GhostAsset/__init__.py                             |    1 +
 Products/Silva/GhostAsset/content.py                              |  116 ++
 Products/Silva/GhostAsset/smi.py                                  |   56 +
 Products/Silva/GhostAsset/views.py                                |   73 +
 Products/Silva/GhostFolder.py                                     |  423 ----------
 Products/Silva/GhostFolder/__init__.py                            |    6 +
 Products/Silva/GhostFolder/addables.py                            |   24 +
 Products/Silva/GhostFolder/content.py                             |  264 ++++++
 Products/Silva/GhostFolder/smi.py                                 |   79 +
 Products/Silva/Image/content.py                                   |   36 +-
 Products/Silva/Image/views.py                                     |    9 +-
 Products/Silva/adapters/haunted.py                                |   36 -
 Products/Silva/install.py                                         |    1 +
 Products/Silva/silvaxml/xmlexport.py                              |   13 +
 Products/Silva/silvaxml/xmlimport.py                              |   71 +-
 Products/Silva/tests/data/test_export_ghost_asset.silvaxml        |  100 ++
 Products/Silva/tests/data/test_export_ghost_folder.silvaxml       |  254 ++++++
 Products/Silva/tests/data/test_export_ghostfolder.silvaxml        |  254 ------
 Products/Silva/tests/data/test_import_ghost_asset.zip             |  Bin 
 Products/Silva/tests/mockers.py                                   |   26 +-
 Products/Silva/tests/test_extension.py                            |    4 +
 Products/Silva/tests/test_file.py                                 |   11 +-
 Products/Silva/tests/test_folder_addables.py                      |    7 +-
 Products/Silva/tests/test_folder_quota.py                         |    6 +-
 Products/Silva/tests/test_ghost_asset.py                          |  123 ++
 Products/Silva/tests/test_ghost_folder.py                         |  200 +++-
 Products/Silva/tests/test_image.py                                |    8 +-
 Products/Silva/tests/test_xml_export.py                           |   28 +-
 Products/Silva/tests/test_xml_import.py                           |   29 +
 44 files changed, 2072 insertions(+), 1190 deletions(-)

diffs (3896 lines):

diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/Asset.py
--- a/Products/Silva/Asset.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/Asset.py	Fri Nov 08 16:36:46 2013 +0100
@@ -63,8 +63,7 @@
         except (AttributeError, NotImplementedError):
             # Well, not all content respect its interface.
             path = '/'.join(self.getPhysicalPath())
-            klass = str(self.__class__)
-            logger.error('bad asset object %s - %s' % (path, klass))
+            logger.error('Invalid asset %s - %s', path, str(self.__class__))
             return -1
 
 
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/EmailMessageService.py
--- a/Products/Silva/EmailMessageService.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/EmailMessageService.py	Fri Nov 08 16:36:46 2013 +0100
@@ -296,7 +296,7 @@
     grok.require('zope2.ViewManagementScreens')
     grok.name('manage_settings')
 
-    label = _(u"Messaging Configuration")
+    label = _(u"Messaging configuration")
     description = _(u"Configure settings for email messaging between members. "
                     u"The default MailHost service is used to send messages.")
     ignoreContent = False
@@ -307,7 +307,7 @@
 class EmailMessageConfiguration(silvaforms.ConfigurationForm):
     grok.context(EmailMessageService)
 
-    label = _(u"Messaging Configuration")
+    label = _(u"Messaging configuration")
     description = _(u"Configure settings for email messaging between members. "
                     u"The default MailHost service is used to send messages.")
     fields = silvaforms.Fields(IEmailMessageSettings)
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/ExtensionService_templates/configureextensions.cpt
--- a/Products/Silva/ExtensionService_templates/configureextensions.cpt	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/ExtensionService_templates/configureextensions.cpt	Fri Nov 08 16:36:46 2013 +0100
@@ -30,6 +30,7 @@
           Cancel
         </a>
         <a class="form-control" name="refresh_all"
+           data-confirmation="Are you sure ?"
            i18n:translate="">
           <ins class="icon form_check"></ins>
           Refresh all
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/File/content.py
--- a/Products/Silva/File/content.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/File/content.py	Fri Nov 08 16:36:46 2013 +0100
@@ -2,17 +2,19 @@
 # Copyright (c) 2002-2013 Infrae. All rights reserved.
 # See also LICENSE.txt
 
+from cStringIO import StringIO
+from cgi import escape
+from types import StringTypes
+import logging
 import os
 import os.path
-import logging
-from types import StringTypes
-from cgi import escape
-from cStringIO import StringIO
+import time
+import warnings
 
 # Zope 3
 from ZODB import blob
 from five import grok
-from zope.component import getUtility
+from zope.component import getUtility, getMultiAdapter
 from zope.event import notify
 from zope.lifecycleevent import ObjectCreatedEvent, ObjectModifiedEvent
 from zope.lifecycleevent.interfaces import IObjectMovedEvent
@@ -31,11 +33,12 @@
 from Products.Silva.File.converters import get_converter_for_mimetype
 
 from silva.core import conf as silvaconf
+from silva.core import interfaces
 from silva.core.conf.utils import ISilvaFactoryDispatcher
-from silva.core import interfaces
+from silva.core.interfaces import ContentError
 from silva.core.interfaces import IMimeTypeClassifier, ISilvaNameChooser
-from silva.core.interfaces import ContentError
 from silva.core.services.interfaces import IFilesService
+from silva.core.views.interfaces import IContentURL
 from silva.translations import translate as _
 
 logger = logging.getLogger('silva.file')
@@ -151,12 +154,27 @@
 
     security.declareProtected(
         SilvaPermissions.View, 'get_download_url')
-    def get_download_url(self):
-        return self.absolute_url()
+    def get_download_url(self, preview=False, request=None):
+        if request is None:
+            request = self.REQUEST
+        url = getMultiAdapter((self, request), IContentURL).url(preview=preview)
+        if preview:
+            # In case of preview we add something that change at the
+            # end of the url to prevent caching from the browser.
+            url += '?' + str(int(time.time()))
+        return url
 
     security.declareProtected(
         SilvaPermissions.View, 'tag')
     def tag(self, **kw):
+        warnings.warn(
+            'tag have been replaced with get_html_tag. '
+            'It will be removed, please update your code.',
+            DeprecationWarning, stacklevel=2)
+        return self.get_html_tag(**kw)
+
+    security.declareProtected(SilvaPermissions.View, 'get_html_tag')
+    def get_html_tag(self, preview=False, request=None, **extra_attributes):
         """ return xhtml tag
 
         Since 'class' is a Python reserved word, it cannot be passed in
@@ -165,19 +183,20 @@
         will accept a 'css_class' argument that will be converted to
         'class' in the output tag to work around this.
         """
-        src = self.get_download_url()
-        named = []
-        tooltip = unicode(_('download'))
+        src = self.get_download_url(preview, request)
+        title = self.get_title_or_id()
 
-        if kw.has_key('css_class'):
-            kw['class'] = kw['css_class']
-            del kw['css_class']
+        if extra_attributes.has_key('css_class'):
+            extra_attributes['class'] = extra_attributes['css_class']
+            del extra_attributes['css_class']
 
-        for name, value in kw.items():
-            named.append('%s="%s"' % (escape(name), escape(value)))
-        named = ' '.join(named)
-        return '<a href="%s" title="%s %s" %s>%s</a>' % (
-            src, tooltip, self.id, named, self.get_title_or_id())
+        extra_html_attributes = [
+            u'{name}="{value}"'.format(name=escape(name, 1),
+                                      value=escape(value, 1))
+            for name, value in extra_attributes.iteritems()]
+
+        return '<a href="%s" title="Download %s" %s>%s</a>' % (
+            src, self.get_filename(), extra_html_attributes, title)
 
     # checks where the mime type is text/* or javascript
     security.declareProtected(
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/File/smi_templates/infoportlet.cpt
--- a/Products/Silva/File/smi_templates/infoportlet.cpt	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/File/smi_templates/infoportlet.cpt	Fri Nov 08 16:36:46 2013 +0100
@@ -1,22 +1,23 @@
-<div class="portlet"
-     i18n:domain="silva">
-  <tal:mime_type tal:condition="viewlet.mime_type">
-    <h4 i18n:translate="">File mime type</h4>
-    <p tal:content="viewlet.mime_type">mime type</p>
-  </tal:mime_type>
-
-  <tal:content_encoding tal:condition="viewlet.content_encoding">
-    <h4 i18n:translate="">File encoding</h4>
-    <p tal:content="viewlet.content_encoding">content encoding</p>
-  </tal:content_encoding>
-
-  <h4 i18n:translate="">Link to file</h4>
+<tal:info i18n:domain="silva">
+  <div class="portlet">
+    <h4 i18n:translate="">Link to file</h4>
     <p>
       <a title="Download" target="_blank"
-         tal:content="context.get_title_or_id()"
-         tal:attributes="href path:context/@@absolute_url"
+         tal:content="context.get_filename()"
+         tal:attributes="href context.get_download_url(preview=True)"
          i18n:attributes="title">
         link
       </a>
     </p>
-</div>
+  </div>
+
+  <div class="portlet" tal:condition="viewlet.mime_type">
+    <h4 i18n:translate="">File mime type</h4>
+    <p tal:content="viewlet.mime_type">mime type</p>
+  </div>
+
+  <div class="portlet" tal:condition="viewlet.content_encoding">
+    <h4 i18n:translate="">File encoding</h4>
+    <p tal:content="viewlet.content_encoding">content encoding</p>
+  </div>
+</tal:info>
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/File/views.py
--- a/Products/Silva/File/views.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/File/views.py	Fri Nov 08 16:36:46 2013 +0100
@@ -125,7 +125,8 @@
     grok.require('zope2.View')
 
     def render(self):
-        return self.content.tag()
+        return self.content.get_html_tag(
+            request=self.request, preview=self.is_preview)
 
 
 def parse_datetime(value):
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/Folder/management.py
--- a/Products/Silva/Folder/management.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/Folder/management.py	Fri Nov 08 16:36:46 2013 +0100
@@ -5,7 +5,7 @@
 import re
 
 from five import grok
-from zope.cachedescriptors.property import CachedProperty
+from zope.cachedescriptors.property import Lazy
 from zope.container.contained import notifyContainerModified
 from zope.event import notify
 from zope.lifecycleevent import ObjectCopiedEvent
@@ -20,7 +20,7 @@
 from OFS.subscribers import compatibilityCall
 
 from Products.Silva import helpers
-from Products.Silva.Ghost import ghost_factory
+from Products.Silva.Ghost import get_ghost_factory
 
 from infrae.comethods import cofunction
 from silva.core import conf as silvaconf
@@ -88,7 +88,7 @@
                 self.context, to_identifier))
         return content
 
-    @CachedProperty
+    @Lazy
     def __addables(self):
         return set(IAddableContents(self.context).get_authorized_addables())
 
@@ -233,12 +233,13 @@
         while content is not None:
             result = self.__verify_copyable(content)
             if result is None:
-                if IAsset.providedBy(content):
+                factory = get_ghost_factory(self.context, content)
+                if factory is None:
                     identifier = self.__make_id('copy', content.getId())
                     result = self.__copy(content, identifier)
                 else:
                     identifier = self.__make_id('ghost', content.getId())
-                    result = ghost_factory(self.context, identifier, content)
+                    result = factory(identifier)
 
             content = yield result
 
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/Ghost.py
--- a/Products/Silva/Ghost.py	Mon Nov 04 17:03:35 2013 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,356 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2002-2013 Infrae. All rights reserved.
-# See also LICENSE.txt
-
-# Zope 3
-from five import grok
-from zope.component import getUtility, queryMultiAdapter
-
-# Zope 2
-from Acquisition import aq_inner, aq_base
-from AccessControl import ClassSecurityInfo, getSecurityManager
-from AccessControl import Unauthorized
-from App.class_init import InitializeClass
-
-# Silva
-from Products.SilvaMetadata.Binding import DefaultMetadataBindingFactory
-from Products.Silva.VersionedContent import VersionedContent
-from Products.Silva.Version import Version
-from Products.Silva import SilvaPermissions
-
-from zeam.form import silva as silvaforms
-from zeam.form.base.errors import Error
-from zeam.form.base.widgets import widgetId
-
-from silva.core.views.interfaces import IPreviewLayer
-from silva.core import conf as silvaconf
-from silva.core.conf.interfaces import IIdentifiedContent
-from silva.core.interfaces import errors
-from silva.core.interfaces.errors import ContentError
-from silva.core.interfaces import (
-    IContainer, IContent, IGhost, IGhostAware, IGhostVersion)
-from silva.core.references.reference import Reference
-from silva.core.references.reference import DeleteSourceReferenceValue
-from silva.core.references.reference import WeakReferenceValue
-from silva.core.references.reference import get_content_id, get_content_from_id
-from silva.core.references.interfaces import IReferenceService
-from silva.core.views import views as silvaviews
-from silva.core.views.interfaces import IView
-from silva.translations import translate as _
-
-
-def validate_target(ghost, target, is_folderish=False, adding=False):
-    """Validate a ghost for its given target.
-    """
-    if target is None:
-        return errors.EmptyInvalidTarget()
-    # Check for cicular reference. You cannot select an ancestor
-    # or descandant of the ghost (or the ghost)
-    target_path = target.getPhysicalPath()
-    ghost_path = ghost.getPhysicalPath()
-    if len(target_path) > len(ghost_path) and not adding:
-        if ghost_path == target_path[:len(ghost_path)]:
-            return errors.CircularInvalidTarget()
-    elif target_path == ghost_path[:len(target_path)]:
-            return errors.CircularInvalidTarget()
-    if IGhostAware.providedBy(target):
-        return errors.GhostInvalidTarget()
-    if is_folderish:
-        # If we are a container, we expect to have a container as
-        # target.
-        if IContent.providedBy(target):
-            return errors.ContainerInvalidTarget()
-        if not IContainer.providedBy(target):
-            return errors.ContainerInvalidTarget()
-    else:
-        # If we are not a container, we expect to have a content
-        # as target.
-        if IContainer.providedBy(target):
-            return errors.ContentInvalidTarget()
-        if not IContent.providedBy(target):
-            return errors.ContentInvalidTarget()
-
-
-class GhostBase(object):
-    """baseclass for Ghosts (or Ghost versions if it's versioned)
-    """
-    security = ClassSecurityInfo()
-
-    security.declareProtected(
-        SilvaPermissions.ChangeSilvaContent, 'set_title')
-    def set_title(self, title):
-        """You cannot change the title of a ghost.
-        """
-        if title is not None:
-            raise ContentError(
-                _(u"A ghost title is immutable."),
-                self.get_silva_object())
-
-    security.declareProtected(
-        SilvaPermissions.AccessContentsInformation, 'get_title')
-    def get_title(self):
-        """Get title.
-        """
-        content = self.get_haunted()
-        if content is not None and content.is_published():
-            return content.get_title()
-        return _(u"Ghost target is broken")
-
-    security.declareProtected(
-        SilvaPermissions.AccessContentsInformation, 'get_short_title')
-    def get_short_title(self):
-        """Get short title.
-        """
-        content = self.get_haunted()
-        if content is not None and content.is_published():
-            return content.get_short_title()
-        return _(u"Ghost target is broken")
-
-    security.declareProtected(
-        SilvaPermissions.AccessContentsInformation, 'get_title_editable')
-    def get_title_editable(self):
-        """Get title.
-        """
-        content = self.get_haunted()
-        if content is not None:
-            return content.get_title_editable()
-        return _(u"Ghost target is broken")
-
-    security.declareProtected(
-        SilvaPermissions.AccessContentsInformation, 'get_short_title_editable')
-    def get_short_title_editable(self):
-        """Get title.
-        """
-        content = self.get_haunted()
-        if content is not None:
-            return content.get_short_title_editable()
-        return _(u"Ghost target is broken")
-
-    security.declareProtected(
-        SilvaPermissions.ChangeSilvaContent, 'set_haunted')
-    def set_haunted(self, content, auto_delete=False):
-        """ Set the content as the haunted object
-        """
-        service = getUtility(IReferenceService)
-        factory = WeakReferenceValue
-        if auto_delete:
-            factory = DeleteSourceReferenceValue
-        reference = service.get_reference(
-            aq_inner(self), name=u"haunted", add=True, factory=factory)
-        if not isinstance(content, int):
-            content = get_content_id(content)
-        reference.set_target_id(content)
-
-    security.declareProtected(SilvaPermissions.View, 'get_haunted')
-    def get_haunted(self):
-        service = getUtility(IReferenceService)
-        reference = service.get_reference(
-            aq_inner(self), name=u"haunted")
-        if reference is not None:
-            return reference.target
-
-    security.declareProtected(SilvaPermissions.View, 'get_link_status')
-    def get_link_status(self):
-        """Return an error code if this version of the ghost is broken.
-        returning None means the ghost is Ok.
-        """
-        return validate_target(
-            self,
-            self.get_haunted(),
-            IContainer.providedBy(self))
-
-
-class GhostMetadataBindingFactory(DefaultMetadataBindingFactory):
-    grok.context(IGhostAware)
-    read_only = True
-
-    def get_content(self):
-        haunted = self.context.get_haunted()
-        if haunted is not None:
-            if IPreviewLayer.providedBy(haunted.REQUEST):
-                return haunted.get_previewable()
-            return haunted.get_viewable()
-        return None
-
-
-class Ghost(VersionedContent):
-    __doc__ = _("""Ghosts are special documents that function as a
-       placeholder for an item in another location (like an alias,
-       symbolic link, shortcut). Unlike a hyperlink, which takes the
-       Visitor to another location, a ghost object keeps the Visitor in the
-       current publication, and presents the content of the ghosted item.
-       The ghost inherits properties from its location (e.g. layout
-       and stylesheets).
-    """)
-
-    meta_type = "Silva Ghost"
-    security = ClassSecurityInfo()
-
-    grok.implements(IGhost)
-    silvaconf.icon('icons/ghost.png')
-    silvaconf.version_class('GhostVersion')
-
-    security.declareProtected(
-        SilvaPermissions.AccessContentsInformation, 'get_haunted')
-    def get_haunted(self):
-        version = self.get_previewable()
-        if version is not None:
-            return version.get_haunted()
-        return None
-
-    security.declareProtected(
-        SilvaPermissions.AccessContentsInformation, 'is_published')
-    def is_published(self):
-        public = self.get_viewable()
-        if public is None:
-            return False
-        haunted = public.get_haunted()
-        if haunted is None:
-            return False
-        return haunted.is_published()
-
-    security.declareProtected(
-        SilvaPermissions.AccessContentsInformation, 'get_modification_datetime')
-    def get_modification_datetime(self):
-        """Return modification datetime.
-        """
-        version = self.get_viewable()
-        if version is not None:
-            content = version.get_haunted()
-            if content is not None:
-                return content.get_modification_datetime()
-        return super(Ghost, self).get_modification_datetime()
-
-InitializeClass(Ghost)
-
-
-class GhostVersion(GhostBase, Version):
-    """Ghost version.
-    """
-    meta_type = 'Silva Ghost Version'
-    grok.implements(IGhostVersion)
-
-    security = ClassSecurityInfo()
-
-    security.declareProtected(
-        SilvaPermissions.AccessContentsInformation, 'fulltext')
-    def fulltext(self):
-       target = self.get_haunted()
-       if target is not None:
-           public_version = target.get_viewable()
-           if public_version and hasattr(aq_base(public_version), 'fulltext'):
-               return public_version.fulltext()
-       return ""
-
-
-class IGhostSchema(IIdentifiedContent):
-
-    haunted = Reference(
-        IContent,
-        title=_(u"Target"),
-        description=_(u"The internal item the ghost is mirroring."),
-        required=True)
-
-
-def TargetValidator(field_name, is_folderish=False, adding=False):
-
-    class Validator(object):
-
-        def __init__(self, form, fields):
-            self.form = form
-            self.fields = fields
-
-        def validate(self, data):
-            """Validate ghost target before setting it.
-            """
-            # This is not beauty, but it works.
-            content_id = data.get(field_name)
-            if content_id is silvaforms.NO_VALUE:
-                # If there value is required it is already checked
-                return []
-            error = validate_target(
-                self.form.context,
-                get_content_from_id(content_id),
-                is_folderish,
-                adding)
-            if error is not None:
-                identifier = widgetId(self.form, self.fields[field_name])
-                return [Error(error.doc(), identifier)]
-            return []
-
-    return Validator
-
-
-class GhostAddForm(silvaforms.SMIAddForm):
-    """Add form for a ghost
-    """
-    grok.name(u"Silva Ghost")
-    grok.context(IGhost)
-
-    fields = silvaforms.Fields(IGhostSchema)
-    fields['haunted'].referenceNotSetLabel = _(
-        u"Click the Lookup button to select an item to haunt.")
-    dataValidators = [
-        TargetValidator('haunted', is_folderish=False, adding=True)]
-
-    def _add(self, parent, data):
-        factory = parent.manage_addProduct['Silva']
-        return factory.manage_addGhost(
-            data['id'], None, haunted=data['haunted'])
-
-
-class GhostEditForm(silvaforms.SMIEditForm):
-    """ Edit form for Ghost
-    """
-    grok.context(IGhost)
-    fields = silvaforms.Fields(IGhostSchema).omit('id')
-    dataValidators = [
-        TargetValidator('haunted', is_folderish=False, adding=False)]
-
-
-class GhostView(silvaviews.View):
-    grok.context(IGhost)
-
-    broken_message = _(u"This content is unavailable. "
-                       u"Please inform the site manager.")
-
-    def render(self):
-        haunted = self.content.get_haunted()
-        if haunted is None:
-            return self.broken_message
-        permission = self.is_preview and 'Read Silva content' or 'View'
-        if not getSecurityManager().checkPermission(permission, haunted):
-            raise Unauthorized(
-                u"You do not have permission to "
-                u"see the target of this ghost")
-        view = queryMultiAdapter((haunted, self.request), name="content.html")
-        if view is None:
-            return self.broken_message
-        if IView.providedBy(view) and view.content is None:
-            return self.broken_message
-        return view()
-
-
-def ghost_factory(container, identifier, target):
-    """add new ghost to container
-
-        container: container to add ghost to (must be acquisition wrapped)
-        id: (str) id for new ghost in container
-        target: object to be haunted (ghosted), acquisition wrapped
-        returns created ghost
-
-        actual ghost created depends on haunted object
-        on IContainer a GhostFolder is created
-        on IVersionedContent a Ghost is created
-    """
-    factory = container.manage_addProduct['Silva']
-    if IGhostAware.providedBy(target):
-        target = target.get_haunted()
-    if target is not None:
-        if IContainer.providedBy(target):
-            factory = factory.manage_addGhostFolder
-        elif IContent.providedBy(target):
-            factory = factory.manage_addGhost
-        factory(identifier, None, haunted=target)
-        return container._getOb(identifier)
-    return None
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/Ghost/__init__.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/Ghost/__init__.py	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2002-2013 Infrae. All rights reserved.
+# See also LICENSE.txt
+
+
+from zeam.component import getComponent
+from silva.core.interfaces import IGhostManager
+
+# For BBB
+from .base import GhostBase, GhostBaseManager, GhostBaseManipulator
+from .content import Ghost, GhostVersion
+from .smi import TargetValidator
+
+
+def get_ghost_factory(container, target):
+    """add new ghost to container
+    """
+    if target is None:
+        return None
+
+    get_manager = getComponent((target,), IGhostManager, default=None)
+    if get_manager is None:
+        return None
+
+    return lambda identifier: get_manager(
+        container=container).modify(target, identifier).create()
+
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/Ghost/base.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/Ghost/base.py	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,185 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2002-2013 Infrae. All rights reserved.
+# See also LICENSE.txt
+
+# Zope 3
+from five import grok
+from zeam.component import getComponent
+from zope.component import getUtility
+
+# Zope 2
+from Acquisition import aq_inner
+from AccessControl import ClassSecurityInfo
+from App.class_init import InitializeClass
+
+# Silva
+from Products.Silva import SilvaPermissions
+
+from silva.core.interfaces import errors
+from silva.core.interfaces.errors import ContentError
+from silva.core.interfaces import IGhostAware, IGhostManager, IPublishable
+from silva.core.references.reference import DeleteSourceReferenceValue
+from silva.core.references.reference import WeakReferenceValue
+from silva.core.references.reference import get_content_id
+from silva.core.references.interfaces import IReferenceService
+from silva.translations import translate as _
+
+
+class GhostBaseManipulator(object):
+
+    def __init__(self, manager, target, identifier):
+        self.manager = manager
+        self.target = target
+        self.identifier = identifier
+
+    def create(self):
+        raise NotImplementedError
+
+    def update(self):
+        raise NotImplementedError
+
+    def need_update(self):
+        raise NotImplementedError
+
+    def verify(self):
+        if self.manager.ghost is None:
+            if self.target is not None:
+                return self.create()
+        elif self.need_update():
+            return self.update()
+        return self.manager.ghost
+
+
+class GhostBaseManager(object):
+    grok.implements(IGhostManager)
+    manipulator = GhostBaseManipulator
+
+    def __init__(self, ghost=None, container=None,
+                 auto_delete=False, auto_publish=False):
+        self.ghost = ghost
+        if container is None:
+            assert ghost is not None, 'Need to provide ghost or a container'
+            container = ghost.get_container()
+        self.container = container
+        self.auto_delete = auto_delete
+        self.auto_publish = auto_publish
+
+    def modify(self, target, identifier=None):
+        if identifier is None:
+            identifier = target
+        if IGhostAware.providedBy(target):
+            target = target.get_haunted()
+        return self.manipulator(self, target, identifier)
+
+    def validate(self, target, adding=False):
+        if target is None:
+            return errors.EmptyInvalidTarget()
+        if IGhostAware.providedBy(target):
+            return errors.GhostInvalidTarget()
+        # Check for cicular reference. You cannot select an ancestor
+        # or descandant of the ghost (or the ghost)
+        target_path = target.getPhysicalPath()
+        if adding:
+            test_path = self.container.getPhysicalPath()
+        else:
+            test_path = self.ghost.getPhysicalPath()
+
+        # XXX !!!!
+        if not adding and len(target_path) > len(test_path):
+            if test_path == target_path[:len(test_path)]:
+                return errors.CircularInvalidTarget()
+        elif target_path == test_path[:len(target_path)]:
+                return errors.CircularInvalidTarget()
+
+
+class GhostBase(object):
+    """baseclass for Ghosts (or Ghost versions if it's versioned)
+    """
+    security = ClassSecurityInfo()
+
+    security.declareProtected(
+        SilvaPermissions.ChangeSilvaContent, 'set_title')
+    def set_title(self, title):
+        """You cannot change the title of a ghost.
+        """
+        if title is not None:
+            raise ContentError(
+                _(u"A ghost title is immutable."),
+                self.get_silva_object())
+
+    security.declareProtected(
+        SilvaPermissions.AccessContentsInformation, 'get_title')
+    def get_title(self):
+        """Get title.
+        """
+        content = self.get_haunted()
+        if content is not None:
+            if not IPublishable.providedBy(content) or content.is_published():
+                return content.get_title()
+        return _(u"Ghost target is broken")
+
+    security.declareProtected(
+        SilvaPermissions.AccessContentsInformation, 'get_short_title')
+    def get_short_title(self):
+        """Get short title.
+        """
+        content = self.get_haunted()
+        if content is not None:
+            if not IPublishable.providedBy(content) or content.is_published():
+                return content.get_short_title()
+        return _(u"Ghost target is broken")
+
+    security.declareProtected(
+        SilvaPermissions.AccessContentsInformation, 'get_title_editable')
+    def get_title_editable(self):
+        """Get title.
+        """
+        content = self.get_haunted()
+        if content is not None:
+            return content.get_title_editable()
+        return _(u"Ghost target is broken")
+
+    security.declareProtected(
+        SilvaPermissions.AccessContentsInformation, 'get_short_title_editable')
+    def get_short_title_editable(self):
+        """Get title.
+        """
+        content = self.get_haunted()
+        if content is not None:
+            return content.get_short_title_editable()
+        return _(u"Ghost target is broken")
+
+    security.declareProtected(
+        SilvaPermissions.ChangeSilvaContent, 'set_haunted')
+    def set_haunted(self, content, auto_delete=False):
+        """ Set the content as the haunted object
+        """
+        service = getUtility(IReferenceService)
+        factory = WeakReferenceValue
+        if auto_delete:
+            factory = DeleteSourceReferenceValue
+        reference = service.get_reference(
+            aq_inner(self), name=u"haunted", add=True, factory=factory)
+        if not isinstance(content, int):
+            content = get_content_id(content)
+        reference.set_target_id(content)
+
+    security.declareProtected(SilvaPermissions.View, 'get_haunted')
+    def get_haunted(self):
+        service = getUtility(IReferenceService)
+        reference = service.get_reference(aq_inner(self), name=u"haunted")
+        if reference is not None:
+            return reference.target
+        return None
+
+    security.declareProtected(SilvaPermissions.View, 'get_link_status')
+    def get_link_status(self):
+        """Return an error code if this version of the ghost is broken.
+        returning None means the ghost is Ok.
+        """
+        ghost = self.get_silva_object()
+        get_manager = getComponent((ghost,), IGhostManager)
+        return get_manager(ghost=ghost).validate(self.get_haunted())
+
+
+InitializeClass(GhostBase)
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/Ghost/content.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/Ghost/content.py	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,148 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2002-2013 Infrae. All rights reserved.
+# See also LICENSE.txt
+
+# Zope 3
+from five import grok
+from zeam.component import component
+
+# Zope 2
+from Acquisition import aq_base
+from AccessControl import ClassSecurityInfo
+from App.class_init import InitializeClass
+
+# Silva
+from Products.Silva.VersionedContent import VersionedContent
+from Products.Silva.Version import Version
+from Products.Silva import SilvaPermissions
+
+from silva.core import conf as silvaconf
+from silva.core.interfaces.errors import ContentInvalidTarget
+from silva.core.interfaces import IContent, IGhost, IGhostVersion
+from silva.core.interfaces import IPublicationWorkflow, IGhostManager
+from silva.translations import translate as _
+
+from .base import GhostBase, GhostBaseManipulator, GhostBaseManager
+
+
+class GhostVersion(GhostBase, Version):
+    """Ghost version.
+    """
+    meta_type = 'Silva Ghost Version'
+    grok.implements(IGhostVersion)
+
+    security = ClassSecurityInfo()
+    security.declareProtected(
+        SilvaPermissions.AccessContentsInformation, 'fulltext')
+    def fulltext(self):
+       target = self.get_haunted()
+       if target is not None:
+           public_version = target.get_viewable()
+           if public_version and hasattr(aq_base(public_version), 'fulltext'):
+               return public_version.fulltext()
+       return ""
+
+
+InitializeClass(GhostVersion)
+
+
+class Ghost(VersionedContent):
+    __doc__ = _("""Ghosts are special documents that function as a
+       placeholder for an item in another location (like an alias,
+       symbolic link, shortcut). Unlike a hyperlink, which takes the
+       Visitor to another location, a ghost object keeps the Visitor in the
+       current publication, and presents the content of the ghosted item.
+       The ghost inherits properties from its location (e.g. layout
+       and stylesheets).
+    """)
+
+    meta_type = "Silva Ghost"
+    security = ClassSecurityInfo()
+
+    grok.implements(IGhost)
+    silvaconf.icon('icons/ghost.png')
+    silvaconf.version_class(GhostVersion)
+
+    security.declareProtected(
+        SilvaPermissions.AccessContentsInformation, 'get_haunted')
+    def get_haunted(self):
+        version = self.get_previewable()
+        if version is not None:
+            return version.get_haunted()
+        return None
+
+    security.declareProtected(
+        SilvaPermissions.AccessContentsInformation, 'is_published')
+    def is_published(self):
+        public = self.get_viewable()
+        if public is None:
+            return False
+        haunted = public.get_haunted()
+        if haunted is None:
+            return False
+        return haunted.is_published()
+
+    security.declareProtected(
+        SilvaPermissions.AccessContentsInformation, 'get_modification_datetime')
+    def get_modification_datetime(self):
+        """Return modification datetime.
+        """
+        version = self.get_viewable()
+        if version is not None:
+            content = version.get_haunted()
+            if content is not None:
+                return content.get_modification_datetime()
+        return super(Ghost, self).get_modification_datetime()
+
+InitializeClass(Ghost)
+
+
+class GhostManipulator(GhostBaseManipulator):
+
+    def create(self):
+        assert self.manager.ghost is None
+        ghost = None
+        factory = self.manager.container.manage_addProduct['Silva']
+        factory.manage_addGhost(self.identifier, None)
+        ghost = self.manager.container._getOb(self.identifier)
+        version = ghost.get_editable()
+        version.set_haunted(
+            self.target, auto_delete=self.manager.auto_delete)
+        if self.manager.auto_publish:
+            IPublicationWorkflow(ghost).publish()
+        self.manager.ghost = ghost
+        return ghost
+
+    def update(self):
+        assert self.manager.ghost is not None
+        if IGhost.providedBy(self.manager.ghost):
+            publication = IPublicationWorkflow(self.manager.ghost)
+            if self.manager.ghost.get_editable() is None:
+                publication.new_version()
+            version = self.manager.ghost.get_editable()
+            version.set_haunted(
+                self.target, auto_delete=self.manager.auto_delete)
+            if self.manager.auto_publish:
+                publication.publish()
+            return self.manager.ghost
+        self.manager.container.manage_delObjects([self.identifier])
+        self.manager.ghost = None
+        return self.create()
+
+    def need_update(self):
+        if IGhost.providedBy(self.manager.ghost):
+            viewable = self.manager.ghost.get_viewable()
+            return self.target != viewable.get_haunted()
+        return IContent.providedBy(self.manager.ghost)
+
+
+@component(IContent, provides=IGhostManager)
+class GhostManager(GhostBaseManager):
+    manipulator = GhostManipulator
+
+    def validate(self, target, adding=False):
+        error = super(GhostManager, self).validate(target, adding)
+        if error is None:
+            if not IContent.providedBy(target):
+                return ContentInvalidTarget()
+        return error
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/Ghost/haunted.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/Ghost/haunted.py	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2002-2013 Infrae. All rights reserved.
+# See also LICENSE.txt
+
+from five import grok
+from silva.core import interfaces
+from silva.core.references.interfaces import IReferenceService
+from zope.component import getUtility
+
+
+class Haunted(grok.Adapter):
+    """Adapted content for retrieving the 'iterator' of haunting
+    objects (Ghosts).
+    """
+    grok.implements(interfaces.IHaunted)
+    grok.context(interfaces.IContent)
+
+    def getHaunting(self):
+        service = getUtility(IReferenceService)
+        for reference in service.get_references_to(
+            self.context, name=u'haunted'):
+            yield reference.source.get_silva_object()
+
+
+class HauntedGhost(Haunted):
+    """Adapted content for retrieving the 'iterator' of haunting
+    objects (Ghosts).
+    """
+    grok.context(interfaces.IGhost)
+
+    def getHaunting(self):
+        # Nothing to look for - Ghost cannot be haunted. Don't yield anything
+        # XXX how to not yield anything??
+        if None:
+            yield None
+
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/Ghost/metadata.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/Ghost/metadata.py	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,22 @@
+
+
+from five import grok
+
+from Products.SilvaMetadata.Binding import DefaultMetadataBindingFactory
+
+from silva.core.interfaces import IGhostAware
+from silva.core.views.interfaces import IPreviewLayer
+
+
+class GhostMetadataBindingFactory(DefaultMetadataBindingFactory):
+    grok.context(IGhostAware)
+    read_only = True
+
+    def get_content(self):
+        haunted = self.context.get_haunted()
+        if haunted is not None:
+            if IPreviewLayer.providedBy(haunted.REQUEST):
+                return haunted.get_previewable()
+            return haunted.get_viewable()
+        return None
+
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/Ghost/smi.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/Ghost/smi.py	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,78 @@
+
+# Zope 3
+from five import grok
+from zeam.form import silva as silvaforms
+from zeam.form.base.errors import Error
+from zeam.form.base.widgets import widgetId
+from zeam.component import getComponent
+
+# Silva
+from silva.core.conf.interfaces import IIdentifiedContent
+from silva.core.interfaces import IGhost, IContent, IGhostManager
+from silva.core.references.reference import Reference
+from silva.core.references.reference import get_content_from_id
+from silva.translations import translate as _
+
+
+class IGhostSchema(IIdentifiedContent):
+
+    haunted = Reference(
+        IContent,
+        title=_(u"Target"),
+        description=_(u"The internal item the ghost is mirroring."),
+        required=True)
+
+
+def TargetValidator(field_name, wanted, adding=False):
+
+    class Validator(object):
+
+        def __init__(self, form, fields):
+            self.form = form
+            self.fields = fields
+
+        def validate(self, data):
+            """Validate ghost target before setting it.
+            """
+            # This is not beauty, but it works.
+            content_id = data.get(field_name)
+            if content_id is silvaforms.NO_VALUE:
+                # If there value is required it is already checked
+                return []
+            getManager = getComponent((wanted,), IGhostManager)
+            if adding:
+                manager = getManager(container=self.form.context)
+            else:
+                manager = getManager(ghost=self.form.context)
+            error = manager.validate(get_content_from_id(content_id), adding)
+            if error is not None:
+                identifier = widgetId(self.form, self.fields[field_name])
+                return [Error(error.doc(), identifier)]
+            return []
+
+    return Validator
+
+
+class GhostAddForm(silvaforms.SMIAddForm):
+    """Add form for a ghost
+    """
+    grok.name(u"Silva Ghost")
+    grok.context(IGhost)
+
+    fields = silvaforms.Fields(IGhostSchema)
+    fields['haunted'].referenceNotSetLabel = _(
+        u"Click the Lookup button to select an item to haunt.")
+    dataValidators = [TargetValidator('haunted', IContent, adding=True)]
+
+    def _add(self, parent, data):
+        factory = parent.manage_addProduct['Silva']
+        return factory.manage_addGhost(
+            data['id'], None, haunted=data['haunted'])
+
+
+class GhostEditForm(silvaforms.SMIEditForm):
+    """ Edit form for Ghost
+    """
+    grok.context(IGhost)
+    fields = silvaforms.Fields(IGhostSchema).omit('id')
+    dataValidators = [TargetValidator('haunted', IContent, adding=False)]
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/Ghost/views.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/Ghost/views.py	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,38 @@
+
+# Zope 3
+from five import grok
+from zope.component import queryMultiAdapter
+
+# Zope 2
+from AccessControl import getSecurityManager
+from AccessControl import Unauthorized
+
+# Silva
+from silva.core.interfaces import IGhost
+from silva.core.views import views as silvaviews
+from silva.core.views.interfaces import IView
+from silva.translations import translate as _
+
+
+class GhostView(silvaviews.View):
+    grok.context(IGhost)
+
+    broken_message = _(u"This content is unavailable. "
+                       u"Please inform the site manager.")
+
+    def render(self):
+        haunted = self.content.get_haunted()
+        if haunted is None:
+            return self.broken_message
+        permission = self.is_preview and 'Read Silva content' or 'View'
+        if not getSecurityManager().checkPermission(permission, haunted):
+            raise Unauthorized(
+                u"You do not have permission to "
+                u"see the target of this ghost")
+        view = queryMultiAdapter((haunted, self.request), name="content.html")
+        if view is None:
+            return self.broken_message
+        if IView.providedBy(view) and view.content is None:
+            return self.broken_message
+        return view()
+
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/GhostAsset/__init__.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/GhostAsset/__init__.py	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,1 @@
+# This is a package.
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/GhostAsset/content.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/GhostAsset/content.py	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,116 @@
+
+# Zope 3
+from five import grok
+from zeam.component import component
+
+# Zope 2
+from AccessControl import ClassSecurityInfo
+from App.class_init import InitializeClass
+
+# Silva
+from Products.Silva import SilvaPermissions
+from Products.Silva.Asset import Asset
+from Products.Silva.Ghost import GhostBase
+from Products.Silva.Ghost import GhostBaseManipulator, GhostBaseManager
+
+from silva.core.interfaces import IGhostAsset, IAsset
+from silva.core.interfaces import IAssetPayload, IGhostManager
+from silva.core.interfaces.errors import AssetInvalidTarget
+
+
+class GhostAsset(GhostBase, Asset):
+    grok.implements(IGhostAsset)
+    security = ClassSecurityInfo()
+    meta_type = "Silva Ghost Asset"
+
+    security.declareProtected(
+        SilvaPermissions.AccessContentsInformation, 'get_filename')
+    def get_filename(self):
+        asset = self.get_haunted()
+        if asset is not None:
+            return asset.get_filename()
+        return ''
+
+    security.declareProtected(
+        SilvaPermissions.AccessContentsInformation, 'get_file_size')
+    def get_file_size(self):
+        asset = self.get_haunted()
+        if asset is not None:
+            return asset.get_file_size()
+        return 0
+
+    security.declareProtected(
+        SilvaPermissions.AccessContentsInformation, 'get_mime_type')
+    def get_mime_type(self):
+        asset = self.get_haunted()
+        if asset is not None:
+            return asset.get_mime_type()
+        return 'application/octet-stream'
+
+    def get_quota_usage(self):
+        # Ghost Assets don't use any quota.
+        return -1
+
+    security.declareProtected(
+        SilvaPermissions.AccessContentsInformation, 'get_modification_datetime')
+    def get_modification_datetime(self):
+        """Return modification datetime.
+        """
+        asset = self.get_haunted()
+        if asset is not None:
+            return asset.get_modification_datetime()
+        return super(GhostAsset, self).get_modification_datetime()
+
+
+InitializeClass(GhostAsset)
+
+
+class GhostAssetManipulator(GhostBaseManipulator):
+
+    def create(self):
+        assert self.manager.ghost is None
+        factory = self.manager.container.manage_addProduct['Silva']
+        factory.manage_addGhostAsset(self.identifier, None)
+        ghost = self.manager.container._getOb(self.identifier)
+        ghost.set_haunted(self.target, auto_delete=self.manager.auto_delete)
+        self.manager.ghost = ghost
+        return ghost
+
+    def update(self):
+        assert self.manager.ghost is not None
+        if IGhostAsset.providedBy(self.manager.ghost):
+            self.manager.ghost.set_haunted(
+                self.target, auto_delete=self.manager.auto_delete)
+            return self.manager.ghost
+        self.manager.container.manage_delObjects([self.identifier])
+        self.manager.ghost = None
+        return self.create()
+
+    def need_update(self):
+        if IGhostAsset.providedBy(self.manager.ghost):
+            return self.target != self.manager.ghost.get_haunted()
+        # Only update if the invalid ghost is an asset.
+        return IAsset.providedBy(self.manager.ghost)
+
+
+@component(IAsset, provides=IGhostManager)
+class GhostAssetManager(GhostBaseManager):
+    manipulator = GhostAssetManipulator
+
+    def validate(self, target, adding=False):
+        error = super(GhostAssetManager, self).validate(target, adding)
+        if error is None:
+            if not IAsset.providedBy(target):
+                return AssetInvalidTarget()
+        return error
+
+
+class GhostAssetPayload(grok.Adapter):
+    grok.implements(IAssetPayload)
+    grok.context(IGhostAsset)
+
+    def get_payload(self):
+        asset = self.context.get_haunted()
+        if asset is not None:
+            return IAssetPayload(asset).get_payload()
+        return None
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/GhostAsset/smi.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/GhostAsset/smi.py	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,56 @@
+
+
+# Zope 3
+from five import grok
+
+# Silva
+from Products.Silva.Ghost import TargetValidator
+from Products.Silva.Asset import AssetEditTab
+
+from silva.core.conf.interfaces import IIdentifiedContent
+from silva.core.references.reference import Reference
+from silva.core.interfaces import IAsset, IGhostAsset
+from silva.translations import translate as _
+from zeam.form import silva as silvaforms
+
+
+class IGhostAssetSchema(IIdentifiedContent):
+
+    haunted = Reference(IAsset,
+            title=_(u"Target"),
+            description=_(u"The asset the ghost is mirroring"),
+            required=True)
+
+
+class GhostAssetAddForm(silvaforms.SMIAddForm):
+    """ Add form for ghost folders
+    """
+    grok.name(u'Silva Ghost Asset')
+
+    fields = silvaforms.Fields(IGhostAssetSchema)
+    fields['haunted'].referenceNotSetLabel = _(
+        u"Click the Lookup button to select an asset to haunt.")
+    dataValidators = [TargetValidator('haunted', IAsset, adding=True)]
+
+    def _add(self, parent, data):
+        factory = parent.manage_addProduct['Silva']
+        return factory.manage_addGhostAsset(
+            data['id'], None, haunted=data['haunted'])
+
+
+class GhostAssetEditForm(silvaforms.SMISubForm):
+    """ Edit form Ghost Folder
+    """
+    grok.context(IGhostAsset)
+    grok.view(AssetEditTab)
+    grok.order(10)
+
+    label = _(u'Edit ghost')
+    ignoreContent = False
+    dataManager = silvaforms.SilvaDataManager
+    dataValidators = [TargetValidator('haunted', IAsset, adding=False)]
+    fields = silvaforms.Fields(IGhostAssetSchema).omit('id')
+    actions  = silvaforms.Actions(
+        silvaforms.CancelEditAction(),
+        silvaforms.EditAction())
+
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/GhostAsset/views.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/GhostAsset/views.py	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2002-2013 Infrae. All rights reserved.
+# See also LICENSE.txt
+
+# Zope 3
+from five import grok
+from zope.component import getMultiAdapter
+from zope.publisher.interfaces.browser import IBrowserRequest
+
+# Zope 2
+from AccessControl import getSecurityManager
+from AccessControl import Unauthorized
+
+# Silva
+from silva.core.views import views as silvaviews
+from silva.core.interfaces import IGhostAsset, IDownloableAsset
+from silva.translations import translate as _
+from silva.core.views.httpheaders import HTTPResponseHeaders
+from silva.core.views.interfaces import IHTTPResponseHeaders
+
+
+class GhostAssetView(silvaviews.View):
+    grok.context(IGhostAsset)
+    grok.require('zope2.View')
+
+    def render(self):
+        haunted = self.context.get_haunted()
+        if haunted is None:
+            return _(u"This content is unavailable. "
+                     u"Please inform the site manager.")
+        permission = self.is_preview and 'Read Silva content' or 'View'
+        if not getSecurityManager().checkPermission(permission, haunted):
+            raise Unauthorized(
+                u"You do not have permission to "
+                u"see the target of this ghost")
+        if IDownloableAsset.providedBy(haunted):
+            return haunted.get_html_tag(
+                request=self.request, preview=self.is_preview)
+        return _(u"This content cannot be previewed.")
+
+
+class GhostAssetDownloadView(silvaviews.View):
+    grok.context(IGhostAsset)
+    grok.require('zope2.View')
+    grok.name('index.html')
+
+    def render(self):
+        haunted = self.context.get_haunted()
+        if haunted is None:
+            self.response.setStatus(404)
+            return u''
+        permission = self.is_preview and 'Read Silva content' or 'View'
+        if not getSecurityManager().checkPermission(permission, haunted):
+            raise Unauthorized(
+                u"You do not have permission to "
+                u"see the target of this ghost")
+        view = getMultiAdapter((haunted, self.request), name='index.html')
+        return view()
+
+
+class GhostAssetResponseHeaders(HTTPResponseHeaders):
+    """This reliably set HTTP headers on file serving, for GET and
+    HEAD requests.
+    """
+    grok.adapts(IBrowserRequest, IGhostAsset)
+
+    def other_headers(self, headers):
+        haunted = self.context.get_haunted()
+        if haunted is not None:
+            headers = getMultiAdapter(
+                (self.request, haunted),
+                IHTTPResponseHeaders)
+            headers.other_headers(headers)
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/GhostFolder.py
--- a/Products/Silva/GhostFolder.py	Mon Nov 04 17:03:35 2013 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,423 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2003-2013 Infrae. All rights reserved.
-# See also LICENSE.txt
-
-# Zope 3
-from five import grok
-from zope.component import getUtility
-from zope.lifecycleevent.interfaces import IObjectCreatedEvent
-from zope.lifecycleevent.interfaces import IObjectCopiedEvent
-
-# Zope 2
-from AccessControl import ClassSecurityInfo
-from AccessControl.security import checkPermission
-from Acquisition import aq_parent
-from App.class_init import InitializeClass
-from OFS.interfaces import IObjectClonedEvent
-
-# silva
-from Products.Silva.Folder import Folder
-from Products.Silva.Publication import Publication
-from Products.Silva import SilvaPermissions, helpers
-from Products.Silva.Ghost import GhostBase
-from Products.Silva.Ghost import TargetValidator
-from Products.SilvaMetadata.interfaces import IMetadataService
-
-from silva.core import conf as silvaconf
-from silva.core.interfaces import IAddableContents
-from silva.core.interfaces import IOrderManager, IPublicationWorkflow
-from silva.core.interfaces import (
-    IContainer, IContent, IGhost,
-    IPublication, IGhostFolder)
-from silva.core.conf.interfaces import IIdentifiedContent
-from silva.core.references.reference import Reference
-from silva.ui.menu import ContentMenu, MenuItem
-from silva.translations import translate as _
-
-from zeam.form import silva as silvaforms
-
-
-class Sync(object):
-
-    def __init__(self, target_container, target, ghost_container, ghost):
-        self.target_container = target_container
-        self.target = target
-        self.ghost_container = ghost_container
-        self.ghost = ghost
-
-        self.target_id = target.getId()
-        if ghost is not None:
-            self.ghost_id = ghost.getId()
-
-    def get_real_target(self):
-        return self.target
-
-    def update(self):
-        raise NotImplementedError
-
-    def verify(self):
-        return True
-
-    def create(self):
-        raise NotImplementedError
-
-    finish = None
-
-
-class SyncContainer(Sync):
-
-    def create(self):
-        target = self.get_real_target()
-        if target is not None:
-            factory = self.ghost_container.manage_addProduct['Silva']
-            factory.manage_addGhostFolder(self.target_id, None)
-            self.ghost = self.ghost_container._getOb(self.target_id)
-            self.ghost.set_haunted(target, auto_delete=True)
-        return self.ghost
-
-    def verify(self):
-        return self.get_real_target() == self.ghost.get_haunted()
-
-    def update(self):
-        self.ghost.set_haunted(self.get_real_target(), auto_delete=True)
-        return self.ghost
-
-    def finish(self):
-        ## XXX: I don't think that works (like ever worked)
-        target = self.get_real_target()
-        if target is not None:
-            orderer = IOrderManager(self.ghost)
-            for index, content in enumerate(target.get_ordered_publishables()):
-                orderer.move(content, index)
-
-
-class SyncGhostContainer(SyncContainer):
-
-    def get_real_target(self):
-        return self.target.get_haunted()
-
-
-class SyncContent(Sync):
-
-    def create(self):
-        target = self.get_real_target()
-        if target is not None:
-            factory = self.ghost_container.manage_addProduct['Silva']
-            factory.manage_addGhost(self.target_id, None)
-            self.ghost = self.ghost_container._getOb(self.target_id)
-            version = self.ghost.get_editable()
-            version.set_haunted(self.get_real_target(), auto_delete=True)
-            IPublicationWorkflow(self.ghost).publish()
-        return self.ghost
-
-    def verify(self):
-        return self.get_real_target() == self.ghost.get_haunted()
-
-    def update(self):
-        publication = IPublicationWorkflow(self.ghost)
-        if self.ghost.get_editable() is None:
-            publication.new_version()
-        version = self.ghost.get_editable()
-        version.set_haunted(self.get_real_target(), auto_delete=True)
-        publication.publish()
-        return self.ghost
-
-
-class SyncGhost(SyncContent):
-
-    def get_real_target(self):
-        return self.target.get_haunted()
-
-
-class SyncCopy(Sync):
-    # this is anything else -- copy it. We cannot check if it was
-    # modified and if copying is really necessary.
-
-    def create(self):
-        ghost = self.target._getCopy(self.ghost_container)
-        self.ghost_container._setObject(self.target_id, ghost)
-        return self.ghost_container._getOb(self.target_id)
-
-    def update(self):
-        assert self.ghost is not None
-        self.ghost_container.manage_delObjects([self.target_id])
-        return self.create()
-
-
-
-
-# sync map... (haunted objects interface, ghost objects interface,
-# update/create class) order is important, i.e. interfaces are
-# checked in this order.
-SYNC_MAP = [
-    (IGhostFolder, IGhostFolder, SyncGhostContainer),
-    (IContainer, IContainer, SyncContainer),
-    (IGhost, IGhost, SyncGhost),
-    (IContent, IGhost, SyncContent),
-    (None, None, SyncCopy),
-    ]
-
-
-class GhostFolder(GhostBase, Folder):
-    __doc__ = _("""Ghost Folders are similar to Ghosts, but instead of being a
-       placeholder for a document, they create placeholders and/or copies of all
-       the contents of the &#8216;original&#8217; folder. The advantage of Ghost
-       Folders is the contents stay in sync with the original, by manual or
-       automatic resyncing. Note that when a folder is
-       ghosted, assets &#8211; such as Images and Files &#8211; are copied
-       (physically duplicated) while documents are ghosted.""")
-
-    meta_type = 'Silva Ghost Folder'
-
-    grok.implements(IGhostFolder)
-    silvaconf.icon('icons/ghost_folder.png')
-    silvaconf.priority(0)
-
-    security = ClassSecurityInfo()
-
-    security.declareProtected(
-        SilvaPermissions.ApproveSilvaContent, 'haunt')
-    def haunt(self):
-        """populate the the ghost folder with ghosts
-        """
-        haunted = self.get_haunted()
-        if haunted is None:
-            return
-        stack = self._haunt_diff(haunted, self)
-        updaters = [SyncContainer(None, haunted, None, self)]
-
-        while stack:
-            # breadth first search
-            h_container, h_id, g_container, g_id = stack.pop(0)
-            if h_id is None:
-                # object was removed from haunted, so just remove it and
-                # continue
-                g_container.manage_delObjects([g_id])
-                continue
-            h_ob = h_container._getOb(h_id)
-
-            if g_id is None:
-                # object was added to haunted
-                g_ob = None
-            else:
-                # object is there but may have changed
-                g_ob = g_container._getOb(g_id)
-            g_ob_new = None
-
-            for h_if, g_if, factory in SYNC_MAP:
-                if h_if and not h_if.providedBy(h_ob):
-                    continue
-                if g_ob is None:
-                    # matching haunted interface, no ghost -> create
-                    updater = factory(h_container, h_ob, g_container, g_ob)
-                    g_ob_new = updater.create()
-                    if updater.finish is not None:
-                        updaters.append(updater)
-                    break
-                if g_if and not g_if.providedBy(g_ob):
-                    # haunted interface machces but ghost interface doesn't
-                    continue
-                # haunted interface and ghost interface match -> update
-                updater = factory(h_container, h_ob, g_container, g_ob)
-                # if the object is not uptodate, update it
-                if not updater.verify():
-                    g_ob_new = updater.update()
-                    if updater.finish is not None:
-                        updaters.append(updater)
-                else:
-                    g_ob_new = g_ob
-                break
-            else:
-                assert True, "no updater was called for %r" % (
-                    (h_container, h_ob, g_container, g_ob),)
-            if IContainer.providedBy(h_ob) and g_ob_new is not None:
-                stack.extend(self._haunt_diff(h_ob, g_ob_new))
-        for updater in updaters:
-            updater.finish()
-
-    def _haunt_diff(self, haunted, ghost):
-        """diffes two containers
-
-            haunted: IContainer, container to be haunted
-            ghost: IContainer, ghost
-
-            returns list of tuple:
-            [(haunted, h_id, ghost, g_id)]
-            whereby
-                h_id is the haunted object's id or None if a ghost exists but
-                    no object to be haunted
-                g_id is the ghost's id or None if the ghost doesn't exist but
-                    has to be created
-                haunted and ghost are the objects passed in
-        """
-        assert IContainer.providedBy(haunted)
-        assert IContainer.providedBy(ghost)
-        h_ids = list(haunted.objectIds())
-        g_ids = list(ghost.objectIds())
-        h_ids.sort()
-        g_ids.sort()
-        ids = []
-        while h_ids or g_ids:
-            h_id = None
-            g_id = None
-            if h_ids:
-                h_id = h_ids[0]
-            if g_ids:
-                g_id = g_ids[0]
-            if h_id == g_id or h_id is None or g_id is None:
-                ids.append((h_id, g_id))
-                if h_ids:
-                    del h_ids[0]
-                if g_ids:
-                    del g_ids[0]
-            elif h_id < g_id:
-                ids.append((h_id, None))
-                del h_ids[0]
-            elif h_id > g_id:
-                ids.append((None, g_id))
-                del g_ids[0]
-        return [(haunted, h_id, ghost, g_id) for (h_id, g_id) in ids]
-
-    security.declareProtected(
-        SilvaPermissions.ApproveSilvaContent, 'to_publication')
-    def to_publication(self):
-        """replace self with a folder"""
-        haunted = self.get_haunted()
-        if haunted is not None:
-            binding = getUtility(IMetadataService).getMetadata(haunted)
-            data_content = binding.get('silva-content', acquire=0)
-            data_extra = binding.get('silva-extra', acquire=0)
-        helpers.convert_content(self, Publication)
-        if haunted is not None:
-            binding = getUtility(IMetadataService).getMetadata(self)
-            binding.setValues('silva-content', data_content)
-            binding.setValues('silva-extra', data_extra)
-
-    security.declareProtected(
-        SilvaPermissions.ApproveSilvaContent, 'to_folder')
-    def to_folder(self):
-        """replace self with a folder"""
-        haunted = self.get_haunted()
-        if haunted is not None:
-            binding = getUtility(IMetadataService).getMetadata(haunted)
-            data_content = binding.get('silva-content', acquire=0)
-            data_extra = binding.get('silva-extra', acquire=0)
-        helpers.convert_content(self, Folder)
-        if haunted is not None:
-            binding = getUtility(IMetadataService).getMetadata(self)
-            binding.setValues('silva-content', data_content)
-            binding.setValues('silva-extra', data_extra)
-
-    # all this is for a nice side bar
-    def is_transparent(self):
-        """show in subtree? depends on haunted object"""
-        content = self.get_haunted()
-        if IContainer.providedBy(content):
-            return content.is_transparent()
-        return 0
-
-    security.declareProtected(SilvaPermissions.View,'get_publication')
-    def get_publication(self):
-        """returns self if haunted object is a publication"""
-        content = self.get_haunted()
-        if IPublication.providedBy(content):
-            return self.aq_inner
-        return aq_parent(self).get_publication()
-
-    def is_deletable(self):
-        pass
-
-
-InitializeClass(GhostFolder)
-
-
-class IGhostFolderSchema(IIdentifiedContent):
-
-    haunted = Reference(IContainer,
-            title=_(u"Target"),
-            description=_(u"The internal folder the ghost is mirroring"),
-            required=True)
-
-
-class SyncAction(silvaforms.Action):
-    description = _(u"Synchronize target and ghost folder content")
-    ignoreRequest = True
-
-    def available(self, form):
-        return checkPermission('silva.ChangeSilvaContent', form.context)
-
-    def __call__(self, form):
-        folder = form.context
-        if folder.get_link_status() is None:
-            folder.haunt()
-            form.send_message(
-                _(u'Ghost Folder synchronized.'), type='feedback')
-            return silvaforms.SUCCESS
-        form.send_message(
-            _(u'Ghost Folder was not synchronized, because the target is invalid.'),
-            type='error')
-        return silvaforms.FAILURE
-
-
-class GhostFolderAddForm(silvaforms.SMIAddForm):
-    """ Add form for ghost folders
-    """
-    grok.name(u'Silva Ghost Folder')
-
-    fields = silvaforms.Fields(IGhostFolderSchema)
-    fields['haunted'].referenceNotSetLabel = _(
-        u"Click the Lookup button to select a container to haunt.")
-    dataValidators = [
-        TargetValidator('haunted', is_folderish=True, adding=True)]
-
-    def _add(self, parent, data):
-        factory = parent.manage_addProduct['Silva']
-        return factory.manage_addGhostFolder(
-            data['id'], None, haunted=data['haunted'])
-
-
-class GhostFolderEditForm(silvaforms.SMIEditForm):
-    """ Edit form Ghost Folder
-    """
-    grok.context(IGhostFolder)
-    grok.name('silva.ui.edit')
-
-    fields = silvaforms.Fields(IGhostFolderSchema).omit('id')
-    dataValidators = [
-        TargetValidator('haunted', is_folderish=True, adding=False)]
-    actions = silvaforms.SMIEditForm.actions + SyncAction(_(u'Synchronize'))
-
-
-class GhostFolderEditMenu(MenuItem):
-    grok.adapts(ContentMenu, IGhostFolder)
-    grok.order(10.1)            # Goes right after the content tab.
-    name = _(u'Edit')
-    screen = GhostFolderEditForm
-
-
[email protected](IGhostFolder, IObjectCreatedEvent)
[email protected](IGhostFolder, IObjectClonedEvent)
-def haunt_created_folder(folder, event):
-    if (folder != event.object or
-        IObjectCopiedEvent.providedBy(event)):
-        return
-    # If the ghost folder is in a valid state after creation or copy,
-    # haunt its content.
-    if folder.get_link_status() is None:
-        folder.haunt()
-
-
-class AddableContents(grok.Adapter):
-    grok.context(IGhostFolder)
-    grok.implements(IAddableContents)
-    grok.provides(IAddableContents)
-
-    def get_authorized_addables(self):
-        return []
-
-    def get_container_addables(self):
-        return []
-
-    def get_all_addables(self):
-        return []
-
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/GhostFolder/__init__.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/GhostFolder/__init__.py	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2003-2013 Infrae. All rights reserved.
+# See also LICENSE.txt
+
+# BBB
+from .content import GhostFolder
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/GhostFolder/addables.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/GhostFolder/addables.py	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2013 Infrae. All rights reserved.
+# See also LICENSE.txt
+
+from five import grok
+from silva.core.interfaces import IGhostFolder, IAddableContents
+
+
+class AddableContents(grok.Adapter):
+    grok.context(IGhostFolder)
+    grok.implements(IAddableContents)
+    grok.provides(IAddableContents)
+
+    def get_authorized_addables(self):
+        return []
+
+    def get_container_addables(self):
+        return []
+
+    def get_all_addables(self):
+        return []
+
+
+
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/GhostFolder/content.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/GhostFolder/content.py	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,264 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2003-2013 Infrae. All rights reserved.
+# See also LICENSE.txt
+
+# Zope 3
+from five import grok
+from zeam.component import component, getComponent
+from zope.component import getUtility
+from zope.lifecycleevent.interfaces import IObjectCopiedEvent
+from zope.lifecycleevent.interfaces import IObjectCreatedEvent
+
+# Zope 2
+from AccessControl import ClassSecurityInfo
+from Acquisition import aq_parent
+from App.class_init import InitializeClass
+from OFS.interfaces import IObjectClonedEvent
+
+# silva
+from Products.Silva import SilvaPermissions, helpers
+from Products.Silva.Folder import Folder
+from Products.Silva.Ghost import GhostBase
+from Products.Silva.Ghost import GhostBaseManipulator, GhostBaseManager
+from Products.Silva.Publication import Publication
+from Products.SilvaMetadata.interfaces import IMetadataService
+
+from silva.core import conf as silvaconf
+from silva.core.interfaces import IContainer, IGhostFolder, IGhostManager
+from silva.core.interfaces import IPublication
+from silva.core.interfaces.errors import ContainerInvalidTarget
+from silva.translations import translate as _
+
+
+class CopyManipulator(GhostBaseManipulator):
+
+    def make_copy(self):
+        ghost = self.target._getCopy(self.manager.container)
+        self.manager.container._setObject(self.identifier, ghost)
+        # Publish if needed ?
+        return self.manager.container._getOb(self.identifier)
+
+    def create(self):
+        assert self.manager.ghost is None
+        ghost = self.make_copy()
+        self.manager.ghost = ghost
+        return ghost
+
+    def update(self):
+        assert self.manager.ghost is not None
+        self.manager.container.manage_delObjects([self.identifier])
+        ghost = self.make_copy()
+        self.manager.ghost = ghost
+        return ghost
+
+    def need_update(self):
+        return True
+
+
+class CopyManager(GhostBaseManager):
+    manipulator = CopyManipulator
+
+
+def get_factory(target):
+    return getComponent((target,), IGhostManager, default=CopyManager)
+
+
+class GhostFolder(GhostBase, Folder):
+    __doc__ = _("""Ghost Folders are similar to Ghosts, but instead of being a
+       placeholder for a document, they create placeholders and/or copies of all
+       the contents of the &#8216;original&#8217; folder. The advantage of Ghost
+       Folders is the contents stay in sync with the original, by manual or
+       automatic resyncing. Note that when a folder is
+       ghosted, assets &#8211; such as Images and Files &#8211; are copied
+       (physically duplicated) while documents are ghosted.""")
+
+    meta_type = 'Silva Ghost Folder'
+
+    grok.implements(IGhostFolder)
+    silvaconf.icon('icons/ghost_folder.png')
+    silvaconf.priority(0)
+
+    security = ClassSecurityInfo()
+
+    security.declareProtected(
+        SilvaPermissions.ApproveSilvaContent, 'haunt')
+    def haunt(self):
+        """populate the the ghost folder with ghosts
+        """
+        haunted = self.get_haunted()
+        if haunted is None:
+            return False
+        stack = self._haunt_diff(haunted, self)
+
+        while stack:
+            # breadth first search
+            h_container, h_id, g_container, g_id = stack.pop(0)
+
+            if h_id is None:
+                # object was removed from haunted, so just remove it and
+                # continue
+                g_container.manage_delObjects([g_id])
+                continue
+
+            h_ob = h_container._getOb(h_id)
+            g_ob = None
+            if g_id is not None:
+                g_ob = g_container._getOb(g_id)
+
+            g_ob = get_factory(h_ob)(
+                ghost=g_ob,
+                container=g_container,
+                auto_delete=True,
+                auto_publish=True).modify(h_ob, h_id).verify()
+
+            if IContainer.providedBy(h_ob) and g_ob is not None:
+                stack.extend(self._haunt_diff(h_ob, g_ob))
+
+        return True
+
+    def _haunt_diff(self, haunted, ghost):
+        """diffes two containers
+
+            haunted: IContainer, container to be haunted
+            ghost: IContainer, ghost
+
+            returns list of tuple:
+            [(haunted, h_id, ghost, g_id)]
+            whereby
+                h_id is the haunted object's id or None if a ghost exists but
+                    no object to be haunted
+                g_id is the ghost's id or None if the ghost doesn't exist but
+                    has to be created
+                haunted and ghost are the objects passed in
+        """
+        assert IContainer.providedBy(haunted)
+        assert IContainer.providedBy(ghost)
+        h_ids = list(haunted.objectIds())
+        g_ids = list(ghost.objectIds())
+        h_ids.sort()
+        g_ids.sort()
+        ids = []
+        while h_ids or g_ids:
+            h_id = None
+            g_id = None
+            if h_ids:
+                h_id = h_ids[0]
+            if g_ids:
+                g_id = g_ids[0]
+            if h_id == g_id or h_id is None or g_id is None:
+                ids.append((h_id, g_id))
+                if h_ids:
+                    del h_ids[0]
+                if g_ids:
+                    del g_ids[0]
+            elif h_id < g_id:
+                ids.append((h_id, None))
+                del h_ids[0]
+            elif h_id > g_id:
+                ids.append((None, g_id))
+                del g_ids[0]
+        return [(haunted, h_id, ghost, g_id) for (h_id, g_id) in ids]
+
+    security.declareProtected(
+        SilvaPermissions.ApproveSilvaContent, 'to_publication')
+    def to_publication(self):
+        """replace self with a folder"""
+        haunted = self.get_haunted()
+        if haunted is not None:
+            binding = getUtility(IMetadataService).getMetadata(haunted)
+            data_content = binding.get('silva-content', acquire=0)
+            data_extra = binding.get('silva-extra', acquire=0)
+        helpers.convert_content(self, Publication)
+        if haunted is not None:
+            binding = getUtility(IMetadataService).getMetadata(self)
+            binding.setValues('silva-content', data_content)
+            binding.setValues('silva-extra', data_extra)
+
+    security.declareProtected(
+        SilvaPermissions.ApproveSilvaContent, 'to_folder')
+    def to_folder(self):
+        """replace self with a folder"""
+        haunted = self.get_haunted()
+        if haunted is not None:
+            binding = getUtility(IMetadataService).getMetadata(haunted)
+            data_content = binding.get('silva-content', acquire=0)
+            data_extra = binding.get('silva-extra', acquire=0)
+        helpers.convert_content(self, Folder)
+        if haunted is not None:
+            binding = getUtility(IMetadataService).getMetadata(self)
+            binding.setValues('silva-content', data_content)
+            binding.setValues('silva-extra', data_extra)
+
+    security.declareProtected(SilvaPermissions.View,'get_publication')
+    def get_publication(self):
+        """returns self if haunted object is a publication"""
+        content = self.get_haunted()
+        if IPublication.providedBy(content):
+            return self.aq_inner
+        return aq_parent(self).get_publication()
+
+    def is_transparent(self):
+        """show in subtree? depends on haunted object"""
+        content = self.get_haunted()
+        if IContainer.providedBy(content):
+            return content.is_transparent()
+        return 0
+
+    def is_deletable(self):
+        pass
+
+
+InitializeClass(GhostFolder)
+
+
+class GhostFolderManipulator(GhostBaseManipulator):
+
+    def create(self):
+        assert self.manager.ghost is None
+        factory = self.manager.container.manage_addProduct['Silva']
+        factory.manage_addGhostFolder(self.identifier, None)
+        ghost = self.manager.container._getOb(self.identifier)
+        ghost.set_haunted(self.target, auto_delete=self.manager.auto_delete)
+        self.manager.ghost = ghost
+        return ghost
+
+    def update(self):
+        assert self.manager.ghost is not None
+        if IGhostFolder.providedBy(self.manager.ghost):
+            self.manager.ghost.set_haunted(
+                self.target, auto_delete=self.manager.auto_delete)
+            return None
+        self.manager.container.manage_delObjects([self.identifier])
+        self.manager.ghost = None
+        return self.create()
+
+    def need_update(self):
+        if IGhostFolder.providedBy(self.manager.ghost):
+            return self.target != self.manager.ghost.get_haunted()
+        # Only update if the invalid ghost is an asset.
+        return IContainer.providedBy(self.manager.ghost)
+
+
+@component(IContainer, provides=IGhostManager)
+class GhostFolderManager(GhostBaseManager):
+    manipulator = GhostFolderManipulator
+
+    def validate(self, target, adding=False):
+        error = super(GhostFolderManager, self).validate(target, adding)
+        if error is None:
+            if not IContainer.providedBy(target):
+                return ContainerInvalidTarget()
+        return error
+
+
[email protected](IGhostFolder, IObjectCreatedEvent)
[email protected](IGhostFolder, IObjectClonedEvent)
+def haunt_created_folder(folder, event):
+    if (folder != event.object or
+        IObjectCopiedEvent.providedBy(event)):
+        return
+    # If the ghost folder is in a valid state after creation or copy,
+    # haunt its content.
+    if folder.get_link_status() is None:
+        folder.haunt()
+
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/GhostFolder/smi.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/GhostFolder/smi.py	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,79 @@
+
+
+# Zope 3
+from five import grok
+
+# Zope 2
+from AccessControl.security import checkPermission
+
+# Silva
+from Products.Silva.Ghost import TargetValidator
+
+from silva.core.conf.interfaces import IIdentifiedContent
+from silva.core.references.reference import Reference
+from silva.core.interfaces import IContainer, IGhostFolder
+from silva.ui.menu import ContentMenu, MenuItem
+from silva.translations import translate as _
+from zeam.form import silva as silvaforms
+
+
+class IGhostFolderSchema(IIdentifiedContent):
+
+    haunted = Reference(IContainer,
+            title=_(u"Target"),
+            description=_(u"The internal folder the ghost is mirroring"),
+            required=True)
+
+
+class SyncAction(silvaforms.Action):
+    description = _(u"Synchronize target and ghost folder content")
+    ignoreRequest = True
+
+    def available(self, form):
+        return checkPermission('silva.ChangeSilvaContent', form.context)
+
+    def __call__(self, form):
+        folder = form.context
+        if folder.get_link_status() is None:
+            folder.haunt()
+            form.send_message(
+                _(u'Ghost Folder synchronized.'), type='feedback')
+            return silvaforms.SUCCESS
+        form.send_message(
+            _(u'Ghost Folder was not synchronized, because the target is invalid.'),
+            type='error')
+        return silvaforms.FAILURE
+
+
+class GhostFolderAddForm(silvaforms.SMIAddForm):
+    """ Add form for ghost folders
+    """
+    grok.name(u'Silva Ghost Folder')
+
+    fields = silvaforms.Fields(IGhostFolderSchema)
+    fields['haunted'].referenceNotSetLabel = _(
+        u"Click the Lookup button to select a container to haunt.")
+    dataValidators = [TargetValidator('haunted', IContainer, adding=True)]
+
+    def _add(self, parent, data):
+        factory = parent.manage_addProduct['Silva']
+        return factory.manage_addGhostFolder(
+            data['id'], None, haunted=data['haunted'])
+
+
+class GhostFolderEditForm(silvaforms.SMIEditForm):
+    """ Edit form Ghost Folder
+    """
+    grok.context(IGhostFolder)
+    grok.name('silva.ui.edit')
+
+    fields = silvaforms.Fields(IGhostFolderSchema).omit('id')
+    dataValidators = [TargetValidator('haunted', IContainer, adding=False)]
+    actions = silvaforms.SMIEditForm.actions + SyncAction(_(u'Synchronize'))
+
+
+class GhostFolderEditMenu(MenuItem):
+    grok.adapts(ContentMenu, IGhostFolder)
+    grok.order(10.1)            # Goes right after the content tab.
+    name = _(u'Edit')
+    screen = GhostFolderEditForm
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/Image/content.py
--- a/Products/Silva/Image/content.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/Image/content.py	Fri Nov 08 16:36:46 2013 +0100
@@ -9,6 +9,7 @@
 import os.path
 import re
 import time
+import warnings
 
 # Zope 3
 from five import grok
@@ -23,13 +24,13 @@
 
 # Silva
 from silva.core import conf as silvaconf
+from silva.core import interfaces
 from silva.core.conf.utils import ISilvaFactoryDispatcher
-from silva.core import interfaces
+from silva.core.interfaces import ContentError
 from silva.core.interfaces import IMimeTypeClassifier, ISilvaNameChooser
-from silva.core.interfaces import ContentError
 from silva.core.services.interfaces import IFilesService
+from silva.core.views.interfaces import IContentURL
 from silva.translations import translate as _
-from silva.core.views.interfaces import IContentURL
 
 from .. import SilvaPermissions
 from ..Asset import Asset
@@ -428,7 +429,18 @@
 
     security.declareProtected(SilvaPermissions.View, 'tag')
     def tag(self, hires=False, thumbnail=False,
-            request=None, preview=False, **extra_attributes):
+             request=None, preview=False, **extra_attributes):
+        warnings.warn(
+            'tag have been replaced with get_html_tag. '
+            'It will be removed, please update your code.',
+            DeprecationWarning, stacklevel=2)
+        return self.get_html_tag(
+            hires=hires, thumbnail=thumbnail,
+            request=request, preview=preview, **extra_attributes)
+
+    security.declareProtected(SilvaPermissions.View, 'get_html_tag')
+    def get_html_tag(self, preview=False, request=None, hires=False,
+                     thumbnail=False, **extra_attributes):
         """ return xhtml tag
 
         Since 'class' is a Python reserved word, it cannot be passed in
@@ -437,10 +449,8 @@
         will accept a 'css_class' argument that will be converted to
         'class' in the output tag to work around this.
         """
-        url = self.url(request=request,
-                       preview=preview,
-                       hires=hires,
-                       thumbnail=thumbnail)
+        url = self.get_download_url(
+            request=request, preview=preview, hires=hires, thumbnail=thumbnail)
 
         title = self.get_title_or_id()
         width, height = self.get_dimensions(thumbnail=thumbnail, hires=hires)
@@ -463,6 +473,16 @@
 
     security.declareProtected(SilvaPermissions.View, 'url')
     def url(self, hires=False, thumbnail=False, request=None, preview=False):
+        warnings.warn(
+            'url have been replaced with get_download_url. '
+            'It will be removed, please update your code.',
+            DeprecationWarning, stacklevel=2)
+        return self.get_download_url(
+            hires=hires, thumbnail=thumbnail,
+            request=request, preview=preview)
+
+    security.declareProtected(SilvaPermissions.View, 'get_download_url')
+    def get_download_url(self, preview=False, request=None, hires=False, thumbnail=False):
         "return url of image"
         if request is None:
             request = self.REQUEST
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/Image/views.py
--- a/Products/Silva/Image/views.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/Image/views.py	Fri Nov 08 16:36:46 2013 +0100
@@ -29,10 +29,11 @@
     thumbnail = False
 
     def render(self):
-        return self.content.tag(request=self.request,
-                                hires=self.hires,
-                                preview=self.is_preview,
-                                thumbnail=self.thumbnail)
+        return self.content.get_html_tag(
+            request=self.request,
+            hires=self.hires,
+            preview=self.is_preview,
+            thumbnail=self.thumbnail)
 
 
 class ImageDownloadView(FileDownloadView):
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/adapters/haunted.py
--- a/Products/Silva/adapters/haunted.py	Mon Nov 04 17:03:35 2013 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,36 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2002-2013 Infrae. All rights reserved.
-# See also LICENSE.txt
-
-from five import grok
-from silva.core import interfaces
-from silva.core.references.interfaces import IReferenceService
-from zope.component import getUtility
-
-
-class Haunted(grok.Adapter):
-    """Adapted content for retrieving the 'iterator' of haunting
-    objects (Ghosts).
-    """
-    grok.implements(interfaces.IHaunted)
-    grok.context(interfaces.IContent)
-
-    def getHaunting(self):
-        service = getUtility(IReferenceService)
-        for reference in service.get_references_to(
-            self.context, name=u'haunted'):
-            yield reference.source.get_silva_object()
-
-
-class HauntedGhost(Haunted):
-    """Adapted content for retrieving the 'iterator' of haunting
-    objects (Ghosts).
-    """
-    grok.context(interfaces.IGhost)
-
-    def getHaunting(self):
-        # Nothing to look for - Ghost cannot be haunted. Don't yield anything
-        # XXX how to not yield anything??
-        if None:
-            yield None
-
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/install.py
--- a/Products/Silva/install.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/install.py	Fri Nov 08 16:36:46 2013 +0100
@@ -121,6 +121,7 @@
         'Add Silva Folders',
         'Add Silva Ghost Versions',
         'Add Silva Ghosts',
+        'Add Silva Ghost Assets',
         'Add Silva Links',
         'Add Silva Link Versions',
         'Add Silva Images',
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/silvaxml/xmlexport.py
--- a/Products/Silva/silvaxml/xmlexport.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/silvaxml/xmlexport.py	Fri Nov 08 16:36:46 2013 +0100
@@ -169,6 +169,19 @@
         self.endElement('image')
 
 
+class GhostAssetProducer(producers.SilvaProducer):
+    """Export a verson of a Silva Asset to XML.
+    """
+    grok.adapts(interfaces.IGhostAsset, Interface)
+
+    def sax(self):
+        self.startElement('ghost_asset', {'id': self.context.id})
+        self.startElement('haunted')
+        self.characters(self.get_reference(u'haunted'))
+        self.endElement('haunted')
+        self.endElement('ghost_asset')
+
+
 class IndexerProducer(producers.SilvaProducer):
     """Export an IndexerProducer to XML.
     """
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/silvaxml/xmlimport.py
--- a/Products/Silva/silvaxml/xmlimport.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/silvaxml/xmlimport.py	Fri Nov 08 16:36:46 2013 +0100
@@ -3,8 +3,9 @@
 # See also LICENSE.txt
 
 from five import grok
+from zope.cachedescriptors.property import Lazy
+from zeam.component import getComponent
 
-from Products.Silva.Ghost import validate_target
 from silva.core import conf as silvaconf
 from silva.core import interfaces
 from silva.core.interfaces.events import IContentImported
@@ -151,6 +152,10 @@
             (NS_SILVA_URI, 'haunted'):
                 self.handlerFactories.contentHandler('haunted'),}
 
+    @Lazy
+    def _getManager(self):
+        return getComponent((interfaces.IGhost,), interfaces.IGhostManager)
+
     def _createVersion(self, identifier):
         factory = self.parent().manage_addProduct['Silva']
         factory.manage_addGhostVersion(identifier, None)
@@ -162,20 +167,21 @@
     def endElementNS(self, name, qname):
         if name == (NS_SILVA_URI, 'content'):
             importer = self.getExtra()
-            ghost = self.result()
+            version = self.result()
             haunted = self.getData('haunted')
             if not haunted:
-                importer.reportProblem(_(u'Missing ghost target.'), ghost)
+                importer.reportProblem(_(u'Missing ghost target.'), version)
             else:
 
                 def set_target(target):
-                    problem = validate_target(ghost, target)
+                    problem = self._getManager(
+                        ghost=version.get_silva_object()).validate(target)
                     if problem is not None:
-                        importer.reportProblem(problem.doc(), ghost)
+                        importer.reportProblem(problem.doc(), version)
                     else:
-                        ghost.set_haunted(target)
+                        version.set_haunted(target)
 
-                importer.resolveImportedPath(ghost, set_target, haunted)
+                importer.resolveImportedPath(version, set_target, haunted)
             self.updateVersionCount()
             self.storeWorkflow()
 
@@ -187,6 +193,10 @@
         return {
             (NS_SILVA_URI, 'haunted'):
                 self.handlerFactories.contentHandler('haunted'),}
+    @Lazy
+    def _getManager(self):
+        return getComponent(
+            (interfaces.IGhostFolder,), interfaces.IGhostManager)
 
     def _createContent(self, identifier):
         factory = self.parent().manage_addProduct['Silva']
@@ -209,7 +219,7 @@
             else:
 
                 def set_target(target):
-                    problem = validate_target(folder, target, is_folderish=True)
+                    problem = self._getManager(ghost=folder).validate(target)
                     if problem is not None:
                         importer.reportProblem(problem.doc(), folder)
                     else:
@@ -363,6 +373,49 @@
             self.notifyImport()
 
 
+class GhostAssetHandler(handlers.SilvaHandler):
+    grok.name('ghost_asset')
+
+    def getOverrides(self):
+        return {
+            (NS_SILVA_URI, 'haunted'):
+                self.handlerFactories.contentHandler('haunted'),}
+
+    @Lazy
+    def _getManager(self):
+        return getComponent((interfaces.IGhostAsset,), interfaces.IGhostManager)
+
+    def _createContent(self, identifier):
+        factory = self.parent().manage_addProduct['Silva']
+        factory.manage_addGhostAsset(identifier, None)
+
+    def _verifyContent(self, content):
+        return interfaces.IGhostAsset.providedBy(content)
+
+    def startElementNS(self, name, qname, attrs):
+        if name == (NS_SILVA_URI, 'ghost_asset'):
+            self.createContent(attrs)
+
+    def endElementNS(self, name, qname):
+        if name == (NS_SILVA_URI, 'ghost_asset'):
+            importer = self.getExtra()
+            asset = self.result()
+            haunted = self.getData('haunted')
+            if not haunted:
+                importer.reportProblem(_(u'Missing ghost target.'), asset)
+            else:
+
+                def set_target(target):
+                    problem = self._getManager(ghost=asset).validate(target)
+                    if problem is not None:
+                        importer.reportProblem(problem.doc(), asset)
+                    else:
+                        asset.set_haunted(target)
+
+                importer.resolveImportedPath(asset, set_target, haunted)
+            self.notifyImport()
+
+
 class UnknownContentHandler(handlers.SilvaHandler):
     """Importer for content which have been exported in a ZEXP.
     """
@@ -380,7 +433,7 @@
     def endElementNS(self, name, qname):
         if name == (NS_SILVA_URI, 'unknown_content'):
             extra = self.getExtra()
-            # XXX check non
+            # XXX check name
             identifier = self.getData('id')
             import_file = extra.getFile('zexps/' + self.getData('zexp'))
             content = extra.root._p_jar.importFile(import_file)
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/tests/data/test_export_ghost_asset.silvaxml
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/tests/data/test_export_ghost_asset.silvaxml	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,100 @@
+<?xml version='1.0' encoding='utf-8'?>
+<silva xmlns="http://infrae.com/namespace/silva" {namespaces} silva_version="{version}">
+  <folder id="folder">
+    <metadata>
+      <set id="silva-content">
+        <silva-content:maintitle>
+          This is &lt;boo&gt;a&lt;/boo&gt; folder
+        </silva-content:maintitle>
+        <silva-content:shorttitle>
+        </silva-content:shorttitle>
+      </set>
+      <set id="silva-extra">
+        <silva-extra:comment>
+        </silva-extra:comment>
+        <silva-extra:contactemail>
+        </silva-extra:contactemail>
+        <silva-extra:contactname>
+        </silva-extra:contactname>
+        <silva-extra:content_description>
+        </silva-extra:content_description>
+        <silva-extra:creationtime>
+          YYYY-MM-DDTHH:MM:SS
+        </silva-extra:creationtime>
+        <silva-extra:creator>
+          author
+        </silva-extra:creator>
+        <silva-extra:keywords>
+        </silva-extra:keywords>
+        <silva-extra:language>
+        </silva-extra:language>
+        <silva-extra:lastauthor>
+          editor
+        </silva-extra:lastauthor>
+        <silva-extra:modificationtime>
+          YYYY-MM-DDTHH:MM:SS
+        </silva-extra:modificationtime>
+        <silva-extra:subject>
+        </silva-extra:subject>
+      </set>
+      <set xmlns:silvasettings="http://infrae.com/namespace/metadata/silva-settings" id="silva-settings">
+        <silvasettings:hide_from_tocs>
+          do not hide
+        </silvasettings:hide_from_tocs>
+      </set>
+    </metadata>
+    <content>
+      <ghost_asset id="ghost">
+        <haunted>
+          folder/logo
+        </haunted>
+      </ghost_asset>
+      <file id="logo">
+        <metadata>
+          <set id="silva-content">
+            <silva-content:maintitle>
+              Silva Logo
+            </silva-content:maintitle>
+            <silva-content:shorttitle>
+            </silva-content:shorttitle>
+          </set>
+          <set id="silva-extra">
+            <silva-extra:comment>
+            </silva-extra:comment>
+            <silva-extra:contactemail>
+            </silva-extra:contactemail>
+            <silva-extra:contactname>
+            </silva-extra:contactname>
+            <silva-extra:content_description>
+            </silva-extra:content_description>
+            <silva-extra:creationtime>
+              YYYY-MM-DDTHH:MM:SS
+            </silva-extra:creationtime>
+            <silva-extra:creator>
+              editor
+            </silva-extra:creator>
+            <silva-extra:keywords>
+            </silva-extra:keywords>
+            <silva-extra:language>
+            </silva-extra:language>
+            <silva-extra:lastauthor>
+              editor
+            </silva-extra:lastauthor>
+            <silva-extra:modificationtime>
+              YYYY-MM-DDTHH:MM:SS
+            </silva-extra:modificationtime>
+            <silva-extra:subject>
+            </silva-extra:subject>
+          </set>
+          <set xmlns:silvasettings="http://infrae.com/namespace/metadata/silva-settings" id="silva-settings">
+            <silvasettings:hide_from_tocs>
+              do not hide
+            </silvasettings:hide_from_tocs>
+          </set>
+        </metadata>
+        <asset id="1">
+        </asset>
+      </file>
+    </content>
+  </folder>
+</silva>
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/tests/data/test_export_ghost_folder.silvaxml
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/tests/data/test_export_ghost_folder.silvaxml	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,254 @@
+<?xml version='1.0' encoding='utf-8'?>
+<silva xmlns="http://infrae.com/namespace/silva" {namespaces} silva_version="{version}">
+  <folder id="folder">
+    <metadata>
+      <set id="silva-content">
+        <silva-content:maintitle>
+          This is &lt;boo&gt;a&lt;/boo&gt; folder
+        </silva-content:maintitle>
+        <silva-content:shorttitle>
+        </silva-content:shorttitle>
+      </set>
+      <set id="silva-extra">
+        <silva-extra:comment>
+        </silva-extra:comment>
+        <silva-extra:contactemail>
+        </silva-extra:contactemail>
+        <silva-extra:contactname>
+        </silva-extra:contactname>
+        <silva-extra:content_description>
+        </silva-extra:content_description>
+        <silva-extra:creationtime>
+          YYYY-MM-DDTHH:MM:SS
+        </silva-extra:creationtime>
+        <silva-extra:creator>
+          author
+        </silva-extra:creator>
+        <silva-extra:keywords>
+        </silva-extra:keywords>
+        <silva-extra:language>
+        </silva-extra:language>
+        <silva-extra:lastauthor>
+          chiefeditor
+        </silva-extra:lastauthor>
+        <silva-extra:modificationtime>
+          YYYY-MM-DDTHH:MM:SS
+        </silva-extra:modificationtime>
+        <silva-extra:subject>
+        </silva-extra:subject>
+      </set>
+      <set xmlns:silvasettings="http://infrae.com/namespace/metadata/silva-settings" id="silva-settings">
+        <silvasettings:hide_from_tocs>
+          do not hide
+        </silvasettings:hide_from_tocs>
+      </set>
+    </metadata>
+    <content>
+      <folder id="container">
+        <metadata>
+          <set id="silva-content">
+            <silva-content:maintitle>
+              Content
+            </silva-content:maintitle>
+            <silva-content:shorttitle>
+            </silva-content:shorttitle>
+          </set>
+          <set id="silva-extra">
+            <silva-extra:comment>
+            </silva-extra:comment>
+            <silva-extra:contactemail>
+            </silva-extra:contactemail>
+            <silva-extra:contactname>
+            </silva-extra:contactname>
+            <silva-extra:content_description>
+            </silva-extra:content_description>
+            <silva-extra:creationtime>
+              YYYY-MM-DDTHH:MM:SS
+            </silva-extra:creationtime>
+            <silva-extra:creator>
+              chiefeditor
+            </silva-extra:creator>
+            <silva-extra:keywords>
+            </silva-extra:keywords>
+            <silva-extra:language>
+            </silva-extra:language>
+            <silva-extra:lastauthor>
+              chiefeditor
+            </silva-extra:lastauthor>
+            <silva-extra:modificationtime>
+              YYYY-MM-DDTHH:MM:SS
+            </silva-extra:modificationtime>
+            <silva-extra:subject>
+            </silva-extra:subject>
+          </set>
+          <set xmlns:silvasettings="http://infrae.com/namespace/metadata/silva-settings" id="silva-settings">
+            <silvasettings:hide_from_tocs>
+              do not hide
+            </silvasettings:hide_from_tocs>
+          </set>
+        </metadata>
+        <content>
+          <default>
+            <auto_toc depth="-1" display_desc_flag="False" id="index" show_icon="False" sort_order="silva" types="Silva Document,Silva Publication,Silva Folder">
+              <metadata>
+                <set id="silva-content">
+                  <silva-content:maintitle>
+                    Content
+                  </silva-content:maintitle>
+                  <silva-content:shorttitle>
+                  </silva-content:shorttitle>
+                </set>
+                <set id="silva-extra">
+                  <silva-extra:comment>
+                  </silva-extra:comment>
+                  <silva-extra:contactemail>
+                  </silva-extra:contactemail>
+                  <silva-extra:contactname>
+                  </silva-extra:contactname>
+                  <silva-extra:content_description>
+                  </silva-extra:content_description>
+                  <silva-extra:creationtime>
+                    YYYY-MM-DDTHH:MM:SS
+                  </silva-extra:creationtime>
+                  <silva-extra:creator>
+                    chiefeditor
+                  </silva-extra:creator>
+                  <silva-extra:keywords>
+                  </silva-extra:keywords>
+                  <silva-extra:language>
+                  </silva-extra:language>
+                  <silva-extra:lastauthor>
+                    chiefeditor
+                  </silva-extra:lastauthor>
+                  <silva-extra:modificationtime>
+                    YYYY-MM-DDTHH:MM:SS
+                  </silva-extra:modificationtime>
+                  <silva-extra:subject>
+                  </silva-extra:subject>
+                </set>
+                <set xmlns:silvasettings="http://infrae.com/namespace/metadata/silva-settings" id="silva-settings">
+                  <silvasettings:hide_from_tocs>
+                    do not hide
+                  </silvasettings:hide_from_tocs>
+                </set>
+              </metadata>
+            </auto_toc>
+          </default>
+          <link id="link">
+            <workflow>
+              <version id="0">
+                <status>
+                  unapproved
+                </status>
+                <publication_datetime>
+                </publication_datetime>
+                <expiration_datetime>
+                </expiration_datetime>
+              </version>
+            </workflow>
+            <content version_id="0">
+              <metadata>
+                <set id="silva-content">
+                  <silva-content:maintitle>
+                    Infrae
+                  </silva-content:maintitle>
+                  <silva-content:shorttitle>
+                  </silva-content:shorttitle>
+                </set>
+                <set id="silva-extra">
+                  <silva-extra:comment>
+                  </silva-extra:comment>
+                  <silva-extra:contactemail>
+                  </silva-extra:contactemail>
+                  <silva-extra:contactname>
+                  </silva-extra:contactname>
+                  <silva-extra:content_description>
+                  </silva-extra:content_description>
+                  <silva-extra:creationtime>
+                    YYYY-MM-DDTHH:MM:SS
+                  </silva-extra:creationtime>
+                  <silva-extra:creator>
+                    chiefeditor
+                  </silva-extra:creator>
+                  <silva-extra:keywords>
+                  </silva-extra:keywords>
+                  <silva-extra:language>
+                  </silva-extra:language>
+                  <silva-extra:lastauthor>
+                    chiefeditor
+                  </silva-extra:lastauthor>
+                  <silva-extra:modificationtime>
+                    YYYY-MM-DDTHH:MM:SS
+                  </silva-extra:modificationtime>
+                  <silva-extra:subject>
+                  </silva-extra:subject>
+                </set>
+                <set xmlns:silvasettings="http://infrae.com/namespace/metadata/silva-settings" id="silva-settings">
+                  <silvasettings:hide_from_tocs>
+                    do not hide
+                  </silvasettings:hide_from_tocs>
+                </set>
+              </metadata>
+              <url>
+                http://infrae.com
+              </url>
+            </content>
+          </link>
+          <file id="file">
+            <metadata>
+              <set id="silva-content">
+                <silva-content:maintitle>
+                  Torvald blob
+                </silva-content:maintitle>
+                <silva-content:shorttitle>
+                </silva-content:shorttitle>
+              </set>
+              <set id="silva-extra">
+                <silva-extra:comment>
+                </silva-extra:comment>
+                <silva-extra:contactemail>
+                </silva-extra:contactemail>
+                <silva-extra:contactname>
+                </silva-extra:contactname>
+                <silva-extra:content_description>
+                </silva-extra:content_description>
+                <silva-extra:creationtime>
+                  YYYY-MM-DDTHH:MM:SS
+                </silva-extra:creationtime>
+                <silva-extra:creator>
+                  chiefeditor
+                </silva-extra:creator>
+                <silva-extra:keywords>
+                </silva-extra:keywords>
+                <silva-extra:language>
+                </silva-extra:language>
+                <silva-extra:lastauthor>
+                  chiefeditor
+                </silva-extra:lastauthor>
+                <silva-extra:modificationtime>
+                  YYYY-MM-DDTHH:MM:SS
+                </silva-extra:modificationtime>
+                <silva-extra:subject>
+                </silva-extra:subject>
+              </set>
+              <set xmlns:silvasettings="http://infrae.com/namespace/metadata/silva-settings" id="silva-settings">
+                <silvasettings:hide_from_tocs>
+                  do not hide
+                </silvasettings:hide_from_tocs>
+              </set>
+            </metadata>
+            <asset id="1">
+            </asset>
+          </file>
+        </content>
+      </folder>
+      <ghost_folder id="ghost">
+        <content>
+          <haunted>
+            folder/container
+          </haunted>
+        </content>
+      </ghost_folder>
+    </content>
+  </folder>
+</silva>
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/tests/data/test_export_ghostfolder.silvaxml
--- a/Products/Silva/tests/data/test_export_ghostfolder.silvaxml	Mon Nov 04 17:03:35 2013 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,254 +0,0 @@
-<?xml version='1.0' encoding='utf-8'?>
-<silva xmlns="http://infrae.com/namespace/silva" {namespaces} silva_version="{version}">
-  <folder id="folder">
-    <metadata>
-      <set id="silva-content">
-        <silva-content:maintitle>
-          This is &lt;boo&gt;a&lt;/boo&gt; folder
-        </silva-content:maintitle>
-        <silva-content:shorttitle>
-        </silva-content:shorttitle>
-      </set>
-      <set id="silva-extra">
-        <silva-extra:comment>
-        </silva-extra:comment>
-        <silva-extra:contactemail>
-        </silva-extra:contactemail>
-        <silva-extra:contactname>
-        </silva-extra:contactname>
-        <silva-extra:content_description>
-        </silva-extra:content_description>
-        <silva-extra:creationtime>
-          YYYY-MM-DDTHH:MM:SS
-        </silva-extra:creationtime>
-        <silva-extra:creator>
-          author
-        </silva-extra:creator>
-        <silva-extra:keywords>
-        </silva-extra:keywords>
-        <silva-extra:language>
-        </silva-extra:language>
-        <silva-extra:lastauthor>
-          chiefeditor
-        </silva-extra:lastauthor>
-        <silva-extra:modificationtime>
-          YYYY-MM-DDTHH:MM:SS
-        </silva-extra:modificationtime>
-        <silva-extra:subject>
-        </silva-extra:subject>
-      </set>
-      <set xmlns:silvasettings="http://infrae.com/namespace/metadata/silva-settings" id="silva-settings">
-        <silvasettings:hide_from_tocs>
-          do not hide
-        </silvasettings:hide_from_tocs>
-      </set>
-    </metadata>
-    <content>
-      <folder id="container">
-        <metadata>
-          <set id="silva-content">
-            <silva-content:maintitle>
-              Content
-            </silva-content:maintitle>
-            <silva-content:shorttitle>
-            </silva-content:shorttitle>
-          </set>
-          <set id="silva-extra">
-            <silva-extra:comment>
-            </silva-extra:comment>
-            <silva-extra:contactemail>
-            </silva-extra:contactemail>
-            <silva-extra:contactname>
-            </silva-extra:contactname>
-            <silva-extra:content_description>
-            </silva-extra:content_description>
-            <silva-extra:creationtime>
-              YYYY-MM-DDTHH:MM:SS
-            </silva-extra:creationtime>
-            <silva-extra:creator>
-              chiefeditor
-            </silva-extra:creator>
-            <silva-extra:keywords>
-            </silva-extra:keywords>
-            <silva-extra:language>
-            </silva-extra:language>
-            <silva-extra:lastauthor>
-              chiefeditor
-            </silva-extra:lastauthor>
-            <silva-extra:modificationtime>
-              YYYY-MM-DDTHH:MM:SS
-            </silva-extra:modificationtime>
-            <silva-extra:subject>
-            </silva-extra:subject>
-          </set>
-          <set xmlns:silvasettings="http://infrae.com/namespace/metadata/silva-settings" id="silva-settings">
-            <silvasettings:hide_from_tocs>
-              do not hide
-            </silvasettings:hide_from_tocs>
-          </set>
-        </metadata>
-        <content>
-          <default>
-            <auto_toc depth="-1" display_desc_flag="False" id="index" show_icon="False" sort_order="silva" types="Silva Document,Silva Publication,Silva Folder">
-              <metadata>
-                <set id="silva-content">
-                  <silva-content:maintitle>
-                    Content
-                  </silva-content:maintitle>
-                  <silva-content:shorttitle>
-                  </silva-content:shorttitle>
-                </set>
-                <set id="silva-extra">
-                  <silva-extra:comment>
-                  </silva-extra:comment>
-                  <silva-extra:contactemail>
-                  </silva-extra:contactemail>
-                  <silva-extra:contactname>
-                  </silva-extra:contactname>
-                  <silva-extra:content_description>
-                  </silva-extra:content_description>
-                  <silva-extra:creationtime>
-                    YYYY-MM-DDTHH:MM:SS
-                  </silva-extra:creationtime>
-                  <silva-extra:creator>
-                    chiefeditor
-                  </silva-extra:creator>
-                  <silva-extra:keywords>
-                  </silva-extra:keywords>
-                  <silva-extra:language>
-                  </silva-extra:language>
-                  <silva-extra:lastauthor>
-                    chiefeditor
-                  </silva-extra:lastauthor>
-                  <silva-extra:modificationtime>
-                    YYYY-MM-DDTHH:MM:SS
-                  </silva-extra:modificationtime>
-                  <silva-extra:subject>
-                  </silva-extra:subject>
-                </set>
-                <set xmlns:silvasettings="http://infrae.com/namespace/metadata/silva-settings" id="silva-settings">
-                  <silvasettings:hide_from_tocs>
-                    do not hide
-                  </silvasettings:hide_from_tocs>
-                </set>
-              </metadata>
-            </auto_toc>
-          </default>
-          <link id="link">
-            <workflow>
-              <version id="0">
-                <status>
-                  unapproved
-                </status>
-                <publication_datetime>
-                </publication_datetime>
-                <expiration_datetime>
-                </expiration_datetime>
-              </version>
-            </workflow>
-            <content version_id="0">
-              <metadata>
-                <set id="silva-content">
-                  <silva-content:maintitle>
-                    Infrae
-                  </silva-content:maintitle>
-                  <silva-content:shorttitle>
-                  </silva-content:shorttitle>
-                </set>
-                <set id="silva-extra">
-                  <silva-extra:comment>
-                  </silva-extra:comment>
-                  <silva-extra:contactemail>
-                  </silva-extra:contactemail>
-                  <silva-extra:contactname>
-                  </silva-extra:contactname>
-                  <silva-extra:content_description>
-                  </silva-extra:content_description>
-                  <silva-extra:creationtime>
-                    YYYY-MM-DDTHH:MM:SS
-                  </silva-extra:creationtime>
-                  <silva-extra:creator>
-                    chiefeditor
-                  </silva-extra:creator>
-                  <silva-extra:keywords>
-                  </silva-extra:keywords>
-                  <silva-extra:language>
-                  </silva-extra:language>
-                  <silva-extra:lastauthor>
-                    chiefeditor
-                  </silva-extra:lastauthor>
-                  <silva-extra:modificationtime>
-                    YYYY-MM-DDTHH:MM:SS
-                  </silva-extra:modificationtime>
-                  <silva-extra:subject>
-                  </silva-extra:subject>
-                </set>
-                <set xmlns:silvasettings="http://infrae.com/namespace/metadata/silva-settings" id="silva-settings">
-                  <silvasettings:hide_from_tocs>
-                    do not hide
-                  </silvasettings:hide_from_tocs>
-                </set>
-              </metadata>
-              <url>
-                http://infrae.com
-              </url>
-            </content>
-          </link>
-          <file id="file">
-            <metadata>
-              <set id="silva-content">
-                <silva-content:maintitle>
-                  Torvald blob
-                </silva-content:maintitle>
-                <silva-content:shorttitle>
-                </silva-content:shorttitle>
-              </set>
-              <set id="silva-extra">
-                <silva-extra:comment>
-                </silva-extra:comment>
-                <silva-extra:contactemail>
-                </silva-extra:contactemail>
-                <silva-extra:contactname>
-                </silva-extra:contactname>
-                <silva-extra:content_description>
-                </silva-extra:content_description>
-                <silva-extra:creationtime>
-                  YYYY-MM-DDTHH:MM:SS
-                </silva-extra:creationtime>
-                <silva-extra:creator>
-                  chiefeditor
-                </silva-extra:creator>
-                <silva-extra:keywords>
-                </silva-extra:keywords>
-                <silva-extra:language>
-                </silva-extra:language>
-                <silva-extra:lastauthor>
-                  chiefeditor
-                </silva-extra:lastauthor>
-                <silva-extra:modificationtime>
-                  YYYY-MM-DDTHH:MM:SS
-                </silva-extra:modificationtime>
-                <silva-extra:subject>
-                </silva-extra:subject>
-              </set>
-              <set xmlns:silvasettings="http://infrae.com/namespace/metadata/silva-settings" id="silva-settings">
-                <silvasettings:hide_from_tocs>
-                  do not hide
-                </silvasettings:hide_from_tocs>
-              </set>
-            </metadata>
-            <asset id="1">
-            </asset>
-          </file>
-        </content>
-      </folder>
-      <ghost_folder id="ghost">
-        <content>
-          <haunted>
-            folder/container
-          </haunted>
-        </content>
-      </ghost_folder>
-    </content>
-  </folder>
-</silva>
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/tests/data/test_import_ghost_asset.zip
Binary file Products/Silva/tests/data/test_import_ghost_asset.zip has changed
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/tests/mockers.py
--- a/Products/Silva/tests/mockers.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/tests/mockers.py	Fri Nov 08 16:36:46 2013 +0100
@@ -4,6 +4,7 @@
 
 from OFS.SimpleItem import SimpleItem
 
+from Products.Silva.Asset import Asset
 from Products.Silva.Publishable import NonPublishable
 from Products.Silva.VersionedContent import VersionedContent
 from Products.Silva.Version import Version
@@ -12,7 +13,7 @@
 from five import grok
 from silva.core import conf as silvaconf
 from silva.core.views import views as silvaviews
-from silva.core.interfaces import INonPublishable, IVersionedContent
+from silva.core.interfaces import INonPublishable, IAsset, IVersionedContent
 from silva.core.interfaces.adapters import IIndexEntries
 from zeam.form import silva as silvaforms
 
@@ -76,12 +77,25 @@
         return self.content.get_title()
 
 
-class IMockupAsset(INonPublishable):
+class IMockupNonPublishable(INonPublishable):
     pass
 
 
-class MockupAsset(NonPublishable, SimpleItem):
-    """A mockup asset.
+class MockupNonPublishable(NonPublishable, SimpleItem):
+    """A mockup non-publishable.
+    """
+    grok.implements(IMockupNonPublishable)
+    meta_type = 'Mockup Non Publishable'
+    silvaconf.priority(-10)
+    silvaconf.icon('tests/mockers.png')
+
+
+class IMockupAsset(IAsset):
+    pass
+
+
+class MockupAsset(Asset, SimpleItem):
+    """A mockup non-publishable.
     """
     grok.implements(IMockupAsset)
     meta_type = 'Mockup Asset'
@@ -93,11 +107,15 @@
     root.manage_permission(
         'Add Mockup Assets', roleinfo.AUTHOR_ROLES)
     root.manage_permission(
+        'Add Mockup Non Publishables', roleinfo.AUTHOR_ROLES)
+    root.manage_permission(
         'Add Mockup Versions', roleinfo.AUTHOR_ROLES)
     root.manage_permission(
         'Add Mockup VersionedContents', roleinfo.AUTHOR_ROLES)
     root.service_metadata.addTypesMapping(
         ['Mockup Version'], ('silva-content', 'silva-extra', 'silva-settings'))
     root.service_metadata.addTypesMapping(
+        ['Mockup Non Publishable'], ('silva-content', 'silva-extra',))
+    root.service_metadata.addTypesMapping(
         ['Mockup Asset'], ('silva-content', 'silva-extra',))
 
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/tests/test_extension.py
--- a/Products/Silva/tests/test_extension.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/tests/test_extension.py	Fri Nov 08 16:36:46 2013 +0100
@@ -63,6 +63,7 @@
              'Silva Folder',
              'Silva Ghost',
              'Silva Ghost Version',
+             'Silva Ghost Asset',
              'Silva Ghost Folder',
              'Silva Image',
              'Silva Indexer',
@@ -73,6 +74,7 @@
              'Silva Simple Member',
              'Silva Filtering Service',
              'Mockup Asset',
+             'Mockup Non Publishable',
              'Mockup VersionedContent',
              'Mockup Version'])
         self.assertEqual(
@@ -97,6 +99,8 @@
              'Silva',
              'Silva',
              'Silva',
+             'Silva',
+             'Silva',
              'Silva'])
 
     def test_extension_egg(self):
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/tests/test_file.py
--- a/Products/Silva/tests/test_file.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/tests/test_file.py	Fri Nov 08 16:36:46 2013 +0100
@@ -66,13 +66,16 @@
         with self.layer.open_fixture('photo.tif') as test_file:
             factory.manage_addFile('test_file_id', 'Test File 1', test_file)
             with self.assertRaises(ValueError) as error:
-                factory.manage_addImage('test_file_id', 'Test File 2',
-                        test_file)
+                factory.manage_addImage(
+                    'test_file_id', 'Test File 2', test_file)
 
-        self.assertEqual(str(error.exception), "Please provide a unique id: ${reason}")
+        self.assertEqual(
+            str(error.exception),
+            "Please provide a unique id: ${reason}")
 
         with self.layer.open_fixture('photo.tif') as test_file:
-            factory.manage_addFile('test_file_unique_id', 'Test File 3', test_file)
+            factory.manage_addFile(
+                'test_file_unique_id', 'Test File 3', test_file)
 
         content = self.root._getOb('test_file_unique_id')
         self.assertTrue(verifyObject(interfaces.IAsset, content))
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/tests/test_folder_addables.py
--- a/Products/Silva/tests/test_folder_addables.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/tests/test_folder_addables.py	Fri Nov 08 16:36:46 2013 +0100
@@ -11,11 +11,13 @@
 DEFAULT_ALL_ADDABLES = [
     'Mockup VersionedContent',
     'Mockup Asset',
+    'Mockup Non Publishable',
     'Silva Folder',
     'Silva Publication',
     'Silva Image',
     'Silva File',
     'Silva Ghost',
+    'Silva Ghost Asset',
     'Silva Ghost Folder',
     'Silva Indexer',
     'Silva Link',
@@ -23,10 +25,12 @@
 AUTHOR_ALL_ADDABLES = [
     'Mockup VersionedContent',
     'Mockup Asset',
+    'Mockup Non Publishable',
     'Silva Folder',
     'Silva Image',
     'Silva File',
     'Silva Ghost',
+    'Silva Ghost Asset',
     'Silva Link',
     'Silva AutoTOC']
 
@@ -98,7 +102,8 @@
         folder_addables = IAddableContents(self.root.folder)
         self.assertTrue(verifyObject(IAddableContents, folder_addables))
         self.assertEqual(
-            folder_addables.get_authorized_addables(), AUTHOR_ALL_ADDABLES)
+            folder_addables.get_authorized_addables(),
+            AUTHOR_ALL_ADDABLES)
 
         self.root.set_silva_addables_allowed_in_container(
             ['Silva Publication', 'Silva File'])
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/tests/test_folder_quota.py
--- a/Products/Silva/tests/test_folder_quota.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/tests/test_folder_quota.py	Fri Nov 08 16:36:46 2013 +0100
@@ -346,9 +346,9 @@
         with manager1.ghoster() as ghoster:
             ghoster(folder2['subfolder1'])
 
-        # And check used space
-        self.assertEqual(folder1.used_space, zipfile_size)
-        self.assertEqual(self.root.used_space, (2 * zipfile_size) + image_size)
+        # And check used space. Ghost Assets don't use any quota.
+        self.assertEqual(folder1.used_space, 0)
+        self.assertEqual(self.root.used_space, zipfile_size + image_size)
         self.assertEqual(folder2.used_space, zipfile_size + image_size)
 
         # Delete the ghost
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/tests/test_ghost_asset.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Products/Silva/tests/test_ghost_asset.py	Fri Nov 08 16:36:46 2013 +0100
@@ -0,0 +1,123 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2013 Infrae. All rights reserved.
+# See also LICENSE.txt
+
+import unittest
+
+from Acquisition import aq_chain
+from DateTime import DateTime
+
+from zope.component import getUtility
+from zope.interface.verify import verifyObject
+
+from Products.Silva.testing import FunctionalLayer, Transaction
+from silva.core.interfaces import IGhostAsset
+from silva.core.services.interfaces import IMetadataService
+
+
+class GhostAssetTestCase(unittest.TestCase):
+    layer = FunctionalLayer
+
+    def setUp(self):
+        self.root = self.layer.get_application()
+        self.layer.login('editor')
+
+        with Transaction():
+            factory = self.root.manage_addProduct['Silva']
+            with self.layer.open_fixture('silva.png') as stream:
+                factory.manage_addImage('logo', 'Silva Logo', stream)
+
+        with Transaction():
+            metadata = getUtility(IMetadataService).getMetadata(self.root.logo)
+            metadata.setValues(
+                'silva-extra',
+                {'modificationtime': DateTime('2010-04-25T12:00:00Z')})
+
+        self.assertEqual(
+            self.root.logo.get_modification_datetime(),
+            DateTime('2010-04-25T12:00:00Z'))
+
+    def test_create_and_access(self):
+        """Create and test a Ghost Asset content type.
+        """
+        with Transaction():
+            factory = self.root.manage_addProduct['Silva']
+            factory.manage_addGhostAsset('ghost', None, haunted=self.root.logo)
+
+        self.assertIn('ghost', self.root.objectIds())
+        ghost = self.root._getOb('ghost')
+        self.assertTrue(verifyObject(IGhostAsset, ghost))
+        self.assertIn(ghost, self.root.get_non_publishables())
+        self.assertEqual(ghost.get_link_status(), None)
+        self.assertEqual(ghost.get_haunted(), self.root.logo)
+        self.assertEqual(ghost.get_filename(), 'logo.png')
+        self.assertEqual(ghost.get_mime_type(), 'image/png')
+        self.assertEqual(
+            ghost.get_file_size(),
+            self.root.logo.get_file_size())
+        self.assertEqual(
+            ghost.get_modification_datetime(),
+            self.root.logo.get_modification_datetime())
+        self.assertEqual(
+            aq_chain(ghost.get_haunted()),
+            aq_chain(self.root.logo))
+
+    def test_download(self):
+        """create and download a Ghost Asset content type.
+        """
+        with Transaction():
+            factory = self.root.manage_addProduct['Silva']
+            factory.manage_addGhostAsset('ghost', None, haunted=self.root.logo)
+
+        with self.layer.get_browser() as browser:
+            self.assertEqual(browser.open('/root/ghost'), 200)
+            self.assertEqual(
+                len(browser.contents),
+                self.root.logo.get_file_size())
+            self.assertEqual(
+                int(browser.headers['Content-Length']),
+                self.root.logo.get_file_size())
+            self.assertEqual(
+                browser.headers['Content-Type'],
+                'image/png')
+            self.assertEqual(
+                browser.headers['Content-Disposition'],
+                'inline;filename=logo.png')
+            self.assertEqual(
+                browser.headers['Last-Modified'],
+                'Sun, 25 Apr 2010 12:00:00 GMT')
+            self.assertIn(
+                browser.headers['Accept-Ranges'],
+                ('none', 'bytes'))
+
+    def test_head_request(self):
+        with Transaction():
+            factory = self.root.manage_addProduct['Silva']
+            factory.manage_addGhostAsset('ghost', None, haunted=self.root.logo)
+
+        with self.layer.get_browser() as browser:
+            self.assertEqual(browser.open('/root/ghost', method='HEAD'), 200)
+            self.assertEqual(
+                len(browser.contents),
+                0)
+            self.assertEqual(
+                int(browser.headers['Content-Length']),
+                self.root.logo.get_file_size())
+            self.assertEqual(
+                browser.headers['Content-Type'],
+                'image/png')
+            self.assertEqual(
+                browser.headers['Content-Disposition'],
+                'inline;filename=logo.png')
+            self.assertIn(
+                browser.headers['Accept-Ranges'],
+                ('none', 'bytes'))
+            self.assertEqual(
+                browser.headers['Last-Modified'],
+                'Sun, 25 Apr 2010 12:00:00 GMT')
+
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(GhostAssetTestCase))
+    return suite
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/tests/test_ghost_folder.py
--- a/Products/Silva/tests/test_ghost_folder.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/tests/test_ghost_folder.py	Fri Nov 08 16:36:46 2013 +0100
@@ -9,12 +9,12 @@
 
 from Acquisition import aq_chain
 from Products.Silva.testing import FunctionalLayer
-from Products.Silva.tests.mockers import IMockupAsset
+from Products.Silva.tests.mockers import IMockupNonPublishable
 from Products.SilvaMetadata.interfaces import IMetadataService, ReadOnlyError
 
 from silva.core.interfaces import IContainerManager, IPublicationWorkflow
-from silva.core.interfaces import IGhostFolder, IGhost
-from silva.core.interfaces import IPublication, IFolder
+from silva.core.interfaces import IGhost, IGhostAsset, IGhostVersion
+from silva.core.interfaces import IPublication, IFolder, IGhostFolder
 from silva.core.interfaces import errors
 from silva.core.references.interfaces import IReferenceService, IReferenceValue
 
@@ -46,7 +46,7 @@
         factory.manage_addMockupVersionedContent('document', 'Document')
         factory.manage_addFolder('folder', 'Folder')
 
-    def test_ghost_folder(self):
+    def test_get_folder(self):
         """Test a Ghost Folder haunting to a Folder.
         """
         factory = self.root.target.manage_addProduct['Silva']
@@ -454,16 +454,106 @@
         self.assertTrue('folder' in ghost.objectIds())
         self.assertFalse('results' in ghost.folder.objectIds())
 
-        ghost.haunt()
-        self.assertTrue('testing' in ghost.objectIds())
+        self.assertTrue(ghost.haunt())
+        self.assertIn('testing', ghost.objectIds())
         self.assertTrue(verifyObject(IGhost, ghost.testing))
-        self.assertTrue('folder' in ghost.objectIds())
-        self.assertTrue('results' in ghost.folder.objectIds())
+        self.assertTrue(verifyObject(IGhostVersion,
+                                     ghost.testing.get_viewable()))
+        self.assertEqual(
+            ghost.testing.get_viewable().get_haunted(),
+            self.root.folder.testing)
+        self.assertEqual(
+            aq_chain(ghost.testing.get_viewable().get_haunted()),
+            aq_chain(self.root.folder.testing))
+        self.assertIn('folder', ghost.objectIds())
+        self.assertIn('results', ghost.folder.objectIds())
         self.assertTrue(verifyObject(IGhostFolder, ghost.folder.results))
+        self.assertEqual(
+            ghost.folder.results.get_haunted(),
+            self.root.folder.folder.results)
+        self.assertEqual(
+            aq_chain(ghost.folder.results.get_haunted()),
+            aq_chain(self.root.folder.folder.results))
+
+    def test_ghost_haunt_add_non_publishables(self):
+        """Test modifications: adding new non publishables in the
+        target folder copy them in the ghost folder when it is
+        haunted.
+        """
+        factory = self.root.target.manage_addProduct['Silva']
+        factory.manage_addGhostFolder('ghost', None, haunted=self.root.folder)
+
+        ghost = self.root.target.ghost
+        factory = self.root.folder.manage_addProduct['Silva']
+        factory.manage_addMockupNonPublishable('stuff', 'Configuration stuff')
+        factory = self.root.folder.publication.manage_addProduct['Silva']
+        factory.manage_addMockupNonPublishable('results', 'Results set 1')
+
+        self.assertIn('publication', ghost.objectIds())
+        self.assertTrue(verifyObject(IGhostFolder, ghost.publication))
+        self.assertNotIn('data', ghost.objectIds())
+        self.assertNotIn('results', ghost.publication.objectIds())
+
+        self.assertTrue(ghost.haunt())
+        self.assertTrue('stuff' in ghost.objectIds())
+        self.assertTrue(verifyObject(IMockupNonPublishable, ghost.stuff))
+        self.assertIn('publication', ghost.objectIds())
+        self.assertTrue(verifyObject(IGhostFolder, ghost.publication))
+        self.assertIn('results', ghost.publication.objectIds())
+        self.assertTrue(verifyObject(IMockupNonPublishable,
+                                     ghost.publication.results))
+
+    def test_ghost_haunt_convert_assets_to_ghost_assets(self):
+        """Test update: old ghost folders might have copys of assets
+        instead of ghost assets poiting to them. In this case, if you
+        re-haunt the folder, they should be replaced with ghost
+        assets.
+        """
+        factory = self.root.target.manage_addProduct['Silva']
+        factory.manage_addGhostFolder('ghost', None, haunted=self.root.folder)
+
+        ghost = self.root.target.ghost
+        factory = self.root.folder.manage_addProduct['Silva']
+        factory.manage_addMockupAsset('data', 'Data set 1')
+        factory = self.root.folder.publication.manage_addProduct['Silva']
+        factory.manage_addMockupAsset('results', 'Results set 1')
+        factory.manage_addMockupAsset('analyze', 'Analyze set 1')
+
+        # Add assets with the same id in the ghost folder.
+        factory = ghost.manage_addProduct['Silva']
+        factory.manage_addMockupAsset('data', 'Data set 1')
+        factory = ghost.publication.manage_addProduct['Silva']
+        factory.manage_addMockupAsset('results', 'Results set 1')
+        factory.manage_addMockupNonPublishable('analyze', 'Analyze set 1')
+
+        # Now if we rehaunt the folder, they should be replaced by ghost assets.
+        self.assertTrue(ghost.haunt())
+        self.assertIn('data', ghost.objectIds())
+        self.assertTrue(verifyObject(IGhostAsset, ghost.data))
+        self.assertEqual(
+            ghost.data.get_haunted(),
+            self.root.folder.data)
+        self.assertEqual(
+            aq_chain(ghost.data.get_haunted()),
+            aq_chain(self.root.folder.data))
+        self.assertIn('publication', ghost.objectIds())
+        self.assertTrue(verifyObject(IGhostFolder, ghost.publication))
+        self.assertIn('results', ghost.publication.objectIds())
+        self.assertTrue(verifyObject(IGhostAsset, ghost.publication.results))
+        self.assertEqual(
+            ghost.publication.results.get_haunted(),
+            self.root.folder.publication.results)
+        self.assertEqual(
+            aq_chain(ghost.publication.results.get_haunted()),
+            aq_chain(self.root.folder.publication.results))
+        # Analyze was not an asset, it didn't get replaced
+        self.assertIn('analyze', ghost.publication.objectIds())
+        self.assertTrue(verifyObject(IMockupNonPublishable,
+                                     ghost.publication.analyze))
 
     def test_ghost_haunt_add_assets(self):
         """Test modifications: adding assets in the target creates new
-        assets in the ghost folder when the ghost folder is haunted.
+        ghost assets in the ghost folder when it is haunted.
         """
         factory = self.root.target.manage_addProduct['Silva']
         factory.manage_addGhostFolder('ghost', None, haunted=self.root.folder)
@@ -474,15 +564,30 @@
         factory = self.root.folder.publication.manage_addProduct['Silva']
         factory.manage_addMockupAsset('results', 'Results set 1')
 
-        self.assertFalse('data' in ghost.objectIds())
-        self.assertTrue('publication' in ghost.objectIds())
-        self.assertFalse('results' in ghost.publication.objectIds())
+        self.assertIn('publication', ghost.objectIds())
+        self.assertTrue(verifyObject(IGhostFolder, ghost.publication))
+        self.assertNotIn('data', ghost.objectIds())
+        self.assertNotIn('results', ghost.publication.objectIds())
 
-        ghost.haunt()
-        self.assertTrue('data' in ghost.objectIds())
-        self.assertTrue(verifyObject(IMockupAsset, ghost.data))
-        self.assertTrue('publication' in ghost.objectIds())
-        self.assertTrue(verifyObject(IMockupAsset, ghost.publication.results))
+        self.assertTrue(ghost.haunt())
+        self.assertIn('data', ghost.objectIds())
+        self.assertTrue(verifyObject(IGhostAsset, ghost.data))
+        self.assertEqual(
+            ghost.data.get_haunted(),
+            self.root.folder.data)
+        self.assertEqual(
+            aq_chain(ghost.data.get_haunted()),
+            aq_chain(self.root.folder.data))
+        self.assertIn('publication', ghost.objectIds())
+        self.assertTrue(verifyObject(IGhostFolder, ghost.publication))
+        self.assertIn('results', ghost.publication.objectIds())
+        self.assertTrue(verifyObject(IGhostAsset, ghost.publication.results))
+        self.assertEqual(
+            ghost.publication.results.get_haunted(),
+            self.root.folder.publication.results)
+        self.assertEqual(
+            aq_chain(ghost.publication.results.get_haunted()),
+            aq_chain(self.root.folder.publication.results))
 
     def test_ghost_haunt_remove_assets(self):
         """Test modifications: removing asssets in the target doesn't
@@ -496,18 +601,35 @@
         factory.manage_addGhostFolder('ghost', None, haunted=self.root.folder)
 
         ghost = self.root.target.ghost
+        self.assertIn('data', ghost.objectIds())
+        self.assertTrue(verifyObject(IGhostAsset, ghost.data))
+        self.assertEqual(
+            ghost.data.get_haunted(),
+            self.root.folder.data)
+        self.assertEqual(
+            aq_chain(ghost.data.get_haunted()),
+            aq_chain(self.root.folder.data))
+        self.assertIn('export', ghost.objectIds())
+        self.assertTrue(verifyObject(IGhostAsset, ghost.export))
+        self.assertEqual(
+            ghost.export.get_haunted(),
+            self.root.folder.export)
+        self.assertEqual(
+            aq_chain(ghost.export.get_haunted()),
+            aq_chain(self.root.folder.export))
+
         with IContainerManager(self.root.folder).deleter() as deleter:
             deleter(self.root.folder.data)
             deleter(self.root.folder.export)
 
-        self.assertTrue('data' in ghost.objectIds())
-        self.assertTrue(verifyObject(IMockupAsset, ghost.data))
-        self.assertTrue('export' in ghost.objectIds())
-        self.assertTrue(verifyObject(IMockupAsset, ghost.export))
+        # Ghost should have been auto-deleted
+        self.assertNotIn('data', ghost.objectIds())
+        self.assertNotIn('export', ghost.objectIds())
 
-        ghost.haunt()
-        self.assertFalse('data' in ghost.objectIds())
-        self.assertFalse('export' in ghost.objectIds())
+        # And they won't come back
+        self.assertTrue(ghost.haunt())
+        self.assertNotIn('data', ghost.objectIds())
+        self.assertNotIn('export', ghost.objectIds())
 
     def test_ghost_haunt_add_ghosts_for_ghosts(self):
         """Test modification: adding ghost to the target will create
@@ -523,8 +645,8 @@
         IPublicationWorkflow(self.root.folder.notes).publish()
         self.assertFalse('notes' in ghost.objectIds())
 
-        ghost.haunt()
-        self.assertTrue('notes' in ghost.objectIds())
+        self.assertTrue(ghost.haunt())
+        self.assertIn('notes', ghost.objectIds())
         self.assertTrue(verifyObject(IGhost, ghost.notes))
         version = ghost.notes.get_editable()
         self.assertIs(version, None)
@@ -567,10 +689,10 @@
 
         # Nothing should change if the folder is ghosted (broken
         # ghosts should not be created).
-        ghost.haunt()
-        self.assertTrue('notes' in self.root.folder.objectIds())
+        self.assertTrue(ghost.haunt())
+        self.assertIn('notes', self.root.folder.objectIds())
         self.assertEqual(self.root.folder.notes.get_haunted(), None)
-        self.assertFalse('notes' in ghost.objectIds())
+        self.assertNotIn('notes', ghost.objectIds())
 
     def test_ghost_haunt_remove_ghosted_content_for_ghosts(self):
         """Test modification: remove a target of a ghost that is
@@ -609,9 +731,9 @@
         self.assertEqual(version.get_haunted(), self.root.folder.document)
 
         # Haunting should remove the ghost since it does no longer exists
-        ghost.haunt()
-        self.assertFalse('notes' in self.root.folder.objectIds())
-        self.assertFalse('notes' in ghost.objectIds())
+        self.assertTrue(ghost.haunt())
+        self.assertNotIn('notes', self.root.folder.objectIds())
+        self.assertNotIn('notes', ghost.objectIds())
 
     def test_ghost_haunt_add_ghost_folders_for_ghost_folders(self):
         """Test modification: add a ghost folder in the target
@@ -627,10 +749,10 @@
         factory = self.root.folder.manage_addProduct['Silva']
         factory.manage_addGhostFolder(
             'backup', None, haunted=self.root.folder.publication)
-        self.assertFalse('backup' in ghost.objectIds())
+        self.assertNotIn('backup', ghost.objectIds())
 
-        ghost.haunt()
-        self.assertTrue('backup' in ghost.objectIds())
+        self.assertTrue(ghost.haunt())
+        self.assertIn('backup', ghost.objectIds())
         self.assertTrue(verifyObject(IGhostFolder, ghost.backup))
         self.assertEqual(ghost.backup.get_link_status(), None)
         self.assertEqual(
@@ -640,7 +762,7 @@
             aq_chain(ghost.backup.get_haunted()),
             aq_chain(self.root.folder.publication))
         # Content in this ghost folder have been ghosted
-        self.assertTrue('document' in ghost.backup.objectIds())
+        self.assertIn('document', ghost.backup.objectIds())
         self.assertTrue(verifyObject(IGhost, ghost.backup.document))
 
     def test_ghost_haunt_remove_ghost_folders_for_ghost_folders(self):
@@ -670,12 +792,12 @@
         self.assertFalse('backup' in ghost.objectIds())
 
         # Ghosting should not change anything
-        ghost.haunt()
-        self.assertTrue('backup' in self.root.folder.objectIds())
+        self.assertTrue(ghost.haunt())
+        self.assertIn('backup', self.root.folder.objectIds())
         self.assertEqual(
             self.root.folder.backup.get_link_status(),
             errors.EmptyInvalidTarget())
-        self.assertFalse('backup' in ghost.objectIds())
+        self.assertNotIn('backup', ghost.objectIds())
 
 
 def test_suite():
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/tests/test_image.py
--- a/Products/Silva/tests/test_image.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/tests/test_image.py	Fri Nov 08 16:36:46 2013 +0100
@@ -71,11 +71,14 @@
         self.assertEquals(image.get_mime_type(), 'image/tiff')
 
         # Image methods
-        self.assertTrue(image.tag() is not None)
+        self.assertTrue(image.get_html_tag() is not None)
         self.assertEquals(image.get_format(), 'TIFF')
         self.assertEquals(image.get_web_format(), 'JPEG')
         self.assertEquals(image.get_dimensions(), (960, 1280))
         self.assertEquals(str(image.get_orientation()), "portrait")
+        self.assertEquals(
+            image.get_download_url(),
+            'http://localhost/root/test_image')
 
         image.set_web_presentation_properties('JPEG', '100x100', '')
         self.assertEquals(image.get_web_format(), 'JPEG')
@@ -87,6 +90,9 @@
 
         data = image.get_image(hires=True, webformat=False)
         self.assertHashEqual(self.image_data, data)
+        self.assertEquals(
+            image.get_download_url(hires=True),
+            'http://localhost/root/test_image?hires')
 
         data = io.BytesIO(image.get_image(hires=True, webformat=True))
         pil_image = PILImage.open(data)
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/tests/test_xml_export.py
--- a/Products/Silva/tests/test_xml_export.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/tests/test_xml_export.py	Fri Nov 08 16:36:46 2013 +0100
@@ -7,7 +7,6 @@
 import re
 import io
 import unittest
-import transaction
 
 from zope.component import getAdapter, getUtility
 
@@ -220,7 +219,7 @@
         self.layer.login('author')
         exporter = self.assertExportEqual(
             self.root.folder,
-            'test_export_ghostfolder.silvaxml')
+            'test_export_ghost_folder.silvaxml')
         self.assertEqual(
             exporter.getZexpPaths(),
             [])
@@ -250,6 +249,31 @@
         self.layer.login('author')
         self.assertExportFail(self.root.ghost)
 
+    def test_ghost_asset(self):
+        """Export a file and a ghost asset.
+        """
+        with Transaction():
+            self.layer.login('editor')
+            factory = self.root.folder.manage_addProduct['Silva']
+            with self.layer.open_fixture('silva.png') as stream:
+                factory.manage_addFile('logo', 'Silva Logo', stream)
+            factory.manage_addGhostAsset(
+                'ghost', None, haunted=self.root.folder.logo)
+
+        self.layer.login('author')
+        exporter = self.assertExportEqual(
+            self.root.folder,
+            'test_export_ghost_asset.silvaxml')
+        self.assertEqual(
+            exporter.getZexpPaths(),
+            [])
+        self.assertEqual(
+            exporter.getAssetPaths(),
+            [(('', 'root', 'folder', 'logo'), '1')])
+        self.assertEqual(
+            exporter.getProblems(),
+            [])
+
     def test_link_relative(self):
         """Export a link with to an another Silva object.
         """
diff -r 419ecbf1c43e -r 13f06e1c78b7 Products/Silva/tests/test_xml_import.py
--- a/Products/Silva/tests/test_xml_import.py	Mon Nov 04 17:03:35 2013 +0100
+++ b/Products/Silva/tests/test_xml_import.py	Fri Nov 08 16:36:46 2013 +0100
@@ -700,6 +700,35 @@
         self.assertEqual(folder.objectIds(), ['zope2folder'])
         self.assertEqual(folder.zope2folder.meta_type, 'Folder')
 
+    def test_ghost_asset(self):
+        """Import a ghost asset pointing to an image.
+        """
+        importer = self.assertImportZip(
+            'test_import_ghost_asset.zip',
+            ['/root/folder',
+             '/root/folder/torvald',
+             '/root/folder/ghost'])
+        self.assertEqual(importer.getProblems(), [])
+        self.assertItemsEqual(
+            self.root.folder.objectIds(),
+            ['torvald',
+             'ghost'])
+
+        image = self.root.folder.torvald
+        self.assertTrue(interfaces.IImage.providedBy(image))
+        self.assertEqual(image.get_web_format(), 'GIF')
+        self.assertEqual(image.get_web_scale(), '200%')
+
+        ghost = self.root.folder.ghost
+        self.assertTrue(interfaces.IGhostAsset.providedBy(ghost))
+        self.assertEqual(ghost.get_haunted(), image)
+        self.assertEqual(aq_chain(ghost.get_haunted()), aq_chain(image))
+
+        get_metadata = self.metadata.getMetadata(image).get
+        self.assertEqual(
+            get_metadata('silva-extra', 'comment'),
+            u'Torvald public face.')
+
     def test_image(self):
         """Import an image.
         """