From ee446c3a3fd7c67af3996f1d2723cfbf4811675a Mon Sep 17 00:00:00 2001 From: Frank Loemker Date: Sun, 14 Jan 2018 23:02:59 -0500 Subject: [PATCH] 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. --- docs/plugins.rst | 1 + docs/settings.rst | 6 + src/wiki/plugins/editsection/__init__.py | 0 .../editsection/markdown_extensions.py | 117 +++++++++++++ src/wiki/plugins/editsection/settings.py | 9 + src/wiki/plugins/editsection/views.py | 161 ++++++++++++++++++ src/wiki/plugins/editsection/wiki_plugin.py | 26 +++ .../static/wiki/bootstrap/less/wiki/wiki.less | 2 + testproject/testproject/settings/base.py | 1 + tests/core/test_models.py | 6 +- tests/core/test_template_tags.py | 3 +- tests/plugins/editsection/__init__.py | 0 tests/plugins/editsection/test_editsection.py | 97 +++++++++++ tests/settings.py | 1 + 14 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 src/wiki/plugins/editsection/__init__.py create mode 100644 src/wiki/plugins/editsection/markdown_extensions.py create mode 100644 src/wiki/plugins/editsection/settings.py create mode 100644 src/wiki/plugins/editsection/views.py create mode 100644 src/wiki/plugins/editsection/wiki_plugin.py create mode 100644 tests/plugins/editsection/__init__.py create mode 100644 tests/plugins/editsection/test_editsection.py diff --git a/docs/plugins.rst b/docs/plugins.rst index 44f558cd..7fa710cb 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -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'`` diff --git a/docs/settings.rst b/docs/settings.rst index c0f1a2cf..87e19acb 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -16,6 +16,12 @@ Plugin attachments .. automodule:: wiki.plugins.attachments.settings :members: +Plugin editSection +------------- + +.. automodule:: wiki.plugins.editsection.settings + :members: + Plugin images ------------- diff --git a/src/wiki/plugins/editsection/__init__.py b/src/wiki/plugins/editsection/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/wiki/plugins/editsection/markdown_extensions.py b/src/wiki/plugins/editsection/markdown_extensions.py new file mode 100644 index 00000000..5f9b0422 --- /dev/null +++ b/src/wiki/plugins/editsection/markdown_extensions.py @@ -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 diff --git a/src/wiki/plugins/editsection/settings.py b/src/wiki/plugins/editsection/settings.py new file mode 100644 index 00000000..c0155101 --- /dev/null +++ b/src/wiki/plugins/editsection/settings.py @@ -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) diff --git a/src/wiki/plugins/editsection/views.py b/src/wiki/plugins/editsection/views.py new file mode 100644 index 00000000..e92b605f --- /dev/null +++ b/src/wiki/plugins/editsection/views.py @@ -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.*?)\n(?P[=-])+[ ]*(\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#{1,6})(?P.*?)#*(\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) diff --git a/src/wiki/plugins/editsection/wiki_plugin.py b/src/wiki/plugins/editsection/wiki_plugin.py new file mode 100644 index 00000000..295651a5 --- /dev/null +++ b/src/wiki/plugins/editsection/wiki_plugin.py @@ -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[0-9-]+)/header/(?P
\w+)/$', + views.EditSection.as_view(), + name='editsection'), + ]} + + markdown_extensions = [EditSectionExtension()] + + def __init__(self): + pass + + +registry.register(EditSectionPlugin) diff --git a/src/wiki/static/wiki/bootstrap/less/wiki/wiki.less b/src/wiki/static/wiki/bootstrap/less/wiki/wiki.less index 542895a7..e7dd5605 100644 --- a/src/wiki/static/wiki/bootstrap/less/wiki/wiki.less +++ b/src/wiki/static/wiki/bootstrap/less/wiki/wiki.less @@ -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 diff --git a/testproject/testproject/settings/base.py b/testproject/testproject/settings/base.py index 97815297..7aca03b3 100644 --- a/testproject/testproject/settings/base.py +++ b/testproject/testproject/settings/base.py @@ -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', ] diff --git a/tests/core/test_models.py b/tests/core/test_models.py index cdf3353c..8cb3d1a8 100644 --- a/tests/core/test_models.py +++ b/tests/core/test_models.py @@ -134,4 +134,8 @@ class ArticleModelTest(TestCase): ArticleRevision.objects.create( article=a, title="test", content="# header" ) - self.assertEqual(a.get_cached_content(), """

header

""") + expected_markdown = ( + """

header""" + """[edit]

""" + ) + self.assertEqual(a.get_cached_content(), expected_markdown) diff --git a/tests/core/test_template_tags.py b/tests/core/test_template_tags.py index 5e4374c5..29490832 100644 --- a/tests/core/test_template_tags.py +++ b/tests/core/test_template_tags.py @@ -197,7 +197,8 @@ class WikiRenderTest(TemplateTestCase): expected_markdown = ( """

This is a normal paragraph

\n""" - """

Headline

""" + """

Headline""" + """[edit]

""" ) # monkey patch diff --git a/tests/plugins/editsection/__init__.py b/tests/plugins/editsection/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/plugins/editsection/test_editsection.py b/tests/plugins/editsection/test_editsection.py new file mode 100644 index 00000000..cb8b3aa1 --- /dev/null +++ b/tests/plugins/editsection/test_editsection.py @@ -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\[edit\].*' + 'Title 2\[edit\].*' + 'Title 3\[edit\].*' + 'Title 4\[edit\].*' + 'Title 5\[edit\].*' + 'Title 6\[edit\].*' + ) + 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\[edit\].*' + 'Title 2\[edit\].*' + 'Header 1\[edit\].*' + 'Content of the new section.*' + 'Title 5\[edit\].*' + 'Title 6\[edit\].*' + ) + 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) diff --git a/tests/settings.py b/tests/settings.py index e61eba91..521cf365 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -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', -- 2.45.2