diff options
-rw-r--r-- | AUTHORS | 1 | ||||
-rw-r--r-- | mediagoblin/static/css/base.css | 20 | ||||
-rw-r--r-- | mediagoblin/templates/mediagoblin/admin/panel.html | 2 | ||||
-rw-r--r-- | mediagoblin/templates/mediagoblin/user_pages/media.html | 24 | ||||
-rw-r--r-- | mediagoblin/tests/test_timesince.py | 57 | ||||
-rw-r--r-- | mediagoblin/tools/template.py | 5 | ||||
-rw-r--r-- | mediagoblin/tools/timesince.py | 95 | ||||
-rw-r--r-- | mediagoblin/tools/translate.py | 20 | ||||
-rw-r--r-- | setup.py | 1 |
9 files changed, 215 insertions, 10 deletions
@@ -30,6 +30,7 @@ Thank you! * Greg Grossmeier * Jakob Kramer * Jef van Schendel +* Jessica Tallon * Jim Campbell * Joar Wandborg * Jorge Araya Navarro diff --git a/mediagoblin/static/css/base.css b/mediagoblin/static/css/base.css index 8df9f2e1..0cb36753 100644 --- a/mediagoblin/static/css/base.css +++ b/mediagoblin/static/css/base.css @@ -360,6 +360,25 @@ textarea#description, textarea#bio { font-size: 0.9em; } +a.comment_authorlink { + text-decoration: none; + padding-right: 5px; + font-weight: bold; + padding-left: 2px; +} + +a.comment_authorlink:hover { + text-decoration: underline; +} + +a.comment_whenlink { + text-decoration: none; +} + +a.comment_whenlink:hover { + text-decoration: underline; +} + .comment_content { margin-left: 8px; margin-top: 8px; @@ -540,6 +559,7 @@ table.media_panel { table.media_panel th { font-weight: bold; padding-bottom: 4px; + text-align: left; } diff --git a/mediagoblin/templates/mediagoblin/admin/panel.html b/mediagoblin/templates/mediagoblin/admin/panel.html index 6bcb5c24..1c3c866e 100644 --- a/mediagoblin/templates/mediagoblin/admin/panel.html +++ b/mediagoblin/templates/mediagoblin/admin/panel.html @@ -104,7 +104,7 @@ <td>{{ media_entry.id }}</td> <td>{{ media_entry.get_uploader.username }}</td> <td><a href="{{ media_entry.url_for_self(request.urlgen) }}">{{ media_entry.title }}</a></td> - <td>{{ media_entry.created.strftime("%F %R") }}</td> + <td><span title='{{ media_entry.created.strftime("%F %R") }}'>{{ timesince(media_entry.created) }}</span></td> </tr> {% endfor %} </table> diff --git a/mediagoblin/templates/mediagoblin/user_pages/media.html b/mediagoblin/templates/mediagoblin/user_pages/media.html index b77c12b9..7dea3f09 100644 --- a/mediagoblin/templates/mediagoblin/user_pages/media.html +++ b/mediagoblin/templates/mediagoblin/user_pages/media.html @@ -117,16 +117,20 @@ <div class="comment_author"> <img src="{{ request.staticdirect('/images/icon_comment.png') }}" /> <a href="{{ request.urlgen('mediagoblin.user_pages.user_home', - user=comment_author.username) }}"> + user=comment_author.username) }}" + class="comment_authorlink"> {{- comment_author.username -}} </a> - {% trans %}at{% endtrans %} <a href="{{ request.urlgen('mediagoblin.user_pages.media_home.view_comment', comment=comment.id, user=media.get_uploader.username, - media=media.slug_or_id) }}#comment"> - {{- comment.created.strftime("%I:%M%p %Y-%m-%d") -}} - </a>: + media=media.slug_or_id) }}#comment" + class="comment_whenlink"> + <span title='{{- comment.created.strftime("%I:%M%p %Y-%m-%d") -}}'> + {%- trans formatted_time=timesince(comment.created) -%} + {{ formatted_time }} ago + {%- endtrans -%} + </span></a>: </div> <div class="comment_content"> {% autoescape False -%} @@ -141,10 +145,12 @@ {% endif %} </div> <div class="media_sidebar"> - {% trans date=media.created.strftime("%Y-%m-%d") -%} - <h3>Added on</h3> - <p>{{ date }}</p> - {%- endtrans %} + <h3>Added</h3> + <p><span title="{{ media.created.strftime("%I:%M%p %Y-%m-%d") }}"> + {%- trans formatted_time=timesince(media.created) -%} + {{ formatted_time }} ago + {%- endtrans -%} + </span></p> {% if media.tags %} {% include "mediagoblin/utils/tags.html" %} {% endif %} diff --git a/mediagoblin/tests/test_timesince.py b/mediagoblin/tests/test_timesince.py new file mode 100644 index 00000000..1f8a082b --- /dev/null +++ b/mediagoblin/tests/test_timesince.py @@ -0,0 +1,57 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from datetime import datetime, timedelta + +from mediagoblin.tools.timesince import is_aware, timesince + + +def test_timesince(test_app): + test_time = datetime.now() + + # it should ignore second and microseconds + assert timesince(test_time, test_time + timedelta(microseconds=1)) == "0 minutes" + assert timesince(test_time, test_time + timedelta(seconds=1)) == "0 minutes" + + # test minutes, hours, days, weeks, months and years (singular and plural) + assert timesince(test_time, test_time + timedelta(minutes=1)) == "1 minute" + assert timesince(test_time, test_time + timedelta(minutes=2)) == "2 minutes" + + assert timesince(test_time, test_time + timedelta(hours=1)) == "1 hour" + assert timesince(test_time, test_time + timedelta(hours=2)) == "2 hours" + + assert timesince(test_time, test_time + timedelta(days=1)) == "1 day" + assert timesince(test_time, test_time + timedelta(days=2)) == "2 days" + + assert timesince(test_time, test_time + timedelta(days=7)) == "1 week" + assert timesince(test_time, test_time + timedelta(days=14)) == "2 weeks" + + assert timesince(test_time, test_time + timedelta(days=30)) == "1 month" + assert timesince(test_time, test_time + timedelta(days=60)) == "2 months" + + assert timesince(test_time, test_time + timedelta(days=365)) == "1 year" + assert timesince(test_time, test_time + timedelta(days=730)) == "2 years" + + # okay now we want to test combinations + # e.g. 1 hour, 5 days + assert timesince(test_time, test_time + timedelta(days=5, hours=1)) == "5 days, 1 hour" + + assert timesince(test_time, test_time + timedelta(days=15)) == "2 weeks, 1 day" + + assert timesince(test_time, test_time + timedelta(days=97)) == "3 months, 1 week" + + assert timesince(test_time, test_time + timedelta(days=2250)) == "6 years, 2 months" + diff --git a/mediagoblin/tools/template.py b/mediagoblin/tools/template.py index 74d811eb..78d65654 100644 --- a/mediagoblin/tools/template.py +++ b/mediagoblin/tools/template.py @@ -29,9 +29,11 @@ from mediagoblin import _version from mediagoblin.tools import common from mediagoblin.tools.translate import get_gettext_translation from mediagoblin.tools.pluginapi import get_hook_templates +from mediagoblin.tools.timesince import timesince from mediagoblin.meddleware.csrf import render_csrf_form_token + SETUP_JINJA_ENVS = {} @@ -73,6 +75,9 @@ def get_jinja_env(template_loader, locale): template_env.filters['urlencode'] = url_quote_plus + # add human readable fuzzy date time + template_env.globals['timesince'] = timesince + # allow for hooking up plugin templates template_env.globals['get_hook_templates'] = get_hook_templates diff --git a/mediagoblin/tools/timesince.py b/mediagoblin/tools/timesince.py new file mode 100644 index 00000000..b761c1be --- /dev/null +++ b/mediagoblin/tools/timesince.py @@ -0,0 +1,95 @@ +# Copyright (c) Django Software Foundation and individual contributors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# 3. Neither the name of Django nor the names of its contributors may be used +# to endorse or promote products derived from this software without +# specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import unicode_literals + +import datetime +import pytz + +from mediagoblin.tools.translate import pass_to_ugettext, lazy_pass_to_ungettext as _ + +"""UTC time zone as a tzinfo instance.""" +utc = pytz.utc if pytz else UTC() + +def is_aware(value): + """ + Determines if a given datetime.datetime is aware. + + The logic is described in Python's docs: + http://docs.python.org/library/datetime.html#datetime.tzinfo + """ + return value.tzinfo is not None and value.tzinfo.utcoffset(value) is not None + +def timesince(d, now=None, reversed=False): + """ + Takes two datetime objects and returns the time between d and now + as a nicely formatted string, e.g. "10 minutes". If d occurs after now, + then "0 minutes" is returned. + + Units used are years, months, weeks, days, hours, and minutes. + Seconds and microseconds are ignored. Up to two adjacent units will be + displayed. For example, "2 weeks, 3 days" and "1 year, 3 months" are + possible outputs, but "2 weeks, 3 hours" and "1 year, 5 days" are not. + + Adapted from http://blog.natbat.co.uk/archive/2003/Jun/14/time_since + """ + chunks = ( + (60 * 60 * 24 * 365, lambda n: _('year', 'years', n)), + (60 * 60 * 24 * 30, lambda n: _('month', 'months', n)), + (60 * 60 * 24 * 7, lambda n : _('week', 'weeks', n)), + (60 * 60 * 24, lambda n : _('day', 'days', n)), + (60 * 60, lambda n: _('hour', 'hours', n)), + (60, lambda n: _('minute', 'minutes', n)) + ) + # Convert datetime.date to datetime.datetime for comparison. + if not isinstance(d, datetime.datetime): + d = datetime.datetime(d.year, d.month, d.day) + if now and not isinstance(now, datetime.datetime): + now = datetime.datetime(now.year, now.month, now.day) + + if not now: + now = datetime.datetime.now(utc if is_aware(d) else None) + + delta = (d - now) if reversed else (now - d) + # ignore microseconds + since = delta.days * 24 * 60 * 60 + delta.seconds + if since <= 0: + # d is in the future compared to now, stop processing. + return '0 ' + pass_to_ugettext('minutes') + for i, (seconds, name) in enumerate(chunks): + count = since // seconds + if count != 0: + break + s = pass_to_ugettext('%(number)d %(type)s') % {'number': count, 'type': name(count)} + if i + 1 < len(chunks): + # Now get the second item + seconds2, name2 = chunks[i + 1] + count2 = (since - (seconds * count)) // seconds2 + if count2 != 0: + s += pass_to_ugettext(', %(number)d %(type)s') % {'number': count2, 'type': name2(count2)} + return s diff --git a/mediagoblin/tools/translate.py b/mediagoblin/tools/translate.py index 1d37c4de..4acafac7 100644 --- a/mediagoblin/tools/translate.py +++ b/mediagoblin/tools/translate.py @@ -123,6 +123,16 @@ def pass_to_ugettext(*args, **kwargs): *args, **kwargs) +def pass_to_ungettext(*args, **kwargs): + """ + Pass a translation on to the appropriate ungettext method. + + The reason we can't have a global ugettext method is because + mg_globals gets swapped out by the application per-request. + """ + return mg_globals.thread_scope.translations.ungettext( + *args, **kwargs) + def lazy_pass_to_ugettext(*args, **kwargs): """ Lazily pass to ugettext. @@ -158,6 +168,16 @@ def lazy_pass_to_ngettext(*args, **kwargs): """ return LazyProxy(pass_to_ngettext, *args, **kwargs) +def lazy_pass_to_ungettext(*args, **kwargs): + """ + Lazily pass to ungettext. + + This is useful if you have to define a translation on a module + level but you need it to not translate until the time that it's + used as a string. + """ + return LazyProxy(pass_to_ungettext, *args, **kwargs) + def fake_ugettext_passthrough(string): """ @@ -61,6 +61,7 @@ setup( 'sqlalchemy-migrate', 'mock', 'itsdangerous', + 'pytz', ## This is optional! # 'translitcodec', ## For now we're expecting that users will install this from |