diff --git a/VERSION b/VERSION index 719cd12..771f209 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.19 \ No newline at end of file +0.3.20 \ No newline at end of file diff --git a/realms/__init__.py b/realms/__init__.py index 6875fae..a5c0481 100644 --- a/realms/__init__.py +++ b/realms/__init__.py @@ -210,7 +210,8 @@ assets.register('main.js', 'vendor/datatables/media/js/jquery.dataTables.js', 'vendor/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.js', 'js/hbs-helpers.js', - 'js/mdr.js') + 'js/mdr.js', + 'js/main.js') assets.register('main.css', 'vendor/bootswatch-dist/css/bootstrap.css', diff --git a/realms/cli.py b/realms/cli.py index a8a7e89..be4237a 100644 --- a/realms/cli.py +++ b/realms/cli.py @@ -330,8 +330,8 @@ def test(): def version(): """ Output version """ - with open('VERSION') as f: - return f.read().strip() + with open(os.path.join(config.APP_PATH, 'VERSION')) as f: + click.echo(f.read().strip()) if __name__ == '__main__': diff --git a/realms/modules/wiki/__init__.py b/realms/modules/wiki/__init__.py index e69de29..2c61d69 100644 --- a/realms/modules/wiki/__init__.py +++ b/realms/modules/wiki/__init__.py @@ -0,0 +1,13 @@ +import os +import sys +from realms import app +from realms.modules.wiki.models import Wiki + +# Init Wiki +Wiki(app.config['WIKI_PATH']) + +# Check paths +for mode in [os.W_OK, os.R_OK]: + for dir_ in [app.config['WIKI_PATH'], os.path.join(app.config['WIKI_PATH'], '.git')]: + if not os.access(dir_, mode): + sys.exit('Read and write access to WIKI_PATH is required (%s)' % dir_) diff --git a/realms/modules/wiki/models.py b/realms/modules/wiki/models.py index 6f87a20..5d6b3fa 100644 --- a/realms/modules/wiki/models.py +++ b/realms/modules/wiki/models.py @@ -14,15 +14,30 @@ from realms.lib.hook import HookMixin def cname_to_filename(cname): + """ Convert canonical name to filename + + :param cname: Canonical name + :return: str -- Filename + + """ return cname.lower() + ".md" def filename_to_cname(filename): - """ It's assumed filename is already cname format + """Convert filename to canonical name. + + .. note:: + + It's assumed filename is already canonical format + """ return os.path.splitext(filename)[0] +class PageNotFound(Exception): + pass + + class Wiki(HookMixin): path = None base_path = '/' @@ -48,56 +63,42 @@ class Wiki(HookMixin): return "Wiki: %s" % self.path def revert_page(self, name, commit_sha, message, username): + """Revert page to passed commit sha1 + + :param name: Name of page to revert. + :param commit_sha: Commit Sha1 to revert to. + :param message: Commit message. + :param username: + :return: Git commit sha1 + + """ page = self.get_page(name, commit_sha) if not page: - # Page not found - return None - commit_info = gittle.utils.git.commit_info(self.gittle[commit_sha.encode('latin-1')]) - message = commit_info['message'] + raise PageNotFound() + + if not message: + commit_info = gittle.utils.git.commit_info(self.gittle[commit_sha.encode('latin-1')]) + message = commit_info['message'] + return self.write_page(name, page['data'], message=message, username=username) def write_page(self, name, content, message=None, create=False, username=None, email=None): + """Write page to git repo - def escape_repl(m): - if m.group(1): - return "```" + escape(m.group(1)) + "```" - - def unescape_repl(m): - if m.group(1): - return "```" + unescape(m.group(1)) + "```" + :param name: Name of page. + :param content: Content of page. + :param message: Commit message. + :param create: Perform git add operation? + :param username: Commit Name. + :param email: Commit Email. + :return: Git commit sha1. + """ cname = to_canonical(name) - - # prevents p tag from being added, we remove this later - content = '
' + content + '
' - content = re.sub(r"```(.*?)```", escape_repl, content, flags=re.DOTALL) - - tree = lxml.html.fromstring(content) - - cleaner = Cleaner(remove_unknown_tags=False, - kill_tags=set(['style']), - safe_attrs_only=False) - tree = cleaner.clean_html(tree) - - content = lxml.html.tostring(tree, encoding='utf-8', method='html') - - # remove added div tags - content = content[5:-6] - - # FIXME this is for block quotes, doesn't work for double ">" - content = re.sub(r"(\n>)", "\n>", content) - content = re.sub(r"(^>)", ">", content) - - # Handlebars partial ">" - content = re.sub(r"\{\{>(.*?)\}\}", r'{{>\1}}', content) - - # Handlebars, allow {{}} inside HTML links - content = content.replace("%7B", "{") - content = content.replace("%7D", "}") - - content = re.sub(r"```(.*?)```", unescape_repl, content, flags=re.DOTALL) - filename = cname_to_filename(cname) + + content = self.clean(content) + with open(self.path + "/" + filename, 'w') as f: f.write(content) @@ -122,7 +123,60 @@ class Wiki(HookMixin): return ret + def clean(self, content): + """Clean any HTML, this might not be necessary. + + :param content: Content of page. + :return: str + + """ + def escape_repl(m): + if m.group(1): + return "```" + escape(m.group(1)) + "```" + + def unescape_repl(m): + if m.group(1): + return "```" + unescape(m.group(1)) + "```" + + # prevents p tag from being added, we remove this later + content = '
' + content + '
' + content = re.sub(r"```(.*?)```", escape_repl, content, flags=re.DOTALL) + + tree = lxml.html.fromstring(content) + + cleaner = Cleaner(remove_unknown_tags=False, + kill_tags={'style'}, + safe_attrs_only=False) + tree = cleaner.clean_html(tree) + + content = lxml.html.tostring(tree, encoding='utf-8', method='html') + + # remove added div tags + content = content[5:-6] + + # FIXME this is for block quotes, doesn't work for double ">" + content = re.sub(r"(\n>)", "\n>", content) + content = re.sub(r"(^>)", ">", content) + + # Handlebars partial ">" + content = re.sub(r"\{\{>(.*?)\}\}", r'{{>\1}}', content) + + # Handlebars, allow {{}} inside HTML links + content = content.replace("%7B", "{") + content = content.replace("%7D", "}") + + content = re.sub(r"```(.*?)```", unescape_repl, content, flags=re.DOTALL) + return content + def rename_page(self, old_name, new_name, user=None): + """Rename page. + + :param old_name: Page that will be renamed. + :param new_name: New name of page. + :param user: User object if any. + :return: str -- Commit sha1 + + """ old_filename, new_filename = map(cname_to_filename, [old_name, new_name]) if old_filename not in self.gittle.index: # old doesn't exist @@ -137,14 +191,38 @@ class Wiki(HookMixin): self.gittle.add(new_filename) self.gittle.rm(old_filename) - self.gittle.commit(name=getattr(user, 'username', self.default_committer_name), - email=getattr(user, 'email', self.default_committer_email), - message="Moved %s to %s" % (old_name, new_name), - files=[old_filename, new_filename]) + commit = self.gittle.commit(name=getattr(user, 'username', self.default_committer_name), + email=getattr(user, 'email', self.default_committer_email), + message="Moved %s to %s" % (old_name, new_name), + files=[old_filename, new_filename]) cache.delete_many(old_filename, new_filename) + return commit + + def delete_page(self, name, user=None): + """Delete page. + :param name: Page that will be deleted + :param user: User object if any + :return: str -- Commit sha1 + + """ + self.gittle.rm(name) + commit = self.gittle.commit(name=getattr(user, 'username', self.default_committer_name), + email=getattr(user, 'email', self.default_committer_email), + message="Deleted %s" % name, + files=[name]) + cache.delete_many(name) + return commit + def get_page(self, name, sha='HEAD'): + """Get page data, partials, commit info. + + :param name: Name of page. + :param sha: Commit sha. + :return: dict + + """ cached = cache.get(name) if cached: return cached @@ -172,22 +250,46 @@ class Wiki(HookMixin): return None def get_meta(self, content): + """Get metadata from page if any. + + :param content: Page content + :return: dict + + """ if not content.startswith("---"): return None + meta_end = re.search("\n(\.{3}|\-{3})", content) + if not meta_end: return None + try: return yaml.safe_load(content[0:meta_end.start()]) except Exception as e: return {'error': e.message} def compare(self, name, old_sha, new_sha): + """Compare two revisions of the same page. + + :param name: Name of page. + :param old_sha: Older sha. + :param new_sha: Newer sha. + :return: str - Raw markup with styles + + """ + + # TODO: This could be effectively done in the browser old = self.get_page(name, sha=old_sha) new = self.get_page(name, sha=new_sha) return ghdiff.diff(old['data'], new['data']) def get_index(self): + """Get repo index of head. + + :return: list -- List of dicts + + """ rv = [] index = self.repo.open_index() for name in index: @@ -201,8 +303,20 @@ class Wiki(HookMixin): return rv def get_history(self, name, limit=100): + """Get page history. + + :param name: Name of page. + :param limit: Limit history size. + :return: list -- List of dicts + + """ + if not len(self.repo.open_index()): + # Index is empty, no commits + return [] + file_path = cname_to_filename(name) versions = [] + walker = self.repo.get_walker(paths=[file_path], max_entries=limit) for entry in walker: change_type = None @@ -220,4 +334,3 @@ class Wiki(HookMixin): type=change_type)) return versions - diff --git a/realms/modules/wiki/views.py b/realms/modules/wiki/views.py index 503dd7b..84e0078 100644 --- a/realms/modules/wiki/views.py +++ b/realms/modules/wiki/views.py @@ -1,4 +1,4 @@ -from flask import g, render_template, request, redirect, Blueprint, flash, url_for, current_app +from flask import abort, g, render_template, request, redirect, Blueprint, flash, url_for, current_app from flask.ext.login import login_required from realms.lib.util import to_canonical, remove_ext from realms.modules.wiki.models import Wiki @@ -17,10 +17,11 @@ def commit(name, sha): cname = to_canonical(name) data = g.current_wiki.get_page(cname, sha=sha) - if data: - return render_template('wiki/page.html', name=name, page=data, commit=sha) - else: - return redirect(url_for('wiki.create', name=cname)) + + if not data: + abort(404) + + return render_template('wiki/page.html', name=name, page=data, commit=sha) @blueprint.route("/_compare//") @@ -35,15 +36,17 @@ def revert(): name = request.form.get('name') commit = request.form.get('commit') cname = to_canonical(name) + message = request.form.get('message', "Reverting %s" % cname) - if cname in app.config.WIKI_LOCKED_PAGES: - flash("Page is locked") - return redirect(url_for(app.config['ROOT_ENDPOINT'])) + if cname in app.config.get('WIKI_LOCKED_PAGES'): + return dict(error=True, message="Page is locked") - g.current_wiki.revert_page(name, commit, message="Reverting %s" % cname, - username=current_user.username) - flash('Page reverted', 'success') - return redirect(url_for('wiki.page', name=cname)) + sha = g.current_wiki.revert_page(name, commit, message=message, + username=current_user.username) + if sha: + flash("Page reverted") + + return dict(sha=sha) @blueprint.route("/_history/") @@ -51,74 +54,40 @@ def history(name): return render_template('wiki/history.html', name=name, history=g.current_wiki.get_history(name)) -@blueprint.route("/_edit/", methods=['GET', 'POST']) +@blueprint.route("/_edit/") @login_required def edit(name): - data = g.current_wiki.get_page(name) cname = to_canonical(name) - if request.method == 'POST': - edit_cname = to_canonical(request.form['name']) + page = g.current_wiki.get_page(name) - if edit_cname in app.config['WIKI_LOCKED_PAGES']: - return redirect(url_for(app.config['ROOT_ENDPOINT'])) + if not page: + # Page doesn't exist + return redirect(url_for('wiki.create', name=cname)) - if edit_cname != cname.lower(): - g.current_wiki.rename_page(cname, edit_cname) - - g.current_wiki.write_page(edit_cname, - request.form['content'], - message=request.form['message'], - username=current_user.username) - else: - if data: - name = remove_ext(data['name']) - content = data.get('data') - g.assets['js'].append('editor.js') - return render_template('wiki/edit.html', - name=name, - content=content, - info=data.get('info'), - sha=data.get('sha'), - partials=data.get('partials')) - else: - return redirect(url_for('wiki.create', name=cname)) + name = remove_ext(page['name']) + g.assets['js'].append('editor.js') + return render_template('wiki/edit.html', + name=name, + content=page.get('data'), + info=page.get('info'), + sha=page.get('sha'), + partials=page.get('partials')) -@blueprint.route("/_delete/", methods=['POST']) -@login_required -def delete(name): - pass - - -@blueprint.route("/_create/", defaults={'name': None}, methods=['GET', 'POST']) -@blueprint.route("/_create/", methods=['GET', 'POST']) +@blueprint.route("/_create/", defaults={'name': None}) +@blueprint.route("/_create/") @login_required def create(name): - if request.method == 'POST': - cname = to_canonical(request.form['name']) + cname = to_canonical(name) if name else "" + if cname and g.current_wiki.get_page(cname): + # Page exists, edit instead + return redirect(url_for('wiki.edit', name=cname)) - if cname in app.config['WIKI_LOCKED_PAGES']: - return redirect(url_for("wiki.create")) - - if not cname: - return redirect(url_for("wiki.create")) - - g.current_wiki.write_page(request.form['name'], - request.form['content'], - message=request.form['message'], - create=True, - username=current_user.username) - else: - cname = to_canonical(name) if name else "" - if cname and g.current_wiki.get_page(cname): - # Page exists, edit instead - return redirect(url_for('wiki.edit', name=cname)) - - g.assets['js'].append('editor.js') - return render_template('wiki/edit.html', - name=cname, - content="", - info={}) + g.assets['js'].append('editor.js') + return render_template('wiki/edit.html', + name=cname, + content="", + info={}) @blueprint.route("/_index") @@ -126,6 +95,48 @@ def index(): return render_template('wiki/index.html', index=g.current_wiki.get_index()) +@blueprint.route("/", methods=['POST', 'PUT', 'DELETE']) +@login_required +def page_write(name): + cname = to_canonical(name) + + if not cname: + return dict(error=True, message="Invalid name") + + if request.method == 'POST': + # Create + if cname in app.config.get('WIKI_LOCKED_PAGES'): + return dict(error=True, message="Page is locked") + + sha = g.current_wiki.write_page(cname, + request.form['content'], + message=request.form['message'], + create=True, + username=current_user.username) + + elif request.method == 'PUT': + edit_cname = to_canonical(request.form['name']) + + if edit_cname in app.config.get('WIKI_LOCKED_PAGES'): + return dict(error=True, message="Page is locked") + + if edit_cname != cname.lower(): + g.current_wiki.rename_page(cname, edit_cname) + + sha = g.current_wiki.write_page(edit_cname, + request.form['content'], + message=request.form['message'], + username=current_user.username) + + return dict(sha=sha) + + else: + # DELETE + sha = g.current_wiki.delete_page(name, user=current_user) + + return dict(sha=sha) + + @blueprint.route("/", defaults={'name': 'home'}) @blueprint.route("/") def page(name): @@ -134,6 +145,7 @@ def page(name): return redirect(url_for('wiki.page', name=cname)) data = g.current_wiki.get_page(cname) + if data: return render_template('wiki/page.html', name=cname, page=data, partials=data.get('partials')) else: diff --git a/realms/static/humans.txt b/realms/static/humans.txt new file mode 100644 index 0000000..e69de29 diff --git a/realms/static/img/favicon.ico b/realms/static/img/favicon.ico new file mode 100644 index 0000000..ba48efe Binary files /dev/null and b/realms/static/img/favicon.ico differ diff --git a/realms/static/js/editor.js b/realms/static/js/editor.js index 1c3635d..249fc0d 100644 --- a/realms/static/js/editor.js +++ b/realms/static/js/editor.js @@ -2,6 +2,8 @@ var $entry_markdown_header = $("#entry-markdown-header"); var $entry_preview_header = $("#entry-preview-header"); var $entry_markdown = $(".entry-markdown"); var $entry_preview = $(".entry-preview"); +var $page_name = $("#page-name"); +var $page_message = $("#page-message"); // Tabs $entry_markdown_header.click(function(){ @@ -66,12 +68,26 @@ var aced = new Aced({ info: Commit.info, submit: function(content) { var data = { - name: $("#page-name").val(), - message: $("#page-message").val(), + name: $page_name.val(), + message: $page_message.val(), content: content }; - $.post(window.location, data, function() { - location.href = Config['RELATIVE_PATH'] + '/' + data['name']; + + var path = Config['RELATIVE_PATH'] + '/' + data['name']; + var type = (Commit.info['sha']) ? "PUT" : "POST"; + + $.ajax({ + type: type, + url: path, + data: data, + dataType: 'json' + }).always(function(data, status, error) { + if (data && data['error']) { + $page_name.addClass('parsley-error'); + bootbox.alert("

" + data['message'] + "

"); + } else { + location.href = path; + } }); } }); \ No newline at end of file diff --git a/realms/static/js/main.js b/realms/static/js/main.js new file mode 100644 index 0000000..5c7e375 --- /dev/null +++ b/realms/static/js/main.js @@ -0,0 +1,40 @@ +$(function(){ + $(".ajax-form").submit(function(e){ + e.preventDefault(); + + var submitting = 'submitting'; + + if ($(this).data(submitting)) { + return; + } + + $(this).data(submitting, 1); + + var action = $(this).attr('action'); + var method = $(this).attr('method'); + var redirect = $(this).data('redirect'); + var data = $(this).serialize(); + + var req = $.ajax({ + type: method, + url: action, + data: data, + dataType: 'json' + }); + + req.done(function() { + if (redirect) { + location.href = redirect; + } + }); + + req.fail(function(data, status, error) { + console.log(data); + }); + + req.always(function() { + $(this).removeData(submitting); + }); + + }); +}); \ No newline at end of file diff --git a/realms/static/robots.txt b/realms/static/robots.txt new file mode 100644 index 0000000..e69de29 diff --git a/realms/templates/wiki/create.html b/realms/templates/wiki/create.html deleted file mode 100644 index 6a7775c..0000000 --- a/realms/templates/wiki/create.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends 'layout.html' %} -{% block body %} - -
-
- - -
- -
- - -
- - -
- -{% endblock %} \ No newline at end of file diff --git a/realms/templates/wiki/page.html b/realms/templates/wiki/page.html index ce97782..bd40d7f 100644 --- a/realms/templates/wiki/page.html +++ b/realms/templates/wiki/page.html @@ -9,7 +9,7 @@ {% block body %} {% if commit %}
-
+