diff --git a/core/views/editor_helpers.py b/core/views/editor_helpers.py new file mode 100644 index 0000000..e0ff5cd --- /dev/null +++ b/core/views/editor_helpers.py @@ -0,0 +1,111 @@ +from django.shortcuts import render, redirect +from django.http import HttpResponse, HttpResponseRedirect, Http404, JsonResponse + +from django.urls import reverse, resolve +from django.template import Context, loader +import re, io +from PIL import Image +from pathlib import Path +import django.forms as forms +import troggle.settings as settings + +from troggle.lib import version_control + +MAX_IMAGE_WIDTH = 1000 +MAX_IMAGE_HEIGTH = 800 + +THUMBNAIL_WIDTH = 200 +THUMBNAIL_HEIGTH = 200 + +def image_selector(request, path): + '''Returns available images''' + directory = path.rsplit('/', 1)[0] + thumbnailspath = Path(settings.EXPOWEB) / directory / "t" + thumbnails = [] + for f in thumbnailspath.iterdir(): + if f.is_file(): + thumbnail_url = reverse('expopage', args=["%s/t/%s" % (directory, f.name)]) + name_base = f.name.rsplit('.', 1)[0] + page_path_base = Path(settings.EXPOWEB) / directory / "l" + if ((page_path_base / ("%s.htm" % name_base)).is_file()): + page_url = reverse('expopage', args=["%s/l/%s.htm" % (directory, name_base)]) + else: + page_url = reverse('expopage', args=["%s/l/%s.html" % (directory, name_base)]) + + thumbnails.append({"thumbnail_url": thumbnail_url, "page_url": page_url}) + + return render(request, 'image_selector.html', {'thumbnails': thumbnails}) + +def new_image_form(request, path): + '''Manages a form to upload new images''' + directory = path.rsplit('/', 1)[0] + if request.method == 'POST': + form = NewWebImageForm(request.POST, request.FILES, directory = directory) + if form.is_valid(): + f = request.FILES["file_"] + binary_data = io.BytesIO() + for chunk in f.chunks(): + binary_data.write(chunk) + i = Image.open(binary_data) + width, height = i.size + if width > MAX_IMAGE_WIDTH or height > MAX_IMAGE_HEIGTH: + scale = max(width / MAX_IMAGE_WIDTH, height / MAX_IMAGE_HEIGTH) + i = i.resize((int(width / scale), int(height / scale)), Image.ANTIALIAS) + tscale = max(width / THUMBNAIL_WIDTH, height / THUMBNAIL_HEIGTH) + thumbnail = i.resize((int(width / tscale), int(height / tscale)), Image.ANTIALIAS) + ib = io.BytesIO() + i.save(ib, format="png") + tb = io.BytesIO() + thumbnail.save(tb, format="png") + image_rel_path, thumb_rel_path, desc_rel_path = form.get_rel_paths() + image_page_template = loader.get_template('image_page_template.html') + image_page = image_page_template.render({'header': form.cleaned_data["header"], 'description': form.cleaned_data["description"], + 'photographer': form.cleaned_data["photographer"], 'year': form.cleaned_data["year"], + 'filepath': f'/{image_rel_path}' + }) + image_path, thumb_path, desc_path = form.get_full_paths() + try: + change_message = form.cleaned_data["change_message"] + version_control.write_and_commit([(desc_path, image_page, "utf-8"), + (image_path, ib.getbuffer(), False), + (thumb_path, tb.getbuffer(), False)], + f'{change_message} - online adding of an image') + except version_control.WriteAndCommitError as e: + return JsonResponse({"error": e.message}) + linked_image_template = loader.get_template('linked_image_template.html') + html_snippet = linked_image_template.render({'thumbnail_url': f'/{thumb_rel_path}', 'page_url': f'/{desc_rel_path}'}, request) + return JsonResponse({"html": html_snippet}) + else: + form = NewWebImageForm(directory = directory) + template = loader.get_template('new_image_form.html') + htmlform = template.render({'form': form, 'path': path}, request) + return JsonResponse({"form": htmlform}) + +class NewWebImageForm(forms.Form): + '''The form used by the editexpopage function + ''' + header = forms.CharField(widget=forms.TextInput(attrs={'size':'60', 'placeholder': "Enter title (displayed as a header and in the tab)"})) + file_ = forms.FileField() + description = forms.CharField(widget=forms.Textarea(attrs={"cols":80, "rows":20, 'placeholder': "Describe the photo (using HTML)"})) + photographer = forms.CharField(widget=forms.TextInput(attrs={'size':'60', 'placeholder': "Photographers name"}), required = False) + year = forms.CharField(widget=forms.TextInput(attrs={'size':'60', 'placeholder': "Year photo was taken"}), required = False) + change_message = forms.CharField(widget=forms.Textarea(attrs={"cols":80, "rows":3, 'placeholder': "Descibe the change made (for git)"})) + + def __init__(self, *args, **kwargs): + self.directory = Path(kwargs.pop('directory')) + super(forms.Form, self).__init__(*args, **kwargs) + + def get_rel_paths(self): + f = self.cleaned_data['file_'] + return [self.directory / "i" / (f.name.rsplit('.', 1)[0] + ".png"), + self.directory / "t" / (f.name.rsplit('.', 1)[0] + ".png"), + self.directory / "l" / (f.name.rsplit('.', 1)[0] + ".html")] + + def get_full_paths(self): + return [Path(settings.EXPOWEB) / x for x in self.get_rel_paths()] + + def clean_file_(self): + for rel_path, full_path in zip(self.get_rel_paths(), self.get_full_paths()): + if full_path.exists(): + raise forms.ValidationError("File already exists in %s" % rel_path) + return self.cleaned_data['file_'] diff --git a/core/views/expo.py b/core/views/expo.py index 00cb6dc..91919a5 100644 --- a/core/views/expo.py +++ b/core/views/expo.py @@ -359,7 +359,7 @@ def editexpopage(request, path): if result != html: # Check if content changed try: change_message = pageform.cleaned_data["change_message"] - version_control.write_and_commit(filepath, result, f'{change_message} - online edit of {path}') + version_control.write_and_commit([(filepath, result, "utf-8")], f'{change_message} - online edit of {path}') except version_control.WriteAndCommitError as e: return render(request,'errors/generic.html', {'message': e.message}) diff --git a/lib/version_control.py b/lib/version_control.py index 6343914..01bd8ba 100644 --- a/lib/version_control.py +++ b/lib/version_control.py @@ -1,28 +1,35 @@ import troggle.settings as settings import subprocess -def write_and_commit(filepath, content, message): +def write_and_commit(files, message): """Writes the content to the filepath and adds and commits the file to git. If this fails, a WriteAndCommitError is raised.""" - cwd = filepath.parent - filename = filepath.name git = settings.GIT - # GIT see also core/models/cave.py writetrogglefile() - # GIT see also core/views/uploads.py dwgupload() + try: + for filepath, content, encoding in files: + cwd = filepath.parent + filename = filepath.name + # GIT see also core/models/cave.py writetrogglefile() + # GIT see also core/views/uploads.py dwgupload() - try: - with open(filepath, "w", encoding="utf8") as f: - print(f'WRITING{cwd}---{filename} ') - # as the wsgi process www-data, we have group write-access but are not owner, so cannot chmod. - # os.chmod(filepath, 0o664) # set file permissions to rw-rw-r-- - f.write(content) - except PermissionError: - raise WriteAndCommitError(f'CANNOT save this file.\nPERMISSIONS incorrectly set on server for this file {filename}. Ask a nerd to fix this.') + if encoding: + mode = "w" + kwargs = {"encoding": encoding} + else: + mode = "wb" + kwargs = {} + try: + with open(filepath, mode, **kwargs) as f: + print(f'WRITING{cwd}---{filename} ') + # as the wsgi process www-data, we have group write-access but are not owner, so cannot chmod. + # os.chmod(filepath, 0o664) # set file permissions to rw-rw-r-- + f.write(content) + except PermissionError: + raise WriteAndCommitError(f'CANNOT save this file.\nPERMISSIONS incorrectly set on server for this file {filename}. Ask a nerd to fix this.') - try: - cp_add = subprocess.run([git, "add", filename], cwd=cwd, capture_output=True, text=True) - if cp_add.returncode != 0: - msgdata = 'Ask a nerd to fix this.\n\n' + cp_add.stderr + '\n\n' + cp_add.stdout + '\n\nreturn code: ' + str(cp_add.returncode) - raise WriteAndCommitError(f'CANNOT git on server for this file {filename}. Edits saved but not added to git.\n\n' + msgdata) + cp_add = subprocess.run([git, "add", filename], cwd=cwd, capture_output=True, text=True) + if cp_add.returncode != 0: + msgdata = 'Ask a nerd to fix this.\n\n' + cp_add.stderr + '\n\n' + cp_add.stdout + '\n\nreturn code: ' + str(cp_add.returncode) + raise WriteAndCommitError(f'CANNOT git on server for this file {filename}. Edits saved but not added to git.\n\n' + msgdata) cp_commit = subprocess.run([git, "commit", "-m", message], cwd=cwd, capture_output=True, text=True) # This produces return code = 1 if it commits OK, but when the repo still needs to be pushed to origin/expoweb diff --git a/templates/editexpopage.html b/templates/editexpopage.html index ef3aa20..b71c91f 100644 --- a/templates/editexpopage.html +++ b/templates/editexpopage.html @@ -5,6 +5,8 @@ <!--<script src="{{ settings.TINY_MCE_MEDIA_URL }}tiny_mce.js" type="text/javascript"></script>--> <!-- <script type="text/javascript"> tinyMCE.init({ mode : "textareas" }); </script>--> +<script src="{{ settings.MEDIA_URL }}admin/js/vendor/jquery/jquery.js" type="text/javascript"></script> + <script src={{ settings.MEDIA_URL }}codemirror/codemirror.js></script> <script src={{ settings.MEDIA_URL }}codemirror/xml.js></script> <script src={{ settings.MEDIA_URL }}codemirror/javascript.js></script> @@ -38,9 +40,82 @@ height: 5%; } </style> + <style type=text/css> + html { + font-family: "Helvetica Neue", sans-serif; + width: 100%; + color: #666666; + text-align: center; + } + + .popup-overlay { + /*Hides pop-up when there is no "active" class*/ + visibility: hidden; + position: absolute; + background: #ffffff; + border: 3px solid #666666; + width: 90%; + height: 80%; + overflow: scroll; + left: 5%; + z-index: 20; + } + + .popup-overlay.active { + /*displays pop-up when "active" class is present*/ + visibility: visible; + text-align: center; + } + + .popup-content { + /*Hides pop-up content when there is no "active" class */ + visibility: hidden; + } + + .popup-content.active { + /*Shows pop-up content when "active" class is present */ + visibility: visible; + } + + button { + display: inline-block; + vertical-align: middle; + border-radius: 30px; + margin: .20rem; + font-size: 1rem; + color: #666666; + background: #ffffff; + border: 1px solid #666666; + } + + button:hover { + border: 1px solid #666666; + background: #666666; + color: #ffffff; + } + </style> {% endblock %} {% block body %} <h1>Edit {{ path }}</h1> +<!--Creates the add image popup--> +<div class="add-image-popup popup-overlay"> + <div class="add-image-popup popup-content"> + <h2>Select Image</h2> + <p id="image_popup_content"> Loading ...</p> + <button onclick="new_image_popup()">Upload Image</button> + <button class="close" onclick="$('.add-image-popup').removeClass('active');">Close</button> + </div> +</div> + +<!--Creates the new image popup--> +<div class="new-image-popup popup-overlay"> + <div class="new-image-popup popup-content"> + <h2>New Image</h2> + <p id="new_image_popup_content"> Loading ...</p> + <button class="close" onclick="$('.new-image-popup').removeClass('active');">Close</button> + </div> +</div> + <form action="" method="post">{% csrf_token %} {{ form.non_field_errors }} <div class="fieldWrapper"> @@ -62,6 +137,7 @@ <button type="button" onclick="addTag('h4', '')">heading 4</button> <button type="button" onclick="addTag('a', 'href=""')">hyperlink</button> <button type="button" onclick="addTag('p', '')">paragraph</button> +<button type="button" onclick="add_image_popup()">image</button> <div class="fieldWrapper"> {{ form.change_message.errors }} <label for="{{ form.title.id_for_label }}">Git change message:</label> @@ -70,6 +146,60 @@ {% include "menu.html" %} <p><input type="submit" value="Submit" /></p> </form> + + +<script> + + +function add_image_popup() { + $('.add-image-popup').addClass('active'); + $('#image_popup_content').load("{% url 'image_selector' path %}", function() { + $('.thumbnail').click(function(){ + $(".add-image-popup").removeClass("active"); + addStr($( this ).attr("data-html")) + }); + }) + } +function new_image_popup() { + $('.add-image-popup').removeClass('active'); + $('.new-image-popup').addClass('active'); + $.ajax({ + type : "GET", + dataType: "json", + url: "{% url 'new_image_form' path %}", + success: function(data){handle_new_image(data)} + }); + } + +function handle_new_image(data) { + if (data.hasOwnProperty('form')) { + $('#new_image_popup_content').html(data.form); + $('#new_image_form').on('submit', function(e){ + e.preventDefault(); + data = $('#new_image_form').serialize(); + + $.ajax({ + type : "POST", + dataType: "json", + url: "{% url 'new_image_form' path %}", + data: new FormData($('#new_image_form')[0]), + processData: false, + contentType: false, + success: function(data){ + handle_new_image(data); + } + }); + }); + } + else if (data.hasOwnProperty('html')) { + $('.new-image-popup').removeClass('active'); + addStr(data.html); + } + else { + alert(data.error); + } + } +</script> <script> var delay; // Initialize CodeMirror editor with a nice html5 canvas demo. @@ -103,5 +233,12 @@ editor.focus(); editor.setCursor({line: to.line , ch : to.ch + 2 + tag.length + attr.length }); } + + function addStr(x){ + var to = editor.getCursor(false); + editor.replaceRange(x, to); + editor.focus(); + editor.setCursor({line: to.line , ch : to.ch + x.length }); + } </script> {% endblock %} diff --git a/templates/image_page_template.html b/templates/image_page_template.html new file mode 100644 index 0000000..4a240e8 --- /dev/null +++ b/templates/image_page_template.html @@ -0,0 +1,24 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +<html> +<head> +<meta http-equiv="Content-Type" content="text/html; charset=utf8" /> +<title> +{{ header }} +</title> +<link rel="stylesheet" type="text/css" href="../../../css/main2.css" /> +</head> + +<body> +<H1>{{ header }}</H1> +<div class="centre"><img alt="" src="{{ filepath }}" /> +</div> + +<p>{{ description }}</p> + +{% if photographer %} +<p class="caption">Photo © {{ photographer }}{% if year %}, {{ year }}{% endif %}</p> +{% endif %} + +<hr /> +</body> +</html> diff --git a/templates/image_selector.html b/templates/image_selector.html new file mode 100644 index 0000000..5ac4e01 --- /dev/null +++ b/templates/image_selector.html @@ -0,0 +1,3 @@ +{% for thumbnail in thumbnails %} + <img class = "thumbnail" src = "{{ thumbnail.thumbnail_url }}" data-html = "{% include 'linked_image_template.html' with thumbnail_url=thumbnail.thumbnail_url page_url=thumbnail.page_url %}"/> +{% endfor %} diff --git a/templates/linked_image_template.html b/templates/linked_image_template.html new file mode 100644 index 0000000..336f1dd --- /dev/null +++ b/templates/linked_image_template.html @@ -0,0 +1 @@ +<a href='{{ page_url }}'><img src='{{ thumbnail_url }}' /></a> diff --git a/templates/new_image_form.html b/templates/new_image_form.html new file mode 100644 index 0000000..1a2f636 --- /dev/null +++ b/templates/new_image_form.html @@ -0,0 +1,5 @@ +<form id="new_image_form" action="{% url 'new_image_form' path %}" method="post" enctype="multipart/form-data"> + {% csrf_token %} + {{ form.as_p }} + <input type="submit" value="Submit"> +</form> diff --git a/urls.py b/urls.py index da420a0..5f5a711 100644 --- a/urls.py +++ b/urls.py @@ -23,6 +23,7 @@ from troggle.core.views.statistics import pathsreport, stats, dataissues from troggle.core.views.expo import expofiles_redirect, expofilessingle, expopage, editexpopage, mediapage, map, mapfile from troggle.core.views.survex import survexcaveslist, survexcavesingle, svx from troggle.core.views.auth import expologin, expologout +from troggle.core.views.editor_helpers import image_selector, new_image_form """This sets the actualurlpatterns[] and urlpatterns[] lists which django uses to resolve urls - in both directions as these are declarative. @@ -190,6 +191,11 @@ trogglepatterns = [ re_path(r'^/loser/(?P<subpath>.*)$', mediapage, {'doc_root': settings.SURVEX_DATA}, name="mediapage"), # Oddly not working !? re_path(r'^map/map.html', map, name="map"), # Redirects to OpenStreetMap JavaScript re_path(r'^map/(?P<path>.*)$', mapfile, name="mapfile"), # css, js, gpx + +# Helpers to edit HTML + re_path(r'^image_selector/(?P<path>.*)', image_selector, name = 'image_selector'), + re_path(r'^new_image_form/(?P<path>.*)', new_image_form, name = 'new_image_form'), + # Final catchall which also serves expoweb handbook pages and images re_path(r'^(.*)$', expopage, name="expopage"), # CATCHALL assumed relative to EXPOWEB