From 7183b4c0dc9a90ce711fa5a3418a95a5c481f6ce Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 28 Jul 2021 14:48:16 -0400 Subject: [PATCH] Edit short links plugin --- coreplugins/editshortlinks/__init__.py | 1 + coreplugins/editshortlinks/api.py | 120 +++++++++++ coreplugins/editshortlinks/disabled | 0 coreplugins/editshortlinks/manifest.json | 13 ++ coreplugins/editshortlinks/plugin.py | 25 +++ .../editshortlinks/public/SLControls.jsx | 200 ++++++++++++++++++ .../editshortlinks/public/SLControls.scss | 16 ++ coreplugins/editshortlinks/public/main.js | 7 + 8 files changed, 382 insertions(+) create mode 100644 coreplugins/editshortlinks/__init__.py create mode 100644 coreplugins/editshortlinks/api.py create mode 100644 coreplugins/editshortlinks/disabled create mode 100644 coreplugins/editshortlinks/manifest.json create mode 100644 coreplugins/editshortlinks/plugin.py create mode 100644 coreplugins/editshortlinks/public/SLControls.jsx create mode 100644 coreplugins/editshortlinks/public/SLControls.scss create mode 100644 coreplugins/editshortlinks/public/main.js diff --git a/coreplugins/editshortlinks/__init__.py b/coreplugins/editshortlinks/__init__.py new file mode 100644 index 00000000..48aad58e --- /dev/null +++ b/coreplugins/editshortlinks/__init__.py @@ -0,0 +1 @@ +from .plugin import * diff --git a/coreplugins/editshortlinks/api.py b/coreplugins/editshortlinks/api.py new file mode 100644 index 00000000..86059a03 --- /dev/null +++ b/coreplugins/editshortlinks/api.py @@ -0,0 +1,120 @@ +import math +import re + +from rest_framework import status +from rest_framework.response import Response +from app.plugins.views import TaskView +from app.plugins import get_current_plugin, signals as plugin_signals +from django.dispatch import receiver +from app.plugins import GlobalDataStore +from django.http import Http404 +from django.shortcuts import redirect +from django.utils.translation import ugettext_lazy as _ + +import logging + +logger = logging.getLogger('app.logger') + +ds = GlobalDataStore('editshortlinks') + +def gen_short_string(num): + num = int(abs(num)) + + def nbase(num, numerals="abcdefghijklmnopqrstuvwxyz0123456789"): + return ((num == 0) and numerals[0]) or (nbase(num // len(numerals), numerals).lstrip(numerals[0]) + numerals[num % len(numerals)]) + + return nbase(num) + +def getShortLinks(username): + return ds.get_json(username + "_shortlinks", { + 't': {}, # task --> short id + 'i': {} # short id --> task + }) + +class DeleteShortLink(TaskView): + def post(self, request, pk=None): + task = self.get_and_check_task(request, pk) + task_id = str(task.id) + username = str(request.user) + + shortlinks = getShortLinks(username) + + short_id = shortlinks['t'].get(task_id) + if short_id is not None: + del shortlinks['i'][short_id] + del shortlinks['t'][task_id] + + ds.set_json(username + "_shortlinks", shortlinks) + + return Response({'success': True}, status=status.HTTP_200_OK) + + +class EditShortLink(TaskView): + def post(self, request, pk=None): + task = self.get_and_check_task(request, pk) + task_id = str(task.id) + username = str(request.user) + short_id = request.data.get("shortId") + if not re.match(r'^[A-Za-z0-9_-]+$', short_id): + return Response({'error': _("Short URLs can only include letters, numbers, underscore and dash characters (A-Z, 0-9, _, -).")}) + + shortlinks = getShortLinks(username) + if shortlinks['i'].get(short_id, task_id) != task_id: + return Response({'error': _("This short URL is already taken.")}) + + # Replace previous if any + prev_short_id = shortlinks['t'].get(task_id) + shortlinks['t'][task_id] = short_id + + if prev_short_id is not None: + del shortlinks['i'][prev_short_id] + + shortlinks['i'][short_id] = task_id + + ds.set_json(username + "_shortlinks", shortlinks) + + return Response({'username': username, 'shortId': short_id}, status=status.HTTP_200_OK) + +class GetShortLink(TaskView): + def post(self, request, pk=None): + task = self.get_and_check_task(request, pk) + task_id = str(task.id) + username = str(request.user) + shortlinks = getShortLinks(username) + + if task_id in shortlinks['t']: + # Return existing short link + return Response({'username': username, 'shortId': shortlinks['t'][task_id]}, status=status.HTTP_200_OK) + else: + # Compute short link, store it + + # Not atomic, but this shouldn't be a big problem + counter = ds.get_int(username + "_counter", 0) + ds.set_int(username + "_counter", counter + 1) + + short_id = gen_short_string(counter) + + # task_id --> short id + shortlinks['t'][task_id] = short_id + + # short id --> task_id + shortlinks['i'][short_id] = task_id + + ds.set_json(username + "_shortlinks", shortlinks) + + return Response({'username': username, 'shortId': short_id}, status=status.HTTP_200_OK) + + +def HandleShortLink(request, view_type, username, short_id): + shortlinks = getShortLinks(username) + if short_id in shortlinks['i']: + task_id = shortlinks['i'][short_id] + if view_type == 'm': + v = 'map' + elif view_type == '3': + v = '3d' + + return redirect('/public/task/{}/{}/'.format(task_id, v)) + else: + raise Http404() + diff --git a/coreplugins/editshortlinks/disabled b/coreplugins/editshortlinks/disabled new file mode 100644 index 00000000..e69de29b diff --git a/coreplugins/editshortlinks/manifest.json b/coreplugins/editshortlinks/manifest.json new file mode 100644 index 00000000..c1b1b313 --- /dev/null +++ b/coreplugins/editshortlinks/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "Editable Short Links", + "webodmMinVersion": "1.9.3", + "description": "Create editable short links when sharing task URLs", + "version": "1.0.0", + "author": "Piero Toffanin", + "email": "pt@uav4geo.com", + "repository": "https://github.com/OpenDroneMap/WebODM", + "tags": ["editable", "short", "links"], + "homepage": "https://github.com/OpenDroneMap/WebODM", + "experimental": false, + "deprecated": false +} \ No newline at end of file diff --git a/coreplugins/editshortlinks/plugin.py b/coreplugins/editshortlinks/plugin.py new file mode 100644 index 00000000..f664e08d --- /dev/null +++ b/coreplugins/editshortlinks/plugin.py @@ -0,0 +1,25 @@ +from app.plugins import PluginBase, Menu, MountPoint +from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from django.utils.translation import gettext as _ +from .api import GetShortLink, EditShortLink, DeleteShortLink, HandleShortLink + +class Plugin(PluginBase): + def build_jsx_components(self): + return ['SLControls.jsx'] + + def include_js_files(self): + return ['main.js'] + + def root_mount_points(self): + return [ + MountPoint(r'^s(?P[m3])/(?P[^/.]+)/(?P[A-Za-z0-9_-]+)/?$', HandleShortLink) + ] + + def api_mount_points(self): + return [ + MountPoint('task/(?P[^/.]+)/shortlink', GetShortLink.as_view()), + MountPoint('task/(?P[^/.]+)/edit', EditShortLink.as_view()), + MountPoint('task/(?P[^/.]+)/delete', DeleteShortLink.as_view()) + ] + diff --git a/coreplugins/editshortlinks/public/SLControls.jsx b/coreplugins/editshortlinks/public/SLControls.jsx new file mode 100644 index 00000000..9f13577e --- /dev/null +++ b/coreplugins/editshortlinks/public/SLControls.jsx @@ -0,0 +1,200 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './SLControls.scss'; +import ErrorMessage from 'webodm/components/ErrorMessage'; +import { _, interpolate } from 'webodm/classes/gettext'; +import $ from 'jquery'; + +export default class SLControls extends React.Component{ + static defaultProps = { + sharePopup: null + }; + + static propTypes = { + sharePopup: PropTypes.object.isRequired + }; + + constructor(props){ + super(props); + + this.state = { + error: '', + loading: false, + useShortLink: false, + shortId: '', + editingShortId: false, + showEditSuccess: false, + savingShortId: false + }; + } + + updateRelShareLink = (res, onSuccess = () => {}) => { + if (res.shortId){ + const linksTarget = this.props.sharePopup.props.linksTarget; + + const {username, shortId} = res; + const linksTargetChar = linksTarget === '3d' ? '3' : 'm'; + + const relShareLink = `s${linksTargetChar}/${username}/${shortId}`; + this.props.sharePopup.setState({relShareLink}); + this.setState({shortId}); + onSuccess(); + }else if (res.error){ + this.setState({error: res.error}); + }else this.setState({error: interpolate(_('Invalid response from server: %(error)s'), { error: JSON.stringify(res)})}); + } + + toggleShortLinks = (e) => { + e.stopPropagation(); + if (!this.state.useShortLink && !this.state.loading){ + this.setState({loading: true}); + + const task = this.props.sharePopup.props.task; + + $.ajax({ + type: 'POST', + url: `/api/plugins/editshortlinks/task/${task.id}/shortlink`, + contentType: 'application/json' + }).done(res => { + this.updateRelShareLink(res); + this.setState({loading: false, useShortLink: !this.state.useShortLink}); + }).fail(error => { + this.setState({error: interpolate(_('Invalid response from server: %(error)s'), { error }), loading: false}); + }); + }else{ + this.props.sharePopup.setState({relShareLink: this.props.sharePopup.getRelShareLink()}); + this.setState({useShortLink: !this.state.useShortLink}); + } + } + + handleEdit = () => { + this.setState({editingShortId: true}); + setTimeout(() => { + this.editTextbox.focus(); + }, 0); + } + + + handleSave = () => { + if (!this.state.savingShortId){ + this.setState({savingShortId: true}); + + const task = this.props.sharePopup.props.task; + + $.ajax({ + type: 'POST', + url: `/api/plugins/editshortlinks/task/${task.id}/edit`, + contentType: 'application/json', + data: JSON.stringify({ + shortId: this.state.shortId + }), + dataType: 'json', + }).done(res => { + this.updateRelShareLink(res, () => { + this.setState({editingShortId: false, showEditSuccess: true}); + setTimeout(() => { + this.setState({showEditSuccess: false}); + }, 2000); + }); + }).fail(error => { + this.setState({error: interpolate(_('Invalid response from server: %(error)s'), { error }), loading: false}); + }).always(() => { + this.setState({savingShortId: false}); + }); + } + } + + handleEditShortId = e => { + this.setState({shortId: e.target.value}); + } + + handleDelete = e => { + if (window.confirm(_("Are you sure?"))){ + this.setState({loading: true}); + const task = this.props.sharePopup.props.task; + + $.ajax({ + type: 'POST', + url: `/api/plugins/editshortlinks/task/${task.id}/delete`, + contentType: 'application/json', + dataType: 'json', + }).done(res => { + if (res.success){ + this.setState({useShortLink: false, shortId: ""}); + this.props.sharePopup.setState({relShareLink: this.props.sharePopup.getRelShareLink()}); + }else if (res.error){ + this.setState({error: res.error}); + }else{ + this.setState({error: interpolate(_('Invalid response from server: %(error)s'), { error: JSON.stringify(res) })}); + } + }).fail(error => { + this.setState({error: interpolate(_('Invalid response from server: %(error)s'), { error }), loading: false}); + }).always(() => { + this.setState({loading: false}); + }); + } + } + + handleShortIdKeyPress = e => { + if (e.key === 'Enter'){ + this.handleSave(); + } + } + + render(){ + const { loading, useShortLink, savingShortId, + shortId, editingShortId, showEditSuccess } = this.state; + + const controls = []; + + if (useShortLink){ + let editIcon = "fa-edit"; + if (showEditSuccess) editIcon = "fa-check"; + + const editButton = (); + + let saveIcon = "fa-save"; + if (savingShortId) saveIcon = "fa-circle-notch fa-spin"; + + const saveButton = (); + + controls.push(
+ { this.editTextbox = ref; }} + > + + + {editingShortId ? saveButton : editButton} + +
); + + controls.push(); + } + + return (
+ {controls} + +
); + } +} diff --git a/coreplugins/editshortlinks/public/SLControls.scss b/coreplugins/editshortlinks/public/SLControls.scss new file mode 100644 index 00000000..b10d29e2 --- /dev/null +++ b/coreplugins/editshortlinks/public/SLControls.scss @@ -0,0 +1,16 @@ +.slcontrols{ + .slcheckbox{ + font-weight: normal; + font-size: 90%; + width: 100%; + } + .sldelete{ + user-select: none; + margin-top: 8px; + display: block; + font-size: 80%; + &:hover{ + cursor: pointer; + } + } +} \ No newline at end of file diff --git a/coreplugins/editshortlinks/public/main.js b/coreplugins/editshortlinks/public/main.js new file mode 100644 index 00000000..57aed16a --- /dev/null +++ b/coreplugins/editshortlinks/public/main.js @@ -0,0 +1,7 @@ +PluginsAPI.SharePopup.addLinkControl([ + 'editshortlinks/build/SLControls.js', + 'editshortlinks/build/SLControls.css' + ],function(args, SLControls){ + return SLControls; + } +); \ No newline at end of file