Merge branch '42cc_4254' of ssh://git.code.sf.net/p/allura/git into 42cc_4254
diff --git a/Allura/allura/config/app_cfg.py b/Allura/allura/config/app_cfg.py
index 7e8925c..e8cd735 100644
--- a/Allura/allura/config/app_cfg.py
+++ b/Allura/allura/config/app_cfg.py
@@ -26,7 +26,7 @@
 
 import allura
 # needed for tg.configuration to work
-from allura.lib import app_globals
+from allura.lib import app_globals, helpers
 
 log = logging.getLogger(__name__)
 
@@ -62,6 +62,7 @@
             autoescape=True,
             extensions=['jinja2.ext.do', 'jinja2.ext.i18n'])
         jinja2_env.install_gettext_translations(pylons.i18n)
+        jinja2_env.filters['filesizeformat'] = helpers.do_filesizeformat
         config['pylons.app_globals'].jinja2_env = jinja2_env
         # Jinja's unable to request c's attributes without strict_c
         config['pylons.strict_c'] = True
diff --git a/Allura/allura/controllers/repository.py b/Allura/allura/controllers/repository.py
index 14e5b1f..cd28efc 100644
--- a/Allura/allura/controllers/repository.py
+++ b/Allura/allura/controllers/repository.py
@@ -18,6 +18,7 @@
 
 import allura.tasks
 from allura.lib import security
+from allura.lib import utils
 from allura.lib import helpers as h
 from allura.lib import widgets as w
 from allura.lib.decorators import require_post
@@ -487,8 +488,10 @@
         else:
             force_display = 'force' in kw
             context = self._blob.context()
+            stats = utils.generate_code_stats(self._blob)
             return dict(
                 blob=self._blob,
+                stats=stats,
                 prev=context.get('prev', None),
                 next=context.get('next', None),
                 force_display=force_display
diff --git a/Allura/allura/lib/markdown_extensions.py b/Allura/allura/lib/markdown_extensions.py
index ba4fcdf..477fdc9 100644
--- a/Allura/allura/lib/markdown_extensions.py
+++ b/Allura/allura/lib/markdown_extensions.py
@@ -170,7 +170,8 @@
 
     def _expand_link(self, link):
         reference = self.alinks.get(link)
-        if not reference:
+        mailto = u'\x02amp\x03#109;\x02amp\x03#97;\x02amp\x03#105;\x02amp\x03#108;\x02amp\x03#116;\x02amp\x03#111;\x02amp\x03#58;'
+        if not reference and not link.startswith(mailto):
             return 'notfound'
         else:
             return ''
@@ -261,6 +262,7 @@
                 return
         if val.startswith('/'): return
         if val.startswith('.'): return
+        if val.startswith('mailto:'): return
         tag[attr] = '../' + val
 
     def _rewrite_abs(self, tag, attr):
diff --git a/Allura/allura/lib/plugin.py b/Allura/allura/lib/plugin.py
index 5342068..0a71818 100644
--- a/Allura/allura/lib/plugin.py
+++ b/Allura/allura/lib/plugin.py
@@ -414,17 +414,17 @@
             if 'tools' in project_template:
                 for i, tool in enumerate(project_template['tools'].keys()):
                     tool_config = project_template['tools'][tool]
+                    tool_options = tool_config.get('options', {})
+                    for k, v in tool_options.iteritems():
+                        if isinstance(v, basestring):
+                            tool_options[k] = \
+                                    string.Template(v).safe_substitute(
+                                        p.__dict__.get('root_project', {}))
                     app = p.install_app(tool,
                         mount_label=tool_config['label'],
                         mount_point=tool_config['mount_point'],
-                        ordinal=i+offset)
-                    if 'options' in tool_config:
-                        for option in tool_config['options']:
-                            value = tool_config['options'][option]
-                            if isinstance(value, basestring):
-                                value = string.Template(value).safe_substitute(
-                                        p.__dict__.get('root_project', {}))
-                            app.config.options[option] = value
+                        ordinal=i + offset,
+                        **tool_options)
                     if tool == 'wiki':
                         from forgewiki import model as WM
                         text = tool_config.get('home_text',
diff --git a/Allura/allura/lib/utils.py b/Allura/allura/lib/utils.py
index b70078a..65c7240 100644
--- a/Allura/allura/lib/utils.py
+++ b/Allura/allura/lib/utils.py
@@ -8,6 +8,7 @@
 import datetime
 import random
 import mimetypes
+import re
 from itertools import groupby
 
 import tg
@@ -410,3 +411,15 @@
             yield (tup[0], '<div id="l%s" class="code_block">%s</div>' % (num, tup[1]))
             num += 1
         yield 0, '</pre>'
+
+def generate_code_stats(blob):
+    stats = {'line_count': 0,
+             'code_size': 0,
+             'data_line_count': 0}
+    code = blob.text
+    lines = code.split('\n')
+    stats['code_size'] = blob.size
+    stats['line_count'] = len(lines)
+    spaces = re.compile(r'^\s*$')
+    stats['data_line_count'] = sum([1 for l in lines if not spaces.match(l)])
+    return stats
diff --git a/Allura/allura/model/artifact.py b/Allura/allura/model/artifact.py
index f887323..6455360 100644
--- a/Allura/allura/model/artifact.py
+++ b/Allura/allura/model/artifact.py
@@ -576,6 +576,9 @@
             'pubdate',
             ('artifact_ref.project_id', 'artifact_ref.mount_point'),
             (('ref_id', pymongo.ASCENDING),
+             ('pubdate', pymongo.DESCENDING)),
+            (('project_id', pymongo.ASCENDING),
+             ('app_config_id', pymongo.ASCENDING),
              ('pubdate', pymongo.DESCENDING))]
 
     _id = FieldProperty(S.ObjectId)
diff --git a/Allura/allura/model/project.py b/Allura/allura/model/project.py
index 5ca3f4c..ac62d41 100644
--- a/Allura/allura/model/project.py
+++ b/Allura/allura/model/project.py
@@ -34,6 +34,7 @@
 class ProjectFile(File):
     class __mongometa__:
         session = main_orm_session
+        indexes = [('project_id', 'category')]
 
     project_id=FieldProperty(S.ObjectId)
     category=FieldProperty(str)
@@ -274,7 +275,7 @@
         else:
             self.acl.append(ace)
     private = property(_get_private, _set_private)
-    
+
     @property
     def is_user_project(self):
         return self.shortname.startswith('u/')
@@ -415,7 +416,7 @@
                 for sm in app.sitemap:
                     entry = sm.bind_app(app)
                     entry.ui_icon='tool-%s' % ac.tool_name.lower()
-                    ordinal = ac.options.get('ordinal', 0) + delta_ordinal
+                    ordinal = int(ac.options.get('ordinal', 0)) + delta_ordinal
                     if ordinal > max_ordinal:
                         max_ordinal = ordinal
                     entries.append({'ordinal':ordinal,'entry':entry})
diff --git a/Allura/allura/model/repository.py b/Allura/allura/model/repository.py
index c5a73b5..a070e4e 100644
--- a/Allura/allura/model/repository.py
+++ b/Allura/allura/model/repository.py
@@ -108,6 +108,10 @@
         '''Return a file-like object that contains the contents of the blob'''
         raise NotImplementedError, 'open_blob'
 
+    def blob_size(self, blob):
+        '''Return a blob size in bytes'''
+        raise NotImplementedError, 'blob_size'
+
     @classmethod
     def shorthand_for_commit(cls, oid):
         return '[%s]' % oid[:6]
@@ -204,6 +208,8 @@
         return self._impl.commit_context(commit)
     def open_blob(self, blob):
         return self._impl.open_blob(blob)
+    def blob_size(self, blob):
+        return self._impl.blob_size(blob)
     def shorthand_for_commit(self, oid):
         return self._impl.shorthand_for_commit(oid)
     def symbolics_for_commit(self, commit):
@@ -1108,6 +1114,10 @@
         return iter(self.open())
 
     @LazyProperty
+    def size(self):
+        return self.repo.blob_size(self)
+
+    @LazyProperty
     def text(self):
         return self.open().read()
 
diff --git a/Allura/allura/templates/repo/file.html b/Allura/allura/templates/repo/file.html
index 9928258..7a60a49 100644
--- a/Allura/allura/templates/repo/file.html
+++ b/Allura/allura/templates/repo/file.html
@@ -68,9 +68,13 @@
   {% elif blob.has_html_view or blob.has_pypeline_view or force_display %}
     <p><a href="?format=raw">Download this file</a></p>
     <div class="clip grid-19">
-      <h3><span class="ico-l"><b data-icon="{{g.icons['table'].char}}" class="ico {{g.icons['table'].css}}"></b> {{h.really_unicode(blob.name)}}</span></h3>
+      <h3>
+        <span class="ico-l"><b data-icon="{{g.icons['table'].char}}" class="ico {{g.icons['table'].css}}"></b> {{h.really_unicode(blob.name)}}</span>
+        &nbsp;&nbsp;
+        {{ stats.line_count }} lines ({{ stats.data_line_count }} with data), {{ stats.code_size|filesizeformat }}
+      </h3>
       {% if blob.has_pypeline_view %}
-        {{h.render_any_markup(blob.name, blob.text, code_mode=True, linenumbers_style=h.TABLE)}}
+        {{h.render_any_markup(blob.name, blob.text, code_mode=True)}}
       {% else %}
         {{g.highlight(blob.text, filename=blob.name)}}
       {% endif %}
diff --git a/Allura/allura/tests/unit/test_utils.py b/Allura/allura/tests/unit/test_utils.py
new file mode 100644
index 0000000..2aecd61
--- /dev/null
+++ b/Allura/allura/tests/unit/test_utils.py
@@ -0,0 +1,28 @@
+import unittest
+from mock import Mock
+
+from alluratest.controller import setup_unit_test
+from allura.lib.utils import generate_code_stats
+
+class TestCodeStats(unittest.TestCase):
+
+    def setUp(self):
+        setup_unit_test()
+
+    def test_generate_code_stats(self):
+        blob = Mock()
+        blob.text = \
+"""class Person(object):
+    
+    def __init__(self, name='Alice'):
+        self.name = name
+
+    def greetings(self):
+        print "Hello, %s" % self.name
+\t\t"""
+        blob.size = len(blob.text)
+
+        stats = generate_code_stats(blob)
+        assert stats['line_count'] == 8
+        assert stats['data_line_count'] == 5
+        assert stats['code_size'] == len(blob.text)
diff --git a/ForgeBlog/forgeblog/command/rssfeeds.py b/ForgeBlog/forgeblog/command/rssfeeds.py
index 6a21127..a02c1ff 100644
--- a/ForgeBlog/forgeblog/command/rssfeeds.py
+++ b/ForgeBlog/forgeblog/command/rssfeeds.py
@@ -15,7 +15,7 @@
 from allura import model as M
 from forgeblog import model as BM
 from forgeblog import version
-from forgeblog.main import ForgeBlogApp 
+from forgeblog.main import ForgeBlogApp
 from allura.lib import exceptions
 
 html2text.BODY_WIDTH = 0
@@ -78,10 +78,6 @@
             else:
                 res_data = u"%s\n" % res_data
 
-        if data[-1:] == "\n" and self.custom_tag_opened:
-            res_data = u"%s%s" % (res_data, self.CUSTTAG_CLOSE)
-            self.custom_tag_opened = False
-
         self.result_doc = u"%s%s" % (self.result_doc, res_data)
 
     def handle_comment(self, data):
@@ -174,29 +170,38 @@
                 content = u''
                 for ct in e.content:
                     if ct.type != 'text/html':
-                        content = u"%s<p>%s</p>" % (content, ct.value)
+                        content += '[plain]%s[/plain]' % ct.value
                     else:
-                        content = content + ct.value
-            else:
-                content = e.summary
+                        if False:
+                            # FIXME: disabled until https://sourceforge.net/p/allura/tickets/4345
+                            # because the bad formatting from [plain] is worse than bad formatting from unintentional markdown syntax
+                            parser = MDHTMLParser()
+                            parser.feed(ct.value)
+                            parser.close() # must be before using the result_doc
+                            markdown_content = html2text.html2text(parser.result_doc, baseurl=e.link)
+                        else:
+                            markdown_content = html2text.html2text(ct.value, baseurl=e.link)
 
-            content = u'%s <a href="%s">link</a>' % (content, e.link)
-            parser = MDHTMLParser()
-            parser.feed(content)
-            parser.close()
-            content = html2text.html2text(parser.result_doc, e.link)
+                        content += markdown_content
+            else:
+                content = '[plain]%s[/plain]' % getattr(e, 'summary',
+                                                    getattr(e, 'subtitle',
+                                                        getattr(e, 'title')))
+
+            # plain tags fix
+            content += u' [link](%s)' % e.link
 
             updated = datetime.utcfromtimestamp(mktime(e.updated_parsed))
 
-            base_slug = BM.BlogPost.make_base_slug(title, updated, feed_url)
-            b_count = BM.BlogPost.query.find(dict(slug=base_slug)).count()
+            base_slug = BM.BlogPost.make_base_slug(title, updated)
+            b_count = BM.BlogPost.query.find(dict(slug=base_slug, app_config_id=appid)).count()
             if b_count == 0:
                 post = BM.BlogPost(title=title, text=content, timestamp=updated,
                                app_config_id=appid,
                                tool_version={'blog': version.__version__},
                                state='published')
                 post.neighborhood_id=c.project.neighborhood_id
-                post.make_slug(source=feed_url)
+                post.make_slug()
                 post.commit()
 
         session(BM.BlogPost).flush()
diff --git a/ForgeBlog/forgeblog/model/blog.py b/ForgeBlog/forgeblog/model/blog.py
index 0644634..8b7f03e 100644
--- a/ForgeBlog/forgeblog/model/blog.py
+++ b/ForgeBlog/forgeblog/model/blog.py
@@ -139,26 +139,17 @@
         return '%s@%s%s' % (self.title.replace('/', '.'), domain, config.common_suffix)
 
     @staticmethod
-    def make_base_slug(title, timestamp, source = None):
+    def make_base_slug(title, timestamp):
         slugsafe = ''.join(
             ch.lower()
             for ch in title.replace(' ', '-')
             if ch.isalnum() or ch == '-')
-        if source is None:
-            base = '%s/%s' % (
+        return '%s/%s' % (
                 timestamp.strftime('%Y/%m'),
                 slugsafe)
-        else:
-            m = hashlib.md5()
-            m.update(source)
-            link_hash_key = m.hexdigest()[:16]
-            base = '%s/%s/%s' % (
-                timestamp.strftime('%Y/%m'),
-                link_hash_key, slugsafe)
-        return base
 
-    def make_slug(self, source = None):
-        base = BlogPost.make_base_slug(self.title, self.timestamp, source)
+    def make_slug(self):
+        base = BlogPost.make_base_slug(self.title, self.timestamp)
         self.slug = base
         while True:
             try:
@@ -166,7 +157,6 @@
                 return self.slug
             except DuplicateKeyError:
                 self.slug = base + '-%.3d' % randint(0,999)
-                return self.slug
 
     def url(self):
         return self.app.url + self.slug + '/'
diff --git a/ForgeGit/forgegit/model/git_repo.py b/ForgeGit/forgegit/model/git_repo.py
index 7601b06..54a8dc1 100644
--- a/ForgeGit/forgegit/model/git_repo.py
+++ b/ForgeGit/forgegit/model/git_repo.py
@@ -272,6 +272,9 @@
         return _OpenedGitBlob(
             self._object(blob.object_id).data_stream)
 
+    def blob_size(self, blob):
+        return self._object(blob.object_id).data_stream.size
+
     def _setup_hooks(self):
         'Set up the git post-commit hook'
         text = self.post_receive_template.substitute(
diff --git a/ForgeHg/forgehg/model/hg.py b/ForgeHg/forgehg/model/hg.py
index 75a858e..2c0017f 100644
--- a/ForgeHg/forgehg/model/hg.py
+++ b/ForgeHg/forgehg/model/hg.py
@@ -257,6 +257,10 @@
         fctx = self._hg[blob.commit.object_id][h.really_unicode(blob.path()).encode('utf-8')[1:]]
         return StringIO(fctx.data())
 
+    def blob_size(self, blob):
+        fctx = self._hg[blob.commit.object_id][h.really_unicode(blob.path()).encode('utf-8')[1:]]
+        return fctx.size()
+
     def _setup_hooks(self):
         'Set up the hg changegroup hook'
         cp = ConfigParser()
diff --git a/ForgeSVN/forgesvn/model/svn.py b/ForgeSVN/forgesvn/model/svn.py
index 128dc2e..d6b7819 100644
--- a/ForgeSVN/forgesvn/model/svn.py
+++ b/ForgeSVN/forgesvn/model/svn.py
@@ -431,6 +431,24 @@
             revision=self._revision(blob.commit.object_id))
         return StringIO(data)
 
+    def blob_size(self, blob):
+        try:
+            data = self._svn.list(
+                   self._url + blob.path(),
+                   revision=self._revision(blob.commit.object_id),
+                   dirent_fields=pysvn.SVN_DIRENT_SIZE)
+        except pysvn.ClientError:
+            log.info('ClientError getting filesize %r %r, returning 0', blob.path(), self._repo, exc_info=True)
+            return 0
+
+        try:
+            size = data[0][0]['size']
+        except (IndexError, KeyError):
+            log.info('Error getting filesize: bad data from svn client %r %r, returning 0', blob.path(), self._repo, exc_info=True)
+            size = 0
+        
+        return size
+
     def _setup_hooks(self):
         'Set up the post-commit and pre-revprop-change hooks'
         text = self.post_receive_template.substitute(
diff --git a/ForgeSVN/forgesvn/widgets.py b/ForgeSVN/forgesvn/widgets.py
index eaf3c82..1cfda7d 100644
--- a/ForgeSVN/forgesvn/widgets.py
+++ b/ForgeSVN/forgesvn/widgets.py
@@ -1,3 +1,5 @@
+import re
+
 from formencode import validators as fev
 
 import ew as ew_core
@@ -5,8 +7,20 @@
 
 from allura.lib.widgets.forms import ForgeForm
 
+class ValidateSvnUrl(fev.URL):
+    url_re = re.compile(r'''
+        ^(http|https|svn)://
+        (?:[%:\w]*@)?                              # authenticator
+        (?P<domain>[a-z0-9][a-z0-9\-]{,62}\.)*     # subdomain
+        (?P<tld>[a-z]{2,63}|xn--[a-z0-9\-]{2,59})  # top level domain
+        (?::[0-9]{1,5})?                           # port
+        # files/delims/etc
+        (?P<path>/[a-z0-9\-\._~:/\?#\[\]@!%\$&\'\(\)\*\+,;=]*)?
+        $
+    ''', re.I | re.VERBOSE)
+
 class ImportForm(ForgeForm):
     submit_text='Import'
     class fields(ew_core.NameList):
         checkout_url = ew.TextField(label='Checkout URL',
-                                    validator=fev.URL())
+                                    validator=ValidateSvnUrl(not_empty=True))
diff --git a/ForgeTracker/forgetracker/model/ticket.py b/ForgeTracker/forgetracker/model/ticket.py
index 0e81a91..d60d8e1 100644
--- a/ForgeTracker/forgetracker/model/ticket.py
+++ b/ForgeTracker/forgetracker/model/ticket.py
@@ -211,6 +211,7 @@
             'ticket_num',
             'app_config_id',
             ('app_config_id', 'custom_fields._milestone'),
+            'import_id',
             ]
         unique_indexes = [
             ('app_config_id', 'ticket_num'),
diff --git a/ForgeWiki/forgewiki/tests/functional/test_root.py b/ForgeWiki/forgewiki/tests/functional/test_root.py
index 43dd786..d99abc4 100644
--- a/ForgeWiki/forgewiki/tests/functional/test_root.py
+++ b/ForgeWiki/forgewiki/tests/functional/test_root.py
@@ -488,3 +488,31 @@
         response = self.app.get('/wiki/browse_pages/')
         assert 'aaa' in response
         assert '?deleted=True">bbb' in response
+
+    def test_mailto_links(self):
+        self.app.get('/wiki/test_mailto/')
+        params = {
+            'title':'test_mailto',
+            'text':'''
+* Automatic mailto #1 <darth.vader@deathstar.org>
+* Automatic mailto #2 <mailto:luke.skywalker@tatooine.org>
+* Handmaid mailto <a href="mailto:yoda@jedi.org">Email Yoda</a>
+''',
+            'labels':'',
+            'labels_old':'',
+            'viewable_by-0.id':'all'}
+        self.app.post('/wiki/test_mailto/update', params=params)
+        r = self.app.get('/wiki/test_mailto/')
+        mailto_links = 0
+        for link in r.html.findAll('a'):
+            if link.get('href') == 'mailto:darth.vader@deathstar.org':
+                assert 'notfound' not in link.get('class', '')
+                mailto_links +=1
+            if link.get('href') == 'mailto:luke.skywalker@tatooine.org':
+                assert 'notfound' not in link.get('class', '')
+                mailto_links += 1
+            if link.get('href') == 'mailto:yoda@jedi.org':
+                assert link.contents == ['Email Yoda']
+                assert 'notfound' not in link.get('class', '')
+                mailto_links += 1
+        assert mailto_links == 3, 'Wrong number of mailto links'
diff --git a/scripts/project-import.py b/scripts/project-import.py
index 3b2685a..b7cd686 100644
--- a/scripts/project-import.py
+++ b/scripts/project-import.py
@@ -4,6 +4,7 @@
 import logging
 import multiprocessing
 import re
+import string
 import sys
 
 import colander as col
@@ -165,6 +166,7 @@
     shortname = p.shortname or p.name.shortname
     project = M.Project.query.get(shortname=shortname,
             neighborhood_id=nbhd._id)
+    project_template = nbhd.get_project_template()
 
     if project and not (options.update and p.shortname):
         log.warning('[%s] Skipping existing project "%s". To update an existing '
@@ -186,6 +188,22 @@
         log.info('[%s] Updating project "%s".' % (worker_name, shortname))
 
     project.notifications_disabled = True
+
+    if options.ensure_tools and 'tools' in project_template:
+        for i, tool in enumerate(project_template['tools'].iterkeys()):
+            tool_config = project_template['tools'][tool]
+            if project.app_instance(tool_config['mount_point']):
+                continue
+            tool_options = tool_config.get('options', {})
+            for k, v in tool_options.iteritems():
+                if isinstance(v, basestring):
+                    tool_options[k] = string.Template(v).safe_substitute(
+                        project.root_project.__dict__.get('root_project', {}))
+            project.install_app(tool,
+                    mount_label=tool_config['label'],
+                    mount_point=tool_config['mount_point'],
+                    **tool_options)
+
     project.summary = p.summary
     project.short_description = p.description
     project.external_homepage = p.external_homepage
@@ -267,6 +285,10 @@
             action='store_true',
             help='Update existing projects. Without this option, existing '
                  'projects will be skipped.')
+    parser.add_argument('--ensure-tools', dest='ensure_tools', default=False,
+            action='store_true',
+            help='Check nbhd project template for default tools, and install '
+                 'them on the project(s) if not already installed.')
     parser.add_argument('--nprocs', '-n', action='store', dest='nprocs', type=int,
             help='Number of processes to divide the work among.',
             default=multiprocessing.cpu_count())