~netlandish/django-wiki

ee446c3a3fd7c67af3996f1d2723cfbf4811675a — Frank Loemker 6 years ago 023d0c4
Added new plugin editsection.

The plugin allows to edit single sections. For that a markdown
extension adds [edit] links to all sections till a configurable
depth. By clicking these links only the selected section, and all
it's subsections, is edited. For the core editing functionality
the standard Edit class is used.

Detecting, as far as possible, SeText headers which start directly
after an other block element, i.e. without an empty line in between,
is done via: Generate during parsing of the raw markdown text only a
candidate list of possible headers, and verify that list after that
by using a Treeprocessor which works on the headers which were
really generated by python-markdown.
M docs/plugins.rst => docs/plugins.rst +1 -0
@@ 5,6 5,7 @@ Add/remove the following to your ``settings.INSTALLED_APPS`` to
enable/disable the core plugins:

-  ``'wiki.plugins.attachments.apps.AttachmentsConfig'``
-  ``'wiki.plugins.editsection.apps.AttachmentsConfig'``
-  ``'wiki.plugins.globalhistory.apps.GlobalHistoryConfig'``
-  ``'wiki.plugins.help.apps.HelpConfig'``
-  ``'wiki.plugins.images.apps.ImagesConfig'``

M docs/settings.rst => docs/settings.rst +6 -0
@@ 16,6 16,12 @@ Plugin attachments
.. automodule:: wiki.plugins.attachments.settings
   :members:

Plugin editSection
-------------

.. automodule:: wiki.plugins.editsection.settings
   :members:

Plugin images
-------------


A src/wiki/plugins/editsection/__init__.py => src/wiki/plugins/editsection/__init__.py +0 -0
A src/wiki/plugins/editsection/markdown_extensions.py => src/wiki/plugins/editsection/markdown_extensions.py +117 -0
@@ 0,0 1,117 @@
from __future__ import unicode_literals

import re
from markdown import Extension
from markdown.treeprocessors import Treeprocessor
from markdown.util import etree
from . import settings


class EditSectionExtension(Extension):
    def __init__(self, *args, **kwargs):
        self.config = {
            'level': [settings.MAX_LEVEL, 'Allow to edit sections till this level'],
            'headers': None,     # List of FindHeader, all headers with there positions
            'location': None,    # To be extracted header
            'header_id': None    # Header text ID of the to be extracted header
        }
        super(EditSectionExtension, self).__init__(**kwargs)

    def extendMarkdown(self, md, md_globals):
        ext = EditSectionProcessor(md)
        ext.config = self.config
        md.treeprocessors.add('editsection', ext, '_end')


def get_header_id(header):
    id = ''.join(w[0] for w in re.findall("\w+", header))
    if not len(id):
        return '_'
    return id


class EditSectionProcessor(Treeprocessor):
    def locate_section(self, node):
        cur_pos = [0] * self.level
        last_level = 0
        cur_header = -1
        sec_level = -1
        sec_start = -1

        for child in node.getchildren():
            match = self.HEADER_RE.match(child.tag.lower())
            if not match:
                continue

            level = int(match.group(1))

            # Find current position in headers
            cur_header += 1
            while (cur_header < len(self.headers) and
                   not self.headers[cur_header].sure_header and child.text != self.headers[cur_header].header):
                cur_header += 1
            if cur_header >= len(self.headers):
                return None

            # End of the searched section found?
            if level <= sec_level:
                return sec_start, self.headers[cur_header].start

            for l in range(level, last_level):
                cur_pos[l] = 0
            cur_pos[level - 1] += 1
            last_level = level

            location = '-'.join(map(str, cur_pos))
            if location != self.location:
                continue

            # Found section start. Check if the header id text is still correct.
            if get_header_id(child.text) != self.header_id:
                return None

            # Correct section start found. Search now for the section end.
            sec_level = level
            sec_start = self.headers[cur_header].start

        if sec_start >= 0:
            return sec_start, 9999999
        return None

    def add_links(self, node):
        cur_pos = [0] * self.level
        last_level = 0

        for child in node.getchildren():
            match = self.HEADER_RE.match(child.tag.lower())
            if not match:
                continue

            level = int(match.group(1))
            for l in range(level, last_level):
                cur_pos[l] = 0
            cur_pos[level - 1] += 1
            last_level = level
            location = '-'.join(map(str, cur_pos))
            header_id = get_header_id(child.text)

            # Insert link to allow editing this section
            link = etree.SubElement(child, 'a')
            link.text = "[edit]"
            link.attrib["class"] = "article-edit"
            link.attrib["href"] = self.markdown.article.get_absolute_url() \
                + "_plugin/editsection/" + location \
                + "/header/" + header_id + "/"

    def run(self, root):
        self.level = self.config.get('level')[0]
        self.HEADER_RE = re.compile('^h([' + ''.join(map(str, range(1, self.level + 1))) + '])')
        self.headers = self.config.get('headers')
        if self.headers:
            self.location = self.config.get('location')
            self.header_id = self.config.get('header_id')
            self.config['location'] = self.locate_section(root)
            self.config['headers'] = None
        else:
            self.add_links(root)
        return root

A src/wiki/plugins/editsection/settings.py => src/wiki/plugins/editsection/settings.py +9 -0
@@ 0,0 1,9 @@
from __future__ import absolute_import, unicode_literals

from django.conf import settings as django_settings

SLUG = 'editsection'

#: Add "[edit]" links to all section headers till this level. By using
#: these links editing only the text from the selected section is possible.
MAX_LEVEL = getattr(django_settings, 'WIKI_EDITSECTION_MAX_LEVEL', 3)

A src/wiki/plugins/editsection/views.py => src/wiki/plugins/editsection/views.py +161 -0
@@ 0,0 1,161 @@
from __future__ import absolute_import, unicode_literals

import re
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext as _
from wiki import models
from wiki.core.markdown import article_markdown
from wiki.core.plugins.registry import get_markdown_extensions
from wiki.decorators import get_article
from wiki.views.article import Edit as EditView
from wiki.plugins.editsection.markdown_extensions import EditSectionExtension
from . import settings


class FindHeader:
    """Locate the start, header text, and end of the header text of the next
    possible section starting from pos. Finds too many occurances for SeText
    headers which are filtered out later in the markdown extension.
    Returns: start pos header sure_header level"""

    SETEXT_RE_TEXT = r'(?P<header1>.*?)\n(?P<level1>[=-])+[ ]*(\n|$)'
    SETEXT_RE = re.compile(r'\n%s' % SETEXT_RE_TEXT, re.MULTILINE)
    HEADER_RE = re.compile(r'((\A ?\n?|\n(?![^\n]{0,3}\w).*?\n)%s'
                           '|(\A|\n)(?P<level2>#{1,6})(?P<header2>.*?)#*(\n|$))' % SETEXT_RE_TEXT, re.MULTILINE)
    ATTR_RE = re.compile(r'[ ]+\{\:?([^\}\n]*)\}[ ]*$')

    def __init__(self, text, pos):
        self.sure_header = False
        match = self.SETEXT_RE.match(text, pos)
        if match:
            self.sure_header = True
        else:
            match = self.HEADER_RE.search(text, pos)
            if not match:
                self.start = len(text) + 1
                self.pos = self.start
                return
        self.pos = match.end() - 1

        # Get level and header text of the section
        token = match.group('level1')
        if token:
            self.header = match.group('header1').strip()
            self.start = match.start('header1')
        else:
            token = match.group('level2')
            self.header = match.group('header2').strip()
            self.start = match.start('level2')
            self.sure_header = True
        # Remove attribute definitions from the header text
        match = self.ATTR_RE.search(self.header)
        if match:
            self.header = self.header[:match.start()].rstrip('#').rstrip()
        # Get level of the section
        if token[0] == '=':
            self.level = 1
        elif token[0] == '-':
            self.level = 2
        else:
            self.level = len(token)


class EditSection(EditView):
    def locate_section(self, article, text):
        """Search for the header self.location (which is not deeper than settings.MAX_LEVEL)
        in text, compare the header text with self.header_id, and return the start position
        and the end position+1 of the complete section started by the header.
        """
        text = text.replace("\r\n", " \n").replace("\r", "\n") + "\n\n"
        text_len = len(text)

        headers = []
        pos = 0
        while pos < text_len:
            # Get meta information and start position of the next section
            header = FindHeader(text, pos)
            pos = header.pos
            if pos >= text_len:
                break
            if header.level > settings.MAX_LEVEL:
                continue
            headers.append(header)

        for e in get_markdown_extensions():
            if isinstance(e, EditSectionExtension):
                e.config['headers'] = headers
                e.config['location'] = self.location
                e.config['header_id'] = self.header_id
                article_markdown(text, article)
                return e.config['location']
        return None

    @method_decorator(get_article(can_write=True, not_locked=True))
    def dispatch(self, request, article, *args, **kwargs):
        self.location = kwargs.pop('location', 0)
        self.header_id = kwargs.pop('header', 0)

        self.urlpath = kwargs.get('urlpath')
        kwargs['path'] = self.urlpath.path

        if request.method == 'GET':
            text = article.current_revision.content
            location = self.locate_section(article, text)
            if location:
                self.orig_section = text[location[0]:location[1]]
                # Pass the to be used content to EditSection
                kwargs['content'] = self.orig_section
                request.session['editSection_content'] = self.orig_section
            else:
                messages.error(
                    request,
                    _("Unable to find the selected section in the current article."
                      " The article was changed in between. Please try again.")
                )
                return redirect('wiki:get', path=self.urlpath.path)
        else:
            kwargs['content'] = request.session.get('editSection_content')
            self.orig_section = kwargs.get('content')

        return super(EditSection, self).dispatch(
            request, article, *args, **kwargs)

    def form_valid(self, form):
        super(EditSection, self).form_valid(form)

        section = self.article.current_revision.content
        if not section.endswith("\n"):
            section += "\r\n\r\n"
        text = get_object_or_404(
            models.ArticleRevision,
            article=self.article,
            id=self.article.current_revision.previous_revision.id).content

        location = self.locate_section(self.article, text)
        if location:
            if self.orig_section != text[location[0]:location[1]]:
                list(messages.get_messages(self.request))
                messages.warning(
                    self.request,
                    _("The selected text section was changed in between."
                      " Please check the history of the article and reinclude"
                      " required changes from the intermediate versions.")
                )
            # Include the edited section into the complete previous article
            self.article.current_revision.content = text[0:location[0]] + section + text[location[1]:]
            self.article.current_revision.save()
        else:
            # Back to the version before replacing the article with the section
            self.article.current_revision = self.article.current_revision.previous_revision
            self.article.save()
            list(messages.get_messages(self.request))
            messages.error(
                self.request,
                _("Unable to find the selected section in the current article. The article"
                  " was changed in between. Your changed section is still available as the"
                  " last now inactive revision of this article. Please try again.")
            )

        return redirect('wiki:get', path=self.urlpath.path)

A src/wiki/plugins/editsection/wiki_plugin.py => src/wiki/plugins/editsection/wiki_plugin.py +26 -0
@@ 0,0 1,26 @@
from __future__ import absolute_import, unicode_literals

from django.conf.urls import url
from wiki.core.plugins import registry
from wiki.core.plugins.base import BasePlugin
from wiki.plugins.editsection.markdown_extensions import EditSectionExtension

from . import settings, views


class EditSectionPlugin(BasePlugin):

    slug = settings.SLUG
    urlpatterns = {'article': [
        url('^(?P<location>[0-9-]+)/header/(?P<header>\w+)/$',
            views.EditSection.as_view(),
            name='editsection'),
    ]}

    markdown_extensions = [EditSectionExtension()]

    def __init__(self):
        pass


registry.register(EditSectionPlugin)

M src/wiki/static/wiki/bootstrap/less/wiki/wiki.less => src/wiki/static/wiki/bootstrap/less/wiki/wiki.less +2 -0
@@ 21,6 21,8 @@

h1#article-title {font-size: 2.5em; margin-top: 0px;}

.article-edit {font-size: @font-size-base; padding-left: 8px}

.wiki-label label { font-size: 16px; font-weight: normal; color: #777;}

.controls ul

M testproject/testproject/settings/base.py => testproject/testproject/settings/base.py +1 -0
@@ 47,6 47,7 @@ INSTALLED_APPS = [
    "wiki.plugins.images.apps.ImagesConfig",
    "wiki.plugins.attachments.apps.AttachmentsConfig",
    "wiki.plugins.notifications.apps.NotificationsConfig",
    'wiki.plugins.editsection.apps.GlobalHistoryConfig',
    'wiki.plugins.globalhistory.apps.GlobalHistoryConfig',
    'mptt',
]

M tests/core/test_models.py => tests/core/test_models.py +5 -1
@@ 134,4 134,8 @@ class ArticleModelTest(TestCase):
        ArticleRevision.objects.create(
            article=a, title="test", content="# header"
        )
        self.assertEqual(a.get_cached_content(), """<h1 id="wiki-toc-header">header</h1>""")
        expected_markdown = (
            """<h1 id="wiki-toc-header">header"""
            """<a class="article-edit" href="/1/_plugin/editsection/1-0-0/header/h/">[edit]</a></h1>"""
        )
        self.assertEqual(a.get_cached_content(), expected_markdown)

M tests/core/test_template_tags.py => tests/core/test_template_tags.py +2 -1
@@ 197,7 197,8 @@ class WikiRenderTest(TemplateTestCase):

        expected_markdown = (
            """<p>This is a normal paragraph</p>\n"""
            """<h1 id="wiki-toc-headline">Headline</h1>"""
            """<h1 id="wiki-toc-headline">Headline"""
            """<a class="article-edit" href="/1/_plugin/editsection/1-0-0/header/H/">[edit]</a></h1>"""
        )

        # monkey patch

A tests/plugins/editsection/__init__.py => tests/plugins/editsection/__init__.py +0 -0
A tests/plugins/editsection/test_editsection.py => tests/plugins/editsection/test_editsection.py +97 -0
@@ 0,0 1,97 @@
from __future__ import print_function, unicode_literals

from django.core.urlresolvers import reverse
from django_functest import FuncBaseMixin
from wiki.models import URLPath

from ...base import (DjangoClientTestBase, RequireRootArticleMixin, WebTestBase)

TEST_CONTENT = (
    'Title 1\n'
    '=======\n'
    '## Title 2\n'
    'Title 3\n'
    '-------\n'
    'a\n'
    'Paragraph\n'
    '-------\n'
    '### Title 4\n'
    '## Title 5\n'
    '# Title 6\n'
)


class EditSectionTests(RequireRootArticleMixin, DjangoClientTestBase):
    def test_editsection(self):
        # Test creating links to allow editing all sections individually
        urlpath = URLPath.create_urlpath(URLPath.root(), "testedit",
                                         title="TestEdit", content=TEST_CONTENT)
        output = urlpath.article.render()
        expected = (
            '(?s)'
            'Title 1<a class="article-edit" href="/testedit/_plugin/editsection/1-0-0/header/T1/">\[edit\]</a>.*'
            'Title 2<a class="article-edit" href="/testedit/_plugin/editsection/1-1-0/header/T2/">\[edit\]</a>.*'
            'Title 3<a class="article-edit" href="/testedit/_plugin/editsection/1-2-0/header/T3/">\[edit\]</a>.*'
            'Title 4<a class="article-edit" href="/testedit/_plugin/editsection/1-2-1/header/T4/">\[edit\]</a>.*'
            'Title 5<a class="article-edit" href="/testedit/_plugin/editsection/1-3-0/header/T5/">\[edit\]</a>.*'
            'Title 6<a class="article-edit" href="/testedit/_plugin/editsection/2-0-0/header/T6/">\[edit\]</a>.*'
        )
        self.assertRegexpMatches(output, expected)

        # Test wrong header text. Editing should fail with a redirect.
        url = reverse('wiki:editsection', kwargs={'path': 'testedit/', 'location': '1-2-1', 'header': 'Test'})
        response = self.client.get(url)
        self.assertRedirects(response, reverse('wiki:get', kwargs={'path': 'testedit/'}))

        # Test extracting sections for editing
        url = reverse('wiki:editsection', kwargs={'path': 'testedit/', 'location': '1-2-1', 'header': 'T4'})
        response = self.client.get(url)
        expected = (
            '>### Title 4\n'
            '<'
        )
        self.assertContains(response, expected)

        url = reverse('wiki:editsection', kwargs={'path': 'testedit/', 'location': '1-2-0', 'header': 'T3'})
        response = self.client.get(url)
        expected = (
            '>Title 3\n'
            '-------\n'
            'a\n'
            'Paragraph\n'
            '-------\n'
            '### Title 4\n'
            '<'
        )
        self.assertContains(response, expected)


class EditSectionEditBase(RequireRootArticleMixin, FuncBaseMixin):
    pass


class EditSectionEditTests(EditSectionEditBase, WebTestBase):
    # Test editing a section
    def test_editsection_edit(self):
        urlpath = URLPath.create_urlpath(URLPath.root(), "testedit",
                                         title="TestEdit", content=TEST_CONTENT)
        old_number = urlpath.article.current_revision.revision_number

        self.get_literal_url(reverse('wiki:editsection', kwargs={'path': 'testedit/', 'location': '1-2-0', 'header': 'T3'}))
        self.fill({
            '#id_content': '# Header 1\nContent of the new section'
        })
        self.submit('#id_save')
        expected = (
            '(?s)'
            'Title 1<a class="article-edit" href="/testedit/_plugin/editsection/1-0-0/header/T1/">\[edit\]</a>.*'
            'Title 2<a class="article-edit" href="/testedit/_plugin/editsection/1-1-0/header/T2/">\[edit\]</a>.*'
            'Header 1<a class="article-edit" href="/testedit/_plugin/editsection/2-0-0/header/H1/">\[edit\]</a>.*'
            'Content of the new section.*'
            'Title 5<a class="article-edit" href="/testedit/_plugin/editsection/2-1-0/header/T5/">\[edit\]</a>.*'
            'Title 6<a class="article-edit" href="/testedit/_plugin/editsection/3-0-0/header/T6/">\[edit\]</a>.*'
        )
        self.assertRegexpMatches(self.last_response.content.decode('utf-8'), expected)

        new_number = URLPath.objects.get(slug='testedit').article.current_revision.revision_number
        self.assertEqual(new_number, old_number + 1)

M tests/settings.py => tests/settings.py +1 -0
@@ 32,6 32,7 @@ INSTALLED_APPS = [
    'sorl.thumbnail',
    'wiki.apps.WikiConfig',
    'wiki.plugins.attachments.apps.AttachmentsConfig',
    'wiki.plugins.editsection.apps.AttachmentsConfig',
    'wiki.plugins.notifications.apps.NotificationsConfig',
    'wiki.plugins.images.apps.ImagesConfig',
    'wiki.plugins.macros.apps.MacrosConfig',