changeset 2298:df86cc3aee33

merged with main repo, 1 test f failing still
author Thomas Waldmann <tw AT waldmann-edv DOT de>
date Tue, 29 Oct 2013 11:39:57 +0100
parents a2de9d58adf3 (current diff) c5af50181353 (diff)
children 01dced4292d8
files MoinMoin/_tests/test_user.py MoinMoin/apps/frontend/views.py MoinMoin/config/default.py MoinMoin/constants/keys.py MoinMoin/forms.py MoinMoin/items/__init__.py MoinMoin/items/_tests/test_Item.py MoinMoin/storage/middleware/_tests/test_indexing.py MoinMoin/storage/middleware/_tests/test_validation.py MoinMoin/storage/middleware/indexing.py MoinMoin/storage/middleware/validation.py MoinMoin/templates/itemviews.html docs/admin/configure.rst docs/index.rst quickinstall quickinstall.bat wikiconfig.py
diffstat 50 files changed, 1833 insertions(+), 268 deletions(-) [+]
line wrap: on
line diff
--- a/MANIFEST.in	Mon Sep 23 20:45:13 2013 +0530
+++ b/MANIFEST.in	Tue Oct 29 11:39:57 2013 +0100
@@ -1,5 +1,5 @@
 include README.txt LICENSE.txt
-include quickinstall quickinstall.bat
+include quickinstall.py
 include wikiconfig.py
 include Makefile
 
@@ -24,10 +24,6 @@
 global-exclude *.rej
 
 prune docs/_build
-prune env
-prune env-pypy
-prune env-py26
-prune env-py27
 
 prune wiki/data/content
 prune wiki/data/userprofiles
--- a/MoinMoin/_tests/test_user.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/_tests/test_user.py	Tue Oct 29 11:39:57 2013 +0100
@@ -12,6 +12,8 @@
 from flask import g as flaskg
 
 from MoinMoin import user
+from MoinMoin.items import Item
+from MoinMoin.constants.keys import (ITEMID, NAME, NAMEPREFIX, NAMERE, NAMESPACE, TAGS)
 
 
 class TestSimple(object):
@@ -119,28 +121,36 @@
 
     # Subscriptions ---------------------------------------------------
 
-    def testSubscriptionSubscribedPage(self):
-        """ user: tests is_subscribed_to  """
-        pagename = u'HelpMiscellaneous'
-        name = u'__Jürgen Herman__'
-        password = name
-        self.createUser(name, password)
-        # Login - this should replace the old password in the user file
-        theUser = user.User(name=name, password=password)
-        theUser.subscribe(pagename)
-        assert theUser.is_subscribed_to([pagename])  # list(!) of pages to check
+    def test_subscriptions(self):
+        pagename = u"Foo:foo 123"
+        tagname = u"xxx"
+        regexp = r"\d+"
+        item = Item.create(pagename)
+        item._save({NAMESPACE: u"", TAGS: [tagname]})
+        item = Item.create(pagename)
+        meta = item.meta
 
-    def testSubscriptionSubPage(self):
-        """ user: tests is_subscribed_to on a subpage """
-        pagename = u'HelpMiscellaneous'
-        testPagename = u'HelpMiscellaneous/FrequentlyAskedQuestions'
-        name = u'__Jürgen Herman__'
+        name = u'bar'
         password = name
-        self.createUser(name, password)
-        # Login - this should replace the old password in the user file
-        theUser = user.User(name=name, password=password)
-        theUser.subscribe(pagename)
-        assert not theUser.is_subscribed_to([testPagename])  # list(!) of pages to check
+        email = "bar@example.org"
+        user.create_user(name, password, email)
+        the_user = user.User(name=name, password=password)
+        assert not the_user.is_subscribed_to(item)
+        the_user.subscribe(NAME, u"SomeOtherPageName", u"")
+        result = the_user.unsubscribe(NAME, u"OneMorePageName", u"")
+        assert result is False
+
+        subscriptions = [(ITEMID, meta[ITEMID], None),
+                         (NAME, pagename, meta[NAMESPACE]),
+                         (TAGS, tagname, meta[NAMESPACE]),
+                         (NAMEPREFIX, pagename[:4], meta[NAMESPACE]),
+                         (NAMERE, regexp, meta[NAMESPACE])]
+        for subscription in subscriptions:
+            keyword, value, namespace = subscription
+            the_user.subscribe(keyword, value, namespace)
+            assert the_user.is_subscribed_to(item)
+            the_user.unsubscribe(keyword, value, namespace, item)
+            assert not the_user.is_subscribed_to(item)
 
     # Bookmarks -------------------------------------------------------
 
--- a/MoinMoin/apps/frontend/_tests/test_frontend.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/apps/frontend/_tests/test_frontend.py	Tue Oct 29 11:39:57 2013 +0100
@@ -172,7 +172,7 @@
         self._test_view('frontend.quicklink_item', status='302 FOUND', viewopts=dict(item_name='DoesntExist'), data=['<!DOCTYPE HTML'])
 
     def test_subscribe_item(self):
-        self._test_view('frontend.subscribe_item', status='302 FOUND', viewopts=dict(item_name='DoesntExist'), data=['<!DOCTYPE HTML'])
+        self._test_view('frontend.subscribe_item', status='404 NOT FOUND', viewopts=dict(item_name='DoesntExist'))
 
     def test_register(self):
         self._test_view('frontend.register')
--- a/MoinMoin/apps/frontend/views.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/apps/frontend/views.py	Tue Oct 29 11:39:57 2013 +0100
@@ -46,9 +46,10 @@
 from MoinMoin.i18n import _, L_, N_
 from MoinMoin.themes import render_template, contenttype_to_class
 from MoinMoin.apps.frontend import frontend
-from MoinMoin.forms import (OptionalText, RequiredText, URL, YourOpenID, YourEmail, RequiredPassword, Checkbox,
-                            InlineCheckbox, Select, Names, Tags, Natural, Hidden, MultiSelect, Enum, validate_name,
-                            NameNotValidError)
+from MoinMoin.forms import (OptionalText, RequiredText, URL, YourOpenID, YourEmail,
+                            RequiredPassword, Checkbox, InlineCheckbox, Select, Names,
+                            Tags, Natural, Hidden, MultiSelect, Enum, Subscriptions,
+                            validate_name, NameNotValidError)
 from MoinMoin.items import BaseChangeForm, Item, NonExistent, NameNotUniqueError, FieldNotUniqueError
 from MoinMoin.items.content import content_registry
 from MoinMoin import user, util
@@ -468,8 +469,7 @@
 @frontend.route('/+content/<itemname:item_name>', defaults=dict(rev=CURRENT))
 def content_item(item_name, rev):
     """ same as show_item, but we only show the content """
-    item_displayed.send(app._get_current_object(),
-                        item_name=item_name)
+    item_displayed.send(app, item_name=item_name)
     try:
         item = Item.create(item_name, rev_id=rev)
     except AccessDenied:
@@ -792,7 +792,7 @@
         item = Item.create(item_name)
         revid, size = item.modify({}, data, contenttype_guessed=contenttype)
         item_modified.send(app._get_current_object(),
-                           item_name=item_name)
+                           item_name=item_name, action=ACTION_SAVE)
         return jsonify(name=subitem_name,
                        size=size,
                        url=url_for('.show_item', item_name=item_name, rev=revid),
@@ -952,8 +952,13 @@
     :type item_name: unicode
     :returns: a page with all the items which link or transclude item_name
     """
+    try:
+        item = Item.create(item_name)
+    except AccessDenied:
+        abort(403)
     refs_here = _backrefs(item_name)
     return render_template('link_list_item_panel.html',
+                           item=item,
                            item_name=item_name,
                            fqname=split_fqname(item_name),
                            headline=_(u"Items which refer to '%(item_name)s'", item_name=item_name),
@@ -1130,18 +1135,25 @@
     u = flaskg.user
     cfg = app.cfg
     msg = None
+    try:
+        item = Item.create(item_name)
+    except AccessDenied:
+        abort(403)
+    if isinstance(item, NonExistent):
+        abort(404)
     if not u.valid:
         msg = _("You must login to use this action: %(action)s.", action="subscribe/unsubscribe"), "error"
     elif not u.may.read(item_name):
         msg = _("You are not allowed to subscribe to an item you may not read."), "error"
-    elif u.is_subscribed_to([item_name]):
+    elif u.is_subscribed_to(item):
         # Try to unsubscribe
-        if not u.unsubscribe(item_name):
-            msg = _("Can't remove regular expression subscription!") + u' ' + \
-                _("Edit the subscription regular expressions in your settings."), "error"
+        if not u.unsubscribe(ITEMID, item.meta[ITEMID]):
+            msg = _(
+                "Can't remove the subscription! You are subscribed to this page in some other way.") + u' ' + _(
+                "Please edit the subscription in your settings."), "error"
     else:
         # Try to subscribe
-        if not u.subscribe(item_name):
+        if not u.subscribe(ITEMID, item.meta[ITEMID]):
             msg = _('You could not get subscribed to this item.'), "error"
     if msg:
         flash(*msg)
@@ -1564,6 +1576,12 @@
     submit_label = L_('Save')
 
 
+class UserSettingsSubscriptionsForm(Form):
+    name = 'usersettings_subscriptions'
+    subscriptions = Subscriptions
+    submit_label = L_('Save')
+
+
 @frontend.route('/+usersettings', methods=['GET', 'POST'])
 def usersettings():
     # TODO use ?next=next_location check if target is in the wiki and not outside domain
@@ -1603,6 +1621,7 @@
         ui=UserSettingsUIForm,
         navigation=UserSettingsNavigationForm,
         options=UserSettingsOptionsForm,
+        subscriptions=UserSettingsSubscriptionsForm,
     )
     forms = dict()
 
@@ -1850,6 +1869,10 @@
     """
     list similar item names
     """
+    try:
+        item = Item.create(item_name)
+    except AccessDenied:
+        abort(403)
     fq_name = split_fqname(item_name)
     start, end, matches = findMatches(fq_name)
     keys = sorted(matches.keys())
@@ -1867,6 +1890,7 @@
                 fq_names.append(fqname)
     return render_template("link_list_item_panel.html",
                            headline=_("Items with similar names to '%(item_name)s'", item_name=item_name),
+                           item=item,
                            item_name=item_name,  # XXX no item
                            fqname=split_fqname(item_name),
                            fq_names=fq_names)
--- a/MoinMoin/config/default.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/config/default.py	Tue Oct 29 11:39:57 2013 +0100
@@ -491,7 +491,7 @@
             DISABLED: False,
             BOOKMARKS: {},
             QUICKLINKS: [],
-            SUBSCRIBED_ITEMS: [],
+            SUBSCRIPTIONS: [],
             EMAIL_SUBSCRIBED_EVENTS: [
                 # XXX PageChangedEvent.__name__
                 # XXX PageRenamedEvent.__name__
--- a/MoinMoin/constants/keys.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/constants/keys.py	Tue Oct 29 11:39:57 2013 +0100
@@ -22,6 +22,7 @@
 # needs more precise name / use case:
 SOMEDICT = u"somedict"
 
+# TODO review plural constants
 CONTENTTYPE = u"contenttype"
 ITEMTYPE = u"itemtype"
 SIZE = u"size"
@@ -67,7 +68,9 @@
 LOCALE = u"locale"
 TIMEZONE = u"timezone"
 ENC_PASSWORD = u"enc_password"
-SUBSCRIBED_ITEMS = u"subscribed_items"
+SUBSCRIPTIONS = u"subscriptions"
+SUBSCRIPTION_IDS = u"subscription_ids"
+SUBSCRIPTION_PATTERNS = u"subscription_patterns"
 BOOKMARKS = u"bookmarks"
 QUICKLINKS = u"quicklinks"
 SESSION_KEY = u"session_key"
@@ -84,6 +87,8 @@
 EMAIL_SUBSCRIBED_EVENTS = u"email_subscribed_events"
 DISABLED = u"disabled"
 EMAIL_UNVALIDATED = u"email_unvalidated"
+NAMERE = u"namere"
+NAMEPREFIX = u"nameprefix"
 
 # in which backend is some revision stored?
 BACKENDNAME = u"backendname"
@@ -92,8 +97,7 @@
     # User objects proxy these attributes of the UserProfile objects:
     NAME, DISABLED, ITEMID, DISPLAY_NAME, ENC_PASSWORD, EMAIL, OPENID,
     MAILTO_AUTHOR, SHOW_COMMENTS, RESULTS_PER_PAGE, EDIT_ON_DOUBLECLICK, SCROLL_PAGE_AFTER_EDIT,
-    EDIT_ROWS, THEME_NAME, LOCALE, TIMEZONE, SUBSCRIBED_ITEMS, QUICKLINKS,
-    CSS_URL,
+    EDIT_ROWS, THEME_NAME, LOCALE, TIMEZONE, SUBSCRIPTIONS, QUICKLINKS, CSS_URL,
 ]
 
 # keys for blog homepages
@@ -116,6 +120,16 @@
 LATEST_REVS = 'latest_revs'
 ALL_REVS = 'all_revs'
 
+# values for ACTION key
+ACTION_SAVE = u"SAVE"
+ACTION_REVERT = u"REVERT"
+ACTION_TRASH = u"TRASH"
+ACTION_COPY = u"COPY"
+ACTION_RENAME = u"RENAME"
+
+# defaul LOCALE key value
+DEFAULT_LOCALE = u"en"
+
 # key for composite name
 FQNAME = u'fqname'
 # Values that FIELD can take in the composite name: [NAMESPACE/][@FIELD/]NAME
--- a/MoinMoin/forms.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/forms.py	Tue Oct 29 11:39:57 2013 +0100
@@ -32,6 +32,9 @@
 from MoinMoin.util.forms import FileStorage
 from MoinMoin.storage.middleware.validation import uuid_validator
 
+COLS = 60
+ROWS = 10
+
 
 class Enum(BaseEnum):
     """
@@ -215,12 +218,51 @@
     def u(self):
         return self.separator.join(child.u for child in self)
 
+
+class SubscriptionsJoinedString(JoinedString):
+    """ A JoinedString that offers the list of children as value property and also
+    appends the name of the item to the end of ITEMID subscriptions.
+    """
+    @property
+    def value(self):
+        subscriptions = []
+        for child in self:
+            if child.value.startswith(ITEMID):
+                value = re.sub(r"\(.*\)", "", child.value)
+            else:
+                value = child.value
+            subscriptions.append(value)
+        return subscriptions
+
+    @property
+    def u(self):
+        subscriptions = []
+        for child in self:
+            if child.u.startswith(ITEMID):
+                value = re.sub(r"\(.*\)", "", child.u)
+                item = flaskg.storage.document(**{ITEMID: value.split(":")[1]})
+                try:
+                    name_ = item.meta['name'][0]
+                except IndexError:
+                    name_ = "This item doesn't exist"
+                value = "{0} ({1})".format(value, name_)
+            else:
+                value = child.u
+            subscriptions.append(value)
+        return self.separator.join(subscriptions)
+
+
 Tags = MyJoinedString.of(String).with_properties(widget=WIDGET_TEXT).using(
     label=L_('Tags'), optional=True, separator=', ', separator_regex=re.compile(r'\s*,\s*'))
 
 Names = MyJoinedString.of(String).with_properties(widget=WIDGET_TEXT).using(
     label=L_('Names'), optional=True, separator=', ', separator_regex=re.compile(r'\s*,\s*')).validated_by(ValidName())
 
+Subscriptions = SubscriptionsJoinedString.of(String).with_properties(
+    widget=WIDGET_MULTILINE_TEXT, rows=ROWS, cols=COLS).using(
+    label=L_('Subscriptions'), optional=True, separator='\n',
+    separator_regex=re.compile(r'[\r\n]+'))
+
 Search = Text.using(default=u'', optional=True).with_properties(widget=WIDGET_SEARCH, placeholder=L_("Search Query"))
 
 _Integer = Integer.validated_by(Converted())
--- a/MoinMoin/i18n/__init__.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/i18n/__init__.py	Tue Oct 29 11:39:57 2013 +0100
@@ -15,8 +15,9 @@
 
 
 from babel import Locale
+from contextlib import contextmanager
 
-from flask import current_app, request
+from flask import current_app, request, _request_ctx_stack
 from flask import g as flaskg
 from flask.ext.babel import Babel, gettext, ngettext, lazy_gettext
 
@@ -66,3 +67,37 @@
     u = getattr(flaskg, 'user', None)
     if u and u.timezone is not None:
         return u.timezone
+
+
+# Original source is a patch to Flask Babel
+# https://github.com/lalinsky/flask-babel/commit/09ee1702c7129598bb202aa40a0e2e19f5414c24
+@contextmanager
+def force_locale(locale):
+    """Temporarily overrides the currently selected locale. Sometimes
+    it is useful to switch the current locale to different one, do
+    some tasks and then revert back to the original one. For example,
+    if the user uses German on the web site, but you want to send
+    them an email in English, you can use this function as a context
+    manager::
+
+        with force_locale('en_US'):
+            send_email(gettext('Hello!'), ...)
+    """
+    ctx = _request_ctx_stack.top
+    if ctx is None:
+        yield
+        return
+    babel = ctx.app.extensions['babel']
+    orig_locale_selector_func = babel.locale_selector_func
+    orig_attrs = {}
+    for key in ('babel_translations', 'babel_locale'):
+        orig_attrs[key] = getattr(ctx, key, None)
+    try:
+        babel.locale_selector_func = lambda: locale
+        for key in orig_attrs:
+            setattr(ctx, key, None)
+        yield
+    finally:
+        babel.locale_selector_func = orig_locale_selector_func
+        for key, value in orig_attrs.iteritems():
+            setattr(ctx, key, value)
--- a/MoinMoin/i18n/_tests/test_i18n.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/i18n/_tests/test_i18n.py	Tue Oct 29 11:39:57 2013 +0100
@@ -5,8 +5,12 @@
 Test for i18n
 """
 
-from MoinMoin.i18n import get_locale, get_timezone
+import pytest
 
+from flask import Flask
+from flask.ext import babel
+
+from MoinMoin.i18n import get_locale, get_timezone, force_locale
 from MoinMoin.i18n import _, L_, N_
 
 
@@ -32,3 +36,19 @@
     assert result1 == 'text1'
     result2 = N_('text1', 'text2', 2)
     assert result2 == 'text2'
+
+
+def test_force_locale():
+    pytest.skip("This test needs to be run with --assert=reinterp or --assert=plain flag")
+    app = Flask(__name__)
+    b = babel.Babel(app)
+
+    @b.localeselector
+    def select_locale():
+        return 'de_DE'
+
+    with app.test_request_context():
+        assert str(babel.get_locale()) == 'de_DE'
+        with force_locale('en_US'):
+            assert str(babel.get_locale()) == 'en_US'
+        assert str(babel.get_locale()) == 'de_DE'
--- a/MoinMoin/items/__init__.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/items/__init__.py	Tue Oct 29 11:39:57 2013 +0100
@@ -50,12 +50,14 @@
     CONTENTTYPE, SIZE, ACTION, ADDRESS, HOSTNAME, USERID, COMMENT,
     HASH_ALGORITHM, ITEMID, REVID, DATAID, CURRENT, PARENTID, NAMESPACE,
     UFIELDS_TYPELIST, UFIELDS, TRASH,
+    ACTION_SAVE, ACTION_REVERT, ACTION_TRASH, ACTION_RENAME
 )
 from MoinMoin.constants.namespaces import NAMESPACE_ALL
 from MoinMoin.constants.contenttypes import CHARSET, CONTENTTYPE_NONEXISTENT
 from MoinMoin.constants.itemtypes import (
     ITEMTYPE_NONEXISTENT, ITEMTYPE_USERPROFILE, ITEMTYPE_DEFAULT,
 )
+from MoinMoin.util.notifications import DESTROY_REV, DESTROY_ALL
 
 from .content import content_registry, Content, NonExistentContent, Draw
 
@@ -428,18 +430,21 @@
         fqname = CompositeName(self.fqname.namespace, self.fqname.field, name)
         if flaskg.storage.get_item(**fqname.query):
             raise NameNotUniqueError(L_("An item named %s already exists in the namespace %s." % (name, fqname.namespace)))
-        return self._rename(name, comment, action=u'RENAME')
+        return self._rename(name, comment, action=ACTION_RENAME)
 
     def delete(self, comment=u''):
         """
         delete this item (remove current name from NAME list)
         """
-        return self._rename(None, comment, action=u'TRASH', delete=True)
+        return self._rename(None, comment, action=ACTION_TRASH, delete=True)
 
     def revert(self, comment=u''):
-        return self._save(self.meta, self.content.data, action=u'REVERT', comment=comment)
+        return self._save(self.meta, self.content.data, action=ACTION_REVERT, comment=comment)
 
     def destroy(self, comment=u'', destroy_item=False):
+        action = DESTROY_ALL if destroy_item else DESTROY_REV
+        item_modified.send(app, item_name=self.name, action=action, meta=self.meta,
+                           content=self.rev.data, comment=comment)
         # called from destroy UI/POST
         if destroy_item:
             # destroy complete item with all revisions, metadata, etc.
@@ -509,7 +514,7 @@
         """
         raise NotImplementedError
 
-    def _save(self, meta, data=None, name=None, action=u'SAVE', contenttype_guessed=None, comment=None,
+    def _save(self, meta, data=None, name=None, action=ACTION_SAVE, contenttype_guessed=None, comment=None,
               overwrite=False, delete=False):
         backend = flaskg.storage
         storage_item = backend.get_item(**self.fqname.query)
@@ -581,7 +586,11 @@
                                              contenttype_guessed=contenttype_guessed,
                                              return_rev=True,
                                              )
-        item_modified.send(app._get_current_object(), item_name=name)
+        # XXX TODO name might be None here (we have a failing unit test)
+        # maybe this needs to be changed so a fqname is used instead of
+        # a simple name
+        assert name is not None  # fail early
+        item_modified.send(app, item_name=name, action=action)
         return newrev.revid, newrev.meta[SIZE]
 
     @property
@@ -754,6 +763,7 @@
         rev_ids = []
         item_templates = self.content.get_templates(self.contenttype)
         return render_template('modify_select_template.html',
+                               item=self,
                                item_name=self.name,
                                fqname=self.fqname,
                                itemtype=self.itemtype,
@@ -826,6 +836,7 @@
         return render_template(self.modify_template,
                                fqname=self.fqname,
                                item_name=self.name,
+                               item=self,
                                rows_meta=str(ROWS_META), cols=str(COLS),
                                form=form,
                                search_form=None,
@@ -877,6 +888,7 @@
 
     def _select_itemtype(self):
         return render_template('modify_select_itemtype.html',
+                               item=self,
                                item_name=self.name,
                                fqname=self.fqname,
                                itemtypes=item_registry.shown_entries,
--- a/MoinMoin/items/_tests/test_Content.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/items/_tests/test_Content.py	Tue Oct 29 11:39:57 2013 +0100
@@ -8,6 +8,7 @@
 """
 
 import pytest
+from io import BytesIO
 
 from flask import Markup
 
@@ -232,14 +233,15 @@
         item = Item.create(item_name)
         contenttype = u'text/plain;charset=utf-8'
         meta = {CONTENTTYPE: contenttype}
-        item._save(meta)
+        data1 = "old_data"
+        item._save(meta, data1)
         item1 = Item.create(item_name)
-        data = 'test_data'
+        data2 = 'new_data'
         comment = u'next revision'
-        item1._save(meta, data, comment=comment)
+        item1._save(meta, data2, comment=comment)
         item2 = Item.create(item_name)
         result = Text._render_data_diff_text(item1.content, item1.rev, item2.rev)
-        expected = u'- \n+ test_data'
+        expected = u'- old_data\n+ new_data'
         assert result == expected
         assert item2.content.data == ''
 
@@ -258,5 +260,39 @@
         assert u'<pre class="highlight">test_data\n' in result
         assert item2.content.data == ''
 
+    def test__get_data_diff_text(self):
+        item_name = u'Text_Item'
+        item = Item.create(item_name)
+        contenttypes = dict(texttypes=[u'text/plain;charset=utf-8',
+                                       u'text/x-markdown;charset=utf-8', ],
+                            othertypes=[u'image/png', u'audio/wave',
+                                        u'video/ogg',
+                                        u'application/x-svgdraw',
+                                        u'application/octet-stream', ])
+        for key in contenttypes:
+            for contenttype in contenttypes[key]:
+                meta = {CONTENTTYPE: contenttype}
+                item._save(meta)
+                item_ = Item.create(item_name)
+                oldfile = BytesIO("x")
+                newfile = BytesIO("xx")
+                difflines = item_.content._get_data_diff_text(oldfile, newfile)
+                if key == 'texttypes':
+                    assert difflines == ['- x', '+ xx']
+                else:
+                    assert difflines == []
+
+    def test__get_data_diff_html(self):
+        item_name = u"Test_Item"
+        item = Item.create(item_name)
+        contenttype = u'text/plain;charset=utf-8'
+        meta = {CONTENTTYPE: contenttype}
+        item._save(meta)
+        item_ = Item.create(item_name)
+        oldfile = BytesIO("")
+        newfile = BytesIO("x")
+        difflines = item_.content._get_data_diff_html(oldfile, newfile)
+        assert difflines == [(1, Markup(u''), 1, Markup(u'<span>x</span>'))]
+
 
 coverage_modules = ['MoinMoin.items.content']
--- a/MoinMoin/items/_tests/test_Item.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/items/_tests/test_Item.py	Tue Oct 29 11:39:57 2013 +0100
@@ -13,7 +13,9 @@
 from MoinMoin._tests import become_trusted, update_item
 from MoinMoin.items import Item, NonExistent, IndexEntry, MixedIndexEntry
 from MoinMoin.util.interwiki import CompositeName
-from MoinMoin.constants.keys import ITEMTYPE, CONTENTTYPE, NAME, NAME_OLD, COMMENT, ACTION, ADDRESS, TRASH, ITEMID, NAME_EXACT
+from MoinMoin.constants.keys import (ITEMTYPE, CONTENTTYPE, NAME, NAME_OLD, COMMENT,
+                                     ADDRESS, TRASH, ITEMID, NAME_EXACT,
+                                     ACTION, ACTION_REVERT)
 from MoinMoin.constants.namespaces import NAMESPACE_DEFAULT
 from MoinMoin.constants.contenttypes import CONTENTTYPE_NONEXISTENT
 from MoinMoin.constants.itemtypes import ITEMTYPE_NONEXISTENT
@@ -331,7 +333,7 @@
         item = Item.create(name)
         item.revert(u'revert')
         item = Item.create(name)
-        assert item.meta[ACTION] == u'REVERT'
+        assert item.meta[ACTION] == ACTION_REVERT
 
     def test_modify(self):
         name = u'Test_Item'
--- a/MoinMoin/items/content.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/items/content.py	Tue Oct 29 11:39:57 2013 +0100
@@ -60,6 +60,8 @@
 from MoinMoin.util.mime import Type, type_moin_document
 from MoinMoin.util.tree import moin_page, html, xlink, docbook
 from MoinMoin.util.iri import Iri
+from MoinMoin.util.diff_text import diff as text_diff
+from MoinMoin.util.diff_html import diff as html_diff
 from MoinMoin.util.crypto import cache_key
 from MoinMoin.util.clock import timed
 from MoinMoin.forms import File
@@ -67,7 +69,8 @@
     GROUP_MARKUP_TEXT, GROUP_OTHER_TEXT, GROUP_IMAGE, GROUP_AUDIO, GROUP_VIDEO,
     GROUP_DRAWING, GROUP_OTHER, CONTENTTYPE_NONEXISTENT, CHARSET
 )
-from MoinMoin.constants.keys import NAME_EXACT, WIKINAME, CONTENTTYPE, SIZE, TAGS, HASH_ALGORITHM
+from MoinMoin.constants.keys import (NAME_EXACT, WIKINAME, CONTENTTYPE, SIZE, TAGS,
+                                     HASH_ALGORITHM, ACTION_SAVE)
 
 
 COLS = 80
@@ -270,6 +273,15 @@
         # override this in child classes
         return ''
 
+    def _get_data_diff_text(self, oldfile, newfile):
+        """ Get the text diff of 2 versions of file contents
+
+        :param oldfile: file that contains old content data (bytes)
+        :param newfile: file that contains new content data (bytes)
+        :return: list of diff lines in a unified format without trailing linefeeds
+        """
+        return []
+
     def get_templates(self, contenttype=None):
         """ create a list of templates (for some specific contenttype) """
         terms = [Term(WIKINAME, app.cfg.interwikiname), Term(TAGS, u'template')]
@@ -472,7 +484,7 @@
             # everything we expected has been added to the tar file, save the container as revision
             meta = {CONTENTTYPE: self.contenttype}
             data = open(temp_fname, 'rb')
-            self.item._save(meta, data, name=self.name, action=u'SAVE', comment='')
+            self.item._save(meta, data, name=self.name, action=ACTION_SAVE, comment='')
             data.close()
             os.remove(temp_fname)
 
@@ -839,12 +851,15 @@
         """ convert data from storage format to memory format """
         return data.decode(CHARSET).replace(u'\r\n', u'\n')
 
-    def _get_data_diff_html(self, oldrev, newrev, template):
-        from MoinMoin.util.diff_html import diff
-        old_text = self.data_storage_to_internal(oldrev.data.read())
-        new_text = self.data_storage_to_internal(newrev.data.read())
-        storage_item = flaskg.storage[self.name]
-        diffs = [(d[0], Markup(d[1]), d[2], Markup(d[3])) for d in diff(old_text, new_text)]
+    def _render_data_diff_html(self, oldrev, newrev, template):
+        """ Render HTML formatted meta and content diff of 2 revisions
+
+        :param oldrev: old revision object
+        :param newrev: new revision object
+        :param template: name of the template to be rendered
+        :return: HTML data with meta and content diff
+        """
+        diffs = self._get_data_diff_html(oldrev.data, newrev.data)
         return render_template(template,
                                item_name=self.name,
                                oldrev=oldrev,
@@ -852,18 +867,44 @@
                                diffs=diffs,
                                )
 
+    def _get_data_diff_html(self, oldfile, newfile):
+        """ Get the HTML diff of 2 versions of file contents
+
+        :param oldfile: file that contains old content data (bytes)
+        :param newfile: file that contains new content data (bytes)
+        :return: list of tuples of the format (left lineno, deleted Markup content,
+                 right lineno, added Markup content)
+        """
+        old_text = self.data_storage_to_internal(oldfile.read())
+        new_text = self.data_storage_to_internal(newfile.read())
+        return [(d[0], Markup(d[1]), d[2], Markup(d[3])) for d in html_diff(old_text, new_text)]
+
+    def _get_data_diff_text(self, oldfile, newfile):
+        """ Get the text diff of 2 versions of file contents
+
+        :param oldfile: file that contains old content data (bytes)
+        :param newfile: file that contains new content data (bytes)
+        :return: list of diff lines in a unified format without trailing linefeeds
+        """
+        old_text = self.data_storage_to_internal(oldfile.read())
+        new_text = self.data_storage_to_internal(newfile.read())
+        return text_diff(old_text.splitlines(), new_text.splitlines())
+
     def _render_data_diff_atom(self, oldrev, newrev):
         """ renders diff in HTML for atom feed """
-        return self._get_data_diff_html(oldrev, newrev, 'diff_text_atom.html')
+        return self._render_data_diff_html(oldrev, newrev, 'diff_text_atom.html')
 
     def _render_data_diff(self, oldrev, newrev):
-        return self._get_data_diff_html(oldrev, newrev, 'diff_text.html')
+        return self._render_data_diff_html(oldrev, newrev, 'diff_text.html')
 
     def _render_data_diff_text(self, oldrev, newrev):
-        from MoinMoin.util import diff_text
-        oldlines = self.data_storage_to_internal(oldrev.data.read()).split('\n')
-        newlines = self.data_storage_to_internal(newrev.data.read()).split('\n')
-        difflines = diff_text.diff(oldlines, newlines)
+        """ Render text diff of 2 revisions' contents
+
+        :param oldrev: old revision object
+        :param newrev: new revision object
+        :return: text data of a content diff
+        """
+        difflines = self._get_data_diff_text(oldrev.data, newrev.data)
         return '\n'.join(difflines)
 
     _render_data_diff_raw = _render_data_diff
--- a/MoinMoin/items/ticket.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/items/ticket.py	Tue Oct 29 11:39:57 2013 +0100
@@ -22,7 +22,8 @@
 from MoinMoin.forms import (Form, OptionalText, OptionalMultilineText, SmallNatural, Tags,
                             Reference, BackReference, SelectSubmit)
 from MoinMoin.storage.middleware.protecting import AccessDenied
-from MoinMoin.constants.keys import ITEMTYPE, CONTENTTYPE, ITEMID, CURRENT, SUPERSEDED_BY, DEPENDS_ON, SUBSCRIBED_ITEMS
+from MoinMoin.constants.keys import (ITEMTYPE, CONTENTTYPE, ITEMID, CURRENT,
+                                     SUPERSEDED_BY, SUBSCRIPTIONS, DEPENDS_ON)
 from MoinMoin.constants.contenttypes import CONTENTTYPE_USER
 from MoinMoin.items import Item, Contentful, register, BaseModifyForm
 from MoinMoin.items.content import NonExistentContent
@@ -59,7 +60,7 @@
         id_ = item.meta[ITEMID]
         self['supersedes'].set(Term(SUPERSEDED_BY, id_))
         self['required_by'].set(Term(DEPENDS_ON, id_))
-        self['subscribers'].set(Term(SUBSCRIBED_ITEMS, id_))
+        self['subscribers'].set(Term(SUBSCRIPTIONS, id_))
 
 
 class TicketForm(BaseModifyForm):
--- a/MoinMoin/log.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/log.py	Tue Oct 29 11:39:57 2013 +0100
@@ -1,7 +1,6 @@
 # Copyright: 2008 MoinMoin:ThomasWaldmann
 # Copyright: 2007 MoinMoin:JohannesBerg
 # License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
-
 """
     MoinMoin - init "logging" system
 
@@ -70,14 +69,14 @@
 keys=root
 
 [handlers]
-keys=stderr
+keys=stderr,email
 
 [formatters]
 keys=default
 
 [logger_root]
 level=%(loglevel)s
-handlers=stderr
+handlers=stderr,email
 
 [handler_stderr]
 class=StreamHandler
@@ -85,6 +84,12 @@
 formatter=default
 args=(sys.stderr, )
 
+[handler_email]
+class=MoinMoin.log.EmailHandler
+level=ERROR
+formatter=default
+args=()
+
 [formatter_default]
 format=%(asctime)s %(levelname)s %(name)s:%(lineno)d %(message)s
 datefmt=
@@ -98,6 +103,7 @@
 
 configured = False
 fallback_config = False
+in_email_handler = False
 
 import warnings
 
@@ -170,3 +176,44 @@
         if isinstance(levelnumber, int):  # that list has also the reverse mapping...
             setattr(logger, levelname, levelnumber)
     return logger
+
+
+class EmailHandler(logging.Handler):
+    """ A custom handler class which sends email for each logging event using
+    wiki mail configuration
+    """
+    def __init__(self, toaddrs=[], subject=u''):
+        """ Initialize the handler
+
+        @param toaddrs: address or a list of email addresses whom to send email
+        @param subject: unicode email's subject
+        """
+        logging.Handler.__init__(self)
+        if isinstance(toaddrs, basestring):
+            toaddrs = [toaddrs]
+        self.toaddrs = toaddrs
+        self.subject = subject
+
+    def emit(self, record):
+        """ Emit a record.
+
+        Send the record to the specified addresses
+        """
+        # the app config is accessible after logging is initialized, so set the
+        # arguments and make the decision to send mail or not here
+        from flask import current_app as app
+        if not app.cfg.email_tracebacks:
+            return
+
+        global in_email_handler
+        if in_email_handler:
+            return
+        in_email_handler = True
+        toaddrs = self.toaddrs if self.toaddrs else app.cfg.admin_emails
+        log_level = logging.getLevelName(self.level)
+        subject = self.subject if self.subject else u'[{0}][{1}] Log message'.format(
+            app.cfg.sitename, log_level)
+        msg = self.format(record)
+        from MoinMoin.mail.sendmail import sendmail
+        sendmail(subject, msg, to=toaddrs)
+        in_email_handler = False
--- a/MoinMoin/mail/sendmail.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/mail/sendmail.py	Tue Oct 29 11:39:57 2013 +0100
@@ -59,7 +59,7 @@
         return str(address)
 
 
-def sendmail(subject, text, to=None, cc=None, bcc=None, mail_from=None):
+def sendmail(subject, text, to=None, cc=None, bcc=None, mail_from=None, html=None):
     """ Create and send a text/plain message
 
     Return a tuple of success or error indicator and message.
@@ -76,6 +76,8 @@
     :type bcc: list
     :param mail_from: override default mail_from
     :type mail_from: unicode
+    :param html: html email body text
+    :type html: unicode
 
     :rtype: tuple
     :returns: (is_ok, Description of error or OK message)
@@ -83,6 +85,8 @@
     import smtplib
     import socket
     from email.message import Message
+    from email.mime.multipart import MIMEMultipart
+    from email.mime.text import MIMEText
     from email.charset import Charset, QP
     from email.utils import formatdate, make_msgid
 
@@ -107,18 +111,28 @@
     # Create a message using CHARSET and quoted printable
     # encoding, which should be supported better by mail clients.
     # TODO: check if its really works better for major mail clients
-    msg = Message()
+    text_msg = Message()
     charset = Charset(CHARSET)
     charset.header_encoding = QP
     charset.body_encoding = QP
-    msg.set_charset(charset)
+    text_msg.set_charset(charset)
 
     # work around a bug in python 2.4.3 and above:
-    msg.set_payload('=')
-    if msg.as_string().endswith('='):
+    text_msg.set_payload('=')
+    if text_msg.as_string().endswith('='):
         text = charset.body_encode(text)
 
-    msg.set_payload(text)
+    text_msg.set_payload(text)
+
+    if html:
+        msg = MIMEMultipart('alternative')
+        msg.attach(text_msg)
+        html = html.encode(CHARSET)
+        html_msg = MIMEText(html, 'html')
+        html_msg.set_charset(charset)
+        msg.attach(html_msg)
+    else:
+        msg = text_msg
 
     address = encodeAddress(mail_from, charset)
     msg['From'] = address
--- a/MoinMoin/script/account/disable.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/script/account/disable.py	Tue Oct 29 11:39:57 2013 +0100
@@ -45,7 +45,7 @@
             u.name = u"{0}-{1}".format(u.name, u.id)
             if u.email:
                 u.email = u"{0}-{1}".format(u.email, u.id)
-            u.subscribed_items = []  # avoid using email
+            u.subscriptions = []
             u.save()
             print "- disabled."
         else:
--- a/MoinMoin/script/account/resetpw.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/script/account/resetpw.py	Tue Oct 29 11:39:57 2013 +0100
@@ -13,7 +13,9 @@
 from flask import current_app as app
 from flask.ext.script import Command, Option
 
-from MoinMoin.constants.keys import ITEMID, NAME, NAME_EXACT, EMAIL
+from MoinMoin.constants.keys import (
+    ITEMID, NAME, NAME_EXACT, EMAIL, EMAIL_UNVALIDATED,
+)
 from MoinMoin import user
 from MoinMoin.app import before_wiki
 
@@ -41,9 +43,9 @@
             return
         u.set_password(password)
         u.save()
-        if not u.email:
-            raise UserHasNoEMail('User profile does not have an E-Mail address (name: %r id: %r)!' % (u.name, u.id))
         if notify and not u.disabled:
+            if not u.email:
+                raise UserHasNoEMail('Notification was requested, but User profile does not have a validated E-Mail address (name: %r id: %r)!' % (u.name, u.itemid))
             mailok, msg = u.mail_password_recovery(subject=subject, text=text)
             if not mailok:
                 raise MailFailed(msg)
@@ -103,7 +105,13 @@
         total = len(uids_metas)
         for nr, (uid, meta) in enumerate(uids_metas, start=1):
             name = meta[NAME]
-            email = meta[EMAIL]
+            email = meta.get(EMAIL)
+            if email is None:
+                email = meta.get(EMAIL_UNVALIDATED)
+                if email is None:
+                    raise ValueError("neither EMAIL nor EMAIL_UNVALIDATED key is present in user profile metadata of uid %r name %r" % (uid, name))
+                else:
+                    email += '[email_unvalidated]'
             try:
                 set_password(uid, password, notify=notify, skip_invalid=skip_invalid,
                              subject=subject, text=text)
--- a/MoinMoin/script/migration/moin19/import19.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/script/migration/moin19/import19.py	Tue Oct 29 11:39:57 2013 +0100
@@ -257,7 +257,7 @@
                     editlog_data = {  # make something up
                         NAME: [item.name],
                         MTIME: int(os.path.getmtime(path)),
-                        ACTION: u'SAVE',
+                        ACTION: ACTION_SAVE,
                     }
             meta, data = split_body(content)
         meta.update(editlog_data)
@@ -333,7 +333,7 @@
         except KeyError:
             meta = {  # make something up
                 MTIME: int(os.path.getmtime(attpath)),
-                ACTION: u'SAVE',
+                ACTION: ACTION_SAVE,
             }
         meta[NAME] = [u'{0}/{1}'.format(item_name, attach_name)]
         if acl is not None:
@@ -377,12 +377,12 @@
                 if extra:
                     result[NAME_OLD] = extra
                 del result[EXTRA]
-                result[ACTION] = u'RENAME'
+                result[ACTION] = ACTION_RENAME
             elif action == 'SAVE/REVERT':
                 if extra:
                     result[REVERTED_TO] = int(extra)
                 del result[EXTRA]
-                result[ACTION] = u'REVERT'
+                result[ACTION] = ACTION_REVERT
         userid = result[USERID]
         #TODO
         #if userid:
@@ -401,7 +401,7 @@
         meta = dict([(k, v) for k, v in meta.items() if v])  # remove keys with empty values
         if meta.get(ACTION) == u'SAVENEW':
             # replace SAVENEW with just SAVE
-            meta[ACTION] = u'SAVE'
+            meta[ACTION] = ACTION_SAVE
         return meta
 
     def find_attach(self, attachname):
@@ -416,7 +416,7 @@
             raise KeyError
         del meta['__rev']
         del meta[EXTRA]  # we have full name in NAME
-        meta[ACTION] = u'SAVE'
+        meta[ACTION] = ACTION_SAVE
         meta = dict([(k, v) for k, v in meta.items() if v])  # remove keys with empty values
         return meta
 
@@ -479,7 +479,7 @@
         meta[ITEMID] = make_uuid()
         meta[REVID] = make_uuid()
         meta[SIZE] = 0
-        meta[ACTION] = u'SAVE'
+        meta[ACTION] = ACTION_SAVE
         self.meta = meta
         self.data = StringIO('')
 
@@ -532,8 +532,8 @@
         # rename aliasname to display_name:
         metadata[DISPLAY_NAME] = metadata.get('aliasname')
 
-        # rename subscribed_pages to subscribed_items
-        metadata[SUBSCRIBED_ITEMS] = metadata.get('subscribed_pages', [])
+        # transfer subscribed_pages to subscription_patterns
+        metadata[SUBSCRIPTIONS] = migrate_subscriptions(metadata.get('subscribed_pages', []))
 
         # convert bookmarks from usecs (and str) to secs (int)
         metadata[BOOKMARKS] = [(interwiki, int(long(bookmark) / 1000000))
@@ -633,3 +633,19 @@
     else:
         raise ValueError("unsupported content object: {0!r}".format(content))
     return size, HASH_ALGORITHM, unicode(hash.hexdigest())
+
+
+def migrate_subscriptions(subscribed_items):
+    """ Transfer subscribed_items meta to subscriptions meta
+
+    :param subscribed_items: a list of moin19-format subscribed_items
+    :return: subscriptions
+    """
+    subscriptions = []
+    for subscribed_item in subscribed_items:
+        # TODO: try to determine if pagename is not a regexp and create
+        # a subscription_id with a NAME keyword
+        # TODO: support interwiki wikiname
+        wikiname, pagename = subscribed_item.split(":", 1)
+        subscriptions.append("{0}::{2}".format(NAMERE, pagename))
+    return subscriptions
--- a/MoinMoin/signalling/log.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/signalling/log.py	Tue Oct 29 11:39:57 2013 +0100
@@ -7,6 +7,7 @@
 
 
 from .signals import *
+from flask import got_request_exception
 
 from MoinMoin import log
 logging = log.getLogger(__name__)
@@ -19,6 +20,11 @@
 
 
 @item_modified.connect_via(ANY)
-def log_item_modified(app, item_name):
+def log_item_modified(app, item_name, **kwargs):
     wiki_name = app.cfg.interwikiname
     logging.info(u"item {0}:{1} modified".format(wiki_name, item_name))
+
+
+@got_request_exception.connect_via(ANY)
+def log_exception(sender, exception, **extra):
+    logging.exception(exception)
--- a/MoinMoin/storage/middleware/_tests/test_indexing.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/storage/middleware/_tests/test_indexing.py	Tue Oct 29 11:39:57 2013 +0100
@@ -16,7 +16,7 @@
 from flask import g as flaskg
 
 from MoinMoin.constants.keys import (NAME, SIZE, ITEMID, REVID, DATAID, HASH_ALGORITHM, CONTENT, COMMENT,
-                                     LATEST_REVS, ALL_REVS, NAMESPACE)
+                                     LATEST_REVS, ALL_REVS, NAMESPACE, NAMERE, NAMEPREFIX)
 from MoinMoin.constants.namespaces import NAMESPACE_USERPROFILES
 
 from MoinMoin.util.interwiki import split_fqname
@@ -369,6 +369,21 @@
         assert expected_revid == doc[REVID]
         assert unicode(data) == doc[CONTENT]
 
+    def test_indexing_subscriptions(self):
+        item_name = u"foo"
+        meta = dict(name=[item_name, ], subscriptions=[u"{0}::foo".format(NAME),
+                                                       u"{0}::.*".format(NAMERE)])
+        item = self.imw[item_name]
+        item.store_revision(meta, StringIO(str(item_name)))
+        doc1 = self.imw.document(subscription_ids=u"{0}::foo".format(NAME))
+        doc2 = self.imw.document(subscription_patterns=u"{0}::.*".format(NAMERE))
+        assert doc1 is not None
+        assert doc2 is not None
+        doc3 = self.imw.document(subscription_ids=u"{0}::.*".format(NAMERE))
+        doc4 = self.imw.document(subscription_patterns=u"{0}::foo".format(NAMEPREFIX))
+        assert doc3 is None
+        assert doc4 is None
+
     def test_namespaces(self):
         item_name_n = u'normal'
         item = self.imw[item_name_n]
--- a/MoinMoin/storage/middleware/_tests/test_validation.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/storage/middleware/_tests/test_validation.py	Tue Oct 29 11:39:57 2013 +0100
@@ -39,7 +39,7 @@
 
         state = {'trusted': False,  # True for loading a serialized representation or other trusted sources
                  keys.NAME: u'somename',  # name we decoded from URL path
-                 keys.ACTION: u'SAVE',
+                 keys.ACTION: keys.ACTION_SAVE,
                  keys.HOSTNAME: u'localhost',
                  keys.ADDRESS: u'127.0.0.1',
                  keys.USERID: make_uuid(),
@@ -70,11 +70,24 @@
             keys.NAME: [u"user name", ],
             keys.NAMESPACE: u"userprofiles",
             keys.EMAIL: u"foo@example.org",
+            keys.SUBSCRIPTIONS: [u"{0}:{1}".format(keys.ITEMID, make_uuid()),
+                                 u"{0}::foo".format(keys.NAME),
+                                 u"{0}::bar".format(keys.TAGS),
+                                 u"{0}::".format(keys.NAMERE),
+                                 u"{0}:userprofiles:a".format(keys.NAMEPREFIX),
+                                 ]
+        }
+
+        invalid_meta = {
+            keys.SUBSCRIPTIONS: [u"", u"unknown_tag:123",
+                                 u"{0}:123".format(keys.ITEMID),
+                                 u"{0}:foo".format(keys.NAME),
+                                 ]
         }
 
         state = {'trusted': False,  # True for loading a serialized representation or other trusted sources
                  keys.NAME: u'somename',  # name we decoded from URL path
-                 keys.ACTION: u'SAVE',
+                 keys.ACTION: keys.ACTION_SAVE,
                  keys.HOSTNAME: u'localhost',
                  keys.ADDRESS: u'127.0.0.1',
                  keys.WIKINAME: u'ThisWiki',
@@ -90,3 +103,11 @@
                 print e.valid, e
             print m.valid, m
         assert valid
+
+        m = UserMetaSchema(invalid_meta)
+        valid = m.validate(state)
+        assert not valid
+        for e in m.children:
+            if e.name in (keys.SUBSCRIPTIONS,):
+                for value in e:
+                    assert not value.valid
--- a/MoinMoin/storage/middleware/indexing.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/storage/middleware/indexing.py	Tue Oct 29 11:39:57 2013 +0100
@@ -135,6 +135,8 @@
     doc = dict([(key, value)
                 for key, value in meta.items()
                 if key in schema])
+    if SUBSCRIPTION_IDS in schema and SUBSCRIPTIONS in meta:
+        doc[SUBSCRIPTION_IDS], doc[SUBSCRIPTION_PATTERNS] = backend_subscriptions_to_index(meta[SUBSCRIPTIONS])
     for key in [MTIME, PTIME]:
         if key in doc:
             # we have UNIX UTC timestamp (int), whoosh wants datetime
@@ -146,6 +148,25 @@
     return doc
 
 
+def backend_subscriptions_to_index(subscriptions):
+    """ Split subscriptions list to subscription_ids and subscription_patterns lists
+    which match the fields of the whoosh schema
+
+    :param subscriptions: user subscriptions meta
+    :return: tuple containing a list of subscription_ids and a list of
+             subscription_patterns
+    """
+    subscription_ids = []
+    subscription_patterns = []
+    for subscription in subscriptions:
+        keyword = subscription.split(':')[0]
+        if keyword in (ITEMID, NAME, TAGS, ):
+            subscription_ids.append(subscription)
+        elif keyword in (NAMERE, NAMEPREFIX, ):
+            subscription_patterns.append(subscription)
+    return subscription_ids, subscription_patterns
+
+
 from MoinMoin.util.mime import Type, type_moin_document
 from MoinMoin.util.tree import moin_page
 from MoinMoin.converter import default_registry
@@ -328,6 +349,9 @@
             EMAIL: ID(stored=True),
             OPENID: ID(stored=True),
             DISABLED: BOOLEAN(stored=True),
+            LOCALE: ID(stored=True),
+            SUBSCRIPTION_IDS: ID(),
+            SUBSCRIPTION_PATTERNS: ID(),
         }
         latest_revs_fields.update(**userprofile_fields)
 
@@ -1015,7 +1039,7 @@
     def store_revision(self, meta, data, overwrite=False,
                        trusted=False,  # True for loading a serialized representation or other trusted sources
                        name=None,  # TODO name we decoded from URL path
-                       action=u'SAVE',
+                       action=ACTION_SAVE,
                        remote_addr=None,
                        userid=None,
                        wikiname=None,
--- a/MoinMoin/storage/middleware/validation.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/storage/middleware/validation.py	Tue Oct 29 11:39:57 2013 +0100
@@ -25,6 +25,7 @@
 from __future__ import absolute_import, division
 
 import time
+import re
 
 from flatland import Dict, List, Unset, Boolean, Integer, String
 
@@ -216,7 +217,8 @@
     v = element.value
     if not isinstance(v, unicode):
         return False
-    if v not in [u'SAVE', u'REVERT', u'TRASH', u'COPY', u'RENAME', ]:
+    if v not in [keys.ACTION_SAVE, keys.ACTION_REVERT, keys.ACTION_TRASH,
+                 keys.ACTION_COPY, keys.ACTION_RENAME]:
         return False
     return True
 
@@ -319,6 +321,55 @@
     except ValueError:
         return False
 
+
+def subscription_validator(element, state):
+    """
+    a subscription
+    """
+    try:
+        keyword, value = element.value.split(":", 1)
+    except ValueError:
+        element.add_error("Subscription must contain colon delimiters.")
+        return False
+
+    if keyword in (keys.ITEMID, ):
+        value_element = String(value)
+        valid = uuid_validator(value_element, state)
+    elif keyword in (keys.NAME, keys.TAGS, keys.NAMERE, keys.NAMEPREFIX, ):
+        try:
+            namespace, value = value.split(":", 1)
+        except ValueError:
+            element.add_error("Subscription must contain 2 colon delimiters.")
+            return False
+        namespace_element = String(namespace)
+        if not namespace_validator(namespace_element, state):
+            element.add_error("Not a valid namespace value.")
+            return False
+    else:
+        element.add_error(
+            "Subscription must start with one of the keywords: "
+            "'{0}', '{1}', '{2}', '{3}' or '{4}'.".format(keys.ITEMID,
+                                                          keys.NAME, keys.TAGS,
+                                                          keys.NAMERE,
+                                                          keys.NAMEPREFIX))
+        return False
+
+    value_element = String(value)
+    if keyword == keys.TAGS:
+        valid = tag_validator(value_element, state)
+    elif keyword in (keys.NAME, keys.NAMEPREFIX, ):
+        valid = name_validator(value_element, state)
+    elif keyword == keys.NAMERE:
+        try:
+            re.compile(value, re.U)
+            valid = True
+        except re.error:
+            valid = False
+    if not valid:
+        element.add_error("Subscription has invalid value.")
+    return valid
+
+
 common_meta = (
     String.named(keys.ITEMID).validated_by(itemid_validator),
     String.named(keys.REVID).validated_by(revid_validator),
@@ -369,7 +420,7 @@
     Boolean.named(keys.SCROLL_PAGE_AFTER_EDIT).using(optional=True),
     Boolean.named(keys.MAILTO_AUTHOR).using(optional=True),
     List.named(keys.QUICKLINKS).of(String.named('quicklinks')).using(optional=True),
-    List.named(keys.SUBSCRIBED_ITEMS).of(String.named('subscribed_item')).using(optional=True),
+    List.named(keys.SUBSCRIPTIONS).of(String.named('subscription').validated_by(subscription_validator)).using(optional=True),
     List.named(keys.EMAIL_SUBSCRIBED_EVENTS).of(String.named('email_subscribed_event')).using(optional=True),
     #TODO: DuckDict.named('bookmarks').using(optional=True),
     *common_meta
--- a/MoinMoin/templates/base.html	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/templates/base.html	Tue Oct 29 11:39:57 2013 +0100
@@ -49,7 +49,7 @@
     {% block icons_stylesheet %}
     <link rel="stylesheet" href="{{ url_for('serve.files', name='font_awesome', filename='css/font-awesome.css') }}" />
     {% endblock %}
-    
+
     <link rel="shortcut icon" href="{{ url_for('static', filename='logos/favicon.ico') }}" />
 
     {% block theme_stylesheets %}
@@ -76,9 +76,6 @@
 </div>
 
 {% block body_scripts %} {# js before </body> reduces IE8 js errors related to svgweb #}
-    <!--[if IE 8]>
-        <script src="{{ url_for('serve.files', name='svgweb', filename='svg.js') }}"></script>
-    <![endif]-->
     <script src="{{ url_for('serve.files', name='jquery', filename='jquery.min.js') }}"></script>
     <script src="{{ url_for('serve.files', name='bootstrap', filename='js/bootstrap.min.js') }}"></script>
     <script src="{{ url_for('frontend.template', filename='common.js') }}"></script>
@@ -87,10 +84,6 @@
         {# TODO: use a local copy later #}
         <script src="http://html5shiv.googlecode.com/svn/trunk/html5.js"></script>
     <![endif]-->
-    <!--[if lt IE 8]>
-        {# required to save user settings with IE7 and earlier #}
-        <script src="{{ url_for('serve.files', name='json_js', filename='json2.js') }}"></script>
-    <![endif]-->
 {% endblock %}
 
 </body>
--- a/MoinMoin/templates/itemviews.html	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/templates/itemviews.html	Tue Oct 29 11:39:57 2013 +0100
@@ -56,7 +56,7 @@
             {%- if endpoint == 'frontend.subscribe_item' and user.valid %}
                 <li>
                     <a href="{{ url_for(endpoint, item_name=fqname) }}" title="{{ title }}" rel="nofollow">
-                        {%- if user.is_subscribed_to([fqname]) %}
+                        {%- if user.is_subscribed_to(item) %}
                             {{ _('Unsubscribe') }}
                         {%- else %}
                             {{ _('Subscribe') }}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/templates/mail/content_diff.html	Tue Oct 29 11:39:57 2013 +0100
@@ -0,0 +1,30 @@
+{% macro content_diff(diff) %}
+<table border="0" cellpadding="0" cellspacing="0" style="margin:0; padding:1em;
+        width:100%; font-family:monospace; background-color:#F9F9F9;">
+{% for line in diff -%}
+{% if line[:2] == "+ " -%}
+    <tr style="color:#00B000; height:1.2em;">
+{% elif line[:2] == "- " -%}
+    <tr style="color:#991111; height:1.2em;">
+{% elif line[:2] == "@@" -%}
+    <tr style="color:#440088; height:1.2em;">
+{% else -%}
+    <tr style="color:#000000; height:1.2em;">
+{%- endif %}
+    {% if line[:2] == "+ " %}
+        <td style="font-family:monospace; width:1.2em; vertical-align:top;">{{ line[0] }}</td>
+        <td style="font-family:monospace;"><ins style="text-decoration:none;">{{ line[2:] }}</ins></td>
+    {% elif line[:2] == "- " -%}
+        <td style="font-family:monospace; width:1.2em; vertical-align:top;">{{ line[0] }}</td>
+        <td style="font-family:monospace;"><del style="text-decoration:none;">{{ line[2:] }}</del></td>
+    {% elif line[:2] == "@@" -%}
+        <td style="font-family:monospace; width:1.2em;"></td>
+        <td style="font-family:monospace;">{{ line }}</td>
+    {% else -%}
+        <td style="font-family:monospace; width:1.2em;"></td>
+        <td style="font-family:monospace;">{{ line[2:] }}</td>
+    {%- endif %}
+    </tr>
+{%- endfor %}
+</table>
+{% endmacro %}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/templates/mail/html_base.html	Tue Oct 29 11:39:57 2013 +0100
@@ -0,0 +1,28 @@
+{# HTML mail templates are based on htmlemailboilerplate authored by Sean Powell, The Engage Group, MIT licensed #}
+{# For more information visit: http://htmlemailboilerplate.com/ #}
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+    <title>{% block title %}{% endblock %}</title>
+    <style type="text/css">
+        #outlook a {padding:0;}
+        #backgroundTable {margin:0; padding:0; width:100% !important; line-height:100% !important;}
+        .ExternalClass {width:100%;}
+        .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font,
+        .ExternalClass td, .ExternalClass div {line-height:100%;}
+        p {margin:1em 0;}
+        h1, h2, h3, h4, h5, h6 {color:black !important;}
+        table td {border-collapse:collapse; padding:0;}
+    </style>
+</head>
+<body style="width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0;">
+<table border="0" cellpadding="0" cellspacing="0" width="100%" id="backgroundTable">
+    <tr>
+        <td>
+            {% block content %}{% endblock %}
+        </td>
+    </tr>
+</table>
+</body>
+</html>
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/templates/mail/meta_diff.html	Tue Oct 29 11:39:57 2013 +0100
@@ -0,0 +1,29 @@
+{% macro meta_diff(diff) %}
+<table border="0" cellpadding="0" cellspacing="0" style="margin:0; padding:10px;
+        width:100%; font-family:monospace; background-color:#F9F9F9">
+{% for change in diff -%}
+{% if change[0] == "insert" -%}
+    <tr style="color:#00B000; height:1.2em;">
+    {% set marker = '+' -%}
+{% elif change[0] == "delete" -%}
+    <tr style="color:#991111; height:1.2em;">
+    {% set marker = '-' -%}
+{%- endif %}
+        <td style="font-family:monospace; width:1.2em; vertical-align:top;">{{ marker }}</td>
+        <td style="font-family:monospace;">
+        {% if change[0] == "insert" -%}
+            <ins style="text-decoration:none;">
+                {{ change[1] | join(".") }}{% if change[1] -%}:{%- endif %}
+                {{ change[2] }}
+            </ins>
+        {% elif change[0] == "delete" -%}
+            <del style="text-decoration:none;">
+                {{ change[1] | join(".") }}{% if change[1] -%}:{%- endif %}
+                {{ change[2] }}
+            </del>
+        {% endif %}
+        </td>
+    </tr>
+{%- endfor %}
+</table>
+{% endmacro %}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/templates/mail/notification.txt	Tue Oct 29 11:39:57 2013 +0100
@@ -0,0 +1,29 @@
+{{ _("Dear Wiki user,") }}
+
+{{ notification_sentence }}
+{{ diff_url }}
+
+{% if comment -%}
+{{ _("Comment:") }}
+{{ comment }}
+{%- endif %}
+
+{% if content_diff_ -%}
+{{ _("Data changes:") }}
+{%- for line in content_diff_ %}
+{{ line }}
+{%- endfor -%}
+{%- endif %}
+
+{% if meta_diff_ -%}
+{{ _("Metadata changes:") }}
+{%- for line in meta_diff_ %}
+{{ line }}
+{%- endfor -%}
+{%- endif %}
+
+{{ _("You are receiving this because you have subscribed to a wiki item on
+'%(wiki_name)s' for change notifications.", wiki_name=wiki_name) }}
+{{ _("Item link: %(item_url)s", item_url=item_url) }}
+{{ _("To unsubscribe use: %(unsubscribe_url)s",
+unsubscribe_url=unsubscribe_url) }}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/templates/mail/notification_main.html	Tue Oct 29 11:39:57 2013 +0100
@@ -0,0 +1,46 @@
+{% extends theme("mail/html_base.html") %}
+{% from "mail/content_diff.html" import content_diff %}
+{% from "mail/meta_diff.html" import meta_diff %}
+
+{% block title %}Wiki Notification{% endblock %}
+
+{% block content -%}
+    <div>
+        <p style="text-align:center; margin-bottom:0.6em;">{{ _("Dear Wiki user,") }}</p>
+        <p style="text-align:center; margin-top:0.4em; margin-bottom:0;">
+            {{ notification_sentence }}
+        </p>
+        <p style="text-align:center; margin-top:0.4em; margin-bottom:0;">
+            {{ _("Item link:") }}
+            <a href="{{ item_url }}"
+               style="text-decoration:none; color:#3CA7DD;">{{ item_url }}
+            </a>
+        </p>
+        <p style="text-align:center; margin-top:0.4em;">
+            <a href="{{ diff_url }}"
+                style="text-decoration:none; color:#3CA7DD;">{{ diff_url }}
+            </a>
+        </p>
+    </div>
+    {% if comment -%}
+    <h3 style="margin:0 1.3em 0.5em;">{{ _("Comment") }}</h3>
+    <p>{{ comment }}</p>
+    {%- endif %}
+    {% if content_diff_ -%}
+    <h3 style="margin:1em 1.3em 0.5em;">{{ _("Content") }}</h3>
+    {{ content_diff(content_diff_) }}
+    {%- endif %}
+    {% if meta_diff_ -%}
+    <h3 style="margin:1em 1.3em 0.5em;">{{ _("Metadata") }}</h3>
+    {{ meta_diff(meta_diff_) }}
+    {%- endif %}
+    <div style="font-size:0.7em; margin-top:2em;">
+        <p style="text-align:center; color:#A6A6A6; margin-bottom:0;">
+            {{ _("You are receiving this because you have subscribed to a wiki
+        item on %(wiki_name)s for change notifications.", wiki_name=wiki_name) }}</p>
+        <p style="text-align:center; margin-top:0;"><strong><a
+                href="{{ unsubscribe_url }}"
+            style="text-decoration:none; color:#3CA7DD;">{{ _("Unsubscribe from
+            this item notifications") }}</a></strong></p>
+    </div>
+{%- endblock %}
\ No newline at end of file
--- a/MoinMoin/templates/usersettings_ajax.html	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/templates/usersettings_ajax.html	Tue Oct 29 11:39:57 2013 +0100
@@ -12,4 +12,6 @@
     {{ user_forms.navigation(form) }}
 {% elif part == 'options' %}
     {{ user_forms.options(form) }}
+{% elif part == 'subscriptions' %}
+    {{ user_forms.subscriptions(form) }}
 {% endif %}
--- a/MoinMoin/templates/usersettings_forms.html	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/templates/usersettings_forms.html	Tue Oct 29 11:39:57 2013 +0100
@@ -83,6 +83,17 @@
 {{ gen.form.close() }}
 {% endmacro %}
 
+{% macro subscriptions(form) %}
+{{ gen.form.open(form, method="post", action=url_for('frontend.usersettings')) }}
+{{ forms.render_errors(form) }}
+<dl>
+    {{ forms.render(form['subscriptions']) }}
+</dl>
+{{ forms.render_hidden('part', 'subscriptions') }}
+{{ forms.render_submit(form) }}
+{{ gen.form.close() }}
+{% endmacro %}
+
 {# javascript functions within common.js are dependent upon the structure, classes and ids defined here #}
 {% macro all_usersettings_forms(form_objs) %}
 <div id="moin-usersettings">
@@ -110,5 +121,9 @@
         <h2 class="moin-settings-head"><a href="#options">{{ _("Options") }}</a></h2>
         {{ options(form_objs.options) }}
     </div>
+    <div id="subscriptions" class="moin-tab-body moin-form">
+        <h2 class="moin-settings-head"><a href="#subscriptions">{{ _("Subscriptions") }}</a></h2>
+        {{ subscriptions(form_objs.subscriptions) }}
+    </div>
 </div>
 {% endmacro %}
--- a/MoinMoin/user.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/MoinMoin/user.py	Tue Oct 29 11:39:57 2013 +0100
@@ -43,12 +43,24 @@
 from MoinMoin.mail import sendmail
 from MoinMoin.util.interwiki import getInterwikiHome, getInterwikiName, is_local_wiki
 from MoinMoin.util.crypto import generate_token, valid_token, make_uuid
+from MoinMoin.util.subscriptions import get_matched_subscription_patterns
 from MoinMoin.storage.error import NoSuchItemError, ItemAlreadyExistsError, NoSuchRevisionError
 
 
-def create_user(username, password, email, openid=None, validate=True, is_encrypted=False, is_disabled=False):
-    """ create a user """
-    # Create user profile
+def create_user(username, password, email, validate=True, is_encrypted=False, **meta):
+    """
+    Create a new user
+
+    :param username: unique user name
+    :param password: user's password - see also is_encrypted param
+    :param email: unique email address
+    :param validate: if True (default) will validate username, password, email
+                        and the uniqueness of the user created
+    :param is_encrypted: if False (default) defines that the password is in
+                        plaintext, when True - password was already encrypted
+    :param meta: a dictionary of key-value pairs that represent user metadata and
+                    will be stored into user profile metadata
+    """
     theuser = User(auth_method="new-user")
 
     # Don't allow creating users with invalid names
@@ -86,14 +98,15 @@
         theuser.profile[EMAIL] = email
 
     # Openid should be unique
+    openid = meta.get(OPENID)
     if validate and openid and search_users(openid=openid):
         return _('This OpenID already belongs to somebody else.')
 
-    theuser.profile[OPENID] = openid
+    theuser.profile[DISABLED] = meta.get("is_disabled", False)
 
-    theuser.profile[DISABLED] = is_disabled
-
-    # save data
+    # TODO requires validation (preferably using flatland)
+    for key, value in meta.items():
+        theuser.profile[key] = value
     theuser.save()
 
 
@@ -184,6 +197,27 @@
     return (name == normalized) and not wikiutil.isGroupItem(name)
 
 
+def assemble_subscription(keyword, value, namespace=None):
+    """ Create a valid subscription string
+
+    :param keyword: the keyword (itemid, name, tags, nameprefix, namere) by which
+                    the type of the subscription is determined
+    :param value: the subscription value (itemid, name, tag, regexp or nameprefix value)
+    :param namespace: the namespace of the subscription
+    :return: subscription string
+    """
+    if keyword == ITEMID:
+        subscription = "{0}:{1}".format(ITEMID, value)
+    elif keyword in [NAME, TAGS, NAMERE, NAMEPREFIX, ]:
+        if namespace is not None:
+            subscription = "{0}:{1}:{2}".format(keyword, namespace, value)
+        else:
+            raise ValueError("The subscription by {0} keyword requires a namespace".format(keyword))
+    else:
+        raise ValueError("Invalid keyword string: {0}".format(keyword))
+    return subscription
+
+
 class UserProfile(object):
     """ A User Profile"""
 
@@ -533,82 +567,83 @@
 
     # Subscribed Items -------------------------------------------------------
 
-    def is_subscribed_to(self, pagelist):
-        """ Check if user subscription matches any page in pagelist.
-
-        The subscription contains interwiki page names. e.g 'WikiName:Page_Name'
+    def is_subscribed_to(self, item):
+        """ Check if user is subscribed to the following item
 
-        TODO: check if it's fast enough when getting called for many
-              users from page.getSubscribersList()
-
-        :param pagelist: list of pages to check for subscription
+        :param item: Item object
         :rtype: bool
-        :returns: if user is subscribed any page in pagelist
+        :returns: if user is subscribed to the item
         """
         if not self.valid:
             return False
 
-        # Create a new list with interwiki names.
-        pages = [getInterwikiName(pagename) for pagename in pagelist]
-        # Create text for regular expression search
-        text = '\n'.join(pages)
-
-        for pattern in self.subscribed_items:
-            # Try simple match first
-            if pattern in pages:
-                return True
-            # Try regular expression search, skipping bad patterns
-            try:
-                pattern = re.compile(r'^{0}$'.format(pattern), re.M)
-            except re.error:
-                continue
-            if pattern.search(text):
-                return True
+        meta = item.meta
+        try:
+            item_namespace = meta[NAMESPACE]
+        except KeyError:
+            return False
+        subscriptions = {"{0}:{1}".format(ITEMID, meta[ITEMID])}
+        subscriptions.update("{0}:{1}:{2}".format(NAME, item_namespace, name)
+                             for name in meta[NAME])
+        subscriptions.update("{0}:{1}:{2}".format(TAGS, item_namespace, tag)
+                             for tag in meta[TAGS])
+        if subscriptions & set(self.subscriptions):
+            return True
 
-        return False
-
-    def subscribe(self, pagename):
-        """ Subscribe to a wiki page.
-
-        Page names are saved as interwiki names.
-
-        :param pagename: name of the page to subscribe
-        :type pagename: unicode
-        :rtype: bool
-        :returns: if page was subscribed
-        """
-        pagename = getInterwikiName(pagename)
-        subscribed_items = self.subscribed_items
-        if pagename not in subscribed_items:
-            subscribed_items.append(pagename)
-            self.save(force=True)
-            # XXX SubscribedToPageEvent
+        if get_matched_subscription_patterns(self.subscriptions, **meta):
             return True
         return False
 
-    def unsubscribe(self, pagename):
-        """ Unsubscribe a wiki page.
-
-        Try to unsubscribe by removing interwiki name from the subscription
-        list.
-
-        Its possible that the user will be subscribed to a page by more
-        than one pattern. It can be both interwiki name and a regex pattern that
-        both match the page. Therefore, we must check if the user is
-        still subscribed to the page after we try to remove names from the list.
+    def subscribe(self, keyword, value, namespace=None):
+        """ Subscribe to a wiki page.
 
-        :param pagename: name of the page to subscribe
-        :type pagename: unicode
+        The user can subscribe in 5 different ways:
+        * by itemid - ITEMID:<itemid value>
+        * by item name - NAME:<namespace>:<name value>
+        * by a tagname - TAGS:<namespace>:<tag value>
+        * by a prefix name - NAMEPREFIX:<namespace>:<name prefix>
+        * by a regular expression - NAMERE:<namespace>:<name regexp>
+
+:       :param keyword: the keyword (itemid, name, tags, nameprefix, namere) by which
+                        the type of the subscription is determined
+        :param value: the subscription value (itemid, name, tag, regexp or nameprefix value)
+        :param namespace: the namespace of the subscription; itemid keyword doesn't
+                            require a namespace
         :rtype: bool
-        :returns: if unsubscribe was successful. If the user has a
-            regular expression that matches, unsubscribe will always fail.
+        :returns: if user was subscribed
         """
-        interWikiName = getInterwikiName(pagename)
-        subscribed_items = self.profile[SUBSCRIBED_ITEMS]
-        if interWikiName and interWikiName in subscribed_items:
-            subscribed_items.remove(interWikiName)
+        subscription = assemble_subscription(keyword, value, namespace)
+        subscriptions = self.subscriptions
+        if subscription not in subscriptions:
+            subscriptions.append(subscription)
             self.save(force=True)
-        return not self.is_subscribed_to([pagename])
+            return True
+        return False
+
+    def unsubscribe(self, keyword, value, namespace=None, item=None):
+        """ Unsubscribe from a wiki page.
+
+        Same as for subscribing, user can also unsubscribe in 5 ways.
+        The unsubscribe action doesn't guarantee that user will not receive any
+        notification for this item, since user can be subscribed by some other
+        patterns that match current item.
+
+        :param keyword: the keyword (itemid, name, tags, nameprefix, namere) by which
+                        the type of the subscription is determined
+        :param value: the subscription value (itemid, name, tag, regexp or nameprefix value)
+        :param namespace: the namespace of the subscription; itemid keyword doesn't
+                            require a namespace
+        :param item: Item object to check if the user is still subscribed
+        :rtype: bool
+        :returns: if user was unsubscribed
+        """
+        subscription = assemble_subscription(keyword, value, namespace)
+        subscriptions = self.subscriptions
+        if subscription in subscriptions:
+            subscriptions.remove(subscription)
+            self.save(force=True)
+            return not self.is_subscribed_to(item) if item else True
+        return False
 
     # Quicklinks -------------------------------------------------------------
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/util/_tests/test_diff_datastruct.py	Tue Oct 29 11:39:57 2013 +0100
@@ -0,0 +1,148 @@
+# Copyright: 2013 MoinMoin:AnaBalica
+# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
+
+"""
+    MoinMoin - MoinMoin.util.diff_datastruct Tests
+"""
+
+import pytest
+
+from MoinMoin.util.diff_datastruct import diff, make_text_diff, Undefined, INSERT, DELETE
+
+
+class TestDiffDatastruct(object):
+
+    def _test_make_text_diff(self, tests):
+        for changes, expected in tests:
+            for got in make_text_diff(changes):
+                assert got == expected
+
+    def test_diff_no_change(self):
+        datastruct = [None, True, 42, u"value", [1, 2, 3], dict(one=1, two=2)]
+        for d in datastruct:
+            assert diff(d, d) == []
+
+    def test_diff_none(self):
+        tests = [(None, None, []),
+                 (Undefined, None, [(INSERT, [], None)]),
+                 (None, Undefined, [(DELETE, [], None)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_bool(self):
+        tests = [(True, True, []),
+                 (Undefined, True, [(INSERT, [], True)]),
+                 (True, Undefined, [(DELETE, [], True)]),
+                 (True, False, [(DELETE, [], True), (INSERT, [], False)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_int(self):
+        tests = [(1, 1, []),
+                 (Undefined, 2, [(INSERT, [], 2)]),
+                 (2, Undefined, [(DELETE, [], 2)]),
+                 (3, 4, [(DELETE, [], 3), (INSERT, [], 4)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_float(self):
+        tests = [(1.1, 1.1, []),
+                 (Undefined, 2.2, [(INSERT, [], 2.2)]),
+                 (2.2, Undefined, [(DELETE, [], 2.2)]),
+                 (3.3, 4.4, [(DELETE, [], 3.3), (INSERT, [], 4.4)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_unicode(self):
+        tests = [(u"same", u"same", []),
+                 (Undefined, u"new", [(INSERT, [], u"new")]),
+                 (u"old", Undefined, [(DELETE, [], u"old")]),
+                 (u"some value", u"some other value",
+                  [(DELETE, [], u"some value"), (INSERT, [], u"some other value")])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_list(self):
+        tests = [([1], [1], []),
+                 (Undefined, [2], [(INSERT, [], [2])]),
+                 ([2], Undefined, [(DELETE, [], [2])]),
+                 ([1, 2], [2, 3], [(DELETE, [], [1]), (INSERT, [], [3])]),
+                 ([9, 8], [8, 7, 6, 5], [(DELETE, [], [9]), (INSERT, [], [7, 6, 5])])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_dict(self):
+        tests = [(dict(same=1), dict(same=1), []),
+                 (Undefined, dict(new=1), [(INSERT, ["new"], 1)]),
+                 (dict(old=1), Undefined, [(DELETE, ["old"], 1)]),
+                 (dict(same=1, old=2), dict(same=1, new1=3, new2=4),
+                  [(INSERT, ["new1"], 3), (INSERT, ["new2"], 4),
+                   (DELETE, ["old"], 2)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_nested_dict(self):
+        tests = [(dict(key=dict(same=None)), dict(key=dict(same=None)), []),
+                 (dict(key=dict()), dict(key=dict(added=None)), [(INSERT, ["key", "added"], None)]),
+                 (dict(key=dict(removed=None)), dict(key=dict()), [(DELETE, ["key", "removed"], None)])]
+        for d1, d2, expected in tests:
+            assert diff(d1, d2) == expected
+
+    def test_diff_str_unicode_keys(self):
+        d1 = {"old": u"old", u"same1": u"same1", "same2": u"same2"}
+        d2 = {u"new": u"new", "same1": u"same1", u"same2": u"same2"}
+        assert diff(d1, d2) == [(INSERT, ["new"], u"new"),
+                                (DELETE, ["old"], u"old")]
+
+    def test_diff_errors(self):
+        tests = [(u"foo", True),
+                 ((1, 2, ), (3, 4, )),
+                 (dict(key=(1, 2, )), dict()),
+                 (None, [1, 2, ])]
+        for d1, d2 in tests:
+            with pytest.raises(TypeError):
+                diff(d1, d2)
+
+    def test_make_text_diff_empty(self):
+        for got in make_text_diff([]):
+            assert got == u""
+
+    def test_make_text_diff_none(self):
+        tests = [([(INSERT, [], None)], u"+ None"),
+                 ([(DELETE, [], None)], u"- None")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_bool(self):
+        tests = [([(INSERT, [], True)], u"+ True"),
+                 ([(DELETE, [], False)], u"- False")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_int(self):
+        tests = [([(INSERT, [], 123)], u"+ 123"),
+                 ([(DELETE, [], 321)], u"- 321")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_float(self):
+        tests = [([(INSERT, [], 1.2)], u"+ 1.2"),
+                 ([(DELETE, [], 3.4)], u"- 3.4")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_unicode(self):
+        tests = [([(INSERT, [], u"new value")], u"+ new value"),
+                 ([(DELETE, [], u"old value")], u"- old value")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_list(self):
+        tests = [([(INSERT, [], [1, 2, 3, ])], u"+ [1, 2, 3]"),
+                 ([(DELETE, [], [4, 5, ])], u"- [4, 5]")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_one_key(self):
+        tests = [([(INSERT, ["key"], u"new value")], u"+ key: new value"),
+                 ([(DELETE, ["key"], u"old value")], u"- key: old value")]
+        self._test_make_text_diff(tests)
+
+    def test_make_text_diff_multiple_keys(self):
+        tests = [([(INSERT, ["key1", "key2"], 1)], u"+ key1.key2: 1"),
+                 ([(DELETE, ["key1", "key2", "key3"], 2)], u"- key1.key2.key3: 2")]
+        self._test_make_text_diff(tests)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/util/_tests/test_notifications.py	Tue Oct 29 11:39:57 2013 +0100
@@ -0,0 +1,102 @@
+# Copyright: 2013 MoinMoin:AnaBalica
+# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
+
+"""
+    MoinMoin - MoinMoin.util.notifications Tests
+"""
+
+from io import StringIO
+
+from flask import g as flaskg
+from flask import current_app as app
+from flask import url_for
+
+from MoinMoin.constants.keys import ACTION_SAVE, ACTION_TRASH
+from MoinMoin.items import Item
+from MoinMoin.util.diff_datastruct import diff as dict_diff
+from MoinMoin.util.notifications import Notification, get_item_last_revisions, DESTROY_REV, DESTROY_ALL
+
+
+class TestNotifications(object):
+    reinit_storage = True
+
+    def setup_method(self, method):
+        self.imw = flaskg.unprotected_storage
+        self.item_name = u"foo"
+
+    def test_get_last_item_revisions(self):
+        assert get_item_last_revisions(app, self.item_name) == []
+        item = self.imw[self.item_name]
+        rev1 = item.store_revision(dict(name=[self.item_name, ]),
+                                   StringIO(u'x'), trusted=True, return_rev=True)
+        assert get_item_last_revisions(app, self.item_name) == [rev1]
+        rev2 = item.store_revision(dict(name=[self.item_name, ]),
+                                   StringIO(u'xx'), trusted=True, return_rev=True)
+        assert get_item_last_revisions(app, self.item_name) == [rev2, rev1]
+        rev3 = item.store_revision(dict(name=[self.item_name, ]),
+                                   StringIO(u'xxx'), trusted=True, return_rev=True)
+        assert get_item_last_revisions(app, self.item_name) == [rev3, rev2]
+
+    def test_get_content_diff(self):
+        item = self.imw[self.item_name]
+        rev1 = item.store_revision(dict(name=[self.item_name, ], contenttype='text/plain'),
+                                   StringIO(u'x'), trusted=True, return_rev=True)
+        notification = Notification(app, self.item_name, [rev1], action=ACTION_SAVE)
+        assert notification.get_content_diff() == ["+ x"]
+        rev1.data.seek(0, 0)
+
+        rev2 = item.store_revision(dict(name=[self.item_name, ], contenttype='text/plain'),
+                                   StringIO(u'xx'), trusted=True, return_rev=True)
+        notification = Notification(app, self.item_name, [rev2, rev1], action=ACTION_SAVE)
+        assert notification.get_content_diff() == ['- x', '+ xx']
+        rev2.data.seek(0, 0)
+
+        notification = Notification(app, self.item_name, [rev2, rev1], action=ACTION_TRASH)
+        assert notification.get_content_diff() == ['- xx']
+        rev2.data.seek(0, 0)
+
+        item = Item.create(self.item_name)
+        notification = Notification(app, self.item_name, [], content=item.rev.data,
+                                    meta=rev2.meta, action=DESTROY_REV)
+        assert notification.get_content_diff() == ['- xx']
+        rev2.data.seek(0, 0)
+
+        item = Item.create(self.item_name)
+        notification = Notification(app, self.item_name, [], content=item.rev.data,
+                                    meta=rev2.meta, action=DESTROY_ALL)
+        assert notification.get_content_diff() == ['- xx']
+
+    def test_get_meta_diff(self):
+        item = self.imw[self.item_name]
+        rev1 = item.store_revision(dict(name=[self.item_name, ]), StringIO(u'x'),
+                                   trusted=True, return_rev=True)
+        notification = Notification(app, self.item_name, [rev1], action=ACTION_SAVE)
+        assert notification.get_meta_diff() == dict_diff(dict(), rev1.meta._meta)
+
+        rev2 = item.store_revision(dict(name=[self.item_name, ]), StringIO(u'xx'),
+                                   trusted=True, return_rev=True)
+        notification = Notification(app, self.item_name, [rev2, rev1], action=ACTION_SAVE)
+        assert notification.get_meta_diff() == dict_diff(rev1.meta._meta, rev2.meta._meta)
+
+        actions = [DESTROY_REV, DESTROY_ALL, ACTION_TRASH, ]
+        for action in actions:
+            notification = Notification(app, self.item_name, [rev2, rev1], meta=rev2.meta, action=action)
+            assert notification.get_meta_diff() == dict_diff(rev2.meta._meta, dict())
+
+    def test_generate_diff_url(self):
+        domain = "http://test.com"
+        notification = Notification(app, self.item_name, [], action=DESTROY_REV)
+        assert notification.generate_diff_url(domain) == u""
+
+        item = self.imw[self.item_name]
+        rev1 = item.store_revision(dict(name=[self.item_name, ]), StringIO(u'x'),
+                                   trusted=True, return_rev=True)
+        notification.revs = [rev1]
+        assert notification.generate_diff_url(domain) == u""
+
+        rev2 = item.store_revision(dict(name=[self.item_name, ]), StringIO(u'xx'),
+                                   trusted=True, return_rev=True)
+        notification.revs = [rev2, rev1]
+        assert notification.generate_diff_url(domain) == u"{0}{1}".format(
+            domain, url_for('frontend.diff', item_name=self.item_name,
+                            rev1=rev1.revid, rev2=rev2.revid))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/util/_tests/test_subscriptions.py	Tue Oct 29 11:39:57 2013 +0100
@@ -0,0 +1,133 @@
+# Copyright: 2013 MoinMoin:AnaBalica
+# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
+
+"""
+    MoinMoin - MoinMoin.util.subscriptions Tests
+"""
+
+import pytest
+
+from MoinMoin import user
+from MoinMoin.items import Item
+from MoinMoin.constants.keys import (ACL, ITEMID, CONTENTTYPE, NAME, NAMERE, NAMEPREFIX,
+                                     SUBSCRIPTIONS, TAGS)
+from MoinMoin.constants.namespaces import NAMESPACE_DEFAULT, NAMESPACE_USERPROFILES
+from MoinMoin.util.subscriptions import get_subscribers, get_matched_subscription_patterns
+
+
+class TestSubscriptions(object):
+    reinit_storage = True
+
+    def setup_method(self, method):
+        # create an item
+        self.item_name = u'foo'
+        self.tagname = u'XXX'
+        self.namespace = NAMESPACE_DEFAULT
+        meta = {CONTENTTYPE: u'text/plain;charset=utf-8', TAGS: [self.tagname]}
+        item = Item.create(self.item_name)
+        item._save(meta)
+        self.item = Item.create(self.item_name)
+
+    def test_get_subscribers(self):
+        users = get_subscribers(**self.item.meta)
+        assert users == set()
+
+        name1 = u'baz'
+        password = u'password'
+        email1 = u'baz@example.org'
+        name2 = u"bar"
+        email2 = u"bar@example.org"
+        name3 = u"barbaz"
+        email3 = u"barbaz@example.org"
+        user.create_user(username=name1, password=password, email=email1, validate=False, locale=u'en')
+        user1 = user.User(name=name1, password=password)
+        user.create_user(username=name2, password=password, email=email2, validate=False)
+        user2 = user.User(name=name2, password=password)
+        user.create_user(username=name3, password=password, email=email3, locale=u"en")
+        user3 = user.User(name=name3, password=password, email1=email3)
+        subscribers = get_subscribers(**self.item.meta)
+        assert subscribers == set()
+
+        namere = r'.*'
+        nameprefix = u"fo"
+        subscription_lists = [
+            ["{0}:{1}".format(ITEMID, self.item.meta[ITEMID])],
+            ["{0}:{1}:{2}".format(TAGS, self.namespace, self.tagname)],
+            ["{0}:{1}:{2}".format(NAME, self.namespace, self.item_name)],
+            ["{0}:{1}:{2}".format(NAMERE, self.namespace, namere)],
+            ["{0}:{1}:{2}".format(NAMEPREFIX, self.namespace, nameprefix)],
+        ]
+        users = [user1, user2, user3]
+        expected_names = {user1.name0, user2.name0}
+        for subscriptions in subscription_lists:
+            for user_ in users:
+                user_.profile._meta[SUBSCRIPTIONS] = subscriptions
+                user_.save(force=True)
+            subscribers = get_subscribers(**self.item.meta)
+            subscribers_names = {subscriber.name for subscriber in subscribers}
+            assert subscribers_names == expected_names
+
+        meta = {CONTENTTYPE: u'text/plain;charset=utf-8',
+                ACL: u"{0}: All:read,write".format(user1.name0)}
+        self.item._save(meta, comment=u"")
+        self.item = Item.create(self.item_name)
+        subscribers = get_subscribers(**self.item.meta)
+        assert {subscriber.name for subscriber in subscribers} == {user2.name0}
+
+    def test_get_matched_subscription_patterns(self):
+        meta = self.item.meta
+        patterns = get_matched_subscription_patterns([], **meta)
+        assert patterns == []
+        non_matching_patterns = [
+            "{0}:{1}:{2}".format(NAMERE, NAMESPACE_USERPROFILES, ".*"),
+            "{0}:{1}:{2}".format(NAMERE, self.namespace, "\d+"),
+            "{0}:{1}:{2}".format(NAMEPREFIX, self.namespace, "bar"),
+        ]
+        patterns = get_matched_subscription_patterns(non_matching_patterns, **meta)
+        assert patterns == []
+
+        matching_patterns = [
+            "{0}:{1}:{2}".format(NAMERE, self.namespace, "fo+"),
+            "{0}:{1}:{2}".format(NAMEPREFIX, self.namespace, "fo"),
+        ]
+        patterns = get_matched_subscription_patterns(non_matching_patterns + matching_patterns, **meta)
+        assert patterns == matching_patterns
+
+    def test_perf_get_subscribers(self):
+        pytest.skip("usually we do no performance tests")
+        password = u"password"
+        subscriptions = [
+            "{0}:{1}".format(ITEMID, self.item.meta[ITEMID]),
+            "{0}:{1}:{2}".format(NAME, self.namespace, self.item_name),
+            "{0}:{1}:{2}".format(TAGS, self.namespace, self.tagname),
+            "{0}:{1}:{2}".format(NAMEPREFIX, self.namespace, u"fo"),
+            "{0}:{1}:{2}".format(NAMERE, self.namespace, r"\wo")
+        ]
+        users = set()
+        expected_names = set()
+        for i in xrange(10000):
+            i = unicode(i)
+            user.create_user(username=i, password=password, email="{0}@example.org".format(i),
+                             validate=False, locale=u'en')
+            user_ = user.User(name=i, password=password)
+            users.add(user_)
+            expected_names.add(user_.name0)
+
+        users_sliced = list(users)[:100]
+        expected_names_sliced = {user_.name0 for user_ in users_sliced}
+        tests = [(users_sliced, expected_names_sliced), (users, expected_names)]
+
+        import time
+        for users_, expected_names_ in tests:
+            print "\nTesting {0} subscribers from a total of {1} users".format(
+                len(users_), len(users))
+            for subscription in subscriptions:
+                for user_ in users_:
+                    user_.profile._meta[SUBSCRIPTIONS] = [subscription]
+                    user_.save(force=True)
+                t = time.time()
+                subscribers = get_subscribers(**self.item.meta)
+                elapsed_time = time.time() - t
+                print "{0}: {1} s".format(subscription.split(':', 1)[0], elapsed_time)
+                subscribers_names = {subscriber.name for subscriber in subscribers}
+                assert subscribers_names == expected_names_
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/util/diff_datastruct.py	Tue Oct 29 11:39:57 2013 +0100
@@ -0,0 +1,94 @@
+# Copyright: 2013 MoinMoin:AnaBalica
+# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
+
+import difflib
+from types import NoneType
+from collections import Hashable
+
+INSERT = u"insert"
+DELETE = u"delete"
+REPLACE = u"replace"
+
+
+class UndefinedType(object):
+    """ Represents a non-existing value """
+
+
+Undefined = UndefinedType()
+
+
+def diff(d1, d2, basekeys=None):
+    """ Get the diff of 2 datastructures (usually 2 meta dicts)
+
+    :param d1: old datastructure
+    :param d2: new datastructure
+    :param basekeys: list of data keys' basenames (default: None, meaning [])
+    :return: a list of tuples of the format (<change type>, <basekeys>, <value>)
+             that can be used to format a diff
+    """
+    if basekeys is None:
+        basekeys = []
+    changes = []
+
+    if isinstance(d1, UndefinedType) and isinstance(d2, (dict, list, )):
+        d1 = type(d2)()
+    elif isinstance(d2, UndefinedType) and isinstance(d1, (dict, list, )):
+        d2 = type(d1)()
+    if isinstance(d1, dict) and isinstance(d2, dict):
+        added = set(d2) - set(d1)
+        removed = set(d1) - set(d2)
+        all_ = set(d1) | set(d2)
+        for key in sorted(all_):
+            keys = basekeys + [key]
+            if key in added:
+                changes.extend(diff(Undefined, d2[key], keys))
+            elif key in removed:
+                changes.extend(diff(d1[key], Undefined, keys))
+            else:
+                changes.extend(diff(d1[key], d2[key], keys))
+    elif isinstance(d1, list) and isinstance(d2, list):
+        hashable = all(isinstance(d1, unicode) or all(
+            isinstance(v, Hashable) for v in d) for d in [d1, d2])
+        if hashable:
+            matches = difflib.SequenceMatcher(None, d1, d2)
+            for tag, d1_start, d1_end, d2_start, d2_end in matches.get_opcodes():
+                if tag == REPLACE:
+                    changes.extend([(DELETE, basekeys, d1[d1_start:d1_end]),
+                                    (INSERT, basekeys, d2[d2_start:d2_end])])
+                elif tag == DELETE:
+                    changes.append((DELETE, basekeys, d1[d1_start:d1_end]))
+                elif tag == INSERT:
+                    changes.append((INSERT, basekeys, d2[d2_start:d2_end]))
+        else:
+            changes.extend(diff(unicode(d1), unicode(d2), basekeys))
+    elif any(isinstance(d, (NoneType, bool, int, long, float, unicode, )) for d in (d1, d2)):
+        if isinstance(d1, UndefinedType):
+            changes.append((INSERT, basekeys, d2))
+        elif isinstance(d2, UndefinedType):
+            changes.append((DELETE, basekeys, d1))
+        elif type(d1) == type(d2):
+            if d1 != d2:
+                changes.extend([(DELETE, basekeys, d1), (INSERT, basekeys, d2)])
+        else:
+            raise TypeError(
+                "Unsupported diff between {0} and {1} data types".format(
+                    type(d1), type(d2)))
+    else:
+        raise TypeError(
+            "Unsupported diff between {0} and {1} data types".format(
+                type(d1), type(d2)))
+    return changes
+
+
+def make_text_diff(changes):
+    """ Transform change tuples into text diffs
+
+    :param changes: a list of tuples of the format (<change type>, <basekeys>, <value>)
+                    that represent a diff
+    :return: a generator of text diffs
+    """
+    marker = {INSERT: u"+", DELETE: u"-"}
+    for change_type, keys, value in changes:
+        yield "{0} {1}{2}{3}".format(marker[change_type],
+                                     ".".join(unicode(key) for key in keys),
+                                     ": " if keys else "", unicode(value))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/util/notifications.py	Tue Oct 29 11:39:57 2013 +0100
@@ -0,0 +1,222 @@
+# Copyright: 2013 MoinMoin:AnaBalica
+# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
+
+"""
+    MoinMoin - Notifications
+"""
+
+from io import BytesIO
+
+from blinker import ANY
+from urlparse import urljoin
+from whoosh.query import Term, And
+
+from flask import url_for, g as flaskg
+
+from MoinMoin.constants.keys import (ACTION_COPY, ACTION_RENAME, ACTION_REVERT,
+                                     ACTION_SAVE, ACTION_TRASH, ALL_REVS, CONTENTTYPE,
+                                     MTIME, NAME_EXACT, WIKINAME)
+from MoinMoin.i18n import _, L_, N_
+from MoinMoin.i18n import force_locale
+from MoinMoin.items.content import Content
+from MoinMoin.mail.sendmail import sendmail
+from MoinMoin.themes import render_template
+from MoinMoin.signalling.signals import item_modified
+from MoinMoin.util.subscriptions import get_subscribers
+from MoinMoin.util.diff_datastruct import make_text_diff, diff as dict_diff
+
+from MoinMoin import log
+logging = log.getLogger(__name__)
+
+# additional action values
+ACTION_CREATE = u"CREATE"
+ACTION_MODIFY = u"MODIFY"
+
+# destroy types
+DESTROY_REV = u"DESTROY_REV"
+DESTROY_ALL = u"DESTROY_ALL"
+
+
+def msgs():
+    """ Encapsulates the main notification messages
+
+    :return: a dictionary of notification messages
+    """
+    _ = lambda x: x
+    messages = {
+        ACTION_CREATE: _("The '%(item_name)s' item on '%(wiki_name)s' has been created by %(user_name)s:"),
+        ACTION_MODIFY: _("The '%(item_name)s' item on '%(wiki_name)s' has been modified by %(user_name)s:"),
+        ACTION_RENAME: _("The '%(item_name)s' item on '%(wiki_name)s' has been renamed by %(user_name)s:"),
+        ACTION_COPY: _("The '%(item_name)s' item on '%(wiki_name)s' has been copied by %(user_name)s:"),
+        ACTION_REVERT: _("The '%(item_name)s' item on '%(wiki_name)s' has been reverted by %(user_name)s:"),
+        ACTION_TRASH: _("The '%(item_name)s' item on '%(wiki_name)s' has been deleted by %(user_name)s:"),
+        DESTROY_REV: _("The '%(item_name)s' item on '%(wiki_name)s' has one revision destroyed by %(user_name)s:"),
+        DESTROY_ALL: _("The '%(item_name)s' item on '%(wiki_name)s' has been destroyed by %(user_name)s:"),
+    }
+    return messages
+
+MESSAGES = msgs()
+
+
+class Notification(object):
+    """
+    Represents a mail notification about an item change
+    """
+    txt_template = "mail/notification.txt"
+    html_template = "mail/notification_main.html"
+
+    def __init__(self, app, item_name, revs, **kwargs):
+        self.app = app
+        self.item_name = item_name
+        self.revs = revs
+        self.action = kwargs.get('action', None)
+        self.content = kwargs.get('content', None)
+        self.meta = kwargs.get('meta', None)
+        self.comment = kwargs.get('comment', None)
+        self.wiki_name = self.app.cfg.interwikiname
+
+        if self.action == ACTION_SAVE:
+            self.action = ACTION_CREATE if len(self.revs) == 1 else ACTION_MODIFY
+
+        if self.action == ACTION_TRASH:
+            self.meta = self.revs[0].meta
+
+        kw = dict(item_name=self.item_name, wiki_name=self.wiki_name, user_name=flaskg.user.name0)
+        self.notification_sentence = L_(MESSAGES[self.action], **kw)
+
+    def get_content_diff(self):
+        """ Create a content diff for the last item change
+
+        :return: list of diff lines
+        """
+        if self.action in [DESTROY_REV, DESTROY_ALL, ]:
+            contenttype = self.meta[CONTENTTYPE]
+            oldfile, newfile = self.content, BytesIO("")
+        elif self.action == ACTION_TRASH:
+            contenttype = self.meta[CONTENTTYPE]
+            oldfile, newfile = self.revs[0].data, BytesIO("")
+        else:
+            newfile = self.revs[0].data
+            if len(self.revs) == 1:
+                contenttype = self.revs[0].meta[CONTENTTYPE]
+                oldfile = BytesIO("")
+            else:
+                from MoinMoin.apps.frontend.views import _common_type
+                contenttype = _common_type(self.revs[0].meta[CONTENTTYPE], self.revs[1].meta[CONTENTTYPE])
+                oldfile = self.revs[1].data
+        content = Content.create(contenttype)
+        return content._get_data_diff_text(oldfile, newfile)
+
+    def get_meta_diff(self):
+        """ Create a meta diff for the last item change
+
+        :return: a list of tuples of the format (<change type>, <basekeys>, <value>)
+                 that can be used to format a diff
+        """
+        if self.action in [ACTION_TRASH, DESTROY_REV, DESTROY_ALL, ]:
+            old_meta, new_meta = dict(self.meta), dict()
+        else:
+            new_meta = dict(self.revs[0].meta)
+            if len(self.revs) == 1:
+                old_meta = dict()
+            else:
+                old_meta = dict(self.revs[1].meta)
+        meta_diff = dict_diff(old_meta, new_meta)
+        return meta_diff
+
+    def generate_diff_url(self, domain):
+        """ Generate the URL that leads to diff page of the last 2 revisions
+
+        :param domain: domain name
+        :return: the absolute URL to the diff page
+        """
+        if len(self.revs) < 2:
+            return u""
+        else:
+            revid1 = self.revs[1].revid
+            revid2 = self.revs[0].revid
+        diff_rel_url = url_for('frontend.diff', item_name=self.item_name, rev1=revid1, rev2=revid2)
+        return urljoin(domain, diff_rel_url)
+
+    def render_templates(self, content_diff, meta_diff):
+        """ Render both plain text and HTML templates by providing all the
+        necessary arguments
+
+        :return: tuple consisting of plain text and HTML notification message
+         """
+        meta_diff_txt = list(make_text_diff(meta_diff))
+        domain = self.app.cfg.interwiki_map[self.app.cfg.interwikiname]
+        unsubscribe_url = urljoin(domain, url_for('frontend.subscribe_item',
+                                                  item_name=self.item_name))
+        diff_url = self.generate_diff_url(domain)
+        item_url = urljoin(domain, url_for('frontend.show_item', item_name=self.item_name))
+        if self.comment is not None:
+            comment = self.meta["comment"]
+        else:
+            comment = self.revs[0].meta["comment"]
+        txt_template = render_template(Notification.txt_template,
+                                       wiki_name=self.wiki_name,
+                                       notification_sentence=self.notification_sentence,
+                                       diff_url=diff_url,
+                                       item_url=item_url,
+                                       comment=comment,
+                                       content_diff_=content_diff,
+                                       meta_diff_=meta_diff_txt,
+                                       unsubscribe_url=unsubscribe_url,
+                                       )
+        html_template = render_template(Notification.html_template,
+                                        wiki_name=self.wiki_name,
+                                        notification_sentence=self.notification_sentence,
+                                        diff_url=diff_url,
+                                        item_url=item_url,
+                                        comment=comment,
+                                        content_diff_=content_diff,
+                                        meta_diff_=meta_diff,
+                                        unsubscribe_url=unsubscribe_url,
+        )
+        return txt_template, html_template
+
+
+def get_item_last_revisions(app, item_name):
+    """ Get 2 or less most recent item revisions from the index
+
+    :param app: local proxy app
+    :param item_name: the name of the item
+    :return: a list of revisions
+    """
+    terms = [Term(WIKINAME, app.cfg.interwikiname), Term(NAME_EXACT, item_name), ]
+    query = And(terms)
+    return list(
+        flaskg.storage.search(query, idx_name=ALL_REVS, sortedby=[MTIME],
+                              reverse=True, limit=2))
+
+
+@item_modified.connect_via(ANY)
+def send_notifications(app, item_name, **kwargs):
+    """ Send mail notifications to subscribers on item change
+
+    :param app: local proxy app
+    :param item_name: name of the changed item
+    :param kwargs: key/value pairs that contain extra information about the item
+                   required in order to create a notification
+    """
+    action = kwargs.get('action')
+    revs = get_item_last_revisions(app, item_name) if action not in [
+        DESTROY_REV, DESTROY_ALL, ] else []
+    notification = Notification(app, item_name, revs, **kwargs)
+    content_diff = notification.get_content_diff()
+    meta_diff = notification.get_meta_diff()
+
+    u = flaskg.user
+    meta = kwargs.get('meta') if action in [DESTROY_REV, DESTROY_ALL, ] else revs[0].meta._meta
+    subscribers = {subscriber for subscriber in get_subscribers(**meta) if
+                   subscriber.itemid != u.itemid}
+    subscribers_locale = {subscriber.locale for subscriber in subscribers}
+    for locale in subscribers_locale:
+        with force_locale(locale):
+            txt_msg, html_msg = notification.render_templates(content_diff, meta_diff)
+            subject = L_('[%(moin_name)s] Update of "%(item_name)s" by %(user_name)s',
+                         moin_name=app.cfg.interwikiname, item_name=item_name, user_name=u.name0)
+            subscribers_emails = [subscriber.email for subscriber in subscribers
+                                  if subscriber.locale == locale]
+            sendmail(subject, txt_msg, to=subscribers_emails, html=html_msg)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/util/subscriptions.py	Tue Oct 29 11:39:57 2013 +0100
@@ -0,0 +1,91 @@
+# Copyright: 2013 MoinMoin:AnaBalica
+# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
+
+"""
+    MoinMoin - Subscriptions
+"""
+
+import re
+from collections import namedtuple
+from itertools import chain
+
+from flask import g as flaskg
+
+from whoosh.query import Term, Or
+
+from MoinMoin.constants.keys import (DEFAULT_LOCALE, EMAIL, EMAIL_UNVALIDATED, ITEMID,
+                                     LATEST_REVS, LOCALE, NAME, NAMERE, NAMEPREFIX,
+                                     NAMESPACE, SUBSCRIPTION_IDS, SUBSCRIPTION_PATTERNS, TAGS)
+from MoinMoin import log
+logging = log.getLogger(__name__)
+
+
+Subscriber = namedtuple('Subscriber', [ITEMID, NAME, EMAIL, LOCALE])
+
+
+def get_subscribers(**meta):
+    """ Get all users that are subscribed to the item
+
+    :param meta: key/value pairs from item metadata - itemid, name, namespace, tags keys
+    :return: a set of Subscriber objects
+    """
+    itemid = meta.get(ITEMID)
+    name = meta.get(NAME)
+    namespace = meta.get(NAMESPACE)
+    tags = meta.get(TAGS)
+    terms = []
+    if itemid is not None:
+        terms.extend([Term(SUBSCRIPTION_IDS, "{0}:{1}".format(ITEMID, itemid))])
+    if namespace is not None:
+        if name is not None:
+            terms.extend(Term(SUBSCRIPTION_IDS, "{0}:{1}:{2}".format(NAME, namespace, name_))
+                         for name_ in name)
+        if tags is not None:
+            terms.extend(Term(SUBSCRIPTION_IDS, "{0}:{1}:{2}".format(TAGS, namespace, tag))
+                         for tag in tags)
+    query = Or(terms)
+    with flaskg.storage.indexer.ix[LATEST_REVS].searcher() as searcher:
+        result_iterators = [searcher.search(query, limit=None), ]
+        subscription_patterns = searcher.lexicon(SUBSCRIPTION_PATTERNS)
+        patterns = get_matched_subscription_patterns(subscription_patterns, **meta)
+        result_iterators.extend(searcher.documents(subscription_patterns=pattern) for pattern in patterns)
+        subscribers = set()
+        for user in chain.from_iterable(result_iterators):
+            email = user.get(EMAIL)
+            if email:
+                from MoinMoin.user import User
+                u = User(uid=user.get(ITEMID))
+                if u.may.read(name):
+                    locale = user.get(LOCALE, DEFAULT_LOCALE)
+                    subscribers.add(Subscriber(user[ITEMID], user[NAME][0], email, locale))
+    return subscribers
+
+
+def get_matched_subscription_patterns(subscription_patterns, **meta):
+    """ Get all the subscriptions with patterns that match at least one of item names
+
+    :param subscription_patterns: a list of subscription patterns (the ones that
+                                    start with NAMERE or NAMEPREFIX)
+    :param meta: key/value pairs from item metadata - name and namespace keys
+    :return: a list of matched subscription patterns
+    """
+    item_names = meta.get(NAME)
+    item_namespace = meta.get(NAMESPACE)
+    matched_subscriptions = []
+    for subscription in subscription_patterns:
+        keyword, value = subscription.split(":", 1)
+        if keyword in (NAMEPREFIX, NAMERE, ) and item_namespace is not None and item_names:
+            namespace, pattern = value.split(":", 1)
+            if item_namespace == namespace:
+                if keyword == NAMEPREFIX:
+                    if any(name.startswith(pattern) for name in item_names):
+                        matched_subscriptions.append(subscription)
+                elif keyword == NAMERE:
+                    try:
+                        pattern = re.compile(pattern, re.U)
+                    except re.error:
+                        logging.error("Subscription pattern '{0}' has failed compilation.".format(pattern))
+                        continue
+                    if any(pattern.search(name) for name in item_names):
+                        matched_subscriptions.append(subscription)
+    return matched_subscriptions
--- a/docs/admin/configure.rst	Mon Sep 23 20:45:13 2013 +0530
+++ b/docs/admin/configure.rst	Tue Oct 29 11:39:57 2013 +0100
@@ -1348,6 +1348,9 @@
     MOINCFG = LocalConfig
     DEBUG = True
 
+
+.. _mail-configuration:
+
 Mail configuration
 ==================
 
@@ -1355,15 +1358,17 @@
 --------------
 Moin can optionally send E-Mail. Possible uses:
 
-* send out item change notifications.
+* send out item change notifications
 * enable users to reset forgotten passwords
+* inform admins about runtime exceptions
 
 You need to configure some settings before sending E-Mail can be supported::
 
     # the "from:" address [Unicode]
     mail_from = u"wiki <wiki@example.org>"
 
-    # a) using an SMTP server, e.g. "mail.provider.com" (None to disable mail)
+    # a) using an SMTP server, e.g. "mail.provider.com" with optional `:port`
+    appendix, which defaults to 25 (set None to disable mail)
     mail_smarthost = "smtp.example.org"
 
     # if you need to use SMTP AUTH at your mail_smarthost:
@@ -1378,6 +1383,19 @@
 
    describe more moin configuration
 
+Admin Traceback E-Mails
+-----------------------
+If you want to enable admins to receive Python tracebacks, you need to configure
+the following::
+
+    # list of admin emails
+    admin_emails = [u"admin <admin@example.org>"]
+
+    # send tracebacks to admins
+    email_tracebacks = True
+
+
+Please also check the logging configuration example in `docs/examples/config/logging/email`.
 
 User E-Mail Address Verification
 --------------------------------
--- a/docs/admin/install.rst	Mon Sep 23 20:45:13 2013 +0530
+++ b/docs/admin/install.rst	Tue Oct 29 11:39:57 2013 +0100
@@ -37,40 +37,38 @@
 from your mercurial moin2 working directory. You should not run this as an
 administrator or root user; use your standard user account::
 
- ./quickinstall  # for Linux or other POSIX OSes
- # or
- quickinstall.bat  # for Windows
+ python quickinstall.py
 
-This will use virtualenv to create a directory `env/` within the current directory,
-create a virtual environment for MoinMoin and then install moin2 including all dependencies into that directory.
-`pip` will automatically fetch all dependencies from PyPI and install them, so this may take a while.
+This will use virtualenv to create a directory `../venv-PROJECT-PYTHON/`
+(PROJECT is same as your current project directory, e.g. moin-2.0, PYTHON is
+the name of your python interpreter, e.g. python), create a virtual environment
+for MoinMoin and then install moin2 including all dependencies into that
+directory.
+
+`pip` will automatically fetch all dependencies from PyPI and install them, so
+this may take a while.
 It will also compile the translations (`*.po` files) to binary `*.mo` files.
 
 Please review the output of the quickinstall script, and check whether there were fatal errors.
 
-If you have a bad network connection that makes the downloads fail, you can try to do the steps in quickinstall manually.
-
 Further, the quickinstall script will create a `moin` script for your
 platform which you can use for starting the built-in server or invoke moin script commands.
 
 After you activated the virtual environment, the built-in server script, which is named 
 `moin`, will be in the standard PATH, so you can just run the command `moin` on the command line.
 
-**Note:** in this special mode, it won't copy the MoinMoin code to the env/ directory,
+**Note:** in this special mode, it won't copy the MoinMoin code to the virtualenv directory,
 it will run everything from your work dir, so you can modify code and directly try it out.
 You only need to do this installation procedure once.
 
-Using a different Python or a different virtualenv directory
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Using a different Python
+~~~~~~~~~~~~~~~~~~~~~~~~
 
-For example, if you want to use `PyPy` and want to name the virtualenv directory `env-pypy`,
-use::
+If you rather like a different Python, just use it to invoke the quickinstall.py
+script - the same python will then be used for the virtual env also::
 
- # for linux
- DIR=env-pypy
- PYTHON=/opt/pypy/bin/pypy
+ /opt/pypy/bin/pypy quickinstall.py  # for linux
 
-That way, you can test with different versions of Python in different virtualenv directories within your moin2 workdir.
 
 Activating the virtual env
 --------------------------
@@ -80,13 +78,16 @@
 nor the moin code nor the libraries it needs. Also, if you want to install
 additional software into the virtual environment, activate it before running pip!::
 
- source env/bin/activate  # for linux (or other posix OSes)
+ source ../venv-moin-2.0-python/bin/activate  # for linux (or other posix OSes)
  # or
- env\Scripts\activate.bat  # for windows
+ ..\venv-moin-2.0-python\Scripts\activate.bat  # for windows
 
 As you have activated the virtual env now, the moin command should be in your
 path now, so you can invoke it using "moin".
 
+Note: the quickinstall script outputs the correct commands for activating
+the virtual env and for the moin executable file.
+
 Letting moin find the wiki configuration
 ----------------------------------------
 
@@ -189,6 +190,9 @@
 
 If this is the case, try it manually::
 
+ # create a virtual environment:
+ virtualenv env
+
  # enter your virtual environment:
  source env/bin/activate
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/docs/examples/config/logging/email	Tue Oct 29 11:39:57 2013 +0100
@@ -0,0 +1,36 @@
+[DEFAULT]
+# List of admin emails, if left blank will extract the list from wikiconfig ADMIN_EMAILS
+admins=[]
+
+# Email subject, if left blank will display the default subject: [Sitename][Loglevel] Log message
+subject=u""
+
+# Default loglevel, to adjust verbosity: DEBUG, INFO, WARNING, ERROR, CRITICAL
+loglevel=ERROR
+
+# Email loglevel
+emailloglevel=ERROR
+
+[loggers]
+keys=root
+
+[handlers]
+keys=email
+
+[formatters]
+keys=mail
+
+[logger_root]
+level=%(loglevel)s
+handlers=email
+
+[handler_email]
+class=MoinMoin.log.EmailHandler
+level=%(emailloglevel)s
+formatter=default
+args=(%(admins)s, %(subject)s)
+
+[formatter_mail]
+format=%(asctime)s %(levelname)s %(name)s:%(lineno)d %(message)s
+datefmt=
+class=logging.Formatter
\ No newline at end of file
--- a/docs/index.rst	Mon Sep 23 20:45:13 2013 +0530
+++ b/docs/index.rst	Tue Oct 29 11:39:57 2013 +0100
@@ -25,6 +25,7 @@
    user/markups
    user/search
    user/namespaces
+   user/subscriptions
 
 Administrating MoinMoin
 =======================
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/docs/user/subscriptions.rst	Tue Oct 29 11:39:57 2013 +0100
@@ -0,0 +1,69 @@
+==================
+User subscriptions
+==================
+
+Users can subscribe to moin items in order to receive notifications about item
+changes. Item changes include:
+
+ * creation of a new item
+ * modification of an existing item
+ * renaming of an item
+ * reverting an item's revision
+ * copying of an item
+ * deletion of an item
+ * destruction of a revision
+ * destruction of all item's revisions
+
+Make sure that Moin is able to send E-Mails, see :ref:`mail-configuration`.
+
+Types of subscriptions
+======================
+
+There are 5 types of subscriptions:
+
+ * by itemid (`itemid:<itemid value>`)
+
+   This is the most common subscription to a single item. The user will be notified
+   even after the item is renamed, because itemid doesn't change. If you click on
+   *Subscribe* on item's page, then you will be subscribed using this type.
+ * by item name (`name:<namespace>:<name value>`),
+
+   The user will be notified, if the name matches any of the item names and also
+   its namespace. Keep in mind that an item can be renamed and notifications for
+   this item would stop working if the new name doesn't match any more.
+ * by tag name (`tags:<namespace>:<tag value>`)
+
+   The user will be notified, if the tag name matches any of the item tags and
+   its namespace.
+ * by a prefix name (`nameprefix:<namespace>:<name prefix>`)
+
+   Used for subscription to a set of items. The user will be notified, if at least
+   one of the item names starts with the given prefix and matches item's namespace.
+   For example if you want to receive notifications about all the items from the
+   default namespace whose name starts with `foo`, you can use `nameprefix::foo`.
+ * by a regular expression (`namere:<namespace>:<name regexp>`)
+
+   Used for subscription to a set of items. The user will be notified, if the
+   regexp matches any of the item names and also its namespace. For example,
+   if you want to receive notifications about all the items on wiki from the default
+   namespace, then you can use `namere::.*`
+
+
+Editing subscriptions
+=====================
+
+The itemid subscription is the most common one and will be used if you click on
+*Subscribe* on item's page. Respectively the *Unsubscribe* will remove the itemid
+subscription.
+
+If you were subscribed to an item by some other way rather than itemid subscription,
+then on *Unsubscribe* you will be told that it is impossible to remove the subscription
+and you need to edit it manually in the User Settings.
+
+All the subscriptions can be added/edited/removed in the User Settings,
+Subscriptions tab. Each subscription is listed on a single line and is
+case-sensitive. Empty lines are ignored.
+
+For itemid subscriptions, we additionally show the current first item name in
+parentheses (this is purely for your information, the name is not stored or used
+in any way).
--- a/quickinstall	Mon Sep 23 20:45:13 2013 +0530
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,45 +0,0 @@
-#!/bin/bash
-# create a virtual environment in directory $DIR/
-#
-# set PYTHON environment variable to change the python version
-# set DIR environment variable to change the virtual env directory
-# set VIRTUALENV environment variable to change the virtualenv command
-# for example: PYTHON=/usr/bin/pypy DIR=env-pypy ./quickinstall
-#
-# needs: virtualenv, pip
-
-DLC=dlc
-
-# if DIR is not given, use ./env
-if [ -z "$DIR" ]; then
-    DIR=env
-fi
-
-# find the right python version
-if [ -z "$PYTHON" ]; then
-    for PYTHON in python{2.7,2,}; do
-        hash $PYTHON 2>&- && break
-    done
-fi
-
-# find the right virtualenv version
-if [ -z "$VIRTUALENV" ]; then
-    for VIRTUALENV in virtualenv{2.7,2,}; do
-        hash $VIRTUALENV 2>&- && break
-    done
-fi
-
-$VIRTUALENV --no-site-packages --python $PYTHON $DIR || exit 1
-
-source $DIR/bin/activate || exit 1
-
-# first install babel, moin's setup.py will emit a warning if it is not there
-pip install --download-cache=$DLC babel || exit 1
-
-# "install" moin2 from repo to the env, this will also install required python
-# packages from pypi. we do this LAST, so that breakage is better visible.
-pip install --download-cache=$DLC -e . || exit 1
-
-# compile the translations
-python setup.py compile_catalog --statistics || exit 1
-
--- a/quickinstall.bat	Mon Sep 23 20:45:13 2013 +0530
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,19 +0,0 @@
-echo off
-echo.
-echo This is the windows version of the "quickinstall" file.
-echo.
-
-echo Creating a virtual environment in directory env/ ...
-virtualenv --no-site-packages env
-
-echo Activating virtual environment ...
-call env\Scripts\activate.bat
-
-echo Installing babel first ...
-pip install --download-cache=dlc babel
-
-echo Installing all required python packages from pypi ...
-pip install --download-cache=dlc -e .
-
-echo Compiling translations (not required if wiki is English only) ...
-python setup.py compile_catalog --statistics
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/quickinstall.py	Tue Oct 29 11:39:57 2013 +0100
@@ -0,0 +1,68 @@
+#!/usr/bin/python
+# Copyright: 2013 MoinMoin:BastianBlank
+# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
+"""
+create a virtual environment and install moin2 (in development mode) and
+its requirements.
+
+needs: virtualenv, pip
+"""
+
+import argparse
+import logging
+import os.path
+import subprocess
+import sys
+import virtualenv
+
+
+class QuickInstall(object):
+    def __init__(self, source, venv=None):
+        self.dir_source = source
+        if not venv:
+            base, source_name = os.path.split(source)
+            venv = os.path.join(base, 'venv-{}-{}'.format(source_name, os.path.basename(sys.executable)))
+        self.dir_venv = venv
+
+    def __call__(self):
+        self.do_venv()
+        self.do_install()
+        self.do_catalog()
+
+        sys.stdout.write("""
+Succesfully created or updated venv
+  {0}
+You can run MoinMoin as
+  {0}/bin/moin
+""".format(self.dir_venv))
+
+    def do_venv(self):
+        virtualenv.create_environment(self.dir_venv)
+
+    def do_install(self):
+        subprocess.check_call((
+            os.path.join(self.dir_venv, 'bin', 'pip'),
+            'install',
+            # XXX: move cache to XDG cache dir
+            '--download-cache',
+            os.path.join(os.path.dirname(self.dir_venv), '.pip-download-cache'),
+            '--editable',
+            self.dir_source
+        ))
+
+    def do_catalog(self):
+        subprocess.check_call((
+            os.path.join(self.dir_venv, 'bin', 'python'),
+            os.path.join(self.dir_source, 'setup.py'),
+            'compile_catalog', '--statistics',
+        ))
+
+
+if __name__ == '__main__':
+    logging.basicConfig(level=logging.INFO)
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument('venv', metavar='VENV', nargs='?', help='location of v(irtual)env')
+    args = parser.parse_args()
+
+    QuickInstall(os.path.dirname(os.path.realpath(sys.argv[0])), venv=args.venv)()
--- a/setup.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/setup.py	Tue Oct 29 11:39:57 2013 +0100
@@ -103,7 +103,7 @@
                               # note: pytest-pep8 1.0.3 needs pytest 2.3
         'whoosh>=2.5.0',  # needed for indexed search
         'sphinx>=1.1',  # needed to build the docs
-        'pdfminer',  # pdf -> text/plain conversion
+        'pdfminer>=20110515',  # pdf -> text/plain conversion
         'passlib>=1.6.0',  # strong password hashing (1.6 needed for consteq)
         'XStatic>=0.0.2',  # support for static file pypi packages
         'XStatic-Bootstrap>=3.0.0.1',
@@ -111,8 +111,6 @@
         'XStatic-CKEditor>=3.6.1.2',
         'XStatic-jQuery>=1.8.2',
         'XStatic-jQuery-File-Upload>=4.4.2',
-        'XStatic-JSON-js',
-        'XStatic-svgweb>=2011.2.3.2',
         'XStatic-TWikiDraw-moin>=2004.10.23.2',
         'XStatic-AnyWikiDraw>=0.14.2',
         'XStatic-svg-edit-moin>=2012.11.15.1',
--- a/wikiconfig.py	Mon Sep 23 20:45:13 2013 +0530
+++ b/wikiconfig.py	Tue Oct 29 11:39:57 2013 +0100
@@ -63,11 +63,9 @@
     # names below must be package names
     mod_names = [
         'jquery', 'jquery_file_upload',
-        'json_js',
         'bootstrap',
         'font_awesome',
         'ckeditor',
-        'svgweb',
         'svgedit_moin', 'twikidraw_moin', 'anywikidraw',
     ]
     pkg = __import__('xstatic.pkg', fromlist=mod_names)
@@ -76,6 +74,11 @@
         xs = XStatic(mod, root_url='/static', provider='local', protocol='http')
         serve_files.update([(xs.name, xs.base_dir)])
 
+    # list of admin emails
+    admin_emails = []
+    # send tracebacks to admins
+    email_tracebacks = False
+
 
 MOINCFG = Config  # Flask only likes uppercase stuff
 # Flask settings - see the flask documentation about their meaning