Skip to content
This repository has been archived by the owner on Aug 26, 2022. It is now read-only.

Commit

Permalink
bug 688244: Section editing on client and server
Browse files Browse the repository at this point in the history
* Inject section editing links on view when user can edit

* Server-side implementation of section editing

* Client-side implementation of inline AJAX-powered section editing

* Document view can be filtered by section, serve raw source without
  template wrapping

* Edit document post with a ?section parameter will load form with just
  content for a single section, save content just to a single section

* Section editing links can be enabled or disabled with a parameter

* Title and slug editing disallowed during section editing, since it
  caused some issues and should really be done in full view of doc

* Watch and Edit links on document view shown only for capable users

* Bugfixes for cases where a large number of revisions ran into problems
  with memcached errors

* Tweaks to CKEditor mdn-buttons plugin to support inline editor

* Tweaks to allowed tags and attributes in bleach handling

* Tweaks to CKEditor HTML writer for cleaner HTML with fewer line breaks
  within headers and paragraphs

* Initial CSS tweaks for editor and CKEditor layout

* Random PEP8 fixes & tweaks
  • Loading branch information
lmorchard committed Oct 28, 2011
1 parent 2688620 commit e2aa1f4
Show file tree
Hide file tree
Showing 11 changed files with 428 additions and 106 deletions.
81 changes: 70 additions & 11 deletions apps/wiki/content.py
@@ -1,7 +1,18 @@
from urllib import urlencode
import bleach

import html5lib
from html5lib.filters._base import Filter as html5lib_Filter

from tower import ugettext as _

from sumo.urlresolvers import reverse


# List of tags supported for section editing. A subset of everything that could
# be considered an HTML5 section
SECTION_EDIT_TAGS = ('h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hgroup', 'section')


def parse(src):
return ContentSectionTool(src)
Expand All @@ -10,12 +21,12 @@ def parse(src):
class ContentSectionTool(object):

def __init__(self, src=None):

self.tree = html5lib.treebuilders.getTreeBuilder("simpletree")

self.parser = html5lib.HTMLParser(tree=self.tree,
self.parser = html5lib.HTMLParser(tree=self.tree,
namespaceHTMLElements=False)

self.serializer = html5lib.serializer.htmlserializer.HTMLSerializer(
omit_optional_tags=False, quote_attr_values=True,
escape_lt_in_attrs=True)
Expand Down Expand Up @@ -51,6 +62,10 @@ def injectSectionIDs(self):
self.stream = SectionIDFilter(self.stream)
return self

def injectSectionEditingLinks(self, slug, locale):
self.stream = SectionEditLinkFilter(self.stream, slug, locale)
return self

def extractSection(self, id):
self.stream = SectionFilter(self.stream, id)
return self
Expand All @@ -64,10 +79,6 @@ def replaceSection(self, id, replace_src):
class SectionIDFilter(html5lib_Filter):
"""Filter which ensures section-related elements have unique IDs"""

NEED_ID_TAGS = ('h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hgroup',
'article', 'aside', 'nav', 'section', 'blockquote',
'details', 'fieldset', 'figure', 'td')

def __init__(self, source):
html5lib_Filter.__init__(self, source)
self.id_cnt = 0
Expand Down Expand Up @@ -96,7 +107,8 @@ def __iter__(self):

# Pass 2: Sprinkle in IDs where they're missing
for token in buffer:
if 'StartTag' == token['type'] and token['name'] in self.NEED_ID_TAGS:
if ('StartTag' == token['type'] and
token['name'] in SECTION_EDIT_TAGS):
attrs = dict(token['data'])
id = attrs.get('id', None)
if not id:
Expand All @@ -105,6 +117,54 @@ def __iter__(self):
yield token


class SectionEditLinkFilter(html5lib_Filter):
"""Filter which injects editing links for sections with IDs"""
# TODO: Am I going filter crazy here? Should this just be a pyquery thing?

def __init__(self, source, slug, locale):
html5lib_Filter.__init__(self, source)
self.slug = slug
self.locale = locale

def __iter__(self):
input = html5lib_Filter.__iter__(self)

for token in input:

yield token

if ('StartTag' == token['type'] and
token['name'] in SECTION_EDIT_TAGS):
attrs = dict(token['data'])
id = attrs.get('id', None)
if id:
out = (
{'type': 'StartTag', 'name': 'a',
'data': {
'title': _('Edit section'),
'class': 'edit-section',
'data-section-id': id,
'data-section-src-url': '%s?%s' % (
reverse('wiki.document',
args=[self.slug],
locale=self.locale),
urlencode({'section': id, 'raw': 'true'})
),
'href': '%s?%s' % (
reverse('wiki.edit_document',
args=[self.slug],
locale=self.locale),
urlencode({'section': id, 'raw': 'true',
'edit_links': 'true'})
)
}},
{'type': 'Characters', 'data': _('Edit')},
{'type': 'EndTag', 'name': 'a'}
)
for t in out:
yield t


class SectionFilter(html5lib_Filter):
"""Filter which can either extract the fragment representing a section by
ID, or substitute a replacement stream for a section. Loosely based on
Expand Down Expand Up @@ -165,13 +225,13 @@ def __iter__(self):

# If started an implicit section, these rules apply to
# siblings...
elif (self.heading is not None and
elif (self.heading is not None and
self.open_level - 1 == self.parent_level):

# The implicit section should stop if we hit another
# sibling heading whose rank is equal or higher, since that
# starts a new implicit section
if (self._isHeading(token) and
if (self._isHeading(token) and
self._getHeadingRank(token) <= self.heading_rank):
self.in_section = False

Expand Down Expand Up @@ -222,4 +282,3 @@ def _getHeadingRank(self, token):
# encountered in the stream. Not doing that right now.
# For now, just assume an hgroup is equivalent to h1
return 1

41 changes: 31 additions & 10 deletions apps/wiki/forms.py
Expand Up @@ -203,6 +203,7 @@ class RevisionForm(forms.ModelForm):
'versions': [(smart_str(c[0][0]), [(v.slug, smart_str(v.name)) for
v in c[1] if v.show_in_ui]) for
c in GROUPED_FIREFOX_VERSIONS]}

content = StrippedCharField(
min_length=5, max_length=100000,
label=_lazy(u'Content:'),
Expand All @@ -225,12 +226,15 @@ class Meta(object):
'based_on')

def __init__(self, *args, **kwargs):

if 'is_iframe_target' in kwargs:
self.is_iframe_target = kwargs['is_iframe_target']
del kwargs['is_iframe_target']
else:
self.is_iframe_target = False

# Snag some optional kwargs and delete them before calling
# super-constructor.
for n in ('section_id', 'is_iframe_target'):
if n not in kwargs:
setattr(self, n, None)
else:
setattr(self, n, kwargs[n])
del kwargs[n]

super(RevisionForm, self).__init__(*args, **kwargs)
self.fields['based_on'].widget = forms.HiddenInput()
Expand All @@ -245,10 +249,11 @@ def __init__(self, *args, **kwargs):
self.initial['slug'] = self.instance.document.slug

content = self.instance.content
self.initial['content'] = (wiki.content
.parse(content)
.injectSectionIDs()
.serialize())
tool = wiki.content.parse(content)
tool.injectSectionIDs()
if self.section_id:
tool.extractSection(self.section_id)
self.initial['content'] = tool.serialize()

self.initial['review_tags'] = [x.name
for x in self.instance.review_tags.all()]
Expand Down Expand Up @@ -289,6 +294,22 @@ def clean_title(self):
def clean_slug(self):
return self._clean_collidable('slug')

def clean_content(self):
"""Validate the content, performing any section editing if necessary"""
content = self.cleaned_data['content']

# If we're editing a section, we need to replace the section content
# from the current revision.
if self.section_id and self.instance and self.instance.document:
# Make sure we start with content form the latest revision.
full_content = self.instance.document.current_revision.content
# Replace the section content with the form content.
tool = wiki.content.parse(full_content)
tool.replaceSection(self.section_id, content)
content = tool.serialize()

return content

def save(self, creator, document, **kwargs):
"""Persist me, and return the saved Revision.
Expand Down
10 changes: 7 additions & 3 deletions apps/wiki/models.py
Expand Up @@ -35,18 +35,22 @@


ALLOWED_TAGS = bleach.ALLOWED_TAGS + [
'span', 'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'pre', 'code', 'dl', 'dt', 'dd',
'table', 'tbody', 'thead', 'tr', 'td',
'div', 'span', 'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'pre', 'code',
'dl', 'dt', 'dd',
'img',
'input',
'table', 'tbody', 'thead', 'tr', 'th', 'td',
'section', 'header', 'footer', 'nav', 'article', 'aside', 'figure',
'dialog', 'hgroup', 'mark', 'time', 'meter', 'command', 'output',
'progress', 'audio', 'video', 'details', 'datagrid', 'datalist', 'table',
'address'
]
ALLOWED_ATTRIBUTES = bleach.ALLOWED_ATTRIBUTES
ALLOWED_ATTRIBUTES['span'] = ['style', ]
ALLOWED_ATTRIBUTES['img'] = ['src', 'id', 'align', 'alt', 'class', 'is', 'title', 'style']
ALLOWED_ATTRIBUTES['a'] = ['id', 'class', 'href', 'title', ]
ALLOWED_ATTRIBUTES.update(dict((x, ['id', ]) for x in (
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'pre', 'code', 'dl', 'dt', 'dd',
'div', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'pre', 'code', 'dl', 'dt', 'dd',
'section', 'header', 'footer', 'nav', 'article', 'aside', 'figure',
'dialog', 'hgroup', 'mark', 'time', 'meter', 'command', 'output',
'progress', 'audio', 'video', 'details', 'datagrid', 'datalist', 'table',
Expand Down
24 changes: 24 additions & 0 deletions apps/wiki/templates/wiki/ckeditor_config.js
@@ -1,3 +1,27 @@
CKEDITOR.on('instanceReady', function (ev) {

var writer = ev.editor.dataProcessor.writer;

writer.indentationChars = ' ';

var oneliner_tags = [
'hgroup', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'th', 'td', 'li'
]
for (var i=0,tag; tag=oneliner_tags[i]; i++) {
writer.setRules(tag, {
indent: true,
breakBeforeOpen: true,
breakAfterOpen: false,
breakBeforeClose: false,
breakAfterClose: true
});
}

// TODO: Need another field here to allow overrides from admin?

});

CKEDITOR.editorConfig = function(config) {

config.extraPlugins = 'autogrow,definitionlist,mdn-buttons';
Expand Down
22 changes: 17 additions & 5 deletions apps/wiki/templates/wiki/document.html
Expand Up @@ -27,8 +27,12 @@ <h1 class="page-title">{{ document.title }}</h1>
</div>
<ul id="page-buttons">
<li class="page-history"><a href="{{ url('wiki.document_revisions', document.slug) }}">{{_('History')}}</a></li>
<li class="page-watch"><a href="{{ url('wiki.document_watch', document.slug) }}">{{_('Watch')}}</a></li>
<li class="page-edit"><a href="{{ url('wiki.edit_document', document.slug) }}">{{_('Edit')}}</a></li>
{% if request.user.is_authenticated() %}
<li class="page-watch"><a href="{{ url('wiki.document_watch', document.slug) }}">{{_('Watch')}}</a></li>
{% endif %}
{% if document.allows_editing_by(request.user) %}
<li class="page-edit"><a href="{{ url('wiki.edit_document', document.slug) }}">{{_('Edit')}}</a></li>
{% endif %}
</ul>
</header>
{% if redirected_from %}
Expand Down Expand Up @@ -67,7 +71,8 @@ <h1 class="page-title">{{ document.title }}</h1>
{% endfor %}
{% endif %}

<div id="wikiArticle" class="page-content boxed">
<div id="wikiArticle" class="page-content boxed"
data-cancel-edit-message="{{ _('Abort editing in progress? Your unsaved changes will be discarded.') }}">
{#
<div id="article-nav">
<div class="page-toc">
Expand All @@ -88,7 +93,7 @@ <h2>Table of Contents</h2>
</div>
#}
{% if not fallback_reason %}
{{ document.html|safe }}
{{ document_html|safe }}
{% elif fallback_reason == 'no_translation' %}
<div id="doc-pending-fallback" class="warning-box">
{% trans help_link=url('wiki.document', 'localize-firefox-help'),
Expand All @@ -97,7 +102,7 @@ <h2>Table of Contents</h2>
<a href="{{ help_link }}">Join us and help get the job done!</a>
{% endtrans %}
</div>
{{ document.html|safe }}
{{ document_html|safe }}
{% elif fallback_reason == 'translation_not_approved' %}
<div id="doc-pending-fallback" class="warning-box">
{# L10n: This is shown for existing, never-approved translations #}
Expand Down Expand Up @@ -127,6 +132,13 @@ <h2>Table of Contents</h2>
#}
</div>
</div>
{% if document.allows_editing_by(request.user) %}
<div class="edited-section-ui template">
<a class="save" href="#">{{ _('Save') }}</a>
<a class="cancel" href="#">{{ _('Cancel') }}</a>
<div class="src"></div>
</div>
{% endif %}
</section>
{% endblock %}

Expand Down
6 changes: 4 additions & 2 deletions apps/wiki/templates/wiki/edit_document.html
Expand Up @@ -23,11 +23,13 @@

<div class="title">
<h1>{{ _('Editing <em>{title}</em>')|fe(title=revision.title) }}</h1>
<button type="button" id="btn-properties" title="Edit Page Title and Properties">{{ _('Edit Page Title and Properties') }}</button>
{% if not section_id %}
<button type="button" id="btn-properties" title="Edit Page Title and Properties">{{ _('Edit Page Title and Properties') }}</button>
{% endif %}
<p class="save-state" id="draft-status">{% trans %}Draft <span id="draft-action"></span> <time id="draft-time" class="timeago" title=""></time>{% endtrans %}</p>
</div>

{% if revision_form %}
{% if revision_form and not section_id %}
<ul class="metadata">
<li><label>{{_('Title:')}}</label> {{ revision_form.title | safe }}</li>
<li><label>{{_('Slug:')}}</label> {{ revision_form.slug | safe }}</li>
Expand Down

0 comments on commit e2aa1f4

Please sign in to comment.