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',