From: Marek Stępniowski Date: Sun, 7 Sep 2008 20:27:14 +0000 (+0200) Subject: Moved catalogue, chunks, compress, newtagging and pagination applications to apps... X-Git-Url: https://git.mdrn.pl/wolnelektury.git/commitdiff_plain/0cae17bec6d31806615fae59a5b3945016285fbe Moved catalogue, chunks, compress, newtagging and pagination applications to apps directory. --- diff --git a/apps/catalogue/__init__.py b/apps/catalogue/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/catalogue/admin.py b/apps/catalogue/admin.py new file mode 100644 index 000000000..2f5d48ba2 --- /dev/null +++ b/apps/catalogue/admin.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +from django.contrib import admin + +from newtagging.admin import TaggableModelAdmin +from catalogue.models import Tag, Book, Fragment + + +class TagAdmin(admin.ModelAdmin): + list_display = ('name', 'slug', 'sort_key', 'category', 'has_description',) + list_filter = ('category',) + search_fields = ('name',) + ordering = ('name',) + + prepopulated_fields = {'slug': ('name',), 'sort_key': ('name',),} + radio_fields = {'category': admin.HORIZONTAL} + + +class BookAdmin(TaggableModelAdmin): + tag_model = Tag + + list_display = ('title', 'slug', 'has_pdf_file', 'has_odt_file', 'has_html_file', 'has_description',) + search_fields = ('title',) + ordering = ('title',) + + prepopulated_fields = {'slug': ('title',)} + + +class FragmentAdmin(TaggableModelAdmin): + tag_model = Tag + + list_display = ('book', 'anchor',) + ordering = ('book', 'anchor',) + + +admin.site.register(Tag, TagAdmin) +admin.site.register(Book, BookAdmin) +admin.site.register(Fragment, FragmentAdmin) + diff --git a/apps/catalogue/fields.py b/apps/catalogue/fields.py new file mode 100644 index 000000000..e1a356ef0 --- /dev/null +++ b/apps/catalogue/fields.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from django import forms +from django.forms.widgets import flatatt +from django.forms.util import smart_unicode +from django.utils.html import escape +from django.utils.safestring import mark_safe +from django.utils.simplejson import dumps + + +class JQueryAutoCompleteWidget(forms.TextInput): + def __init__(self, source, options=None, *args, **kwargs): + self.source = source + self.options = None + if options: + self.options = dumps(options) + super(JQueryAutoCompleteWidget, self).__init__(*args, **kwargs) + + def render_js(self, field_id): + source = "'%s'" % escape(self.source) + options = '' + if self.options: + options += ', %s' % self.options + + return u'$(\'#%s\').autocomplete(%s%s);' % (field_id, source, options) + + def render(self, name, value=None, attrs=None): + final_attrs = self.build_attrs(attrs, name=name) + if value: + final_attrs['value'] = smart_unicode(value) + + if not self.attrs.has_key('id'): + final_attrs['id'] = 'id_%s' % name + + html = u''' + + ''' % { + 'attrs' : flatatt(final_attrs), + 'js' : self.render_js(final_attrs['id']), + } + + return mark_safe(html) + + +class JQueryAutoCompleteField(forms.CharField): + def __init__(self, source, options=None, *args, **kwargs): + if 'widget' not in kwargs: + kwargs['widget'] = JQueryAutoCompleteWidget(source, options) + + super(JQueryAutoCompleteField, self).__init__(*args, **kwargs) + diff --git a/apps/catalogue/forms.py b/apps/catalogue/forms.py new file mode 100644 index 000000000..279ec7193 --- /dev/null +++ b/apps/catalogue/forms.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +from django import forms +from slughifi import slughifi + +from catalogue.models import Tag +from catalogue.fields import JQueryAutoCompleteField + + +class SearchForm(forms.Form): + q = JQueryAutoCompleteField('/katalog/tags/', {'minChars': 2, 'selectFirst': True, 'cacheLength': 50}) + tags = forms.CharField(widget=forms.HiddenInput, required=False) + + def __init__(self, *args, **kwargs): + tags = kwargs.pop('tags', []) + super(SearchForm, self).__init__(*args, **kwargs) + self.fields['q'].widget.attrs['title'] = u'tytuł utworu, motyw lub kategoria' + self.fields['tags'].initial = '/'.join(tag.slug for tag in Tag.get_tag_list(tags)) + + +class UserSetsForm(forms.Form): + def __init__(self, book, user, *args, **kwargs): + super(UserSetsForm, self).__init__(*args, **kwargs) + self.fields['set_ids'] = forms.ChoiceField( + choices=[(tag.id, tag.name) for tag in Tag.objects.filter(category='set', user=user)], + ) + + +class ObjectSetsForm(forms.Form): + def __init__(self, obj, user, *args, **kwargs): + super(ObjectSetsForm, self).__init__(*args, **kwargs) + self.fields['set_ids'] = forms.MultipleChoiceField( + label=u'Półki', + required=False, + choices=[(tag.id, tag.name) for tag in Tag.objects.filter(category='set', user=user)], + initial=[tag.id for tag in obj.tags.filter(category='set', user=user)], + widget=forms.CheckboxSelectMultiple + ) + + +class NewSetForm(forms.Form): + name = forms.CharField(max_length=50, required=True) + + def save(self, user, commit=True): + name = self.cleaned_data['name'] + new_set = Tag(name=name, slug=slughifi(name), sort_key=slughifi(name), + category='set', user=user) + + new_set.save() + return new_set + diff --git a/apps/catalogue/management/__init__.py b/apps/catalogue/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/catalogue/management/commands/__init__.py b/apps/catalogue/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/catalogue/management/commands/importbooks.py b/apps/catalogue/management/commands/importbooks.py new file mode 100644 index 000000000..22dd237f7 --- /dev/null +++ b/apps/catalogue/management/commands/importbooks.py @@ -0,0 +1,48 @@ +import os + +from django.core.management.base import BaseCommand +from django.core.management.color import color_style +from optparse import make_option + +from catalogue.models import Book + + +class Command(BaseCommand): + option_list = BaseCommand.option_list + ( + make_option('--verbosity', action='store', dest='verbosity', default='1', + type='choice', choices=['0', '1', '2'], + help='Verbosity level; 0=minimal output, 1=normal output, 2=all output'), + ) + help = 'Imports books from the specified directories.' + args = 'directory [directory ...]' + + def handle(self, *directories, **options): + from django.db import transaction + + self.style = color_style() + + verbosity = int(options.get('verbosity', 1)) + show_traceback = options.get('traceback', False) + + # Start transaction management. + transaction.commit_unless_managed() + transaction.enter_transaction_management() + transaction.managed(True) + + for dir_name in directories: + if not os.path.isdir(dir_name): + print self.style.ERROR("Skipping '%s': not a directory." % dir_name) + else: + for file_name in os.listdir(dir_name): + file_path = os.path.join(dir_name, file_name) + if not os.path.splitext(file_name)[1] == '.xml': + print self.style.NOTICE("Skipping '%s': not an XML file." % file_path) + continue + if verbosity > 0: + print "Parsing '%s'" % file_path + + Book.from_xml_file(file_path) + + transaction.commit() + transaction.leave_transaction_management() + diff --git a/apps/catalogue/models.py b/apps/catalogue/models.py new file mode 100644 index 000000000..9786150e6 --- /dev/null +++ b/apps/catalogue/models.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +from django.db import models +from django.db.models import permalink, Q +from django.utils.translation import ugettext_lazy as _ +from django.contrib.auth.models import User +from django.core.files import File +from django.template.loader import render_to_string +from django.utils.safestring import mark_safe + +from newtagging.models import TagBase +from newtagging import managers + +from librarian import html, dcparser + + +TAG_CATEGORIES = ( + ('author', _('author')), + ('epoch', _('epoch')), + ('kind', _('kind')), + ('genre', _('genre')), + ('theme', _('theme')), + ('set', _('set')), +) + + +class TagSubcategoryManager(models.Manager): + def __init__(self, subcategory): + super(TagSubcategoryManager, self).__init__() + self.subcategory = subcategory + + def get_query_set(self): + return super(TagSubcategoryManager, self).get_query_set().filter(category=self.subcategory) + + +class Tag(TagBase): + name = models.CharField(_('name'), max_length=50, unique=True, db_index=True) + slug = models.SlugField(_('slug'), unique=True, db_index=True) + sort_key = models.SlugField(_('sort key'), db_index=True) + category = models.CharField(_('category'), max_length=50, blank=False, null=False, + db_index=True, choices=TAG_CATEGORIES) + description = models.TextField(blank=True) + + user = models.ForeignKey(User, blank=True, null=True) + + def has_description(self): + return len(self.description) > 0 + has_description.short_description = _('description') + has_description.boolean = True + + @permalink + def get_absolute_url(self): + return ('catalogue.views.tagged_object_list', [self.slug]) + + class Meta: + ordering = ('sort_key',) + verbose_name = _('tag') + verbose_name_plural = _('tags') + + def __unicode__(self): + return self.name + + @staticmethod + def get_tag_list(tags): + if isinstance(tags, basestring): + tag_slugs = tags.split('/') + return [Tag.objects.get(slug=slug) for slug in tag_slugs] + else: + return TagBase.get_tag_list(tags) + + +class Book(models.Model): + title = models.CharField(_('title'), max_length=120) + slug = models.SlugField(_('slug'), unique=True, db_index=True) + description = models.TextField(_('description'), blank=True) + created_at = models.DateTimeField(_('creation date'), auto_now=True) + _short_html = models.TextField(_('short HTML'), editable=False) + + # Formats + xml_file = models.FileField(_('XML file'), upload_to='books/xml', blank=True) + pdf_file = models.FileField(_('PDF file'), upload_to='books/pdf', blank=True) + odt_file = models.FileField(_('ODT file'), upload_to='books/odt', blank=True) + html_file = models.FileField(_('HTML file'), upload_to='books/html', blank=True) + + parent = models.ForeignKey('self', blank=True, null=True, related_name='children') + + objects = models.Manager() + tagged = managers.ModelTaggedItemManager(Tag) + tags = managers.TagDescriptor(Tag) + + def short_html(self): + if len(self._short_html): + return mark_safe(self._short_html) + else: + tags = self.tags.filter(~Q(category__in=('set', 'theme'))) + tags = [u'%s' % (tag.get_absolute_url(), tag.name) for tag in tags] + + formats = [] + if self.html_file: + formats.append(u'Czytaj online' % self.html_file.url) + if self.pdf_file: + formats.append(u'Plik PDF' % self.pdf_file.url) + if self.odt_file: + formats.append(u'Plik ODT' % self.odt_file.url) + + self._short_html = unicode(render_to_string('catalogue/book_short.html', + {'book': self, 'tags': tags, 'formats': formats})) + self.save() + return mark_safe(self._short_html) + + def has_description(self): + return len(self.description) > 0 + has_description.short_description = _('description') + has_description.boolean = True + + def has_pdf_file(self): + return bool(self.pdf_file) + has_pdf_file.short_description = 'PDF' + has_pdf_file.boolean = True + + def has_odt_file(self): + return bool(self.odt_file) + has_odt_file.short_description = 'ODT' + has_odt_file.boolean = True + + def has_html_file(self): + return bool(self.html_file) + has_html_file.short_description = 'HTML' + has_html_file.boolean = True + + @staticmethod + def from_xml_file(xml_file): + from tempfile import NamedTemporaryFile + from slughifi import slughifi + from markupstring import MarkupString + + # Read book metadata + book_info = dcparser.parse(xml_file) + book = Book(title=book_info.title, slug=slughifi(book_info.title)) + book.save() + + book_tags = [] + for category in ('kind', 'genre', 'author', 'epoch'): + tag_name = getattr(book_info, category) + tag_sort_key = tag_name + if category == 'author': + tag_sort_key = tag_name.last_name + tag_name = ' '.join(tag_name.first_names) + ' ' + tag_name.last_name + tag, created = Tag.objects.get_or_create(name=tag_name, + slug=slughifi(tag_name), sort_key=slughifi(tag_sort_key), category=category) + tag.save() + book_tags.append(tag) + book.tags = book_tags + + if hasattr(book_info, 'parts'): + for part_url in book_info.parts: + base, slug = part_url.rsplit('/', 1) + child_book = Book.objects.get(slug=slug) + child_book.parent = book + child_book.save() + + # Save XML and HTML files + book.xml_file.save('%s.xml' % book.slug, File(file(xml_file)), save=False) + + html_file = NamedTemporaryFile() + html.transform(book.xml_file.path, html_file) + book.html_file.save('%s.html' % book.slug, File(html_file), save=False) + + # Extract fragments + closed_fragments, open_fragments = html.extract_fragments(book.html_file.path) + book_themes = [] + for fragment in closed_fragments.values(): + text = fragment.to_string() + short_text = '' + if (len(MarkupString(text)) > 240): + short_text = unicode(MarkupString(text)[:160]) + new_fragment = Fragment(text=text, short_text=short_text, anchor=fragment.id, book=book) + + theme_names = [s.strip() for s in fragment.themes.split(',')] + themes = [] + for theme_name in theme_names: + tag, created = Tag.objects.get_or_create(name=theme_name, + slug=slughifi(theme_name), sort_key=slughifi(theme_name), category='theme') + tag.save() + themes.append(tag) + new_fragment.save() + new_fragment.tags = list(book.tags) + themes + book_themes += themes + + book_themes = set(book_themes) + book.tags = list(book.tags) + list(book_themes) + return book.save() + + @permalink + def get_absolute_url(self): + return ('catalogue.views.book_detail', [self.slug]) + + class Meta: + ordering = ('title',) + verbose_name = _('book') + verbose_name_plural = _('books') + + def __unicode__(self): + return self.title + + +class Fragment(models.Model): + text = models.TextField() + short_text = models.TextField(editable=False) + _short_html = models.TextField(editable=False) + anchor = models.IntegerField() + book = models.ForeignKey(Book, related_name='fragments') + + objects = models.Manager() + tagged = managers.ModelTaggedItemManager(Tag) + tags = managers.TagDescriptor(Tag) + + def short_html(self): + if len(self._short_html): + return mark_safe(self._short_html) + else: + book_authors = [u'%s' % (tag.get_absolute_url(), tag.name) + for tag in self.book.tags if tag.category == 'author'] + + self._short_html = unicode(render_to_string('catalogue/fragment_short.html', + {'fragment': self, 'book': self.book, 'book_authors': book_authors})) + self.save() + return mark_safe(self._short_html) + + class Meta: + ordering = ('book', 'anchor',) + verbose_name = _('fragment') + verbose_name_plural = _('fragments') + diff --git a/apps/catalogue/templatetags/__init__.py b/apps/catalogue/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/catalogue/templatetags/catalogue_tags.py b/apps/catalogue/templatetags/catalogue_tags.py new file mode 100644 index 000000000..8cce80de5 --- /dev/null +++ b/apps/catalogue/templatetags/catalogue_tags.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- +from django import template +from django.template import Node, Variable +from django.utils.encoding import smart_str +from django.core.urlresolvers import reverse +from django.contrib.auth.forms import UserCreationForm, AuthenticationForm +from django.db.models import Q + + +register = template.Library() + + +class RegistrationForm(UserCreationForm): + def as_ul(self): + "Returns this form rendered as HTML
  • s -- excluding the ." + return self._html_output(u'
  • %(errors)s%(label)s %(field)s%(help_text)s
  • ', u'
  • %s
  • ', '', u' %s', False) + + +class LoginForm(AuthenticationForm): + def as_ul(self): + "Returns this form rendered as HTML
  • s -- excluding the ." + return self._html_output(u'
  • %(errors)s%(label)s %(field)s%(help_text)s
  • ', u'
  • %s
  • ', '', u' %s', False) + + +def iterable(obj): + try: + iter(obj) + return True + except TypeError: + return False + + +def capfirst(text): + try: + return '%s%s' % (text[0].upper(), text[1:]) + except IndexError: + return '' + + +@register.simple_tag +def title_from_tags(tags): + def split_tags(tags): + result = {} + for tag in tags: + result[tag.category] = tag + return result + + class Flection(object): + def get_case(self, name, flection): + return name + flection = Flection() + + self = split_tags(tags) + + title = u'' + + # Specjalny przypadek oglądania wszystkich lektur na danej półce + if len(self) == 1 and 'set' in self: + return u'Półka %s' % self['set'] + + # Specjalny przypadek "Twórczość w pozytywizmie", wtedy gdy tylko epoka + # jest wybrana przez użytkownika + if 'epoch' in self and len(self) == 1: + text = u'Twórczość w %s' % flection.get_case(unicode(self['epoch']), u'miejscownik') + return capfirst(text) + + # Specjalny przypadek "Dramat w twórczości Sofoklesa", wtedy gdy podane + # są tylko rodzaj literacki i autor + if 'kind' in self and 'author' in self and len(self) == 2: + text = u'%s w twórczości %s' % (unicode(self['kind']), + flection.get_case(unicode(self['author']), u'dopełniacz')) + return capfirst(text) + + # Przypadki ogólniejsze + if 'theme' in self: + title += u'Motyw %s' % unicode(self['theme']) + + if 'genre' in self: + if 'theme' in self: + title += u' w %s' % flection.get_case(unicode(self['genre']), u'miejscownik') + else: + title += unicode(self['genre']) + + if 'kind' in self or 'author' in self or 'epoch' in self: + if 'genre' in self or 'theme' in self: + if 'kind' in self: + title += u' w %s ' % flection.get_case(unicode(self['kind']), u'miejscownik') + else: + title += u' w twórczości ' + else: + title += u'%s ' % unicode(self.get('kind', u'twórczość')) + + if 'author' in self: + title += flection.get_case(unicode(self['author']), u'dopełniacz') + elif 'epoch' in self: + title += flection.get_case(unicode(self['epoch']), u'dopełniacz') + + return capfirst(title) + + +@register.simple_tag +def user_creation_form(): + return RegistrationForm(prefix='registration').as_ul() + + +@register.simple_tag +def authentication_form(): + return LoginForm(prefix='login').as_ul() + + +@register.inclusion_tag('catalogue/breadcrumbs.html') +def breadcrumbs(tags, search_form=True): + from catalogue.forms import SearchForm + context = {'tag_list': tags} + if search_form: + context['search_form'] = SearchForm(tags=tags) + return context + + +@register.tag +def catalogue_url(parser, token): + bits = token.split_contents() + tag_name = bits[0] + + tags_to_add = [] + tags_to_remove = [] + for bit in bits[1:]: + if bit[0] == '-': + tags_to_remove.append(bit[1:]) + else: + tags_to_add.append(bit) + + return CatalogueURLNode(tags_to_add, tags_to_remove) + + +class CatalogueURLNode(Node): + def __init__(self, tags_to_add, tags_to_remove): + self.tags_to_add = [Variable(tag) for tag in tags_to_add] + self.tags_to_remove = [Variable(tag) for tag in tags_to_remove] + + def render(self, context): + tags_to_add = [] + tags_to_remove = [] + + for tag_variable in self.tags_to_add: + tag = tag_variable.resolve(context) + if isinstance(tag, (list, dict)): + tags_to_add += [t for t in tag] + else: + tags_to_add.append(tag) + + for tag_variable in self.tags_to_remove: + tag = tag_variable.resolve(context) + if iterable(tag): + tags_to_remove += [t for t in tag] + else: + tags_to_remove.append(tag) + + tag_slugs = [tag.slug for tag in tags_to_add] + for tag in tags_to_remove: + try: + tag_slugs.remove(tag.slug) + except KeyError: + pass + + if len(tag_slugs) > 0: + return reverse('tagged_object_list', kwargs={'tags': '/'.join(tag_slugs)}) + else: + return reverse('main_page') + + +@register.inclusion_tag('catalogue/latest_blog_posts.html') +def latest_blog_posts(feed_url, posts_to_show=5): + import feedparser + import datetime + + feed = feedparser.parse(feed_url) + posts = [] + for i in range(posts_to_show): + pub_date = feed['entries'][i].updated_parsed + published = datetime.date(pub_date[0], pub_date[1], pub_date[2] ) + posts.append({ + 'title': feed['entries'][i].title, + 'summary': feed['entries'][i].summary, + 'link': feed['entries'][i].link, + 'date': published, + }) + return {'posts': posts} + diff --git a/apps/catalogue/urls.py b/apps/catalogue/urls.py new file mode 100644 index 000000000..f4779067c --- /dev/null +++ b/apps/catalogue/urls.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from django.conf.urls.defaults import * + + +urlpatterns = patterns('catalogue.views', + url(r'^$', 'main_page', name='main_page'), + url(r'^polki/$', 'user_shelves', name='user_shelves'), + url(r'^polki/(?P[a-zA-Z0-9-]+)/usun/$', 'delete_shelf', name='delete_shelf'), + url(r'^lektury/', 'book_list', name='book_list'), + url(r'^lektura/(?P[a-zA-Z0-9-]+)/polki/', 'book_sets', name='book_shelves'), + url(r'^fragment/(?P[0-9]+)/polki/', 'fragment_sets', name='fragment_shelves'), + url(r'^polki/nowa/$', 'new_set', name='new_set'), + url(r'^lektura/(?P[a-zA-Z0-9-]+)/$', 'book_detail', name='book_detail'), + url(r'^tags/$', 'tags_starting_with', name='hint'), + url(r'^szukaj/$', 'search', name='search'), + url(r'^(?P[a-zA-Z0-9-/]+)/$', 'tagged_object_list', name='tagged_object_list'), +) + diff --git a/apps/catalogue/utils.py b/apps/catalogue/utils.py new file mode 100644 index 000000000..d1cee5045 --- /dev/null +++ b/apps/catalogue/utils.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- + + +def split_tags(tags): + result = {} + for tag in tags: + result.setdefault(tag.category, []).append(tag) + return result + diff --git a/apps/catalogue/views.py b/apps/catalogue/views.py new file mode 100644 index 000000000..ad9bde747 --- /dev/null +++ b/apps/catalogue/views.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- +from django.template import RequestContext +from django.shortcuts import render_to_response, get_object_or_404 +from django.http import HttpResponse, HttpResponseRedirect, Http404 +from django.core.urlresolvers import reverse +from django.db.models import Q +from django.contrib.auth.decorators import login_required +from django.utils.datastructures import SortedDict +from django.views.decorators.http import require_POST +from django.contrib import auth +from django.contrib.auth.forms import UserCreationForm, AuthenticationForm +from django.utils import simplejson +from django.utils.functional import Promise +from django.utils.encoding import force_unicode + +from catalogue import models +from catalogue import forms +from catalogue.utils import split_tags +from newtagging import views as newtagging_views + + +class LazyEncoder(simplejson.JSONEncoder): + def default(self, obj): + if isinstance(obj, Promise): + return force_unicode(obj) + return obj + + +def search(request): + query = request.GET.get('q', '') + tags = request.GET.get('tags', '') + if tags == '': + tags = [] + + try: + tag_list = models.Tag.get_tag_list(tags) + tag = models.Tag.objects.get(name=query) + except models.Tag.DoesNotExist: + try: + book = models.Book.objects.get(title=query) + return HttpResponseRedirect(book.get_absolute_url()) + except models.Book.DoesNotExist: + return HttpResponseRedirect(reverse('catalogue.views.main_page')) + else: + tag_list.append(tag) + return HttpResponseRedirect(reverse('catalogue.views.tagged_object_list', + kwargs={'tags': '/'.join(tag.slug for tag in tag_list)} + )) + + +def tags_starting_with(request): + try: + prefix = request.GET['q'] + if len(prefix) < 2: + raise KeyError + + books = models.Book.objects.filter(title__icontains=prefix) + tags = models.Tag.objects.filter(name__icontains=prefix) + if request.user.is_authenticated(): + tags = tags.filter(~Q(category='set') | Q(user=request.user)) + else: + tags = tags.filter(~Q(category='set')) + + completions = [book.title for book in books] + [tag.name for tag in tags] + + return HttpResponse('\n'.join(completions)) + + except KeyError: + return HttpResponse('') + + +def main_page(request): + if request.user.is_authenticated(): + extra_where = '(NOT catalogue_tag.category = "set" OR catalogue_tag.user_id = %d)' % request.user.id + else: + extra_where = 'NOT catalogue_tag.category = "set"' + tags = models.Tag.objects.usage_for_model(models.Book, counts=True, extra={'where': [extra_where]}) + fragment_tags = models.Tag.objects.usage_for_model(models.Fragment, counts=True, + extra={'where': ['catalogue_tag.category = "theme"']}) + categories = split_tags(tags) + + form = forms.SearchForm() + return render_to_response('catalogue/main_page.html', locals(), + context_instance=RequestContext(request)) + + +def book_list(request): + books = models.Book.objects.all() + form = forms.SearchForm() + + books_by_first_letter = SortedDict() + for book in books: + books_by_first_letter.setdefault(book.title[0], []).append(book) + + return render_to_response('catalogue/book_list.html', locals(), + context_instance=RequestContext(request)) + + +def tagged_object_list(request, tags=''): + try: + tags = models.Tag.get_tag_list(tags) + except models.Tag.DoesNotExist: + raise Http404 + + model = models.Book + theme_is_set = any(tag.category == 'theme' for tag in tags) + if theme_is_set: + model = models.Fragment + + if request.user.is_authenticated(): + extra_where = '(NOT catalogue_tag.category = "set" OR catalogue_tag.user_id = %d)' % request.user.id + else: + extra_where = 'NOT catalogue_tag.category = "set"' + related_tags = models.Tag.objects.related_for_model(tags, model, counts=True, extra={'where': [extra_where]}) + categories = split_tags(related_tags) + + return newtagging_views.tagged_object_list( + request, + tag_model=models.Tag, + queryset_or_model=model, + tags=tags, + template_name='catalogue/tagged_object_list.html', + extra_context = {'categories': categories }, + ) + + +def book_detail(request, slug): + book = get_object_or_404(models.Book, slug=slug) + tags = list(book.tags.filter(~Q(category='set'))) + categories = split_tags(tags) + + return render_to_response('catalogue/book_detail.html', locals(), + context_instance=RequestContext(request)) + + +def logout_then_redirect(request): + auth.logout(request) + return HttpResponseRedirect(request.GET.get('next', '/')) + + +@require_POST +def register(request): + registration_form = UserCreationForm(request.POST, prefix='registration') + if registration_form.is_valid(): + user = registration_form.save() + user = auth.authenticate( + username=registration_form.cleaned_data['username'], + password=registration_form.cleaned_data['password1'] + ) + auth.login(request, user) + response_data = {'success': True, 'errors': {}} + else: + response_data = {'success': False, 'errors': registration_form.errors} + return HttpResponse(LazyEncoder(ensure_ascii=False).encode(response_data)) + + +@require_POST +def login(request): + form = AuthenticationForm(data=request.POST, prefix='login') + if form.is_valid(): + auth.login(request, form.get_user()) + response_data = {'success': True, 'errors': {}} + else: + response_data = {'success': False, 'errors': form.errors} + return HttpResponse(LazyEncoder(ensure_ascii=False).encode(response_data)) + + +def book_sets(request, slug): + book = get_object_or_404(models.Book, slug=slug) + user_sets = models.Tag.objects.filter(category='set', user=request.user) + book_sets = book.tags.filter(category='set', user=request.user) + + if not request.user.is_authenticated(): + return HttpResponse('

    Aby zarządzać swoimi półkami, musisz się zalogować.

    ') + + if request.method == 'POST': + form = forms.ObjectSetsForm(book, request.user, request.POST) + if form.is_valid(): + book.tags = ([models.Tag.objects.get(pk=id) for id in form.cleaned_data['set_ids']] + + list(book.tags.filter(~Q(category='set') | ~Q(user=request.user)))) + if request.is_ajax(): + return HttpResponse('

    Półki zostały zapisane.

    ') + else: + return HttpResponseRedirect('/') + else: + form = forms.ObjectSetsForm(book, request.user) + new_set_form = forms.NewSetForm() + + return render_to_response('catalogue/book_sets.html', locals(), + context_instance=RequestContext(request)) + + +def fragment_sets(request, id): + fragment = get_object_or_404(models.Fragment, pk=id) + user_sets = models.Tag.objects.filter(category='set', user=request.user) + fragment_sets = fragment.tags.filter(category='set', user=request.user) + + if not request.user.is_authenticated(): + return HttpResponse('

    Aby zarządzać swoimi półkami, musisz się zalogować.

    ') + + if request.method == 'POST': + form = forms.ObjectSetsForm(fragment, request.user, request.POST) + if form.is_valid(): + fragment.tags = ([models.Tag.objects.get(pk=id) for id in form.cleaned_data['set_ids']] + + list(fragment.tags.filter(~Q(category='set') | ~Q(user=request.user)))) + if request.is_ajax(): + return HttpResponse('

    Półki zostały zapisane.

    ') + else: + return HttpResponseRedirect('/') + else: + form = forms.ObjectSetsForm(fragment, request.user) + new_set_form = forms.NewSetForm() + + return render_to_response('catalogue/fragment_sets.html', locals(), + context_instance=RequestContext(request)) + + +@login_required +@require_POST +def new_set(request): + new_set_form = forms.NewSetForm(request.POST) + if new_set_form.is_valid(): + new_set = new_set_form.save(request.user) + return HttpResponse(u'

    Półka %s została utworzona

    ' % new_set) + + return render_to_response('catalogue/book_sets.html', locals(), + context_instance=RequestContext(request)) + + +@login_required +@require_POST +def delete_shelf(request, slug): + user_set = get_object_or_404(models.Tag, slug=slug, category='set', user=request.user) + user_set.delete() + return HttpResponse(u'

    Półka %s została usunięta

    ' % user_set.name) + + +@login_required +def user_shelves(request): + shelves = models.Tag.objects.filter(category='set', user=request.user) + new_set_form = forms.NewSetForm() + return render_to_response('catalogue/user_shelves.html', locals(), + context_instance=RequestContext(request)) + diff --git a/apps/chunks/__init__.py b/apps/chunks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/chunks/admin.py b/apps/chunks/admin.py new file mode 100644 index 000000000..c44d5a037 --- /dev/null +++ b/apps/chunks/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from chunks.models import Chunk + + +class ChunkAdmin(admin.ModelAdmin): + list_display = ('key', 'description',) + search_fields = ('key', 'content',) + +admin.site.register(Chunk, ChunkAdmin) + diff --git a/apps/chunks/models.py b/apps/chunks/models.py new file mode 100644 index 000000000..396d221ed --- /dev/null +++ b/apps/chunks/models.py @@ -0,0 +1,21 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ + + +class Chunk(models.Model): + """ + A Chunk is a piece of content associated with a unique key that can be inserted into + any template with the use of a special template tag. + """ + key = models.CharField(_('key'), help_text=_('A unique name for this chunk of content'), primary_key=True, max_length=255) + description = models.CharField(_('description'), blank=True, max_length=255) + content = models.TextField(_('content'), blank=True) + + class Meta: + ordering = ('key',) + verbose_name = _('chunk') + verbose_name_plural = _('chunks') + + def __unicode__(self): + return u'%s' % (self.key,) + diff --git a/apps/chunks/templatetags/__init__.py b/apps/chunks/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/chunks/templatetags/chunks.py b/apps/chunks/templatetags/chunks.py new file mode 100644 index 000000000..f79d495ec --- /dev/null +++ b/apps/chunks/templatetags/chunks.py @@ -0,0 +1,45 @@ +from django import template +from django.db import models +from django.core.cache import cache + +register = template.Library() + +Chunk = models.get_model('chunks', 'chunk') +CACHE_PREFIX = "chunk_" + +def do_get_chunk(parser, token): + # split_contents() knows not to split quoted strings. + tokens = token.split_contents() + if len(tokens) < 2 or len(tokens) > 3: + raise template.TemplateSyntaxError, "%r tag should have either 2 or 3 arguments" % (tokens[0],) + if len(tokens) == 2: + tag_name, key = tokens + cache_time = 0 + if len(tokens) == 3: + tag_name, key, cache_time = tokens + # Check to see if the key is properly double/single quoted + if not (key[0] == key[-1] and key[0] in ('"', "'")): + raise template.TemplateSyntaxError, "%r tag's argument should be in quotes" % tag_name + # Send key without quotes and caching time + return ChunkNode(key[1:-1], cache_time) + +class ChunkNode(template.Node): + def __init__(self, key, cache_time=0): + self.key = key + self.cache_time = cache_time + + def render(self, context): + try: + cache_key = CACHE_PREFIX + self.key + c = cache.get(cache_key) + if c is None: + c = Chunk.objects.get(key=self.key) + cache.set(cache_key, c, int(self.cache_time)) + content = c.content + except Chunk.DoesNotExist: + n = Chunk(key=self.key) + n.save() + return '' + return content + +register.tag('chunk', do_get_chunk) diff --git a/apps/compress/__init__.py b/apps/compress/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/compress/conf/__init__.py b/apps/compress/conf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/compress/conf/settings.py b/apps/compress/conf/settings.py new file mode 100644 index 000000000..f6949a201 --- /dev/null +++ b/apps/compress/conf/settings.py @@ -0,0 +1,22 @@ +from django.core.exceptions import ImproperlyConfigured +from django.conf import settings + +COMPRESS = getattr(settings, 'COMPRESS', not settings.DEBUG) +COMPRESS_AUTO = getattr(settings, 'COMPRESS_AUTO', True) +COMPRESS_VERSION = getattr(settings, 'COMPRESS_VERSION', False) +COMPRESS_VERSION_PLACEHOLDER = getattr(settings, 'COMPRESS_VERSION_PLACEHOLDER', '?') +COMPRESS_VERSION_DEFAULT = getattr(settings, 'COMPRESS_VERSION_DEFAULT', '0') + +COMPRESS_CSS_FILTERS = getattr(settings, 'COMPRESS_CSS_FILTERS', ['compress.filters.csstidy.CSSTidyFilter']) +COMPRESS_JS_FILTERS = getattr(settings, 'COMPRESS_JS_FILTERS', ['compress.filters.jsmin.JSMinFilter']) +COMPRESS_CSS = getattr(settings, 'COMPRESS_CSS', {}) +COMPRESS_JS = getattr(settings, 'COMPRESS_JS', {}) + +if COMPRESS_CSS_FILTERS is None: + COMPRESS_CSS_FILTERS = [] + +if COMPRESS_JS_FILTERS is None: + COMPRESS_JS_FILTERS = [] + +if COMPRESS_VERSION and not COMPRESS_AUTO: + raise ImproperlyConfigured('COMPRESS_AUTO needs to be True when using COMPRESS_VERSION.') diff --git a/apps/compress/filter_base.py b/apps/compress/filter_base.py new file mode 100644 index 000000000..9b98531b6 --- /dev/null +++ b/apps/compress/filter_base.py @@ -0,0 +1,14 @@ +class FilterBase: + def __init__(self, verbose): + self.verbose = verbose + + def filter_css(self, css): + raise NotImplementedError + def filter_js(self, js): + raise NotImplementedError + +class FilterError(Exception): + """ + This exception is raised when a filter fails + """ + pass \ No newline at end of file diff --git a/apps/compress/filters/__init__.py b/apps/compress/filters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/compress/filters/csstidy/__init__.py b/apps/compress/filters/csstidy/__init__.py new file mode 100644 index 000000000..d40e8eebb --- /dev/null +++ b/apps/compress/filters/csstidy/__init__.py @@ -0,0 +1,33 @@ +import os +import warnings +import tempfile + +from django.conf import settings + +from compress.filter_base import FilterBase + +BINARY = getattr(settings, 'CSSTIDY_BINARY', 'csstidy') +ARGUMENTS = getattr(settings, 'CSSTIDY_ARGUMENTS', '--template=highest') + +warnings.simplefilter('ignore', RuntimeWarning) + +class CSSTidyFilter(FilterBase): + def filter_css(self, css): + tmp_file = tempfile.NamedTemporaryFile(mode='w+b') + tmp_file.write(css) + tmp_file.flush() + + output_file = tempfile.NamedTemporaryFile(mode='w+b') + + command = '%s %s %s %s' % (BINARY, tmp_file.name, ARGUMENTS, output_file.name) + + command_output = os.popen(command).read() + + filtered_css = output_file.read() + output_file.close() + tmp_file.close() + + if self.verbose: + print command_output + + return filtered_css diff --git a/apps/compress/filters/csstidy_python/__init__.py b/apps/compress/filters/csstidy_python/__init__.py new file mode 100644 index 000000000..7d581ed43 --- /dev/null +++ b/apps/compress/filters/csstidy_python/__init__.py @@ -0,0 +1,19 @@ +from django.conf import settings + +from compress.filter_base import FilterBase +from compress.filters.csstidy_python.csstidy import CSSTidy + +COMPRESS_CSSTIDY_SETTINGS = getattr(settings, 'COMPRESS_CSSTIDY_SETTINGS', {}) + +class CSSTidyFilter(FilterBase): + def filter_css(self, css): + tidy = CSSTidy() + + for k, v in COMPRESS_CSSTIDY_SETTINGS.items(): + tidy.setSetting(k, v) + + tidy.parse(css) + + r = tidy.Output('string') + + return r diff --git a/apps/compress/filters/csstidy_python/csstidy.py b/apps/compress/filters/csstidy_python/csstidy.py new file mode 100644 index 000000000..6ae8dc732 --- /dev/null +++ b/apps/compress/filters/csstidy_python/csstidy.py @@ -0,0 +1,636 @@ +# CSSTidy - CSS Parse +# +# CSS Parser class +# +# This file is part of CSSTidy. +# +# CSSTidy is free software you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation either version 2 of the License, or +# (at your option) any later version. +# +# CSSTidy is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with CSSTidy if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# @license http://opensource.org/licenses/gpl-license.php GNU Public License +# @package csstidy +# @author Dj Gilcrease (digitalxero at gmail dot com) 2005-2006 + +import re + +from optimizer import CSSOptimizer +from output import CSSPrinter +import data +from tools import SortedDict + +class CSSTidy(object): + #Saves the parsed CSS + _css = "" + _raw_css = SortedDict() + _optimized_css = SortedDict() + + #List of Tokens + _tokens = [] + + #Printer class + _output = None + + #Optimiser class + _optimizer = None + + #Saves the CSS charset (@charset) + _charset = '' + + #Saves all @import URLs + _import = [] + + #Saves the namespace + _namespace = '' + + #Contains the version of csstidy + _version = '1.3' + + #Stores the settings + _settings = {} + + # Saves the parser-status. + # + # Possible values: + # - is = in selector + # - ip = in property + # - iv = in value + # - instr = in string (started at " or ' or ( ) + # - ic = in comment (ignore everything) + # - at = in @-block + _status = 'is' + + #Saves the current at rule (@media) + _at = '' + + #Saves the current selector + _selector = '' + + #Saves the current property + _property = '' + + #Saves the position of , in selectors + _sel_separate = [] + + #Saves the current value + _value = '' + + #Saves the current sub-value + _sub_value = '' + + #Saves all subvalues for a property. + _sub_value_arr = [] + + #Saves the char which opened the last string + _str_char = '' + _cur_string = '' + + #Status from which the parser switched to ic or instr + _from = '' + + #Variable needed to manage string-in-strings, for example url("foo.png") + _str_in_str = False + + #=True if in invalid at-rule + _invalid_at = False + + #=True if something has been added to the current selector + _added = False + + #Saves the message log + _log = SortedDict() + + #Saves the line number + _line = 1 + + def __init__(self): + self._settings['remove_bslash'] = True + self._settings['compress_colors'] = True + self._settings['compress_font-weight'] = True + self._settings['lowercase_s'] = False + self._settings['optimise_shorthands'] = 2 + self._settings['remove_last_'] = False + self._settings['case_properties'] = 1 + self._settings['sort_properties'] = False + self._settings['sort_selectors'] = False + self._settings['merge_selectors'] = 2 + self._settings['discard_invalid_properties'] = False + self._settings['css_level'] = 'CSS2.1' + self._settings['preserve_css'] = False + self._settings['timestamp'] = False + self._settings['template'] = 'highest_compression' + + #Maps self._status to methods + self.__statusMethod = {'is':self.__parseStatus_is, 'ip': self.__parseStatus_ip, 'iv':self.__parseStatus_iv, 'instr':self.__parseStatus_instr, 'ic':self.__parseStatus_ic, 'at':self.__parseStatus_at} + + self._output = CSSPrinter(self) + self._optimizer = CSSOptimizer(self) + + #Public Methods + def getSetting(self, setting): + return self._settings.get(setting, False) + + #Set the value of a setting. + def setSetting(self, setting, value): + self._settings[setting] = value + return True + + def log(self, message, ttype, line = -1): + if line == -1: + line = self._line + + line = int(line) + + add = {'m': message, 't': ttype} + + if not self._log.has_key(line): + self._log[line] = [] + self._log[line].append(add) + elif add not in self._log[line]: + self._log[line].append(add) + + + #Checks if a character is escaped (and returns True if it is) + def escaped(self, string, pos): + return not (string[pos-1] != '\\' or self.escaped(string, pos-1)) + + #Adds CSS to an existing media/selector + def merge_css_blocks(self, media, selector, css_add): + for prop, value in css_add.iteritems(): + self.__css_add_property(media, selector, prop, value, False) + + #Checks if $value is !important. + def is_important(self, value): + return '!important' in value.lower() + + #Returns a value without !important + def gvw_important(self, value): + if self.is_important(value): + ret = value.strip() + ret = ret[0:-9] + ret = ret.strip() + ret = ret[0:-1] + ret = ret.strip() + return ret + + return value + + def parse(self, cssString): + #Switch from \r\n to \n + self._css = cssString.replace("\r\n", "\n") + ' ' + self._raw_css = {} + self._optimized_css = {} + self._curComment = '' + + #Start Parsing + i = 0 + while i < len(cssString): + if self._css[i] == "\n" or self._css[i] == "\r": + self._line += 1 + + i += self.__statusMethod[self._status](i) + + i += 1; + + self._optimized_css = self._optimizer.optimize(self._raw_css) + + def parseFile(self, filename): + try: + f = open(filename, "r") + self.parse(f.read()) + finally: + f.close() + + #Private Methods + def __parseStatus_is(self, idx): + """ + Parse in Selector + """ + ret = 0 + + if self.__is_token(self._css, idx): + if self._css[idx] == '/' and self._css[idx+1] == '*' and self._selector.strip() == '': + self._status = 'ic' + self._from = 'is' + return 1 + + elif self._css[idx] == '@' and self._selector.strip() == '': + #Check for at-rule + self._invalid_at = True + + for name, ttype in data.at_rules.iteritems(): + if self._css[idx+1:len(name)].lower() == name.lower(): + if ttype == 'at': + self._at = '@' + name + else: + self._selector = '@' + name + + self._status = ttype + self._invalid_at = False + ret += len(name) + + if self._invalid_at: + self._selector = '@' + invalid_at_name = '' + for j in xrange(idx+1, len(self._css)): + if not self._css[j].isalpha(): + break; + + invalid_at_name += self._css[j] + + self.log('Invalid @-rule: ' + invalid_at_name + ' (removed)', 'Warning') + + elif self._css[idx] == '"' or self._css[idx] == "'": + self._cur_string = self._css[idx] + self._status = 'instr' + self._str_char = self._css[idx] + self._from = 'is' + + elif self._invalid_at and self._css[idx] == ';': + self._invalid_at = False + self._status = 'is' + + elif self._css[idx] == '{': + self._status = 'ip' + self.__add_token(data.SEL_START, self._selector) + self._added = False; + + elif self._css[idx] == '}': + self.__add_token(data.AT_END, self._at) + self._at = '' + self._selector = '' + self._sel_separate = [] + + elif self._css[idx] == ',': + self._selector = self._selector.strip() + ',' + self._sel_separate.append(len(self._selector)) + + elif self._css[idx] == '\\': + self._selector += self.__unicode(idx) + + #remove unnecessary universal selector, FS#147 + elif not (self._css[idx] == '*' and self._css[idx+1] in ('.', '#', '[', ':')): + self._selector += self._css[idx] + + else: + lastpos = len(self._selector)-1 + + if lastpos == -1 or not ((self._selector[lastpos].isspace() or self.__is_token(self._selector, lastpos) and self._selector[lastpos] == ',') and self._css[idx].isspace()): + self._selector += self._css[idx] + + return ret + + def __parseStatus_ip(self, idx): + """ + Parse in property + """ + if self.__is_token(self._css, idx): + if (self._css[idx] == ':' or self._css[idx] == '=') and self._property != '': + self._status = 'iv' + + if not self.getSetting('discard_invalid_properties') or self.__property_is_valid(self._property): + self.__add_token(data.PROPERTY, self._property) + + elif self._css[idx] == '/' and self._css[idx+1] == '*' and self._property == '': + self._status = 'ic' + self._from = 'ip' + return 1 + + elif self._css[idx] == '}': + self.__explode_selectors() + self._status = 'is' + self._invalid_at = False + self.__add_token(data.SEL_END, self._selector) + self._selector = '' + self._property = '' + + elif self._css[idx] == ';': + self._property = '' + + elif self._css[idx] == '\\': + self._property += self.__unicode(idx) + + elif not self._css[idx].isspace(): + self._property += self._css[idx] + + return 0 + + def __parseStatus_iv(self, idx): + """ + Parse in value + """ + pn = (( self._css[idx] == "\n" or self._css[idx] == "\r") and self.__property_is_next(idx+1) or idx == len(self._css)) #CHECK# + if self.__is_token(self._css, idx) or pn: + if self._css[idx] == '/' and self._css[idx+1] == '*': + self._status = 'ic' + self._from = 'iv' + return 1 + + elif self._css[idx] == '"' or self._css[idx] == "'" or self._css[idx] == '(': + self._cur_string = self._css[idx] + self._str_char = ')' if self._css[idx] == '(' else self._css[idx] + self._status = 'instr' + self._from = 'iv' + + elif self._css[idx] == ',': + self._sub_value = self._sub_value.strip() + ',' + + elif self._css[idx] == '\\': + self._sub_value += self.__unicode(idx) + + elif self._css[idx] == ';' or pn: + if len(self._selector) > 0 and self._selector[0] == '@' and data.at_rules.has_key(self._selector[1:]) and data.at_rules[self._selector[1:]] == 'iv': + self._sub_value_arr.append(self._sub_value.strip()) + + self._status = 'is' + + if '@charset' in self._selector: + self._charset = self._sub_value_arr[0] + + elif '@namespace' in self._selector: + self._namespace = ' '.join(self._sub_value_arr) + + elif '@import' in self._selector: + self._import.append(' '.join(self._sub_value_arr)) + + + self._sub_value_arr = [] + self._sub_value = '' + self._selector = '' + self._sel_separate = [] + + else: + self._status = 'ip' + + elif self._css[idx] != '}': + self._sub_value += self._css[idx] + + if (self._css[idx] == '}' or self._css[idx] == ';' or pn) and self._selector != '': + if self._at == '': + self._at = data.DEFAULT_AT + + #case settings + if self.getSetting('lowercase_s'): + self._selector = self._selector.lower() + + self._property = self._property.lower() + + if self._sub_value != '': + self._sub_value_arr.append(self._sub_value) + self._sub_value = '' + + self._value = ' '.join(self._sub_value_arr) + + + self._selector = self._selector.strip() + + valid = self.__property_is_valid(self._property) + + if (not self._invalid_at or self.getSetting('preserve_css')) and (not self.getSetting('discard_invalid_properties') or valid): + self.__css_add_property(self._at, self._selector, self._property, self._value) + self.__add_token(data.VALUE, self._value) + + if not valid: + if self.getSetting('discard_invalid_properties'): + self.log('Removed invalid property: ' + self._property, 'Warning') + + else: + self.log('Invalid property in ' + self.getSetting('css_level').upper() + ': ' + self._property, 'Warning') + + self._property = ''; + self._sub_value_arr = [] + self._value = '' + + if self._css[idx] == '}': + self.__explode_selectors() + self.__add_token(data.SEL_END, self._selector) + self._status = 'is' + self._invalid_at = False + self._selector = '' + + elif not pn: + self._sub_value += self._css[idx] + + if self._css[idx].isspace(): + if self._sub_value != '': + self._sub_value_arr.append(self._sub_value) + self._sub_value = '' + + return 0 + + def __parseStatus_instr(self, idx): + """ + Parse in String + """ + if self._str_char == ')' and (self._css[idx] == '"' or self._css[idx] == "'") and not self.escaped(self._css, idx): + self._str_in_str = not self._str_in_str + + temp_add = self._css[idx] # ...and no not-escaped backslash at the previous position + if (self._css[idx] == "\n" or self._css[idx] == "\r") and not (self._css[idx-1] == '\\' and not self.escaped(self._css, idx-1)): + temp_add = "\\A " + self.log('Fixed incorrect newline in string', 'Warning') + + if not (self._str_char == ')' and self._css[idx].isspace() and not self._str_in_str): + self._cur_string += temp_add + + if self._css[idx] == self._str_char and not self.escaped(self._css, idx) and not self._str_in_str: + self._status = self._from + regex = re.compile(r'([\s]+)', re.I | re.U | re.S) + if regex.match(self._cur_string) is None and self._property != 'content': + if self._str_char == '"' or self._str_char == "'": + self._cur_string = self._cur_string[1:-1] + + elif len(self._cur_string) > 3 and (self._cur_string[1] == '"' or self._cur_string[1] == "'"): + self._cur_string = self._cur_string[0] + self._cur_string[2:-2] + self._cur_string[-1] + + if self._from == 'iv': + self._sub_value += self._cur_string + + elif self._from == 'is': + self._selector += self._cur_string + + return 0 + + def __parseStatus_ic(self, idx): + """ + Parse css In Comment + """ + if self._css[idx] == '*' and self._css[idx+1] == '/': + self._status = self._from + self.__add_token(data.COMMENT, self._curComment) + self._curComment = '' + return 1 + + else: + self._curComment += self._css[idx] + + return 0 + + def __parseStatus_at(self, idx): + """ + Parse in at-block + """ + if self.__is_token(string, idx): + if self._css[idx] == '/' and self._css[idx+1] == '*': + self._status = 'ic' + self._from = 'at' + return 1 + + elif self._css[i] == '{': + self._status = 'is' + self.__add_token(data.AT_START, self._at) + + elif self._css[i] == ',': + self._at = self._at.strip() + ',' + + elif self._css[i] == '\\': + self._at += self.__unicode(i) + else: + lastpos = len(self._at)-1 + if not (self._at[lastpos].isspace() or self.__is_token(self._at, lastpos) and self._at[lastpos] == ',') and self._css[i].isspace(): + self._at += self._css[i] + + return 0 + + def __explode_selectors(self): + #Explode multiple selectors + if self.getSetting('merge_selectors') == 1: + new_sels = [] + lastpos = 0; + self._sel_separate.append(len(self._selector)) + + for num in xrange(len(self._sel_separate)): + pos = self._sel_separate[num] + if num == (len(self._sel_separate)): #CHECK# + pos += 1 + + new_sels.append(self._selector[lastpos:(pos-lastpos-1)]) + lastpos = pos + + if len(new_sels) > 1: + for selector in new_sels: + self.merge_css_blocks(self._at, selector, self._raw_css[self._at][self._selector]) + + del self._raw_css[self._at][self._selector] + + self._sel_separate = [] + + #Adds a property with value to the existing CSS code + def __css_add_property(self, media, selector, prop, new_val): + if self.getSetting('preserve_css') or new_val.strip() == '': + return + + if not self._raw_css.has_key(media): + self._raw_css[media] = SortedDict() + + if not self._raw_css[media].has_key(selector): + self._raw_css[media][selector] = SortedDict() + + self._added = True + if self._raw_css[media][selector].has_key(prop): + if (self.is_important(self._raw_css[media][selector][prop]) and self.is_important(new_val)) or not self.is_important(self._raw_css[media][selector][prop]): + del self._raw_css[media][selector][prop] + self._raw_css[media][selector][prop] = new_val.strip() + + else: + self._raw_css[media][selector][prop] = new_val.strip() + + #Checks if the next word in a string from pos is a CSS property + def __property_is_next(self, pos): + istring = self._css[pos: len(self._css)] + pos = istring.find(':') + if pos == -1: + return False; + + istring = istring[:pos].strip().lower() + if data.all_properties.has_key(istring): + self.log('Added semicolon to the end of declaration', 'Warning') + return True + + return False; + + #Checks if a property is valid + def __property_is_valid(self, prop): + return (data.all_properties.has_key(prop) and data.all_properties[prop].find(self.getSetting('css_level').upper()) != -1) + + #Adds a token to self._tokens + def __add_token(self, ttype, cssdata, do=False): + if self.getSetting('preserve_css') or do: + if ttype == data.COMMENT: + token = [ttype, cssdata] + else: + token = [ttype, cssdata.strip()] + + self._tokens.append(token) + + #Parse unicode notations and find a replacement character + def __unicode(self, idx): + ##FIX## + return '' + + #Starts parsing from URL + ##USED? + def __parse_from_url(self, url): + try: + if "http" in url.lower() or "https" in url.lower(): + f = urllib.urlopen(url) + else: + f = open(url) + + data = f.read() + return self.parse(data) + finally: + f.close() + + #Checks if there is a token at the current position + def __is_token(self, string, idx): + return (string[idx] in data.tokens and not self.escaped(string, idx)) + + + #Property Methods + def _getOutput(self): + self._output.prepare(self._optimized_css) + return self._output.render + + def _getLog(self): + ret = "" + ks = self._log.keys() + ks.sort() + for line in ks: + for msg in self._log[line]: + ret += "Type: " + msg['t'] + "\n" + ret += "Message: " + msg['m'] + "\n" + ret += "\n" + + return ret + + def _getCSS(self): + return self._css + + + #Properties + Output = property(_getOutput, None) + Log = property(_getLog, None) + CSS = property(_getCSS, None) + + +if __name__ == '__main__': + import sys + tidy = CSSTidy() + f = open(sys.argv[1], "r") + css = f.read() + f.close() + tidy.parse(css) + tidy.Output('file', filename="Stylesheet.min.css") + print tidy.Output() + #print tidy._import \ No newline at end of file diff --git a/apps/compress/filters/csstidy_python/data.py b/apps/compress/filters/csstidy_python/data.py new file mode 100644 index 000000000..bd728cbaa --- /dev/null +++ b/apps/compress/filters/csstidy_python/data.py @@ -0,0 +1,421 @@ +# Various CSS Data for CSSTidy +# +# This file is part of CSSTidy. +# +# CSSTidy is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# CSSTidy is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with CSSTidy; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# @license http://opensource.org/licenses/gpl-license.php GNU Public License +# @package csstidy +# @author Florian Schmitz (floele at gmail dot com) 2005 + +AT_START = 1 +AT_END = 2 +SEL_START = 3 +SEL_END = 4 +PROPERTY = 5 +VALUE = 6 +COMMENT = 7 +DEFAULT_AT = 41 + +# All whitespace allowed in CSS +# +# @global array whitespace +# @version 1.0 +whitespace = frozenset([' ',"\n","\t","\r","\x0B"]) + +# All CSS tokens used by csstidy +# +# @global string tokens +# @version 1.0 +tokens = '/@}{;:=\'"(,\\!$%&)#+.<>?[]^`|~' + +# All CSS units (CSS 3 units included) +# +# @see compress_numbers() +# @global array units +# @version 1.0 +units = frozenset(['in','cm','mm','pt','pc','px','rem','em','%','ex','gd','vw','vh','vm','deg','grad','rad','ms','s','khz','hz']) + +# Available at-rules +# +# @global array at_rules +# @version 1.0 +at_rules = {'page':'is', 'font-face':'is', 'charset':'iv', 'import':'iv', 'namespace':'iv', 'media':'at'} + +# Properties that need a value with unit +# +# @todo CSS3 properties +# @see compress_numbers() +# @global array unit_values +# @version 1.2 +unit_values = frozenset(['background', 'background-position', 'border', 'border-top', 'border-right', 'border-bottom', + 'border-left', 'border-width', 'border-top-width', 'border-right-width', 'border-left-width', + 'border-bottom-width', 'bottom', 'border-spacing', 'font-size','height', 'left', 'margin', 'margin-top', + 'margin-right', 'margin-bottom', 'margin-left', 'max-height', 'max-width', 'min-height', 'min-width', + 'outline-width', 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left','position', + 'right', 'top', 'text-indent', 'letter-spacing', 'word-spacing', 'width' + ]) + + +# Properties that allow as value +# +# @todo CSS3 properties +# @see compress_numbers() +# @global array color_values +# @version 1.0 +color_values = frozenset(['background-color', 'border-color', 'border-top-color', 'border-right-color', + 'border-bottom-color', 'border-left-color', 'color', 'outline-color']) + + +# Default values for the background properties +# +# @todo Possibly property names will change during CSS3 development +# @global array background_prop_default +# @see dissolve_short_bg() +# @see merge_bg() +# @version 1.0 +background_prop_default = {} +background_prop_default['background-image'] = 'none' +background_prop_default['background-size'] = 'auto' +background_prop_default['background-repeat'] = 'repeat' +background_prop_default['background-position'] = '0 0' +background_prop_default['background-attachment'] = 'scroll' +background_prop_default['background-clip'] = 'border' +background_prop_default['background-origin'] = 'padding' +background_prop_default['background-color'] = 'transparent' + +# A list of non-W3C color names which get replaced by their hex-codes +# +# @global array replace_colors +# @see cut_color() +# @version 1.0 +replace_colors = {} +replace_colors['aliceblue'] = '#F0F8FF' +replace_colors['antiquewhite'] = '#FAEBD7' +replace_colors['aquamarine'] = '#7FFFD4' +replace_colors['azure'] = '#F0FFFF' +replace_colors['beige'] = '#F5F5DC' +replace_colors['bisque'] = '#FFE4C4' +replace_colors['blanchedalmond'] = '#FFEBCD' +replace_colors['blueviolet'] = '#8A2BE2' +replace_colors['brown'] = '#A52A2A' +replace_colors['burlywood'] = '#DEB887' +replace_colors['cadetblue'] = '#5F9EA0' +replace_colors['chartreuse'] = '#7FFF00' +replace_colors['chocolate'] = '#D2691E' +replace_colors['coral'] = '#FF7F50' +replace_colors['cornflowerblue'] = '#6495ED' +replace_colors['cornsilk'] = '#FFF8DC' +replace_colors['crimson'] = '#DC143C' +replace_colors['cyan'] = '#00FFFF' +replace_colors['darkblue'] = '#00008B' +replace_colors['darkcyan'] = '#008B8B' +replace_colors['darkgoldenrod'] = '#B8860B' +replace_colors['darkgray'] = '#A9A9A9' +replace_colors['darkgreen'] = '#006400' +replace_colors['darkkhaki'] = '#BDB76B' +replace_colors['darkmagenta'] = '#8B008B' +replace_colors['darkolivegreen'] = '#556B2F' +replace_colors['darkorange'] = '#FF8C00' +replace_colors['darkorchid'] = '#9932CC' +replace_colors['darkred'] = '#8B0000' +replace_colors['darksalmon'] = '#E9967A' +replace_colors['darkseagreen'] = '#8FBC8F' +replace_colors['darkslateblue'] = '#483D8B' +replace_colors['darkslategray'] = '#2F4F4F' +replace_colors['darkturquoise'] = '#00CED1' +replace_colors['darkviolet'] = '#9400D3' +replace_colors['deeppink'] = '#FF1493' +replace_colors['deepskyblue'] = '#00BFFF' +replace_colors['dimgray'] = '#696969' +replace_colors['dodgerblue'] = '#1E90FF' +replace_colors['feldspar'] = '#D19275' +replace_colors['firebrick'] = '#B22222' +replace_colors['floralwhite'] = '#FFFAF0' +replace_colors['forestgreen'] = '#228B22' +replace_colors['gainsboro'] = '#DCDCDC' +replace_colors['ghostwhite'] = '#F8F8FF' +replace_colors['gold'] = '#FFD700' +replace_colors['goldenrod'] = '#DAA520' +replace_colors['greenyellow'] = '#ADFF2F' +replace_colors['honeydew'] = '#F0FFF0' +replace_colors['hotpink'] = '#FF69B4' +replace_colors['indianred'] = '#CD5C5C' +replace_colors['indigo'] = '#4B0082' +replace_colors['ivory'] = '#FFFFF0' +replace_colors['khaki'] = '#F0E68C' +replace_colors['lavender'] = '#E6E6FA' +replace_colors['lavenderblush'] = '#FFF0F5' +replace_colors['lawngreen'] = '#7CFC00' +replace_colors['lemonchiffon'] = '#FFFACD' +replace_colors['lightblue'] = '#ADD8E6' +replace_colors['lightcoral'] = '#F08080' +replace_colors['lightcyan'] = '#E0FFFF' +replace_colors['lightgoldenrodyellow'] = '#FAFAD2' +replace_colors['lightgrey'] = '#D3D3D3' +replace_colors['lightgreen'] = '#90EE90' +replace_colors['lightpink'] = '#FFB6C1' +replace_colors['lightsalmon'] = '#FFA07A' +replace_colors['lightseagreen'] = '#20B2AA' +replace_colors['lightskyblue'] = '#87CEFA' +replace_colors['lightslateblue'] = '#8470FF' +replace_colors['lightslategray'] = '#778899' +replace_colors['lightsteelblue'] = '#B0C4DE' +replace_colors['lightyellow'] = '#FFFFE0' +replace_colors['limegreen'] = '#32CD32' +replace_colors['linen'] = '#FAF0E6' +replace_colors['magenta'] = '#FF00FF' +replace_colors['mediumaquamarine'] = '#66CDAA' +replace_colors['mediumblue'] = '#0000CD' +replace_colors['mediumorchid'] = '#BA55D3' +replace_colors['mediumpurple'] = '#9370D8' +replace_colors['mediumseagreen'] = '#3CB371' +replace_colors['mediumslateblue'] = '#7B68EE' +replace_colors['mediumspringgreen'] = '#00FA9A' +replace_colors['mediumturquoise'] = '#48D1CC' +replace_colors['mediumvioletred'] = '#C71585' +replace_colors['midnightblue'] = '#191970' +replace_colors['mintcream'] = '#F5FFFA' +replace_colors['mistyrose'] = '#FFE4E1' +replace_colors['moccasin'] = '#FFE4B5' +replace_colors['navajowhite'] = '#FFDEAD' +replace_colors['oldlace'] = '#FDF5E6' +replace_colors['olivedrab'] = '#6B8E23' +replace_colors['orangered'] = '#FF4500' +replace_colors['orchid'] = '#DA70D6' +replace_colors['palegoldenrod'] = '#EEE8AA' +replace_colors['palegreen'] = '#98FB98' +replace_colors['paleturquoise'] = '#AFEEEE' +replace_colors['palevioletred'] = '#D87093' +replace_colors['papayawhip'] = '#FFEFD5' +replace_colors['peachpuff'] = '#FFDAB9' +replace_colors['peru'] = '#CD853F' +replace_colors['pink'] = '#FFC0CB' +replace_colors['plum'] = '#DDA0DD' +replace_colors['powderblue'] = '#B0E0E6' +replace_colors['rosybrown'] = '#BC8F8F' +replace_colors['royalblue'] = '#4169E1' +replace_colors['saddlebrown'] = '#8B4513' +replace_colors['salmon'] = '#FA8072' +replace_colors['sandybrown'] = '#F4A460' +replace_colors['seagreen'] = '#2E8B57' +replace_colors['seashell'] = '#FFF5EE' +replace_colors['sienna'] = '#A0522D' +replace_colors['skyblue'] = '#87CEEB' +replace_colors['slateblue'] = '#6A5ACD' +replace_colors['slategray'] = '#708090' +replace_colors['snow'] = '#FFFAFA' +replace_colors['springgreen'] = '#00FF7F' +replace_colors['steelblue'] = '#4682B4' +replace_colors['tan'] = '#D2B48C' +replace_colors['thistle'] = '#D8BFD8' +replace_colors['tomato'] = '#FF6347' +replace_colors['turquoise'] = '#40E0D0' +replace_colors['violet'] = '#EE82EE' +replace_colors['violetred'] = '#D02090' +replace_colors['wheat'] = '#F5DEB3' +replace_colors['whitesmoke'] = '#F5F5F5' +replace_colors['yellowgreen'] = '#9ACD32' + +#A list of optimized colors +optimize_colors = {} +optimize_colors['black'] = '#000' +optimize_colors['fuchsia'] = '#F0F' +optimize_colors['white'] = '#FFF' +optimize_colors['yellow'] = '#FF0' +optimize_colors['cyan'] = '#0FF' +optimize_colors['magenta'] = '#F0F' +optimize_colors['lightslategray'] = '#789' + +optimize_colors['#800000'] = 'maroon' +optimize_colors['#FFA500'] = 'orange' +optimize_colors['#808000'] = 'olive' +optimize_colors['#800080'] = 'purple' +optimize_colors['#008000'] = 'green' +optimize_colors['#000080'] = 'navy' +optimize_colors['#008080'] = 'teal' +optimize_colors['#C0C0C0'] = 'silver' +optimize_colors['#808080'] = 'gray' +optimize_colors['#4B0082'] = 'indigo' +optimize_colors['#FFD700'] = 'gold' +optimize_colors['#A52A2A'] = 'brown' +optimize_colors['#00FFFF'] = 'cyan' +optimize_colors['#EE82EE'] = 'violet' +optimize_colors['#DA70D6'] = 'orchid' +optimize_colors['#FFE4C4'] = 'bisque' +optimize_colors['#F0E68C'] = 'khaki' +optimize_colors['#F5DEB3'] = 'wheat' +optimize_colors['#FF7F50'] = 'coral' +optimize_colors['#F5F5DC'] = 'beige' +optimize_colors['#F0FFFF'] = 'azure' +optimize_colors['#A0522D'] = 'sienna' +optimize_colors['#CD853F'] = 'peru' +optimize_colors['#FFFFF0'] = 'ivory' +optimize_colors['#DDA0DD'] = 'plum' +optimize_colors['#D2B48C'] = 'tan' +optimize_colors['#FFC0CB'] = 'pink' +optimize_colors['#FFFAFA'] = 'snow' +optimize_colors['#FA8072'] = 'salmon' +optimize_colors['#FF6347'] = 'tomato' +optimize_colors['#FAF0E6'] = 'linen' +optimize_colors['#F00'] = 'red' + + +# A list of all shorthand properties that are devided into four properties and/or have four subvalues +# +# @global array shorthands +# @todo Are there new ones in CSS3? +# @see dissolve_4value_shorthands() +# @see merge_4value_shorthands() +# @version 1.0 +shorthands = {} +shorthands['border-color'] = ['border-top-color','border-right-color','border-bottom-color','border-left-color'] +shorthands['border-style'] = ['border-top-style','border-right-style','border-bottom-style','border-left-style'] +shorthands['border-width'] = ['border-top-width','border-right-width','border-bottom-width','border-left-width'] +shorthands['margin'] = ['margin-top','margin-right','margin-bottom','margin-left'] +shorthands['padding'] = ['padding-top','padding-right','padding-bottom','padding-left'] +shorthands['-moz-border-radius'] = 0 + +# All CSS Properties. Needed for csstidy::property_is_next() +# +# @global array all_properties +# @todo Add CSS3 properties +# @version 1.0 +# @see csstidy::property_is_next() +all_properties = {} +all_properties['background'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['background-color'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['background-image'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['background-repeat'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['background-attachment'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['background-position'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['border'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['border-top'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['border-right'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['border-bottom'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['border-left'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['border-color'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['border-top-color'] = 'CSS2.0,CSS2.1' +all_properties['border-bottom-color'] = 'CSS2.0,CSS2.1' +all_properties['border-left-color'] = 'CSS2.0,CSS2.1' +all_properties['border-right-color'] = 'CSS2.0,CSS2.1' +all_properties['border-style'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['border-top-style'] = 'CSS2.0,CSS2.1' +all_properties['border-right-style'] = 'CSS2.0,CSS2.1' +all_properties['border-left-style'] = 'CSS2.0,CSS2.1' +all_properties['border-bottom-style'] = 'CSS2.0,CSS2.1' +all_properties['border-width'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['border-top-width'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['border-right-width'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['border-left-width'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['border-bottom-width'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['border-collapse'] = 'CSS2.0,CSS2.1' +all_properties['border-spacing'] = 'CSS2.0,CSS2.1' +all_properties['bottom'] = 'CSS2.0,CSS2.1' +all_properties['caption-side'] = 'CSS2.0,CSS2.1' +all_properties['content'] = 'CSS2.0,CSS2.1' +all_properties['clear'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['clip'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['color'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['counter-reset'] = 'CSS2.0,CSS2.1' +all_properties['counter-increment'] = 'CSS2.0,CSS2.1' +all_properties['cursor'] = 'CSS2.0,CSS2.1' +all_properties['empty-cells'] = 'CSS2.0,CSS2.1' +all_properties['display'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['direction'] = 'CSS2.0,CSS2.1' +all_properties['float'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['font'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['font-family'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['font-style'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['font-variant'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['font-weight'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['font-stretch'] = 'CSS2.0' +all_properties['font-size-adjust'] = 'CSS2.0' +all_properties['font-size'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['height'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['left'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['line-height'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['list-style'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['list-style-type'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['list-style-image'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['list-style-position'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['margin'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['margin-top'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['margin-right'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['margin-bottom'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['margin-left'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['marks'] = 'CSS1.0,CSS2.0' +all_properties['marker-offset'] = 'CSS2.0' +all_properties['max-height'] = 'CSS2.0,CSS2.1' +all_properties['max-width'] = 'CSS2.0,CSS2.1' +all_properties['min-height'] = 'CSS2.0,CSS2.1' +all_properties['min-width'] = 'CSS2.0,CSS2.1' +all_properties['overflow'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['orphans'] = 'CSS2.0,CSS2.1' +all_properties['outline'] = 'CSS2.0,CSS2.1' +all_properties['outline-width'] = 'CSS2.0,CSS2.1' +all_properties['outline-style'] = 'CSS2.0,CSS2.1' +all_properties['outline-color'] = 'CSS2.0,CSS2.1' +all_properties['padding'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['padding-top'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['padding-right'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['padding-bottom'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['padding-left'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['page-break-before'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['page-break-after'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['page-break-inside'] = 'CSS2.0,CSS2.1' +all_properties['page'] = 'CSS2.0' +all_properties['position'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['quotes'] = 'CSS2.0,CSS2.1' +all_properties['right'] = 'CSS2.0,CSS2.1' +all_properties['size'] = 'CSS1.0,CSS2.0' +all_properties['speak-header'] = 'CSS2.0,CSS2.1' +all_properties['table-layout'] = 'CSS2.0,CSS2.1' +all_properties['top'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['text-indent'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['text-align'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['text-decoration'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['text-shadow'] = 'CSS2.0' +all_properties['letter-spacing'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['word-spacing'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['text-transform'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['white-space'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['unicode-bidi'] = 'CSS2.0,CSS2.1' +all_properties['vertical-align'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['visibility'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['width'] = 'CSS1.0,CSS2.0,CSS2.1' +all_properties['widows'] = 'CSS2.0,CSS2.1' +all_properties['z-index'] = 'CSS1.0,CSS2.0,CSS2.1' + +# Speech # +all_properties['volume'] = 'CSS2.0,CSS2.1' +all_properties['speak'] = 'CSS2.0,CSS2.1' +all_properties['pause'] = 'CSS2.0,CSS2.1' +all_properties['pause-before'] = 'CSS2.0,CSS2.1' +all_properties['pause-after'] = 'CSS2.0,CSS2.1' +all_properties['cue'] = 'CSS2.0,CSS2.1' +all_properties['cue-before'] = 'CSS2.0,CSS2.1' +all_properties['cue-after'] = 'CSS2.0,CSS2.1' +all_properties['play-during'] = 'CSS2.0,CSS2.1' +all_properties['azimuth'] = 'CSS2.0,CSS2.1' +all_properties['elevation'] = 'CSS2.0,CSS2.1' +all_properties['speech-rate'] = 'CSS2.0,CSS2.1' +all_properties['voice-family'] = 'CSS2.0,CSS2.1' +all_properties['pitch'] = 'CSS2.0,CSS2.1' +all_properties['pitch-range'] = 'CSS2.0,CSS2.1' +all_properties['stress'] = 'CSS2.0,CSS2.1' +all_properties['richness'] = 'CSS2.0,CSS2.1' +all_properties['speak-punctuation'] = 'CSS2.0,CSS2.1' +all_properties['speak-numeral'] = 'CSS2.0,CSS2.1' \ No newline at end of file diff --git a/apps/compress/filters/csstidy_python/optimizer.py b/apps/compress/filters/csstidy_python/optimizer.py new file mode 100644 index 000000000..7cd284cfc --- /dev/null +++ b/apps/compress/filters/csstidy_python/optimizer.py @@ -0,0 +1,383 @@ +# CSSTidy - CSS Optimizer +# +# CSS Optimizer class +# +# This file is part of CSSTidy. +# +# CSSTidy is free software you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation either version 2 of the License, or +# (at your option) any later version. +# +# CSSTidy is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with CSSTidy if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# @license http://opensource.org/licenses/gpl-license.php GNU Public License +# @package csstidy +# @author Dj Gilcrease (digitalxero at gmail dot com) 2005-2006 + +import data +from tools import SortedDict + + +class CSSOptimizer(object): + def __init__(self, parser): + #raw_css is a dict + self.parser = parser + self._optimized_css = SortedDict + + +#PUBLIC METHODS + def optimize(self, raw_css): + if self.parser.getSetting('preserve_css'): + return raw_css + + self._optimized_css = raw_css + + if self.parser.getSetting('merge_selectors') == 2: + self.__merge_selectors() + + ##OPTIMIZE## + for media, css in self._optimized_css.iteritems(): + for selector, cssdata in css.iteritems(): + if self.parser.getSetting('optimise_shorthands') >= 1: + cssdata = self.__merge_4value_shorthands(cssdata) + + if self.parser.getSetting('optimise_shorthands') >= 2: + cssdata = self.__merge_bg(cssdata) + + for item, value in cssdata.iteritems(): + value = self.__compress_numbers(item, value) + value = self.__compress_important(value) + + if item in data.color_values and self.parser.getSetting('compress_colors'): + old = value[:] + value = self.__compress_color(value) + if old != value: + self.parser.log('In "' + selector + '" Optimised ' + item + ': Changed ' + old + ' to ' + value, 'Information') + + if item == 'font-weight' and self.parser.getSetting('compress_font-weight'): + if value == 'bold': + value = '700' + self.parser.log('In "' + selector + '" Optimised font-weight: Changed "bold" to "700"', 'Information') + + elif value == 'normal': + value = '400' + self.parser.log('In "' + selector + '" Optimised font-weight: Changed "normal" to "400"', 'Information') + + self._optimized_css[media][selector][item] = value + + + return self._optimized_css + + +#PRIVATE METHODS + def __merge_bg(self, cssdata): + """ + Merges all background properties + @cssdata (dict) is a dictionary of the selector properties + """ + #Max number of background images. CSS3 not yet fully implemented + img = 1 + clr = 1 + bg_img_list = [] + if cssdata.has_key('background-image'): + img = len(cssdata['background-image'].split(',')) + bg_img_list = self.parser.gvw_important(cssdata['background-image']).split(',') + + elif cssdata.has_key('background-color'): + clr = len(cssdata['background-color'].split(',')) + + + number_of_values = max(img, clr, 1) + + new_bg_value = '' + important = '' + + for i in xrange(number_of_values): + for bg_property, default_value in data.background_prop_default.iteritems(): + #Skip if property does not exist + if not cssdata.has_key(bg_property): + continue + + cur_value = cssdata[bg_property] + + #Skip some properties if there is no background image + if (len(bg_img_list) > i and bg_img_list[i] == 'none') and bg_property in frozenset(['background-size', 'background-position', 'background-attachment', 'background-repeat']): + continue + + #Remove !important + if self.parser.is_important(cur_value): + important = ' !important' + cur_value = self.parser.gvw_important(cur_value) + + #Do not add default values + if cur_value == default_value: + continue + + temp = cur_value.split(',') + + if len(temp) > i: + if bg_property == 'background-size': + new_bg_value += '(' + temp[i] + ') ' + + else: + new_bg_value += temp[i] + ' ' + + new_bg_value = new_bg_value.strip() + if i != (number_of_values-1): + new_bg_value += ',' + + #Delete all background-properties + for bg_property, default_value in data.background_prop_default.iteritems(): + try: + del cssdata[bg_property] + except: + pass + + #Add new background property + if new_bg_value != '': + cssdata['background'] = new_bg_value + important + + return cssdata + + def __merge_4value_shorthands(self, cssdata): + """ + Merges Shorthand properties again, the opposite of dissolve_4value_shorthands() + @cssdata (dict) is a dictionary of the selector properties + """ + for key, value in data.shorthands.iteritems(): + important = '' + if value != 0 and cssdata.has_key(value[0]) and cssdata.has_key(value[1]) and cssdata.has_key(value[2]) and cssdata.has_key(value[3]): + cssdata[key] = '' + + for i in xrange(4): + val = cssdata[value[i]] + if self.parser.is_important(val): + important = '!important' + cssdata[key] += self.parser.gvw_important(val) + ' ' + + else: + cssdata[key] += val + ' ' + + del cssdata[value[i]] + if cssdata.has_key(key): + cssdata[key] = self.__shorthand(cssdata[key] + important.strip()) + + return cssdata + + + def __merge_selectors(self): + """ + Merges selectors with same properties. Example: a{color:red} b{color:red} . a,b{color:red} + Very basic and has at least one bug. Hopefully there is a replacement soon. + @selector_one (string) is the current selector + @value_one (dict) is a dictionary of the selector properties + Note: Currently is the elements of a selector are identical, but in a different order, they are not merged + """ + + ##OPTIMIZE## + ##FIX## + + raw_css = self._optimized_css.copy() + delete = [] + add = SortedDict() + for media, css in raw_css.iteritems(): + for selector_one, value_one in css.iteritems(): + newsel = selector_one + + for selector_two, value_two in css.iteritems(): + if selector_one == selector_two: + #We need to skip self + continue + + if value_one == value_two: + #Ok, we need to merge these two selectors + newsel += ', ' + selector_two + delete.append((media, selector_two)) + + + if not add.has_key(media): + add[media] = SortedDict() + + add[media][newsel] = value_one + delete.append((media, selector_one)) + + for item in delete: + try: + del self._optimized_css[item[0]][item[1]] + except: + #Must have already been deleted + continue + + for media, css in add.iteritems(): + self._optimized_css[media].update(css) + + + + def __shorthand(self, value): + """ + Compresses shorthand values. Example: margin:1px 1px 1px 1px . margin:1px + @value (string) + """ + + ##FIX## + + important = ''; + if self.parser.is_important(value): + value_list = self.parser.gvw_important(value) + important = '!important' + else: + value_list = value + + ret = value + value_list = value_list.split(' ') + + if len(value_list) == 4: + if value_list[0] == value_list[1] and value_list[0] == value_list[2] and value_list[0] == value_list[3]: + ret = value_list[0] + important + + elif value_list[1] == value_list[3] and value_list[0] == value_list[2]: + ret = value_list[0] + ' ' + value_list[1] + important + + elif value_list[1] == value_list[3]: + ret = value_list[0] + ' ' + value_list[1] + ' ' + value_list[2] + important + + elif len(value_list) == 3: + if value_list[0] == value_list[1] and value_list[0] == value_list[2]: + ret = value_list[0] + important + + elif value_list[0] == value_list[2]: + return value_list[0] + ' ' + value_list[1] + important + + elif len(value_list) == 2: + if value_list[0] == value_list[1]: + ret = value_list[0] + important + + if ret != value: + self.parser.log('Optimised shorthand notation: Changed "' + value + '" to "' + ret + '"', 'Information') + + return ret + + def __compress_important(self, value): + """ + Removes unnecessary whitespace in ! important + @value (string) + """ + if self.parser.is_important(value): + value = self.parser.gvw_important(value) + '!important' + + return value + + def __compress_numbers(self, prop, value): + """ + Compresses numbers (ie. 1.0 becomes 1 or 1.100 becomes 1.1 ) + @value (string) is the posible number to be compressed + """ + + ##FIX## + + value = value.split('/') + + for l in xrange(len(value)): + #continue if no numeric value + if not (len(value[l]) > 0 and (value[l][0].isdigit() or value[l][0] in ('+', '-') )): + continue + + #Fix bad colors + if prop in data.color_values: + value[l] = '#' + value[l] + + is_floatable = False + try: + float(value[l]) + is_floatable = True + except: + pass + + if is_floatable and float(value[l]) == 0: + value[l] = '0' + + elif value[l][0] != '#': + unit_found = False + for unit in data.units: + pos = value[l].lower().find(unit) + if pos != -1 and prop not in data.shorthands: + value[l] = self.__remove_leading_zeros(float(value[l][:pos])) + unit + unit_found = True + break; + + if not unit_found and prop in data.unit_values and prop not in data.shorthands: + value[l] = self.__remove_leading_zeros(float(value[l])) + 'px' + + elif not unit_found and prop not in data.shorthands: + value[l] = self.__remove_leading_zeros(float(value[l])) + + + if len(value) > 1: + return '/'.join(value) + + return value[0] + + def __remove_leading_zeros(self, float_val): + """ + Removes the leading zeros from a float value + @float_val (float) + @returns (string) + """ + #Remove leading zero + if abs(float_val) < 1: + if float_val < 0: + float_val = '-' . str(float_val)[2:] + else: + float_val = str(float_val)[1:] + + return str(float_val) + + def __compress_color(self, color): + """ + Color compression function. Converts all rgb() values to #-values and uses the short-form if possible. Also replaces 4 color names by #-values. + @color (string) the {posible} color to change + """ + + #rgb(0,0,0) . #000000 (or #000 in this case later) + if color[:4].lower() == 'rgb(': + color_tmp = color[4:(len(color)-5)] + color_tmp = color_tmp.split(',') + + for c in color_tmp: + c = c.strip() + if c[:-1] == '%': + c = round((255*color_tmp[i])/100) + + if color_tmp[i] > 255: + color_tmp[i] = 255 + + color = '#' + + for i in xrange(3): + if color_tmp[i] < 16: + color += '0' + str(hex(color_tmp[i])).replace('0x', '') + else: + color += str(hex(color_tmp[i])).replace('0x', '') + + #Fix bad color names + if data.replace_colors.has_key(color.lower()): + color = data.replace_colors[color.lower()] + + #aabbcc . #abc + if len(color) == 7: + color_temp = color.lower() + if color_temp[0] == '#' and color_temp[1] == color_temp[2] and color_temp[3] == color_temp[4] and color_temp[5] == color_temp[6]: + color = '#' + color[1] + color[3] + color[5] + + if data.optimize_colors.has_key(color.lower()): + color = data.optimize_colors[color.lower()] + + return color \ No newline at end of file diff --git a/apps/compress/filters/csstidy_python/output.py b/apps/compress/filters/csstidy_python/output.py new file mode 100644 index 000000000..795a0d050 --- /dev/null +++ b/apps/compress/filters/csstidy_python/output.py @@ -0,0 +1,101 @@ +# CSSTidy - CSS Printer +# +# CSS Printer class +# +# This file is part of CSSTidy. +# +# CSSTidy is free software you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation either version 2 of the License, or +# (at your option) any later version. +# +# CSSTidy is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with CSSTidy if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# @license http://opensource.org/licenses/gpl-license.php GNU Public License +# @package csstidy +# @author Dj Gilcrease (digitalxero at gmail dot com) 2005-2006 + +import data + +class CSSPrinter(object): + def __init__(self, parser): + self.parser = parser + self._css = {} + self.__renderMethods = {'string': self.__renderString, 'file': self.__renderFile} + +#PUBLIC METHODS + def prepare(self, css): + self._css = css + + def render(self, output="string", *args, **kwargs): + return self.__renderMethods[output](*args, **kwargs) + +#PRIVATE METHODS + def __renderString(self, *args, **kwargs): + ##OPTIMIZE## + template = self.parser.getSetting('template') + ret = "" + + if template == 'highest_compression': + top_line_end = "" + iner_line_end = "" + bottom_line_end = "" + indent = "" + + elif template == 'high_compression': + top_line_end = "\n" + iner_line_end = "" + bottom_line_end = "\n" + indent = "" + + elif template == 'default': + top_line_end = "\n" + iner_line_end = "\n" + bottom_line_end = "\n\n" + indent = "" + + elif template == 'low_compression': + top_line_end = "\n" + iner_line_end = "\n" + bottom_line_end = "\n\n" + indent = " " + + if self.parser.getSetting('timestamp'): + ret += '/# CSSTidy ' + self.parser.version + ': ' + datetime.now().strftime("%a, %d %b %Y %H:%M:%S +0000") + ' #/' + top_line_end + + for item in self.parser._import: + ret += '@import(' + item + ');' + top_line_end + + for item in self.parser._charset: + ret += '@charset(' + item + ');' + top_line_end + + for item in self.parser._namespace: + ret += '@namespace(' + item + ');' + top_line_end + + for media, css in self._css.iteritems(): + for selector, cssdata in css.iteritems(): + ret += selector + '{' + top_line_end + + for item, value in cssdata.iteritems(): + ret += indent + item + ':' + value + ';' + iner_line_end + + ret += '}' + bottom_line_end + + return ret + + def __renderFile(self, filename=None, *args, **kwargs): + if filename is None: + return self.__renderString() + + try: + f = open(filename, "w") + f.write(self.__renderString()) + finally: + f.close() \ No newline at end of file diff --git a/apps/compress/filters/csstidy_python/tools.py b/apps/compress/filters/csstidy_python/tools.py new file mode 100644 index 000000000..e62faef2c --- /dev/null +++ b/apps/compress/filters/csstidy_python/tools.py @@ -0,0 +1,109 @@ + +class SortedDict(dict): + """ + A dictionary that keeps its keys in the order in which they're inserted. + """ + def __init__(self, data=None): + if data is None: + data = {} + super(SortedDict, self).__init__(data) + if isinstance(data, dict): + self.keyOrder = data.keys() + else: + self.keyOrder = [] + for key, value in data: + if key not in self.keyOrder: + self.keyOrder.append(key) + + def __deepcopy__(self, memo): + from copy import deepcopy + return self.__class__([(key, deepcopy(value, memo)) + for key, value in self.iteritems()]) + + def __setitem__(self, key, value): + super(SortedDict, self).__setitem__(key, value) + if key not in self.keyOrder: + self.keyOrder.append(key) + + def __delitem__(self, key): + super(SortedDict, self).__delitem__(key) + self.keyOrder.remove(key) + + def __iter__(self): + for k in self.keyOrder: + yield k + + def pop(self, k, *args): + result = super(SortedDict, self).pop(k, *args) + try: + self.keyOrder.remove(k) + except ValueError: + # Key wasn't in the dictionary in the first place. No problem. + pass + return result + + def popitem(self): + result = super(SortedDict, self).popitem() + self.keyOrder.remove(result[0]) + return result + + def items(self): + return zip(self.keyOrder, self.values()) + + def iteritems(self): + for key in self.keyOrder: + yield key, super(SortedDict, self).__getitem__(key) + + def keys(self): + return self.keyOrder[:] + + def iterkeys(self): + return iter(self.keyOrder) + + def values(self): + return [super(SortedDict, self).__getitem__(k) for k in self.keyOrder] + + def itervalues(self): + for key in self.keyOrder: + yield super(SortedDict, self).__getitem__(key) + + def update(self, dict_): + for k, v in dict_.items(): + self.__setitem__(k, v) + + def setdefault(self, key, default): + if key not in self.keyOrder: + self.keyOrder.append(key) + return super(SortedDict, self).setdefault(key, default) + + def value_for_index(self, index): + """Returns the value of the item at the given zero-based index.""" + return self[self.keyOrder[index]] + + def insert(self, index, key, value): + """Inserts the key, value pair before the item with the given index.""" + if key in self.keyOrder: + n = self.keyOrder.index(key) + del self.keyOrder[n] + if n < index: + index -= 1 + self.keyOrder.insert(index, key) + super(SortedDict, self).__setitem__(key, value) + + def copy(self): + """Returns a copy of this object.""" + # This way of initializing the copy means it works for subclasses, too. + obj = self.__class__(self) + obj.keyOrder = self.keyOrder[:] + return obj + + def __repr__(self): + """ + Replaces the normal dict.__repr__ with a version that returns the keys + in their sorted order. + """ + return '{%s}' % ', '.join(['%r: %r' % (k, v) for k, v in self.items()]) + + def clear(self): + super(SortedDict, self).clear() + self.keyOrder = [] \ No newline at end of file diff --git a/apps/compress/filters/jsmin/__init__.py b/apps/compress/filters/jsmin/__init__.py new file mode 100644 index 000000000..d22620081 --- /dev/null +++ b/apps/compress/filters/jsmin/__init__.py @@ -0,0 +1,6 @@ +from compress.filters.jsmin.jsmin import jsmin +from compress.filter_base import FilterBase + +class JSMinFilter(FilterBase): + def filter_js(self, js): + return jsmin(js) \ No newline at end of file diff --git a/apps/compress/filters/jsmin/jsmin.py b/apps/compress/filters/jsmin/jsmin.py new file mode 100644 index 000000000..4f9d384f1 --- /dev/null +++ b/apps/compress/filters/jsmin/jsmin.py @@ -0,0 +1,218 @@ +#!/usr/bin/python + +# This code is original from jsmin by Douglas Crockford, it was translated to +# Python by Baruch Even. The original code had the following copyright and +# license. +# +# /* jsmin.c +# 2007-05-22 +# +# Copyright (c) 2002 Douglas Crockford (www.crockford.com) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# The Software shall be used for Good, not Evil. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# */ + +from StringIO import StringIO + +def jsmin(js): + ins = StringIO(js) + outs = StringIO() + JavascriptMinify().minify(ins, outs) + str = outs.getvalue() + if len(str) > 0 and str[0] == '\n': + str = str[1:] + return str + +def isAlphanum(c): + """return true if the character is a letter, digit, underscore, + dollar sign, or non-ASCII character. + """ + return ((c >= 'a' and c <= 'z') or (c >= '0' and c <= '9') or + (c >= 'A' and c <= 'Z') or c == '_' or c == '$' or c == '\\' or (c is not None and ord(c) > 126)); + +class UnterminatedComment(Exception): + pass + +class UnterminatedStringLiteral(Exception): + pass + +class UnterminatedRegularExpression(Exception): + pass + +class JavascriptMinify(object): + + def _outA(self): + self.outstream.write(self.theA) + def _outB(self): + self.outstream.write(self.theB) + + def _get(self): + """return the next character from stdin. Watch out for lookahead. If + the character is a control character, translate it to a space or + linefeed. + """ + c = self.theLookahead + self.theLookahead = None + if c == None: + c = self.instream.read(1) + if c >= ' ' or c == '\n': + return c + if c == '': # EOF + return '\000' + if c == '\r': + return '\n' + return ' ' + + def _peek(self): + self.theLookahead = self._get() + return self.theLookahead + + def _next(self): + """get the next character, excluding comments. peek() is used to see + if a '/' is followed by a '/' or '*'. + """ + c = self._get() + if c == '/': + p = self._peek() + if p == '/': + c = self._get() + while c > '\n': + c = self._get() + return c + if p == '*': + c = self._get() + while 1: + c = self._get() + if c == '*': + if self._peek() == '/': + self._get() + return ' ' + if c == '\000': + raise UnterminatedComment() + + return c + + def _action(self, action): + """do something! What you do is determined by the argument: + 1 Output A. Copy B to A. Get the next B. + 2 Copy B to A. Get the next B. (Delete A). + 3 Get the next B. (Delete B). + action treats a string as a single character. Wow! + action recognizes a regular expression if it is preceded by ( or , or =. + """ + if action <= 1: + self._outA() + + if action <= 2: + self.theA = self.theB + if self.theA == "'" or self.theA == '"': + while 1: + self._outA() + self.theA = self._get() + if self.theA == self.theB: + break + if self.theA <= '\n': + raise UnterminatedStringLiteral() + if self.theA == '\\': + self._outA() + self.theA = self._get() + + + if action <= 3: + self.theB = self._next() + if self.theB == '/' and (self.theA == '(' or self.theA == ',' or + self.theA == '=' or self.theA == ':' or + self.theA == '[' or self.theA == '?' or + self.theA == '!' or self.theA == '&' or + self.theA == '|' or self.theA == ';' or + self.theA == '{' or self.theA == '}' or + self.theA == '\n'): + self._outA() + self._outB() + while 1: + self.theA = self._get() + if self.theA == '/': + break + elif self.theA == '\\': + self._outA() + self.theA = self._get() + elif self.theA <= '\n': + raise UnterminatedRegularExpression() + self._outA() + self.theB = self._next() + + + def _jsmin(self): + """Copy the input to the output, deleting the characters which are + insignificant to JavaScript. Comments will be removed. Tabs will be + replaced with spaces. Carriage returns will be replaced with linefeeds. + Most spaces and linefeeds will be removed. + """ + self.theA = '\n' + self._action(3) + + while self.theA != '\000': + if self.theA == ' ': + if isAlphanum(self.theB): + self._action(1) + else: + self._action(2) + elif self.theA == '\n': + if self.theB in ['{', '[', '(', '+', '-']: + self._action(1) + elif self.theB == ' ': + self._action(3) + else: + if isAlphanum(self.theB): + self._action(1) + else: + self._action(2) + else: + if self.theB == ' ': + if isAlphanum(self.theA): + self._action(1) + else: + self._action(3) + elif self.theB == '\n': + if self.theA in ['}', ']', ')', '+', '-', '"', '\'']: + self._action(1) + else: + if isAlphanum(self.theA): + self._action(1) + else: + self._action(3) + else: + self._action(1) + + def minify(self, instream, outstream): + self.instream = instream + self.outstream = outstream + self.theA = '\n' + self.theB = None + self.theLookahead = None + + self._jsmin() + self.instream.close() + +if __name__ == '__main__': + import sys + jsm = JavascriptMinify() + jsm.minify(sys.stdin, sys.stdout) \ No newline at end of file diff --git a/apps/compress/filters/yui/__init__.py b/apps/compress/filters/yui/__init__.py new file mode 100644 index 000000000..1e2e711fd --- /dev/null +++ b/apps/compress/filters/yui/__init__.py @@ -0,0 +1,44 @@ +import subprocess + +from django.conf import settings + +from compress.filter_base import FilterBase, FilterError + +BINARY = getattr(settings, 'COMPRESS_YUI_BINARY', 'java -jar yuicompressor.jar') +CSS_ARGUMENTS = getattr(settings, 'COMPRESS_YUI_CSS_ARGUMENTS', '') +JS_ARGUMENTS = getattr(settings, 'COMPRESS_YUI_JS_ARGUMENTS', '') + +class YUICompressorFilter(FilterBase): + + def filter_common(self, content, type_, arguments): + command = '%s --type=%s %s' % (BINARY, type_, arguments) + + if self.verbose: + command += ' --verbose' + + p = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE) + p.stdin.write(content) + p.stdin.close() + + filtered_css = p.stdout.read() + p.stdout.close() + + err = p.stderr.read() + p.stderr.close() + + if p.wait() != 0: + if not err: + err = 'Unable to apply YUI Compressor filter' + + raise FilterError(err) + + if self.verbose: + print err + + return filtered_css + + def filter_js(self, js): + return self.filter_common(js, 'js', JS_ARGUMENTS) + + def filter_css(self, css): + return self.filter_common(css, 'css', CSS_ARGUMENTS) \ No newline at end of file diff --git a/apps/compress/management/__init__.py b/apps/compress/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/compress/management/commands/__init__.py b/apps/compress/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/compress/management/commands/synccompress.py b/apps/compress/management/commands/synccompress.py new file mode 100644 index 000000000..6e31d254d --- /dev/null +++ b/apps/compress/management/commands/synccompress.py @@ -0,0 +1,51 @@ +from django.core.management.base import NoArgsCommand +from optparse import make_option + +from django.conf import settings + +class Command(NoArgsCommand): + option_list = NoArgsCommand.option_list + ( + make_option('--force', action='store_true', default=False, help='Force update of all files, even if the source files are older than the current compressed file.'), + make_option('--verbosity', action='store', dest='verbosity', default='1', + type='choice', choices=['0', '1', '2'], + help='Verbosity level; 0=minimal output, 1=normal output, 2=all output'), + ) + help = 'Updates and compresses CSS and JavsScript on-demand, without restarting Django' + args = '' + + def handle_noargs(self, **options): + + force = options.get('force', False) + verbosity = int(options.get('verbosity', 1)) + + from compress.utils import needs_update, filter_css, filter_js + + for name, css in settings.COMPRESS_CSS.items(): + u, version = needs_update(css['output_filename'], css['source_filenames']) + + if (force or u) or verbosity >= 2: + msg = 'CSS Group \'%s\'' % name + print msg + print len(msg) * '-' + print "Version: %s" % version + + if force or u: + filter_css(css, verbosity) + + if (force or u) or verbosity >= 2: + print + + for name, js in settings.COMPRESS_JS.items(): + u, version = needs_update(js['output_filename'], js['source_filenames']) + + if (force or u) or verbosity >= 2: + msg = 'JavaScript Group \'%s\'' % name + print msg + print len(msg) * '-' + print "Version: %s" % version + + if force or u: + filter_js(js, verbosity) + + if (force or u) or verbosity >= 2: + print \ No newline at end of file diff --git a/apps/compress/models.py b/apps/compress/models.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/compress/signals.py b/apps/compress/signals.py new file mode 100644 index 000000000..bd76a76e7 --- /dev/null +++ b/apps/compress/signals.py @@ -0,0 +1,4 @@ +from django.dispatch import Signal + +css_filtered = Signal() +js_filtered = Signal() diff --git a/apps/compress/templates/compress/css.html b/apps/compress/templates/compress/css.html new file mode 100644 index 000000000..68ddbac25 --- /dev/null +++ b/apps/compress/templates/compress/css.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/compress/templates/compress/css_ie.html b/apps/compress/templates/compress/css_ie.html new file mode 100644 index 000000000..80372dc8e --- /dev/null +++ b/apps/compress/templates/compress/css_ie.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/compress/templates/compress/js.html b/apps/compress/templates/compress/js.html new file mode 100644 index 000000000..bfa2b593c --- /dev/null +++ b/apps/compress/templates/compress/js.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/compress/templates/compress/js_ie.html b/apps/compress/templates/compress/js_ie.html new file mode 100644 index 000000000..8235fe2c3 --- /dev/null +++ b/apps/compress/templates/compress/js_ie.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/compress/templatetags/__init__.py b/apps/compress/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/compress/templatetags/compressed.py b/apps/compress/templatetags/compressed.py new file mode 100644 index 000000000..2a020099f --- /dev/null +++ b/apps/compress/templatetags/compressed.py @@ -0,0 +1,104 @@ +import os + +from django import template + +from django.conf import settings as django_settings + +from compress.conf import settings +from compress.utils import media_root, media_url, needs_update, filter_css, filter_js, get_output_filename, get_version + +register = template.Library() + +def render_common(template_name, obj, filename, version): + if settings.COMPRESS: + filename = get_output_filename(filename, version) + + context = obj.get('extra_context', {}) + context['url'] = media_url(filename) + + return template.loader.render_to_string(template_name, context) + +def render_css(css, filename, version=None): + return render_common(css.get('template_name', 'compress/css.html'), css, filename, version) + +def render_js(js, filename, version=None): + return render_common(js.get('template_name', 'compress/js.html'), js, filename, version) + +class CompressedCSSNode(template.Node): + def __init__(self, name): + self.name = name + + def render(self, context): + css_name = template.Variable(self.name).resolve(context) + + try: + css = settings.COMPRESS_CSS[css_name] + except KeyError: + return '' # fail silently, do not return anything if an invalid group is specified + + if settings.COMPRESS: + + version = None + + if settings.COMPRESS_AUTO: + u, version = needs_update(css['output_filename'], css['source_filenames']) + if u: + filter_css(css) + + return render_css(css, css['output_filename'], version) + else: + # output source files + r = '' + for source_file in css['source_filenames']: + r += render_css(css, source_file) + + return r + +class CompressedJSNode(template.Node): + def __init__(self, name): + self.name = name + + def render(self, context): + js_name = template.Variable(self.name).resolve(context) + + try: + js = settings.COMPRESS_JS[js_name] + except KeyError: + return '' # fail silently, do not return anything if an invalid group is specified + + if settings.COMPRESS: + + version = None + + if settings.COMPRESS_AUTO: + u, version = needs_update(js['output_filename'], js['source_filenames']) + if u: + filter_js(js) + + return render_js(js, js['output_filename'], version) + else: + # output source files + r = '' + for source_file in js['source_filenames']: + r += render_js(js, source_file) + return r + +#@register.tag +def compressed_css(parser, token): + try: + tag_name, name = token.split_contents() + except ValueError: + raise template.TemplateSyntaxError, '%r requires exactly one argument: the name of a group in the COMPRESS_CSS setting' % token.split_contents()[0] + + return CompressedCSSNode(name) +compressed_css = register.tag(compressed_css) + +#@register.tag +def compressed_js(parser, token): + try: + tag_name, name = token.split_contents() + except ValueError: + raise template.TemplateSyntaxError, '%r requires exactly one argument: the name of a group in the COMPRESS_JS setting' % token.split_contents()[0] + + return CompressedJSNode(name) +compressed_js = register.tag(compressed_js) diff --git a/apps/compress/utils.py b/apps/compress/utils.py new file mode 100644 index 000000000..1e0681f07 --- /dev/null +++ b/apps/compress/utils.py @@ -0,0 +1,130 @@ +import os +import re +import tempfile + +from django.conf import settings as django_settings +from django.utils.http import urlquote +from django.dispatch import dispatcher + +from compress.conf import settings +from compress.signals import css_filtered, js_filtered + +def get_filter(compressor_class): + """ + Convert a string version of a function name to the callable object. + """ + + if not hasattr(compressor_class, '__bases__'): + + try: + compressor_class = compressor_class.encode('ascii') + mod_name, class_name = get_mod_func(compressor_class) + if class_name != '': + compressor_class = getattr(__import__(mod_name, {}, {}, ['']), class_name) + except (ImportError, AttributeError): + raise Exception('Failed to import filter %s' % compressor_class) + + return compressor_class + +def get_mod_func(callback): + """ + Converts 'django.views.news.stories.story_detail' to + ('django.views.news.stories', 'story_detail') + """ + + try: + dot = callback.rindex('.') + except ValueError: + return callback, '' + return callback[:dot], callback[dot+1:] + +def needs_update(output_file, source_files): + """ + Scan the source files for changes and returns True if the output_file needs to be updated. + """ + + mtime = max_mtime(source_files) + version = get_version(mtime) + + compressed_file_full = media_root(get_output_filename(output_file, version)) + + if not os.path.exists(compressed_file_full): + return True, version + + # Check if the output file is outdated + return (os.stat(compressed_file_full).st_mtime < mtime), mtime + +def media_root(filename): + """ + Return the full path to ``filename``. ``filename`` is a relative path name in MEDIA_ROOT + """ + return os.path.join(django_settings.MEDIA_ROOT, filename) + +def media_url(url): + return django_settings.MEDIA_URL + urlquote(url) + +def concat(filenames, separator=''): + """ + Concatenate the files from the list of the ``filenames``, ouput separated with ``separator``. + """ + r = '' + + for filename in filenames: + fd = open(media_root(filename), 'rb') + r += fd.read() + r += separator + fd.close() + + return r + +def max_mtime(files): + return int(max([os.stat(media_root(f)).st_mtime for f in files])) + +def save_file(filename, contents): + fd = open(media_root(filename), 'wb+') + fd.write(contents) + fd.close() + +def get_output_filename(filename, version): + if settings.COMPRESS_VERSION and version is not None: + return filename.replace(settings.COMPRESS_VERSION_PLACEHOLDER, get_version(version)) + else: + return filename.replace(settings.COMPRESS_VERSION_PLACEHOLDER, settings.COMPRESS_VERSION_DEFAULT) + +def get_version(version): + try: + return str(int(version)) + except ValueError: + return str(version) + +def remove_files(path, filename, verbosity=0): + regex = re.compile(r'^%s$' % (os.path.basename(get_output_filename(settings.COMPRESS_VERSION_PLACEHOLDER.join([re.escape(part) for part in filename.split(settings.COMPRESS_VERSION_PLACEHOLDER)]), r'\d+')))) + + for f in os.listdir(path): + if regex.match(f): + if verbosity >= 1: + print "Removing outdated file %s" % f + + os.unlink(os.path.join(path, f)) + +def filter_common(obj, verbosity, filters, attr, separator, signal): + output = concat(obj['source_filenames'], separator) + filename = get_output_filename(obj['output_filename'], get_version(max_mtime(obj['source_filenames']))) + + if settings.COMPRESS_VERSION: + remove_files(os.path.dirname(media_root(filename)), obj['output_filename'], verbosity) + + if verbosity >= 1: + print "Saving %s" % filename + + for f in filters: + output = getattr(get_filter(f)(verbose=(verbosity >= 2)), attr)(output) + + save_file(filename, output) + signal.send(None) + +def filter_css(css, verbosity=0): + return filter_common(css, verbosity, filters=settings.COMPRESS_CSS_FILTERS, attr='filter_css', separator='', signal=css_filtered) + +def filter_js(js, verbosity=0): + return filter_common(js, verbosity, filters=settings.COMPRESS_JS_FILTERS, attr='filter_js', separator=';', signal=js_filtered) diff --git a/apps/newtagging/__init__.py b/apps/newtagging/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/newtagging/admin.py b/apps/newtagging/admin.py new file mode 100644 index 000000000..956d2cf9b --- /dev/null +++ b/apps/newtagging/admin.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +from django.contrib import admin +from django import forms +from django.utils.safestring import mark_safe +from django.utils.translation import ugettext_lazy as _ + + +class FilteredSelectMultiple(forms.SelectMultiple): + """ + A SelectMultiple with a JavaScript filter interface. + + Note that the resulting JavaScript assumes that the SelectFilter2.js + library and its dependencies have been loaded in the HTML page. + """ + def _media(self): + from django.conf import settings + js = ['js/SelectBox.js' , 'js/SelectFilter2.js'] + return forms.Media(js=['%s%s' % (settings.ADMIN_MEDIA_PREFIX, url) for url in js]) + media = property(_media) + + def __init__(self, verbose_name, is_stacked, attrs=None, choices=()): + self.verbose_name = verbose_name + self.is_stacked = is_stacked + super(FilteredSelectMultiple, self).__init__(attrs, choices) + + def render(self, name, value, attrs=None, choices=()): + from django.conf import settings + output = [super(FilteredSelectMultiple, self).render(name, value, attrs, choices)] + output.append(u'\n' % \ + (name, self.verbose_name.replace('"', '\\"'), int(self.is_stacked), settings.ADMIN_MEDIA_PREFIX)) + return mark_safe(u''.join(output)) + + +class TaggableModelForm(forms.ModelForm): + tags = forms.MultipleChoiceField(label=_('tags').capitalize(), required=True, widget=FilteredSelectMultiple(_('tags'), False)) + + def __init__(self, *args, **kwargs): + if 'instance' in kwargs: + if 'initial' not in kwargs: + kwargs['initial'] = {} + kwargs['initial']['tags'] = [tag.id for tag in self.tag_model.objects.get_for_object(kwargs['instance'])] + super(TaggableModelForm, self).__init__(*args, **kwargs) + self.fields['tags'].choices = [(tag.id, tag.name) for tag in self.tag_model.objects.all()] + + def save(self, commit): + obj = super(TaggableModelForm, self).save() + tag_ids = self.cleaned_data['tags'] + tags = self.tag_model.objects.filter(pk__in=tag_ids) + self.tag_model.objects.update_tags(obj, tags) + return obj + + def save_m2m(self): + # TODO: Shouldn't be needed + pass + + +class TaggableModelAdmin(admin.ModelAdmin): + form = TaggableModelForm + + def get_form(self, request, obj=None): + form = super(TaggableModelAdmin, self).get_form(request, obj) + form.tag_model = self.tag_model + return form + diff --git a/apps/newtagging/managers.py b/apps/newtagging/managers.py new file mode 100644 index 000000000..1dbcb2999 --- /dev/null +++ b/apps/newtagging/managers.py @@ -0,0 +1,78 @@ +""" +Custom managers for Django models registered with the tagging +application. +""" +from django.contrib.contenttypes.models import ContentType +from django.db import models + + +class ModelTagManager(models.Manager): + """ + A manager for retrieving tags for a particular model. + """ + def __init__(self, tag_model): + super(ModelTagManager, self).__init__() + self.tag_model = tag_model + + def get_query_set(self): + content_type = ContentType.objects.get_for_model(self.model) + return self.tag_model.objects.filter( + items__content_type__pk=content_type.pk).distinct() + + def related(self, tags, *args, **kwargs): + return self.tag_model.objects.related_for_model(tags, self.model, *args, **kwargs) + + def usage(self, *args, **kwargs): + return self.tag_model.objects.usage_for_model(self.model, *args, **kwargs) + + +class ModelTaggedItemManager(models.Manager): + """ + A manager for retrieving model instances based on their tags. + """ + def __init__(self, tag_model): + super(ModelTaggedItemManager, self).__init__() + self.intermediary_table_model = tag_model.objects.intermediary_table_model + + def related_to(self, obj, queryset=None, num=None): + if queryset is None: + return self.intermediary_table_model.objects.get_related(obj, self.model, num=num) + else: + return self.intermediary_table_model.objects.get_related(obj, queryset, num=num) + + def with_all(self, tags, queryset=None): + if queryset is None: + return self.intermediary_table_model.objects.get_by_model(self.model, tags) + else: + return self.intermediary_table_model.objects.get_by_model(queryset, tags) + + def with_any(self, tags, queryset=None): + if queryset is None: + return self.intermediary_table_model.objects.get_union_by_model(self.model, tags) + else: + return self.intermediary_table_model.objects.get_union_by_model(queryset, tags) + + +class TagDescriptor(object): + """ + A descriptor which provides access to a ``ModelTagManager`` for + model classes and simple retrieval, updating and deletion of tags + for model instances. + """ + def __init__(self, tag_model): + self.tag_model = tag_model + + def __get__(self, instance, owner): + if not instance: + tag_manager = ModelTagManager(self.tag_model) + tag_manager.model = owner + return tag_manager + else: + return self.tag_model.objects.get_for_object(instance) + + def __set__(self, instance, value): + self.tag_model.objects.update_tags(instance, value) + + def __del__(self, instance): + self.tag_model.objects.update_tags(instance, []) + diff --git a/apps/newtagging/models.py b/apps/newtagging/models.py new file mode 100644 index 000000000..0c2b0e1f0 --- /dev/null +++ b/apps/newtagging/models.py @@ -0,0 +1,510 @@ +""" +Models and managers for generic tagging. +""" +# Python 2.3 compatibility +if not hasattr(__builtins__, 'set'): + from sets import Set as set + +from django.contrib.contenttypes import generic +from django.contrib.contenttypes.models import ContentType +from django.db import connection, models +from django.utils.translation import ugettext_lazy as _ +from django.db.models.base import ModelBase + +qn = connection.ops.quote_name + +try: + from django.db.models.query import parse_lookup +except ImportError: + parse_lookup = None + + +def get_queryset_and_model(queryset_or_model): + """ + Given a ``QuerySet`` or a ``Model``, returns a two-tuple of + (queryset, model). + + If a ``Model`` is given, the ``QuerySet`` returned will be created + using its default manager. + """ + try: + return queryset_or_model, queryset_or_model.model + except AttributeError: + return queryset_or_model._default_manager.all(), queryset_or_model + + +############ +# Managers # +############ +class TagManager(models.Manager): + def __init__(self, intermediary_table_model): + super(TagManager, self).__init__() + self.intermediary_table_model = intermediary_table_model + + def update_tags(self, obj, tags): + """ + Update tags associated with an object. + """ + content_type = ContentType.objects.get_for_model(obj) + current_tags = list(self.filter(items__content_type__pk=content_type.pk, + items__object_id=obj.pk)) + updated_tags = self.model.get_tag_list(tags) + + # Remove tags which no longer apply + tags_for_removal = [tag for tag in current_tags \ + if tag not in updated_tags] + if len(tags_for_removal): + self.intermediary_table_model._default_manager.filter(content_type__pk=content_type.pk, + object_id=obj.pk, + tag__in=tags_for_removal).delete() + # Add new tags + for tag in updated_tags: + if tag not in current_tags: + self.intermediary_table_model._default_manager.create(tag=tag, content_object=obj) + + def get_for_object(self, obj): + """ + Create a queryset matching all tags associated with the given + object. + """ + ctype = ContentType.objects.get_for_model(obj) + return self.filter(items__content_type__pk=ctype.pk, + items__object_id=obj.pk) + + def _get_usage(self, model, counts=False, min_count=None, extra_joins=None, extra_criteria=None, params=None, extra=None): + """ + Perform the custom SQL query for ``usage_for_model`` and + ``usage_for_queryset``. + """ + if min_count is not None: counts = True + + model_table = qn(model._meta.db_table) + model_pk = '%s.%s' % (model_table, qn(model._meta.pk.column)) + tag_columns = self._get_tag_columns() + + if extra is None: extra = {} + extra_where = '' + if 'where' in extra: + extra_where = 'AND ' + ' AND '.join(extra['where']) + + query = """ + SELECT DISTINCT %(tag_columns)s%(count_sql)s + FROM + %(tag)s + INNER JOIN %(tagged_item)s + ON %(tag)s.id = %(tagged_item)s.tag_id + INNER JOIN %(model)s + ON %(tagged_item)s.object_id = %(model_pk)s + %%s + WHERE %(tagged_item)s.content_type_id = %(content_type_id)s + %%s + %(extra_where)s + GROUP BY %(tag)s.id, %(tag)s.name + %%s + ORDER BY %(tag)s.%(ordering)s ASC""" % { + 'tag': qn(self.model._meta.db_table), + 'ordering': ', '.join(qn(field) for field in self.model._meta.ordering), + 'tag_columns': tag_columns, + 'count_sql': counts and (', COUNT(%s)' % model_pk) or '', + 'tagged_item': qn(self.intermediary_table_model._meta.db_table), + 'model': model_table, + 'model_pk': model_pk, + 'extra_where': extra_where, + 'content_type_id': ContentType.objects.get_for_model(model).pk, + } + + min_count_sql = '' + if min_count is not None: + min_count_sql = 'HAVING COUNT(%s) >= %%s' % model_pk + params.append(min_count) + + cursor = connection.cursor() + cursor.execute(query % (extra_joins, extra_criteria, min_count_sql), params) + tags = [] + for row in cursor.fetchall(): + t = self.model(*row[:len(self.model._meta.fields)]) + if counts: + t.count = row[len(self.model._meta.fields)] + tags.append(t) + return tags + + def usage_for_model(self, model, counts=False, min_count=None, filters=None, extra=None): + """ + Obtain a list of tags associated with instances of the given + Model class. + + If ``counts`` is True, a ``count`` attribute will be added to + each tag, indicating how many times it has been used against + the Model class in question. + + If ``min_count`` is given, only tags which have a ``count`` + greater than or equal to ``min_count`` will be returned. + Passing a value for ``min_count`` implies ``counts=True``. + + To limit the tags (and counts, if specified) returned to those + used by a subset of the Model's instances, pass a dictionary + of field lookups to be applied to the given Model as the + ``filters`` argument. + """ + if extra is None: extra = {} + if filters is None: filters = {} + + if not parse_lookup: + # post-queryset-refactor (hand off to usage_for_queryset) + queryset = model._default_manager.filter() + for f in filters.items(): + queryset.query.add_filter(f) + usage = self.usage_for_queryset(queryset, counts, min_count, extra) + else: + # pre-queryset-refactor + extra_joins = '' + extra_criteria = '' + params = [] + if len(filters) > 0: + joins, where, params = parse_lookup(filters.items(), model._meta) + extra_joins = ' '.join(['%s %s AS %s ON %s' % (join_type, table, alias, condition) + for (alias, (table, join_type, condition)) in joins.items()]) + extra_criteria = 'AND %s' % (' AND '.join(where)) + usage = self._get_usage(model, counts, min_count, extra_joins, extra_criteria, params, extra) + + return usage + + def usage_for_queryset(self, queryset, counts=False, min_count=None, extra=None): + """ + Obtain a list of tags associated with instances of a model + contained in the given queryset. + + If ``counts`` is True, a ``count`` attribute will be added to + each tag, indicating how many times it has been used against + the Model class in question. + + If ``min_count`` is given, only tags which have a ``count`` + greater than or equal to ``min_count`` will be returned. + Passing a value for ``min_count`` implies ``counts=True``. + """ + if parse_lookup: + raise AttributeError("'TagManager.usage_for_queryset' is not compatible with pre-queryset-refactor versions of Django.") + + extra_joins = ' '.join(queryset.query.get_from_clause()[0][1:]) + where, params = queryset.query.where.as_sql() + if where: + extra_criteria = 'AND %s' % where + else: + extra_criteria = '' + return self._get_usage(queryset.model, counts, min_count, extra_joins, extra_criteria, params, extra) + + def related_for_model(self, tags, model, counts=False, min_count=None, extra=None): + """ + Obtain a list of tags related to a given list of tags - that + is, other tags used by items which have all the given tags. + + If ``counts`` is True, a ``count`` attribute will be added to + each tag, indicating the number of items which have it in + addition to the given list of tags. + + If ``min_count`` is given, only tags which have a ``count`` + greater than or equal to ``min_count`` will be returned. + Passing a value for ``min_count`` implies ``counts=True``. + """ + if min_count is not None: counts = True + tags = self.model.get_tag_list(tags) + tag_count = len(tags) + tagged_item_table = qn(self.intermediary_table_model._meta.db_table) + tag_columns = self._get_tag_columns() + + if extra is None: extra = {} + extra_where = '' + if 'where' in extra: + extra_where = 'AND ' + ' AND '.join(extra['where']) + + query = """ + SELECT %(tag_columns)s%(count_sql)s + FROM %(tagged_item)s INNER JOIN %(tag)s ON %(tagged_item)s.tag_id = %(tag)s.id + WHERE %(tagged_item)s.content_type_id = %(content_type_id)s + AND %(tagged_item)s.object_id IN + ( + SELECT %(tagged_item)s.object_id + FROM %(tagged_item)s, %(tag)s + WHERE %(tagged_item)s.content_type_id = %(content_type_id)s + AND %(tag)s.id = %(tagged_item)s.tag_id + AND %(tag)s.id IN (%(tag_id_placeholders)s) + GROUP BY %(tagged_item)s.object_id + HAVING COUNT(%(tagged_item)s.object_id) = %(tag_count)s + ) + AND %(tag)s.id NOT IN (%(tag_id_placeholders)s) + %(extra_where)s + GROUP BY %(tag)s.id, %(tag)s.name + %(min_count_sql)s + ORDER BY %(tag)s.%(ordering)s ASC""" % { + 'tag': qn(self.model._meta.db_table), + 'ordering': ', '.join(qn(field) for field in self.model._meta.ordering), + 'tag_columns': tag_columns, + 'count_sql': counts and ', COUNT(%s.object_id)' % tagged_item_table or '', + 'tagged_item': tagged_item_table, + 'content_type_id': ContentType.objects.get_for_model(model).pk, + 'tag_id_placeholders': ','.join(['%s'] * tag_count), + 'extra_where': extra_where, + 'tag_count': tag_count, + 'min_count_sql': min_count is not None and ('HAVING COUNT(%s.object_id) >= %%s' % tagged_item_table) or '', + } + + params = [tag.pk for tag in tags] * 2 + if min_count is not None: + params.append(min_count) + + cursor = connection.cursor() + cursor.execute(query, params) + related = [] + for row in cursor.fetchall(): + tag = self.model(*row[:len(self.model._meta.fields)]) + if counts is True: + tag.count = row[len(self.model._meta.fields)] + related.append(tag) + return related + + def _get_tag_columns(self): + tag_table = qn(self.model._meta.db_table) + return ', '.join('%s.%s' % (tag_table, qn(field.column)) for field in self.model._meta.fields) + + +class TaggedItemManager(models.Manager): + """ + FIXME There's currently no way to get the ``GROUP BY`` and ``HAVING`` + SQL clauses required by many of this manager's methods into + Django's ORM. + + For now, we manually execute a query to retrieve the PKs of + objects we're interested in, then use the ORM's ``__in`` + lookup to return a ``QuerySet``. + + Once the queryset-refactor branch lands in trunk, this can be + tidied up significantly. + """ + def __init__(self, tag_model): + super(TaggedItemManager, self).__init__() + self.tag_model = tag_model + + def get_by_model(self, queryset_or_model, tags): + """ + Create a ``QuerySet`` containing instances of the specified + model associated with a given tag or list of tags. + """ + tags = self.tag_model.get_tag_list(tags) + tag_count = len(tags) + if tag_count == 0: + # No existing tags were given + queryset, model = get_queryset_and_model(queryset_or_model) + return model._default_manager.none() + elif tag_count == 1: + # Optimisation for single tag - fall through to the simpler + # query below. + tag = tags[0] + else: + return self.get_intersection_by_model(queryset_or_model, tags) + + queryset, model = get_queryset_and_model(queryset_or_model) + content_type = ContentType.objects.get_for_model(model) + opts = self.model._meta + tagged_item_table = qn(opts.db_table) + return queryset.extra( + tables=[opts.db_table], + where=[ + '%s.content_type_id = %%s' % tagged_item_table, + '%s.tag_id = %%s' % tagged_item_table, + '%s.%s = %s.object_id' % (qn(model._meta.db_table), + qn(model._meta.pk.column), + tagged_item_table) + ], + params=[content_type.pk, tag.pk], + ) + + def get_intersection_by_model(self, queryset_or_model, tags): + """ + Create a ``QuerySet`` containing instances of the specified + model associated with *all* of the given list of tags. + """ + tags = self.tag_model.get_tag_list(tags) + tag_count = len(tags) + queryset, model = get_queryset_and_model(queryset_or_model) + + if not tag_count: + return model._default_manager.none() + + model_table = qn(model._meta.db_table) + # This query selects the ids of all objects which have all the + # given tags. + query = """ + SELECT %(model_pk)s + FROM %(model)s, %(tagged_item)s + WHERE %(tagged_item)s.content_type_id = %(content_type_id)s + AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s) + AND %(model_pk)s = %(tagged_item)s.object_id + GROUP BY %(model_pk)s + HAVING COUNT(%(model_pk)s) = %(tag_count)s""" % { + 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)), + 'model': model_table, + 'tagged_item': qn(self.model._meta.db_table), + 'content_type_id': ContentType.objects.get_for_model(model).pk, + 'tag_id_placeholders': ','.join(['%s'] * tag_count), + 'tag_count': tag_count, + } + + cursor = connection.cursor() + cursor.execute(query, [tag.pk for tag in tags]) + object_ids = [row[0] for row in cursor.fetchall()] + if len(object_ids) > 0: + return queryset.filter(pk__in=object_ids) + else: + return model._default_manager.none() + + def get_union_by_model(self, queryset_or_model, tags): + """ + Create a ``QuerySet`` containing instances of the specified + model associated with *any* of the given list of tags. + """ + tags = self.tag_model.get_tag_list(tags) + tag_count = len(tags) + queryset, model = get_queryset_and_model(queryset_or_model) + + if not tag_count: + return model._default_manager.none() + + model_table = qn(model._meta.db_table) + # This query selects the ids of all objects which have any of + # the given tags. + query = """ + SELECT %(model_pk)s + FROM %(model)s, %(tagged_item)s + WHERE %(tagged_item)s.content_type_id = %(content_type_id)s + AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s) + AND %(model_pk)s = %(tagged_item)s.object_id + GROUP BY %(model_pk)s""" % { + 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)), + 'model': model_table, + 'tagged_item': qn(self.model._meta.db_table), + 'content_type_id': ContentType.objects.get_for_model(model).pk, + 'tag_id_placeholders': ','.join(['%s'] * tag_count), + } + + cursor = connection.cursor() + cursor.execute(query, [tag.pk for tag in tags]) + object_ids = [row[0] for row in cursor.fetchall()] + if len(object_ids) > 0: + return queryset.filter(pk__in=object_ids) + else: + return model._default_manager.none() + + def get_related(self, obj, queryset_or_model, num=None): + """ + Retrieve a list of instances of the specified model which share + tags with the model instance ``obj``, ordered by the number of + shared tags in descending order. + + If ``num`` is given, a maximum of ``num`` instances will be + returned. + """ + queryset, model = get_queryset_and_model(queryset_or_model) + model_table = qn(model._meta.db_table) + content_type = ContentType.objects.get_for_model(obj) + related_content_type = ContentType.objects.get_for_model(model) + query = """ + SELECT %(model_pk)s, COUNT(related_tagged_item.object_id) AS %(count)s + FROM %(model)s, %(tagged_item)s, %(tag)s, %(tagged_item)s related_tagged_item + WHERE %(tagged_item)s.object_id = %%s + AND %(tagged_item)s.content_type_id = %(content_type_id)s + AND %(tag)s.id = %(tagged_item)s.tag_id + AND related_tagged_item.content_type_id = %(related_content_type_id)s + AND related_tagged_item.tag_id = %(tagged_item)s.tag_id + AND %(model_pk)s = related_tagged_item.object_id""" + if content_type.pk == related_content_type.pk: + # Exclude the given instance itself if determining related + # instances for the same model. + query += """ + AND related_tagged_item.object_id != %(tagged_item)s.object_id""" + query += """ + GROUP BY %(model_pk)s + ORDER BY %(count)s DESC + %(limit_offset)s""" + query = query % { + 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)), + 'count': qn('count'), + 'model': model_table, + 'tagged_item': qn(self.model._meta.db_table), + 'tag': qn(self.model._meta.get_field('tag').rel.to._meta.db_table), + 'content_type_id': content_type.pk, + 'related_content_type_id': related_content_type.pk, + 'limit_offset': num is not None and connection.ops.limit_offset_sql(num) or '', + } + + cursor = connection.cursor() + cursor.execute(query, [obj.pk]) + object_ids = [row[0] for row in cursor.fetchall()] + if len(object_ids) > 0: + # Use in_bulk here instead of an id__in lookup, because id__in would + # clobber the ordering. + object_dict = queryset.in_bulk(object_ids) + return [object_dict[object_id] for object_id in object_ids \ + if object_id in object_dict] + else: + return [] + + +########## +# Models # +########## +def create_intermediary_table_model(model): + """Create an intermediary table model for the specific tag model""" + name = model.__name__ + 'Relation' + + class Meta: + db_table = '%s_relation' % model._meta.db_table + unique_together = (('tag', 'content_type', 'object_id'),) + + def obj_unicode(self): + return u'%s [%s]' % (self.content_type.get_object_for_this_type(pk=self.object_id), self.tag) + + # Set up a dictionary to simulate declarations within a class + attrs = { + '__module__': model.__module__, + 'Meta': Meta, + 'tag': models.ForeignKey(model, verbose_name=_('tag'), related_name='items'), + 'content_type': models.ForeignKey(ContentType, verbose_name=_('content type')), + 'object_id': models.PositiveIntegerField(_('object id'), db_index=True), + 'content_object': generic.GenericForeignKey('content_type', 'object_id'), + '__unicode__': obj_unicode, + } + + return type(name, (models.Model,), attrs) + + +class TagMeta(ModelBase): + "Metaclass for tag models (models inheriting from TagBase)." + def __new__(cls, name, bases, attrs): + model = super(TagMeta, cls).__new__(cls, name, bases, attrs) + if not model._meta.abstract: + # Create an intermediary table and register custom managers for concrete models + model.intermediary_table_model = create_intermediary_table_model(model) + TagManager(model.intermediary_table_model).contribute_to_class(model, 'objects') + TaggedItemManager(model).contribute_to_class(model.intermediary_table_model, 'objects') + return model + + +class TagBase(models.Model): + """Abstract class to be inherited by model classes.""" + __metaclass__ = TagMeta + + class Meta: + abstract = True + + @staticmethod + def get_tag_list(tag_list): + """ + Utility function for accepting tag input in a flexible manner. + + You should probably override this method in your subclass. + """ + if isinstance(tag_list, TagBase): + return [tag_list] + else: + return tag_list + diff --git a/apps/newtagging/views.py b/apps/newtagging/views.py new file mode 100644 index 000000000..150a08477 --- /dev/null +++ b/apps/newtagging/views.py @@ -0,0 +1,47 @@ +""" +Tagging related views. +""" +from django.http import Http404 +from django.utils.translation import ugettext as _ +from django.views.generic.list_detail import object_list + + +def tagged_object_list(request, queryset_or_model=None, tag_model=None, tags=None, + related_tags=False, related_tag_counts=True, **kwargs): + """ + A thin wrapper around + ``django.views.generic.list_detail.object_list`` which creates a + ``QuerySet`` containing instances of the given queryset or model + tagged with the given tag. + + In addition to the context variables set up by ``object_list``, a + ``tag`` context variable will contain the ``Tag`` instance for the + tag. + + If ``related_tags`` is ``True``, a ``related_tags`` context variable + will contain tags related to the given tag for the given model. + Additionally, if ``related_tag_counts`` is ``True``, each related + tag will have a ``count`` attribute indicating the number of items + which have it in addition to the given tag. + """ + # Check attributes + if queryset_or_model is None: + raise AttributeError(_('tagged_object_list must be called with a queryset or a model.')) + if tag_model is None: + raise AttributeError(_('tagged_object_list must be called with a tag model.')) + if tags is None: + raise AttributeError(_('tagged_object_list must be called with a tag.')) + + tag_instances = tag_model.get_tag_list(tags) + if tag_instances is None: + raise Http404(_('No tags found matching "%s".') % tags) + queryset = tag_model.intermediary_table_model.objects.get_intersection_by_model(queryset_or_model, tag_instances) + if not kwargs.has_key('extra_context'): + kwargs['extra_context'] = {} + kwargs['extra_context']['tags'] = tag_instances + if related_tags: + kwargs['extra_context']['related_tags'] = \ + tag_model.objects.related_for_model(tag_instances, queryset_or_model, + counts=related_tag_counts) + return object_list(request, queryset, **kwargs) + diff --git a/apps/pagination/__init__.py b/apps/pagination/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apps/pagination/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/pagination/middleware.py b/apps/pagination/middleware.py new file mode 100644 index 000000000..0bab76712 --- /dev/null +++ b/apps/pagination/middleware.py @@ -0,0 +1,10 @@ +class PaginationMiddleware(object): + """ + Inserts a variable representing the current page onto the request object if + it exists in either **GET** or **POST** portions of the request. + """ + def process_request(self, request): + try: + request.page = int(request.REQUEST['page']) + except (KeyError, ValueError): + request.page = 1 \ No newline at end of file diff --git a/apps/pagination/models.py b/apps/pagination/models.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apps/pagination/models.py @@ -0,0 +1 @@ + diff --git a/apps/pagination/templates/pagination/pagination.html b/apps/pagination/templates/pagination/pagination.html new file mode 100644 index 000000000..3799314e4 --- /dev/null +++ b/apps/pagination/templates/pagination/pagination.html @@ -0,0 +1,25 @@ +{% if is_paginated %} + +{% endif %} diff --git a/apps/pagination/templatetags/__init__.py b/apps/pagination/templatetags/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apps/pagination/templatetags/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/pagination/templatetags/pagination_tags.py b/apps/pagination/templatetags/pagination_tags.py new file mode 100644 index 000000000..55e5392fe --- /dev/null +++ b/apps/pagination/templatetags/pagination_tags.py @@ -0,0 +1,200 @@ +try: + set +except NameError: + from sets import Set as set +from django import template +from django.db.models.query import QuerySet +from django.core.paginator import Paginator, QuerySetPaginator, InvalidPage + +register = template.Library() + +DEFAULT_PAGINATION = 20 +DEFAULT_WINDOW = 4 +DEFAULT_ORPHANS = 0 + +def do_autopaginate(parser, token): + """ + Splits the arguments to the autopaginate tag and formats them correctly. + """ + split = token.split_contents() + if len(split) == 2: + return AutoPaginateNode(split[1]) + elif len(split) == 3: + try: + paginate_by = int(split[2]) + except ValueError: + raise template.TemplateSyntaxError(u'Got %s, but expected integer.' % split[2]) + return AutoPaginateNode(split[1], paginate_by=paginate_by) + elif len(split) == 4: + try: + paginate_by = int(split[2]) + except ValueError: + raise template.TemplateSyntaxError(u'Got %s, but expected integer.' % split[2]) + try: + orphans = int(split[3]) + except ValueError: + raise template.TemplateSyntaxError(u'Got %s, but expected integer.' % split[3]) + return AutoPaginateNode(split[1], paginate_by=paginate_by, orphans=orphans) + else: + raise template.TemplateSyntaxError('%r tag takes one required argument and one optional argument' % split[0]) + +class AutoPaginateNode(template.Node): + """ + Emits the required objects to allow for Digg-style pagination. + + First, it looks in the current context for the variable specified. This + should be either a QuerySet or a list. + + 1. If it is a QuerySet, this ``AutoPaginateNode`` will emit a + ``QuerySetPaginator`` and the current page object into the context names + ``paginator`` and ``page_obj``, respectively. + + 2. If it is a list, this ``AutoPaginateNode`` will emit a simple + ``Paginator`` and the current page object into the context names + ``paginator`` and ``page_obj``, respectively. + + It will then replace the variable specified with only the objects for the + current page. + + .. note:: + + It is recommended to use *{% paginate %}* after using the autopaginate + tag. If you choose not to use *{% paginate %}*, make sure to display the + list of availabale pages, or else the application may seem to be buggy. + """ + def __init__(self, queryset_var, paginate_by=DEFAULT_PAGINATION, orphans=DEFAULT_ORPHANS): + self.queryset_var = template.Variable(queryset_var) + self.paginate_by = paginate_by + self.orphans = orphans + + def render(self, context): + key = self.queryset_var.var + value = self.queryset_var.resolve(context) + if issubclass(value.__class__, QuerySet): + model = value.model + paginator_class = QuerySetPaginator + else: + value = list(value) + try: + model = value[0].__class__ + except IndexError: + return u'' + paginator_class = Paginator + paginator = paginator_class(value, self.paginate_by, self.orphans) + try: + page_obj = paginator.page(context['request'].page) + except InvalidPage: + context[key] = [] + context['invalid_page'] = True + return u'' + context[key] = page_obj.object_list + context['paginator'] = paginator + context['page_obj'] = page_obj + return u'' + +def paginate(context, window=DEFAULT_WINDOW): + """ + Renders the ``pagination/pagination.html`` template, resulting in a + Digg-like display of the available pages, given the current page. If there + are too many pages to be displayed before and after the current page, then + elipses will be used to indicate the undisplayed gap between page numbers. + + Requires one argument, ``context``, which should be a dictionary-like data + structure and must contain the following keys: + + ``paginator`` + A ``Paginator`` or ``QuerySetPaginator`` object. + + ``page_obj`` + This should be the result of calling the page method on the + aforementioned ``Paginator`` or ``QuerySetPaginator`` object, given + the current page. + + This same ``context`` dictionary-like data structure may also include: + + ``getvars`` + A dictionary of all of the **GET** parameters in the current request. + This is useful to maintain certain types of state, even when requesting + a different page. + """ + try: + paginator = context['paginator'] + page_obj = context['page_obj'] + page_range = paginator.page_range + # First and last are simply the first *n* pages and the last *n* pages, + # where *n* is the current window size. + first = set(page_range[:window]) + last = set(page_range[-window:]) + # Now we look around our current page, making sure that we don't wrap + # around. + current_start = page_obj.number-1-window + if current_start < 0: + current_start = 0 + current_end = page_obj.number-1+window + if current_end < 0: + current_end = 0 + current = set(page_range[current_start:current_end]) + pages = [] + # If there's no overlap between the first set of pages and the current + # set of pages, then there's a possible need for elusion. + if len(first.intersection(current)) == 0: + first_list = sorted(list(first)) + second_list = sorted(list(current)) + pages.extend(first_list) + diff = second_list[0] - first_list[-1] + # If there is a gap of two, between the last page of the first + # set and the first page of the current set, then we're missing a + # page. + if diff == 2: + pages.append(second_list[0] - 1) + # If the difference is just one, then there's nothing to be done, + # as the pages need no elusion and are correct. + elif diff == 1: + pass + # Otherwise, there's a bigger gap which needs to be signaled for + # elusion, by pushing a None value to the page list. + else: + pages.append(None) + pages.extend(second_list) + else: + pages.extend(sorted(list(first.union(current)))) + # If there's no overlap between the current set of pages and the last + # set of pages, then there's a possible need for elusion. + if len(current.intersection(last)) == 0: + second_list = sorted(list(last)) + diff = second_list[0] - pages[-1] + # If there is a gap of two, between the last page of the current + # set and the first page of the last set, then we're missing a + # page. + if diff == 2: + pages.append(second_list[0] - 1) + # If the difference is just one, then there's nothing to be done, + # as the pages need no elusion and are correct. + elif diff == 1: + pass + # Otherwise, there's a bigger gap which needs to be signaled for + # elusion, by pushing a None value to the page list. + else: + pages.append(None) + pages.extend(second_list) + else: + pages.extend(sorted(list(last.difference(current)))) + to_return = { + 'pages': pages, + 'page_obj': page_obj, + 'paginator': paginator, + 'is_paginated': paginator.count > paginator.per_page, + } + if 'request' in context: + getvars = context['request'].GET.copy() + if 'page' in getvars: + del getvars['page'] + if len(getvars.keys()) > 0: + to_return['getvars'] = "&%s" % getvars.urlencode() + else: + to_return['getvars'] = '' + return to_return + except KeyError: + return {} +register.inclusion_tag('pagination/pagination.html', takes_context=True)(paginate) +register.tag('autopaginate', do_autopaginate) \ No newline at end of file diff --git a/apps/pagination/tests.py b/apps/pagination/tests.py new file mode 100644 index 000000000..837e55cfe --- /dev/null +++ b/apps/pagination/tests.py @@ -0,0 +1,52 @@ +""" +>>> from django.core.paginator import Paginator +>>> from pagination.templatetags.pagination_tags import paginate +>>> from django.template import Template, Context + +>>> p = Paginator(range(15), 2) +>>> paginate({'paginator': p, 'page_obj': p.page(1)})['pages'] +[1, 2, 3, 4, 5, 6, 7, 8] + +>>> p = Paginator(range(17), 2) +>>> paginate({'paginator': p, 'page_obj': p.page(1)})['pages'] +[1, 2, 3, 4, 5, 6, 7, 8, 9] + +>>> p = Paginator(range(19), 2) +>>> paginate({'paginator': p, 'page_obj': p.page(1)})['pages'] +[1, 2, 3, 4, None, 7, 8, 9, 10] + +>>> p = Paginator(range(21), 2) +>>> paginate({'paginator': p, 'page_obj': p.page(1)})['pages'] +[1, 2, 3, 4, None, 8, 9, 10, 11] + +# Testing orphans +>>> p = Paginator(range(5), 2, 1) +>>> paginate({'paginator': p, 'page_obj': p.page(1)})['pages'] +[1, 2] + +>>> p = Paginator(range(21), 2, 1) +>>> paginate({'paginator': p, 'page_obj': p.page(1)})['pages'] +[1, 2, 3, 4, None, 7, 8, 9, 10] + +>>> t = Template("{% load pagination_tags %}{% autopaginate var 2 %}{% paginate %}") + +# WARNING: Please, please nobody read this portion of the code! +>>> class GetProxy(object): +... def __iter__(self): yield self.__dict__.__iter__ +... def copy(self): return self +... def urlencode(self): return u'' +... def keys(self): return [] +>>> class RequestProxy(object): +... page = 1 +... GET = GetProxy() +>>> +# ENDWARNING + +>>> t.render(Context({'var': range(21), 'request': RequestProxy()})) +u'\\n