From 4437d85206a7deb768c75a4fd1cb1b474e87efe3 Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Wed, 1 Jun 2011 17:30:57 +0200 Subject: [PATCH] editing and merging books, adding and editing book chunks, some chunk management stub functionality, moved text compiling to xml_tools --- apps/dvcs/models.py | 5 +- apps/wiki/constants.py | 22 +--- apps/wiki/forms.py | 56 +++++++- apps/wiki/models.py | 98 ++++++++------ apps/wiki/templates/wiki/book_append_to.html | 13 ++ apps/wiki/templates/wiki/book_detail.html | 58 ++++++--- apps/wiki/templates/wiki/book_edit.html | 13 ++ apps/wiki/templates/wiki/chunk_add.html | 13 ++ apps/wiki/templates/wiki/chunk_edit.html | 13 ++ apps/wiki/urls.py | 8 ++ apps/wiki/views.py | 127 +++++++++++++++++-- apps/wiki/xml_tools.py | 89 +++++++++++++ redakcja/static/css/filelist.css | 11 ++ 13 files changed, 436 insertions(+), 90 deletions(-) create mode 100755 apps/wiki/templates/wiki/book_append_to.html create mode 100755 apps/wiki/templates/wiki/book_edit.html create mode 100755 apps/wiki/templates/wiki/chunk_add.html create mode 100755 apps/wiki/templates/wiki/chunk_edit.html create mode 100755 apps/wiki/xml_tools.py diff --git a/apps/dvcs/models.py b/apps/dvcs/models.py index 6c5796af..5ce00c0f 100644 --- a/apps/dvcs/models.py +++ b/apps/dvcs/models.py @@ -167,10 +167,11 @@ class Document(models.Model): """ File in repository. """ - creator = models.ForeignKey(User, null=True, blank=True) + creator = models.ForeignKey(User, null=True, blank=True, editable=False) head = models.ForeignKey(Change, null=True, blank=True, default=None, - help_text=_("This document's current head.")) + help_text=_("This document's current head."), + editable=False) def __unicode__(self): return u"{0}, HEAD: {1}".format(self.id, self.head_id) diff --git a/apps/wiki/constants.py b/apps/wiki/constants.py index 6781a48e..fe0a4462 100644 --- a/apps/wiki/constants.py +++ b/apps/wiki/constants.py @@ -1,21 +1,5 @@ # -*- coding: utf-8 -*- -from django.utils.translation import ugettext_lazy as _ +import re -DOCUMENT_STAGES = ( - ("", u"-----"), - ("first_correction", _(u"First correction")), - ("tagging", _(u"Tagging")), - ("proofreading", _(u"Initial Proofreading")), - ("annotation-proofreading", _(u"Annotation Proofreading")), - ("modernisation", _(u"Modernisation")), - ("annotations", _(u"Annotations")), - ("themes", _(u"Themes")), - ("editor-proofreading", _(u"Editor's Proofreading")), - ("technical-editor-proofreading", _(u"Technical Editor's Proofreading")), -) - -DOCUMENT_TAGS = DOCUMENT_STAGES + \ - (("ready-to-publish", _(u"Ready to publish")),) - -DOCUMENT_TAGS_DICT = dict(DOCUMENT_TAGS) -DOCUMENT_STAGES_DICT = dict(DOCUMENT_STAGES) +RE_TRIM_BEGIN = re.compile("^$", re.M) +RE_TRIM_END = re.compile("^$", re.M) diff --git a/apps/wiki/forms.py b/apps/wiki/forms.py index f3362e81..7cabe325 100644 --- a/apps/wiki/forms.py +++ b/apps/wiki/forms.py @@ -4,7 +4,7 @@ # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. # from django import forms -from wiki.models import Book +from wiki.models import Book, Chunk from django.utils.translation import ugettext_lazy as _ from dvcs.models import Tag @@ -135,3 +135,57 @@ class DocumentTextRevertForm(forms.Form): label=_(u"Your comments"), help_text=_(u"Describe the reason for reverting."), ) + + +class ChunkForm(forms.ModelForm): + """ + Form used for editing a chunk. + """ + + class Meta: + model = Chunk + exclude = ['number'] + + def clean_slug(self): + slug = self.cleaned_data['slug'] + try: + chunk = Chunk.objects.get(book=self.instance.book, slug=slug) + except Chunk.DoesNotExist: + return slug + if chunk == self: + return slug + raise forms.ValidationError(_('Chunk with this slug already exists')) + + +class ChunkAddForm(ChunkForm): + """ + Form used for adding a chunk to a document. + """ + + def clean_slug(self): + slug = self.cleaned_data['slug'] + try: + user = Chunk.objects.get(book=self.instance.book, slug=slug) + except Chunk.DoesNotExist: + return slug + raise forms.ValidationError(_('Chunk with this slug already exists')) + + + + +class BookAppendForm(forms.Form): + """ + Form for appending a book to another book. + It means moving all chunks from book A to book B and deleting A. + """ + + append_to = forms.ModelChoiceField(queryset=Book.objects.all()) + + +class BookForm(forms.ModelForm): + """ + Form used for editing a Book. + """ + + class Meta: + model = Book diff --git a/apps/wiki/models.py b/apps/wiki/models.py index 60702065..4dc70173 100644 --- a/apps/wiki/models.py +++ b/apps/wiki/models.py @@ -13,16 +13,12 @@ from django.utils.translation import ugettext_lazy as _ from django.template.loader import render_to_string from dvcs import models as dvcs_models - +from wiki.xml_tools import compile_text import logging logger = logging.getLogger("fnp.wiki") -RE_TRIM_BEGIN = re.compile("^$", re.M) -RE_TRIM_END = re.compile("^$", re.M) - - class Book(models.Model): """ A document edited on the wiki """ @@ -46,6 +42,9 @@ class Book(models.Model): def __unicode__(self): return self.title + def get_absolute_url(self): + return reverse("wiki_book", args=[self.slug]) + def save(self, reset_list_html=True, *args, **kwargs): if reset_list_html: self._list_html = None @@ -79,18 +78,6 @@ class Book(models.Model): self.save(reset_list_html=False) return mark_safe(self._list_html) - @staticmethod - def trim(text, trim_begin=True, trim_end=True): - """ - Cut off everything before RE_TRIM_BEGIN and after RE_TRIM_END, so - that eg. one big XML file can be compiled from many small XML files. - """ - if trim_begin: - text = RE_TRIM_BEGIN.split(text, maxsplit=1)[-1] - if trim_end: - text = RE_TRIM_END.split(text, maxsplit=1)[0] - return text - @staticmethod def publish_tag(): return dvcs_models.Tag.get('publish') @@ -98,11 +85,8 @@ class Book(models.Model): def materialize(self, tag=None): """ Get full text of the document compiled from chunks. - Takes the current versions of all texts for now, but it should - be possible to specify a tag or a point in time for compiling. - - First non-empty text's beginning isn't trimmed, - and last non-empty text's end isn't trimmed. + Takes the current versions of all texts + or versions most recently tagged by a given tag. """ if tag: changes = [chunk.last_tagged(tag) for chunk in self] @@ -110,23 +94,7 @@ class Book(models.Model): changes = [chunk.head for chunk in self] if None in changes: raise self.NoTextError('Some chunks have no available text.') - texts = [] - trim_begin = False - text = '' - for chunk in changes: - next_text = chunk.materialize() - if not next_text: - continue - if text: - # trim the end, because there's more non-empty text - # don't trim beginning, if `text' is the first non-empty part - texts.append(self.trim(text, trim_begin=trim_begin)) - trim_begin = True - text = next_text - # don't trim the end, because there's no more text coming after `text' - # only trim beginning if it's not still the first non-empty - texts.append(self.trim(text, trim_begin=trim_begin, trim_end=False)) - return "".join(texts) + return compile_text(change.materialize() for change in changes) def publishable(self): if not len(self): @@ -136,6 +104,48 @@ class Book(models.Model): return False return True + def make_chunk_slug(self, proposed): + """ + Finds a chunk slug not yet used in the book. + """ + slugs = set(c.slug for c in self) + i = 1 + new_slug = proposed + while new_slug in slugs: + new_slug = "%s-%d" % (proposed, i) + i += 1 + return new_slug + + def append(self, other): + number = self[len(self) - 1].number + 1 + single = len(other) == 1 + for chunk in other: + # move chunk to new book + chunk.book = self + chunk.number = number + + # try some title guessing + if other.title.startswith(self.title): + other_title_part = other.title[len(self.title):].lstrip(' /') + else: + other_title_part = other.title + + if single: + # special treatment for appending one-parters: + # just use the guessed title and original book slug + chunk.comment = other_title_part + if other.slug.startswith(self.slug): + chunk_slug = other.slug[len(self.slug):].lstrip('-_') + else: + chunk_slug = other.slug + chunk.slug = self.make_chunk_slug(chunk_slug) + else: + chunk.comment = "%s, %s" % (other_title_part, chunk.comment) + chunk.slug = self.make_chunk_slug(chunk.slug) + chunk.save() + number += 1 + other.delete() + @staticmethod def listener_create(sender, instance, created, **kwargs): if created: @@ -147,7 +157,7 @@ models.signals.post_save.connect(Book.listener_create, sender=Book) class Chunk(dvcs_models.Document): """ An editable chunk of text. Every Book text is divided into chunks. """ - book = models.ForeignKey(Book) + book = models.ForeignKey(Book, editable=False) number = models.IntegerField() slug = models.SlugField() comment = models.CharField(max_length=255) @@ -176,6 +186,14 @@ class Chunk(dvcs_models.Document): def publishable(self): return self.last_tagged(Book.publish_tag()) + def split(self, slug, comment='', creator=None): + """ Create an empty chunk after this one """ + self.book.chunk_set.filter(number__gt=self.number).update( + number=models.F('number')+1) + new_chunk = self.book.chunk_set.create(number=self.number+1, + creator=creator, slug=slug, comment=comment) + return new_chunk + @staticmethod def listener_saved(sender, instance, created, **kwargs): if instance.book: diff --git a/apps/wiki/templates/wiki/book_append_to.html b/apps/wiki/templates/wiki/book_append_to.html new file mode 100755 index 00000000..0350aa63 --- /dev/null +++ b/apps/wiki/templates/wiki/book_append_to.html @@ -0,0 +1,13 @@ +{% extends "wiki/base.html" %} +{% load i18n %} + +{% block leftcolumn %} +
+ {{ form.as_p }} + +

+
+{% endblock leftcolumn %} + +{% block rightcolumn %} +{% endblock rightcolumn %} diff --git a/apps/wiki/templates/wiki/book_detail.html b/apps/wiki/templates/wiki/book_detail.html index 0a03d8be..d72befac 100755 --- a/apps/wiki/templates/wiki/book_detail.html +++ b/apps/wiki/templates/wiki/book_detail.html @@ -3,25 +3,51 @@ {% block leftcolumn %} +{% trans "edit" %}

{{ book.title }}

-

- {% for chunk in book %} - {{ chunk.comment }}
+ + {% for c in chunks %} + + + + + + + {% endfor %} -

- -

-{% trans "Full XML" %}
-{% trans "HTML version" %}
-{% trans "TXT version" %}
-{% comment %} -{% trans "EPUB version" %}
-{% trans "PDF version" %}
-{% endcomment %} -

- -

+
{{ c.chunk.comment }}{% if c.chunk.publishable %}P{% endif %}[{% trans "edit" %}]{% if c.bad_master %}{{ c.bad_master }}{% endif %}[+]
+ +

{% trans "Append to other book" %}

+ +{% if book.publishable %} +

+ {% trans "Full XML" %}
+ {% trans "HTML version" %}
+ {% trans "TXT version" %}
+ {% comment %} + {% trans "EPUB version" %}
+ {% trans "PDF version" %}
+ {% endcomment %} +

+ +

+{% else %} + {% trans "This book cannot be published yet" %} +{% endif %} {% endblock leftcolumn %} diff --git a/apps/wiki/templates/wiki/book_edit.html b/apps/wiki/templates/wiki/book_edit.html new file mode 100755 index 00000000..d5f527dc --- /dev/null +++ b/apps/wiki/templates/wiki/book_edit.html @@ -0,0 +1,13 @@ +{% extends "wiki/base.html" %} +{% load i18n %} + +{% block leftcolumn %} +
+ {{ form.as_p }} + +

+
+{% endblock leftcolumn %} + +{% block rightcolumn %} +{% endblock rightcolumn %} diff --git a/apps/wiki/templates/wiki/chunk_add.html b/apps/wiki/templates/wiki/chunk_add.html new file mode 100755 index 00000000..2b8939e0 --- /dev/null +++ b/apps/wiki/templates/wiki/chunk_add.html @@ -0,0 +1,13 @@ +{% extends "wiki/base.html" %} +{% load i18n %} + +{% block leftcolumn %} +
+ {{ form.as_p }} + +

+
+{% endblock leftcolumn %} + +{% block rightcolumn %} +{% endblock rightcolumn %} diff --git a/apps/wiki/templates/wiki/chunk_edit.html b/apps/wiki/templates/wiki/chunk_edit.html new file mode 100755 index 00000000..d5f527dc --- /dev/null +++ b/apps/wiki/templates/wiki/chunk_edit.html @@ -0,0 +1,13 @@ +{% extends "wiki/base.html" %} +{% load i18n %} + +{% block leftcolumn %} +
+ {{ form.as_p }} + +

+
+{% endblock leftcolumn %} + +{% block rightcolumn %} +{% endblock rightcolumn %} diff --git a/apps/wiki/urls.py b/apps/wiki/urls.py index 3e5b0675..393afa5e 100644 --- a/apps/wiki/urls.py +++ b/apps/wiki/urls.py @@ -55,5 +55,13 @@ urlpatterns = patterns('wiki.views', url(r'^book/(?P[^/]+)/html$', 'book_html', name="wiki_book_html"), #url(r'^book/(?P[^/]+)/epub$', 'book_epub', name="wiki_book_epub"), #url(r'^book/(?P[^/]+)/pdf$', 'book_pdf', name="wiki_book_pdf"), + url(r'^chunk_add/(?P[^/]+)/(?P[^/]+)/$', + 'chunk_add', name="wiki_chunk_add"), + url(r'^chunk_edit/(?P[^/]+)/(?P[^/]+)/$', + 'chunk_edit', name="wiki_chunk_edit"), + url(r'^book_append/(?P[^/]+)/$', + 'book_append', name="wiki_book_append"), + url(r'^book_edit/(?P[^/]+)/$', + 'book_edit', name="wiki_book_edit"), ) diff --git a/apps/wiki/views.py b/apps/wiki/views.py index 60630783..146db697 100644 --- a/apps/wiki/views.py +++ b/apps/wiki/views.py @@ -17,8 +17,7 @@ from django.shortcuts import get_object_or_404, redirect from django.http import Http404 from wiki.models import Book, Chunk, Theme -from wiki.forms import (DocumentTextSaveForm, DocumentTextRevertForm, DocumentTagForm, DocumentCreateForm, DocumentsUploadForm, - ChunkFormSet) +from wiki import forms from datetime import datetime from django.utils.encoding import smart_unicode from django.utils.translation import ugettext_lazy as _ @@ -27,6 +26,7 @@ from django.middleware.gzip import GZipMiddleware import librarian.html import librarian.text +from wiki.xml_tools import GradedText # # Quick hack around caching problems, TODO: use ETags @@ -79,9 +79,9 @@ def editor(request, slug, chunk=None, template_name='wiki/document_details.html' return direct_to_template(request, template_name, extra_context={ 'chunk': chunk, 'forms': { - "text_save": DocumentTextSaveForm(prefix="textsave"), - "text_revert": DocumentTextRevertForm(prefix="textrevert"), - "add_tag": DocumentTagForm(prefix="addtag"), + "text_save": forms.DocumentTextSaveForm(prefix="textsave"), + "text_revert": forms.DocumentTextRevertForm(prefix="textrevert"), + "add_tag": forms.DocumentTagForm(prefix="addtag"), }, 'REDMINE_URL': settings.REDMINE_URL, }) @@ -119,7 +119,7 @@ def create_missing(request, slug): slug = slug.replace(' ', '-') if request.method == "POST": - form = DocumentCreateForm(request.POST, request.FILES) + form = forms.DocumentCreateForm(request.POST, request.FILES) if form.is_valid(): if request.user.is_authenticated(): @@ -134,7 +134,7 @@ def create_missing(request, slug): return http.HttpResponseRedirect(reverse("wiki_editor", args=[book.slug])) else: - form = DocumentCreateForm(initial={ + form = forms.DocumentCreateForm(initial={ "slug": slug, "title": slug.replace('-', ' ').title(), }) @@ -147,7 +147,7 @@ def create_missing(request, slug): def upload(request): if request.method == "POST": - form = DocumentsUploadForm(request.POST, request.FILES) + form = forms.DocumentsUploadForm(request.POST, request.FILES) if form.is_valid(): import slughifi @@ -196,7 +196,7 @@ def upload(request): "error_list": error_list, }) else: - form = DocumentsUploadForm() + form = forms.DocumentsUploadForm() return direct_to_template(request, "wiki/document_upload.html", extra_context={ "form": form, @@ -212,7 +212,7 @@ def text(request, slug, chunk=None): raise Http404 if request.method == 'POST': - form = DocumentTextSaveForm(request.POST, prefix="textsave") + form = forms.DocumentTextSaveForm(request.POST, prefix="textsave") if form.is_valid(): if request.user.is_authenticated(): author = request.user @@ -288,7 +288,7 @@ def book_html(request, slug): @never_cache @require_POST def revert(request, slug, chunk=None): - form = DocumentTextRevertForm(request.POST, prefix="textrevert") + form = forms.DocumentTextRevertForm(request.POST, prefix="textrevert") if form.is_valid(): try: doc = Chunk.get(slug, chunk) @@ -399,15 +399,118 @@ def history(request, slug, chunk=None): def book(request, slug): book = get_object_or_404(Book, slug=slug) + # do we need some automation? + some_wl = False + first_master = None + chunks = [] + + for chunk in book: + graded = GradedText(chunk.materialize()) + chunk_dict = { + "chunk": chunk, + "graded": graded, + } + if graded.is_wl(): + some_wl = True + master = graded.master() + if first_master is None: + first_master = master + elif master != first_master: + chunk_dict['bad_master'] = master + chunks.append(chunk_dict) + return direct_to_template(request, "wiki/book_detail.html", extra_context={ "book": book, + "chunks": chunks, + "some_wl": some_wl, + "first_master": first_master, + }) + + +def chunk_add(request, slug, chunk): + try: + doc = Chunk.get(slug, chunk) + except (Chunk.MultipleObjectsReturned, Chunk.DoesNotExist): + raise Http404 + + if request.method == "POST": + form = forms.ChunkAddForm(request.POST, instance=doc) + if form.is_valid(): + if request.user.is_authenticated(): + creator = request.user + else: + creator = None + doc.split(creator=creator, + slug=form.cleaned_data['slug'], + comment=form.cleaned_data['comment'], + ) + + return http.HttpResponseRedirect(doc.book.get_absolute_url()) + else: + form = forms.ChunkAddForm(initial={ + "slug": str(doc.number + 1), + "comment": "cz. %d" % (doc.number + 1, ), + }) + + return direct_to_template(request, "wiki/chunk_add.html", extra_context={ + "chunk": doc, + "form": form, + }) + + +def chunk_edit(request, slug, chunk): + try: + doc = Chunk.get(slug, chunk) + except (Chunk.MultipleObjectsReturned, Chunk.DoesNotExist): + raise Http404 + if request.method == "POST": + form = forms.ChunkForm(request.POST, instance=doc) + if form.is_valid(): + form.save() + return http.HttpResponseRedirect(doc.book.get_absolute_url()) + else: + form = forms.ChunkForm(instance=doc) + return direct_to_template(request, "wiki/chunk_edit.html", extra_context={ + "chunk": doc, + "form": form, + }) + + +def book_append(request, slug): + book = get_object_or_404(Book, slug=slug) + if request.method == "POST": + form = forms.BookAppendForm(request.POST) + if form.is_valid(): + append_to = form.cleaned_data['append_to'] + append_to.append(book) + return http.HttpResponseRedirect(append_to.get_absolute_url()) + else: + form = forms.BookAppendForm() + return direct_to_template(request, "wiki/book_append_to.html", extra_context={ + "book": book, + "form": form, + }) + + +def book_edit(request, slug): + book = get_object_or_404(Book, slug=slug) + if request.method == "POST": + form = forms.BookForm(request.POST, instance=book) + if form.is_valid(): + form.save() + return http.HttpResponseRedirect(book.get_absolute_url()) + else: + form = forms.BookForm(instance=book) + return direct_to_template(request, "wiki/book_edit.html", extra_context={ + "book": book, + "form": form, }) @require_POST @ajax_require_permission('wiki.can_change_tags') def add_tag(request, slug, chunk=None): - form = DocumentTagForm(request.POST, prefix="addtag") + form = forms.DocumentTagForm(request.POST, prefix="addtag") if form.is_valid(): try: doc = Chunk.get(slug, chunk) diff --git a/apps/wiki/xml_tools.py b/apps/wiki/xml_tools.py new file mode 100755 index 00000000..a4de433c --- /dev/null +++ b/apps/wiki/xml_tools.py @@ -0,0 +1,89 @@ +import re + +from lxml import etree + +from wiki.constants import RE_TRIM_BEGIN, RE_TRIM_END + +class GradedText(object): + _is_xml = None + _edoc = None + _is_wl = None + _master = None + + ROOT = 'utwor' + MASTERS = ['powiesc', + 'opowiadanie', + 'liryka_l', + 'liryka_lp', + 'dramat_wierszowany_l', + 'dramat_wierszowany_lp', + 'dramat_wspolczesny', + ] + RDF = '{http://www.w3.org/1999/02/22-rdf-syntax-ns#}RDF' + + def __init__(self, text): + self._text = text + + def is_xml(self): + if self._is_xml is None: + try: + self._edoc = etree.fromstring(self._text) + except etree.XMLSyntaxError: + self._is_xml = False + else: + self._is_xml = True + del self._text + return self._is_xml + + def is_wl(self): + if self._is_wl is None: + if self.is_xml(): + e = self._edoc + self._is_wl = e.tag == self.ROOT and ( + len(e) == 1 and e[0].tag in self.MASTERS or + len(e) == 2 and e[0].tag == self.RDF + and e[1].tag in self.MASTERS) + if self._is_wl: + self._master = e[-1].tag + del self._edoc + else: + self._is_wl = False + return self._is_wl + + def master(self): + assert self.is_wl() + return self._master + + +def _trim(text, trim_begin=True, trim_end=True): + """ + Cut off everything before RE_TRIM_BEGIN and after RE_TRIM_END, so + that eg. one big XML file can be compiled from many small XML files. + """ + if trim_begin: + text = RE_TRIM_BEGIN.split(text, maxsplit=1)[-1] + if trim_end: + text = RE_TRIM_END.split(text, maxsplit=1)[0] + return text + + +def compile_text(parts): + """ + Compiles full text from an iterable of parts, + trimming where applicable. + """ + texts = [] + trim_begin = False + text = '' + for next_text in parts: + if not next_text: + continue + # trim the end, because there's more non-empty text + # don't trim beginning, if `text' is the first non-empty part + texts.append(_trim(text, trim_begin=trim_begin)) + trim_begin = True + text = next_text + # don't trim the end, because there's no more text coming after `text' + # only trim beginning if it's not still the first non-empty + texts.append(_trim(text, trim_begin=trim_begin, trim_end=False)) + return "".join(texts) diff --git a/redakcja/static/css/filelist.css b/redakcja/static/css/filelist.css index 7dfdf807..c2f59f3d 100644 --- a/redakcja/static/css/filelist.css +++ b/redakcja/static/css/filelist.css @@ -98,3 +98,14 @@ td { .chunk-list { padding-left: 2em; } + + +.chunk-wl { + background-color: #afa; +} +.chunk-plain { + background-color: #aaa; +} +.chunk-xml { + background-color: #faa; +} -- 2.20.1