diff --git a/coreplugins/dronedb/api_views.py b/coreplugins/dronedb/api_views.py index 97283315b..b35cf2513 100644 --- a/coreplugins/dronedb/api_views.py +++ b/coreplugins/dronedb/api_views.py @@ -2,6 +2,7 @@ import importlib import json from posixpath import join +import re import time import requests import os @@ -24,20 +25,23 @@ VALID_IMAGE_EXTENSIONS = ['.tiff', '.tif', '.png', '.jpeg', '.jpg'] +# Regex pattern for valid tag format: "org" or "org/dataset" with lowercase alphanumeric and hyphens +TAG_PATTERN = re.compile(r'^[a-z0-9][a-z0-9\-]*(/[a-z0-9][a-z0-9\-]*)?$') + def is_valid(file): _, file_extension = path.splitext(file) return file_extension.lower() in VALID_IMAGE_EXTENSIONS or file == 'gcp_list.txt' or file == 'geo.txt' def get_settings(request): ds = get_current_plugin().get_user_data_store(request.user) - + registry_url = ds.get_string('registry_url') or DEFAULT_HUB_URL username = ds.get_string('username') or None password = ds.get_string('password') or None token = ds.get_string('token') or None return registry_url, username, password, token - + def update_token(request, token): ds = get_current_plugin().get_user_data_store(request.user) @@ -70,7 +74,7 @@ def post(self, request): ddb = DroneDB(hub_url, username, password) - return Response({'success': ddb.login()}, status=status.HTTP_200_OK) + return Response({'success': ddb.login()}, status=status.HTTP_200_OK) except(Exception) as e: return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) @@ -137,36 +141,36 @@ def post(self, request): try: - result, org, ds, folder, count, size = verify_url(url, username, password).values() + result, org, ds, folder, count, size = verify_url(url, username, password).values() if (not result): - return Response({'error': 'Invalid url.'}, status=status.HTTP_400_BAD_REQUEST) + return Response({'error': 'Invalid url.'}, status=status.HTTP_400_BAD_REQUEST) - return Response({'count': count, 'success': result, 'ds' : ds, 'org': org, 'folder': folder or None, 'size': size} + return Response({'count': count, 'success': result, 'ds' : ds, 'org': org, 'folder': folder or None, 'size': size} if org else {'success': False}, status=status.HTTP_200_OK) except Exception as e: - return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) class InfoTaskView(TaskView): def get(self, request): - + registry_url, username, _, _ = get_settings(request) - + return Response({ 'hubUrl': registry_url, 'username': username }, status=status.HTTP_200_OK) - + class ImportDatasetTaskView(TaskView): def post(self, request, project_pk=None, pk=None): - + task = self.get_and_check_task(request, pk) # Read form data ddb_url = request.data.get('ddb_url', None) - + if ddb_url == None: return Response({'error': 'DroneDB url must be set.'}, status=status.HTTP_400_BAD_REQUEST) - + registry_url, orgSlug, dsSlug, folder = parse_url(ddb_url).values() _, username, password, token = get_settings(request) @@ -175,27 +179,27 @@ def post(self, request, project_pk=None, pk=None): # Get the files from the folder rawfiles = ddb.get_files_list(orgSlug, dsSlug, folder) files = [file for file in rawfiles if is_valid(file['path'])] - - # Verify that the folder url is valid + + # Verify that the folder url is valid if len(files) == 0: return Response({'error': 'Empty dataset or folder.'}, status=status.HTTP_400_BAD_REQUEST) - + # Update the task with the new information task.console += "Importing {} images...\n".format(len(files)) task.images_count = len(files) task.pending_action = pending_actions.IMPORT task.save() - + # Associate the folder url with the project and task combined_id = "{}_{}".format(project_pk, pk) - + datastore = get_current_plugin().get_global_data_store() datastore.set_json(combined_id, { - "ddbUrl": ddb_url, - "token": ddb.token, + "ddbUrl": ddb_url, + "token": ddb.token, "ddbWebUrl": "{}/r/{}/{}/{}".format(to_web_protocol(registry_url), orgSlug, dsSlug, folder.rstrip('/')) }) - + #ddb.refresh_token() # Start importing the files in the background @@ -211,7 +215,7 @@ def import_files(task_id, carrier): from app.security import path_traversal_check files = carrier['files'] - + headers = {} if carrier['token'] != None: @@ -225,15 +229,15 @@ def download_file(task, file): with open(path, 'wb') as fd: for chunk in download_stream.iter_content(4096): fd.write(chunk) - + logger.info("Will import {} files".format(len(files))) task = models.Task.objects.get(pk=task_id) task.create_task_directories() task.save() - + try: downloaded_total = 0 - for file in files: + for file in files: download_file(task, file) task.check_if_canceled() models.Task.objects.filter(pk=task.id).update(upload_progress=(float(downloaded_total) / float(len(files)))) @@ -286,7 +290,7 @@ def ddb_cleanup(sender, task_id, **kwargs): logger.info("Info task {0} ({1})".format(str(task_id), status_key)) datastore.del_key(status_key) - + class StatusTaskView(TaskView): def get(self, request, pk): @@ -295,7 +299,7 @@ def get(self, request, pk): # Associate the folder url with the project and task status_key = get_status_key(pk) - + datastore = get_current_plugin().get_global_data_store() task_info = datastore.get_json(status_key, { @@ -311,7 +315,7 @@ def get(self, request, pk): return Response(task_info, status=status.HTTP_200_OK) DRONEDB_ASSETS = [ - 'orthophoto.tif', + 'orthophoto.tif', 'orthophoto.png', 'georeferenced_model.laz', 'dtm.tif', @@ -321,15 +325,31 @@ def get(self, request, pk): 'ground_control_points.geojson' ] -class ShareTaskView(TaskView): +class ShareTaskView(TaskView): def post(self, request, pk): from app.plugins import logger task = self.get_and_check_task(request, pk) + # Get optional tag and datasetName from request + tag = request.data.get('tag', None) + dataset_name = request.data.get('datasetName', None) or task.name + + # Validate tag format if provided + if tag is not None and tag.strip(): + tag = tag.strip().lower() + if not TAG_PATTERN.match(tag): + return Response({ + 'error': 'Invalid tag format. Must be "org" or "org/dataset" with lowercase alphanumeric characters and hyphens only.' + }, status=status.HTTP_400_BAD_REQUEST) + + # Sanitize dataset_name (remove potentially dangerous characters) + if dataset_name: + dataset_name = dataset_name.strip()[:255] # Limit length + status_key = get_status_key(pk) - + datastore = get_current_plugin().get_global_data_store() data = { @@ -348,7 +368,7 @@ def post(self, request, pk): available_assets = [task.get_asset_file_or_stream(f) for f in list(set(task.available_assets) & set(DRONEDB_ASSETS))] - if 'textured_model.zip' in task.available_assets: + if 'textured_model.zip' in task.available_assets: texture_files = [join(task.assets_path('odm_texturing'), f) for f in listdir(task.assets_path('odm_texturing')) if isfile(join(task.assets_path('odm_texturing'), f))] available_assets.extend(texture_files) @@ -356,25 +376,25 @@ def post(self, request, pk): files = [{'path': f, 'name': f[len(assets_path)+1:], 'size': os.path.getsize(f)} for f in available_assets] - share_to_ddb.delay(pk, settings, files) + share_to_ddb.delay(pk, settings, files, tag, dataset_name) - return Response(data, status=status.HTTP_200_OK) + return Response(data, status=status.HTTP_200_OK) @task -def share_to_ddb(pk, settings, files): - +def share_to_ddb(pk, settings, files, tag=None, dataset_name=None): + from app.plugins import logger - - status_key = get_status_key(pk) + + status_key = get_status_key(pk) datastore = get_current_plugin().get_global_data_store() registry_url, username, password, token = settings - + ddb = DroneDB(registry_url, username, password, token) - # Init share (to check) - share_token = ddb.share_init() + # Init share with optional tag and dataset name + share_token = ddb.share_init(tag=tag, dataset_name=dataset_name) status = datastore.get_json(status_key) @@ -394,9 +414,9 @@ def share_to_ddb(pk, settings, files): while attempt < 3: try: - + attempt += 1 - + up = ddb.share_upload(share_token, file['path'], file['name']) logger.info("Uploaded " + file['name'] + " to Dronedb (hash: " + up['hash'] + ")") @@ -405,7 +425,7 @@ def share_to_ddb(pk, settings, files): status['uploadedSize'] += file['size'] datastore.set_json(status_key, status) - + break except Exception as e: @@ -423,7 +443,7 @@ def share_to_ddb(pk, settings, files): res = ddb.share_commit(share_token) - + status['status'] = 3 # Done status['shareUrl'] = registry_url + res['url'] diff --git a/coreplugins/dronedb/ddb.py b/coreplugins/dronedb/ddb.py index 1d99d15d8..bd46310a3 100644 --- a/coreplugins/dronedb/ddb.py +++ b/coreplugins/dronedb/ddb.py @@ -8,7 +8,7 @@ DEFAULT_HUB_URL = 'https://hub.dronedb.app' class DroneDB: - + def __init__(self, registry_url, username, password, token=None, update_token=None): if not self.validate_url(registry_url): @@ -19,7 +19,7 @@ def __init__(self, registry_url, username, password, token=None, update_token=No self.token = token self.public = False if username else True self.update_token = update_token - + self.__registry_url = registry_url[:-1] if registry_url.endswith('/') else registry_url self.__authenticate_url = self.__registry_url + "/users/authenticate" self.__refresh_url = self.__registry_url + "/users/authenticate/refresh" @@ -28,12 +28,12 @@ def __init__(self, registry_url, username, password, token=None, update_token=No self.__get_folders_url = self.__registry_url + "/orgs/{0}/ds/{1}/search" self.__get_files_list_url = self.__registry_url + "/orgs/{0}/ds/{1}/list" self.__download_file_url = self.__registry_url + "/orgs/{0}/ds/{1}/download?path={2}&inline=1" - + self.__share_init_url = self.__registry_url + "/share/init" self.__share_upload_url = self.__registry_url + "/share/upload/{0}" self.__share_commit_url = self.__registry_url + "/share/commit/{0}" - + # Validate url def validate_url(self, url): try: @@ -60,116 +60,116 @@ def login(self): # Get the token self.token = response.json()['token'] - logger.info("Logged in to DroneDB as user " + self.username + ".") + logger.info("Logged in to DroneDB as user " + self.username + ".") if (self.update_token is not None): self.update_token(self.token) return True - + except(Exception) as e: logger.error(e) - return False + return False def refresh_token(self): - + if (self.public): logger.info("Cannot refresh token.") return False - + try: - + response = self.wrapped_call('POST', self.__refresh_url) self.token = response.json()['token'] if (self.update_token is not None): self.update_token(self.token) - + except Exception as e: - raise Exception("Failed to refresh token.") from e - + raise Exception("Failed to refresh token.") from e + def wrapped_call(self, type, url, data=None, params=None, files=None, attempts=3): - + headers = {} - + cnt = attempts while True: - + if not self.public and self.token is None and not self.login(): raise ValueError("Could not authenticate to DroneDB.") - + if self.token is not None: headers = {'Authorization': 'Bearer ' + self.token } - + response = requests.request(type, url, data=data, params=params, headers=headers, files=files) - + if response.status_code == 200: return response - - if response.status_code == 401: + + if response.status_code == 401: if (self.public): raise DroneDBException("Failed to call '" + url + "': unauthorized.") - + if not self.login(): raise DroneDBException("Failed to re-authenticate to DroneDB, cannot call '" + url + "'.") else: cnt -= 1 if cnt == 0: raise DroneDBException("Failed all attempts to re-authenticate to DroneDB, cannot call '" + url + "'.") - else: - res = response.json() - raise DroneDBException("Failed to call '" + url + "'.", res) - + else: + res = response.json() + raise DroneDBException("Failed to call '" + url + "'.", res) + def get_organizations(self): - + try: - + response = self.wrapped_call('GET', self.__get_organizations_url) return [{'slug': o['slug'], 'name': o['name']} for o in response.json()] - + except Exception as e: raise Exception("Failed to get organizations.") from e def get_datasets(self, orgSlug): - + try: - + response = self.wrapped_call('GET', self.__get_datasets_url.format(orgSlug)) return [ - {'slug': o['slug'], - 'name': o['properties'].get('meta', {}).get('name', {}).get('data', o['slug']), - 'public': o['properties'].get('public'), - 'size': o['size'], + {'slug': o['slug'], + 'name': o['properties'].get('meta', {}).get('name', {}).get('data', o['slug']), + 'public': o['properties'].get('public'), + 'size': o['size'], 'entries': o['properties'].get('entries') } for o in response.json()] - + except Exception as e: raise Exception("Failed to get datasets.") from e - - + + def get_folders(self, orgSlug, dsSlug): - + try: - + # Type 1 is folder payload = {'query': '*', 'recursive': True, 'type': 1} response = self.wrapped_call('POST', self.__get_folders_url.format(orgSlug, dsSlug), data=payload) - return [o['path'] for o in response.json()] - + return [o['path'] for o in response.json()] + except Exception as e: raise Exception("Failed to get folders.") from e def get_files_list(self, orgSlug, dsSlug, folder=None): - + try: - + # Type 1 is folder params = {'path': '' if folder is None else folder} @@ -182,50 +182,55 @@ def get_files_list(self, orgSlug, dsSlug, folder=None): files = filter(lambda itm: itm['type'] != 1, response.json()) return [ - {'path': o['path'], + {'path': o['path'], # extract name from path 'name': o['path'].split('/')[-1], - 'type': o['type'], + 'type': o['type'], 'size': o['size'], 'url': self.__download_file_url.format(orgSlug, dsSlug, o['path']) } for o in files] - + except Exception as e: raise Exception("Failed to get files list.") from e - def share_init(self, tag=None): + def share_init(self, tag=None, dataset_name=None): try: - - data = {'tag': tag} if tag is not None else None - + + data = {} + if tag is not None: + data['tag'] = tag + if dataset_name is not None: + data['datasetName'] = dataset_name + + # Send form data body (fields are optional but body must be present for [FromForm] binding) response = self.wrapped_call('POST', self.__share_init_url, data=data) - + return response.json()['token'] - + except Exception as e: raise Exception("Failed to initialize share.") from e - + def share_upload(self, token, path, name): try: - + # Get file name files = { 'file': open(path, 'rb') } data = {'path': name} - + response = self.wrapped_call('POST', self.__share_upload_url.format(token), files=files, data=data) - + return response.json() - + except Exception as e: raise Exception("Failed to upload file.") from e - + def share_commit(self, token): - try: - + try: + response = self.wrapped_call('POST', self.__share_commit_url.format(token)) - + return response.json() - + except Exception as e: raise Exception("Failed to commit share.") from e @@ -240,10 +245,10 @@ def verify_url(url, username=None, password=None): # return some info return { 'success': True, - 'orgSlug': orgSlug, - 'dsSlug': dsSlug, - 'folder': folder, - 'count': len(files), + 'orgSlug': orgSlug, + 'dsSlug': dsSlug, + 'folder': folder, + 'count': len(files), 'size': sum(i['size'] for i in files) } @@ -251,10 +256,10 @@ def verify_url(url, username=None, password=None): logger.error(e) return { 'success': False, - 'orgSlug': None, - 'dsSlug': None, - 'folder': None, - 'count': None, + 'orgSlug': None, + 'dsSlug': None, + 'folder': None, + 'count': None, 'size': None } @@ -275,17 +280,17 @@ def parse_url(url): if p.netloc == '': raise ValueError("Invalid URL.") - + scheme = p.scheme # used to skip the /r/: if ddb url we have no /r/ instead if http we have it if p.scheme == 'ddb': - scheme = 'https' + scheme = 'https' elif p.scheme == 'ddb+unsafe': - scheme = 'http' - + scheme = 'http' + offset = 1 if segments[1] == 'r' else 0 - + if (len(segments) < offset + 3): raise ValueError("Invalid URL.") diff --git a/coreplugins/dronedb/plugin.py b/coreplugins/dronedb/plugin.py index 49eddb0fb..b5188bd81 100644 --- a/coreplugins/dronedb/plugin.py +++ b/coreplugins/dronedb/plugin.py @@ -3,14 +3,14 @@ from coreplugins.dronedb.ddb import DEFAULT_HUB_URL from .api_views import ( - CheckUrlTaskView, - FoldersTaskView, - ImportDatasetTaskView, - CheckCredentialsTaskView, - OrganizationsTaskView, - DatasetsTaskView, - StatusTaskView, - VerifyUrlTaskView, + CheckUrlTaskView, + FoldersTaskView, + ImportDatasetTaskView, + CheckCredentialsTaskView, + OrganizationsTaskView, + DatasetsTaskView, + StatusTaskView, + VerifyUrlTaskView, InfoTaskView, ShareTaskView ) @@ -36,10 +36,10 @@ def include_js_files(self): return ["load_buttons.js"] def include_css_files(self): - return ["build/ImportView.css", "style.css"] + return ["build/ImportView.css", "build/ShareButton.css", "style.css"] def build_jsx_components(self): - return ["ImportView.jsx", "ShareButton.jsx"] + return ["ImportView.jsx", "ShareButton.jsx", "components/ShareDialog.jsx"] def api_mount_points(self): return [ @@ -53,7 +53,7 @@ def api_mount_points(self): MountPoint("organizations", OrganizationsTaskView.as_view()), MountPoint("verifyurl", VerifyUrlTaskView.as_view()), MountPoint("info", InfoTaskView.as_view()), - ] + ] def HomeView(self): @login_required @@ -70,8 +70,8 @@ def home(request): ds.set_string('token', None) messages.success(request, 'Settings updated.') - form = SettingsForm(initial={'username': ds.get_string('username', default=""), - 'password': ds.get_string('password', default=""), + form = SettingsForm(initial={'username': ds.get_string('username', default=""), + 'password': ds.get_string('password', default=""), 'registry_url': ds.get_string('registry_url', default="") or DEFAULT_HUB_URL}) return render(request, self.template_path("app.html"), { @@ -80,7 +80,7 @@ def home(request): }) return home - + def app_mount_points(self): return [ MountPoint("$", self.HomeView()), diff --git a/coreplugins/dronedb/public/ShareButton.jsx b/coreplugins/dronedb/public/ShareButton.jsx index 0fe74bf68..91f98dcae 100644 --- a/coreplugins/dronedb/public/ShareButton.jsx +++ b/coreplugins/dronedb/public/ShareButton.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import Storage from 'webodm/classes/Storage'; import ErrorMessage from 'webodm/components/ErrorMessage'; +import ShareDialog from './components/ShareDialog'; import $ from 'jquery'; const STATE_IDLE = 0; @@ -9,29 +9,32 @@ const STATE_RUNNING = 1; const STATE_ERROR = 2; const STATE_DONE = 3; +const DRONEDB_ASSETS = [ + 'orthophoto.tif', + 'orthophoto.png', + 'georeferenced_model.laz', + 'dtm.tif', + 'dsm.tif', + 'shots.geojson', + 'report.pdf', + 'ground_control_points.geojson' +]; + const ICON_CLASS_MAPPER = [ - // Idle - 'ddb-icon fa-fw', - // Running - 'fa fa-circle-notch fa-spin fa-fw', - // Error - 'fa fa-exclamation-triangle', - // Done - 'fas fa-external-link-alt' + 'ddb-icon fa-fw', // Idle + 'fa fa-circle-notch fa-spin fa-fw', // Running + 'fa fa-exclamation-triangle', // Error + 'fas fa-external-link-alt' // Done ]; const BUTTON_TEXT_MAPPER = [ - // Idle - 'Share to DroneDB', - // Running - 'Sharing', - // Error retry - 'Error, retry', - // Done - 'View on DroneDB' + 'Share to DroneDB', // Idle + 'Sharing', // Running + 'Error, retry', // Error + 'View on DroneDB' // Done ]; -export default class ShareButton extends React.Component{ +export default class ShareButton extends React.Component { static defaultProps = { task: null }; @@ -40,97 +43,136 @@ export default class ShareButton extends React.Component{ task: PropTypes.object.isRequired, }; - constructor(props){ + constructor(props) { super(props); - this.state = { + this.state = { taskInfo: null, error: '', - monitorTimeout: null + showDialog: false, + filesToShare: [] }; + // Instance property for timeout (not in state) + this.monitorTimeout = null; } - - componentDidMount(){ + + componentDidMount() { this.updateTaskInfo(false); } + componentWillUnmount() { + if (this.monitorTimeout) clearTimeout(this.monitorTimeout); + } + updateTaskInfo = (showErrors) => { const { task } = this.props; return $.ajax({ - type: 'GET', - url: `/api/plugins/dronedb/tasks/${task.id}/status`, - contentType: 'application/json' - }).done(taskInfo => { - this.setState({taskInfo}); - if (taskInfo.error && showErrors) this.setState({error: taskInfo.error}); - }).fail(error => { - this.setState({error: error.statusText}); + type: 'GET', + url: `/api/plugins/dronedb/tasks/${task.id}/status`, + contentType: 'application/json' + }).done(taskInfo => { + this.setState({ taskInfo }); + if (taskInfo.error && showErrors) this.setState({ error: taskInfo.error }); + }).fail(error => { + this.setState({ error: error.statusText }); + }); + }; + + getFilesToShare() { + const { task } = this.props; + const availableAssets = task.available_assets || []; + + // Filter assets that can be shared + const files = availableAssets + .filter(asset => DRONEDB_ASSETS.includes(asset)) + .map(asset => ({ + name: asset, + size: 0 // Size will be determined server-side + })); + + // Add texture files indicator if available + if (availableAssets.includes('textured_model.zip')) { + files.push({ + name: 'textured_model/*', + size: 0 }); - } + } - componentWillUnmount(){ - if (this.monitorTimeout) clearTimeout(this.monitorTimeout); + return files; } - shareToDdb = (formData) => { + shareToDdb = (options = {}) => { const { task } = this.props; + const { tag, datasetName } = options; + + this.setState({ showDialog: false }); return $.ajax({ url: `/api/plugins/dronedb/tasks/${task.id}/share`, contentType: 'application/json', + data: JSON.stringify({ tag, datasetName }), dataType: 'json', type: 'POST' - }).done(taskInfo => { - - this.setState({taskInfo}); + }).done(taskInfo => { + this.setState({ taskInfo }); this.monitorProgress(); - - }); - } + }).fail(error => { + this.setState(prevState => ({ + error: error.responseJSON?.error || 'Failed to start sharing', + taskInfo: { ...prevState.taskInfo, status: STATE_ERROR } + })); + }); + }; monitorProgress = () => { - if (this.state.taskInfo.status == STATE_RUNNING){ - // Monitor progress + if (this.state.taskInfo?.status === STATE_RUNNING) { this.monitorTimeout = setTimeout(() => { this.updateTaskInfo(true).always(this.monitorProgress); }, 3000); } - } + }; - handleClick = e => { + handleClick = () => { + const { taskInfo } = this.state; - if (this.state.taskInfo.status == STATE_IDLE || this.state.taskInfo.status == STATE_ERROR) { - this.shareToDdb(); + if (taskInfo?.status === STATE_IDLE || taskInfo?.status === STATE_ERROR) { + // Open dialog to select destination + const filesToShare = this.getFilesToShare(); + this.setState({ showDialog: true, filesToShare }); } - if (this.state.taskInfo.status == STATE_DONE){ - window.open(this.state.taskInfo.shareUrl, '_blank'); + if (taskInfo?.status === STATE_DONE) { + window.open(taskInfo.shareUrl, '_blank'); } + }; - } + handleDialogHide = () => { + this.setState({ showDialog: false }); + }; + handleShare = (options) => { + this.shareToDdb(options); + }; - render(){ - const { taskInfo, error } = this.state; + render() { + const { task } = this.props; + const { taskInfo, error, showDialog, filesToShare } = this.state; const getButtonIcon = () => { - - if (taskInfo == null) return "fa fa-circle-notch fa-spin fa-fw"; + if (taskInfo == null) return "fa fa-circle-notch fa-spin fa-fw"; if (taskInfo.error) return "fa fa-exclamation-triangle"; - return ICON_CLASS_MAPPER[taskInfo.status]; }; const getButtonLabel = () => { - if (taskInfo == null) return "Share to DroneDB"; if (taskInfo.error) return "DroneDB plugin error"; - var text = BUTTON_TEXT_MAPPER[taskInfo.status]; + let text = BUTTON_TEXT_MAPPER[taskInfo.status]; - if (taskInfo.status == STATE_RUNNING && taskInfo.uploadedSize > 0 && taskInfo.totalSize > 0) { - var progress = (taskInfo.uploadedSize / taskInfo.totalSize) * 100; + if (taskInfo.status === STATE_RUNNING && taskInfo.uploadedSize > 0 && taskInfo.totalSize > 0) { + const progress = (taskInfo.uploadedSize / taskInfo.totalSize) * 100; text += ` (${progress.toFixed(2)}%)`; } @@ -139,11 +181,29 @@ export default class ShareButton extends React.Component{ return (
- - {this.state.error &&
} + + {error && ( +
+ +
+ )} + +
); } diff --git a/coreplugins/dronedb/public/components/ShareDialog.jsx b/coreplugins/dronedb/public/components/ShareDialog.jsx new file mode 100644 index 000000000..f3f33faf2 --- /dev/null +++ b/coreplugins/dronedb/public/components/ShareDialog.jsx @@ -0,0 +1,354 @@ +import PropTypes from 'prop-types'; +import React, { Component } from "react"; +import { Modal, Button, FormGroup, FormControl, Radio } from "react-bootstrap"; +import Select from 'react-select'; +import "./ShareDialog.scss"; + +const SHARE_MODE_QUICK = 'quick'; +const SHARE_MODE_SELECT = 'select'; + +export default class ShareDialog extends Component { + static defaultProps = { + show: false, + taskName: '', + filesToShare: [] + }; + + static propTypes = { + onHide: PropTypes.func.isRequired, + onShare: PropTypes.func.isRequired, + show: PropTypes.bool.isRequired, + apiURL: PropTypes.string.isRequired, + taskName: PropTypes.string, + filesToShare: PropTypes.array + }; + + constructor(props) { + super(props); + this.state = this.getInitialState(); + } + + getInitialState() { + return { + error: "", + shareMode: SHARE_MODE_QUICK, + organizations: [], + datasets: [], + loadingOrganizations: false, + loadingDatasets: false, + selectedOrganization: null, + selectedDataset: null, + newDatasetName: '', + createNewDataset: true, + info: null + }; + } + + formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 bytes'; + const k = 1024; + const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]; + } + + componentWillUnmount() { + // Abort pending AJAX requests to prevent setState on unmounted component + if (this.infoRequest) this.infoRequest.abort(); + if (this.orgsRequest) this.orgsRequest.abort(); + if (this.datasetsRequest) this.datasetsRequest.abort(); + } + + handleOnShow = () => { + this.setState(this.getInitialState()); + this.setState({ + loadingOrganizations: true, + newDatasetName: this.props.taskName || '' + }); + + // Load user info first, then organizations to avoid race condition + this.infoRequest = $.get(`${this.props.apiURL}/info`) + .done(infoResult => { + this.setState({ info: infoResult }); + + // Load organizations after info is available + this.orgsRequest = $.get(`${this.props.apiURL}/organizations`) + .done(result => { + const orgs = result.map(org => ({ + label: org.name !== org.slug ? `${org.name} (${org.slug})` : org.slug, + value: org.slug + })); + + this.setState({ organizations: orgs, loadingOrganizations: false }); + + if (orgs.length > 0) { + // Try to find user's personal organization using infoResult directly + const userOrg = infoResult ? + orgs.find(org => org.value === infoResult.username) : null; + this.handleSelectOrganization(userOrg || orgs[0]); + } + }) + .fail((jqXHR) => { + if (jqXHR.statusText === 'abort') return; + const detail = jqXHR.responseJSON?.detail || jqXHR.statusText || 'Unknown error'; + this.setState({ + error: `Cannot load organizations: ${detail}`, + loadingOrganizations: false + }); + }); + }) + .fail((jqXHR) => { + if (jqXHR.statusText === 'abort') return; + // If info fails, still try to load organizations + this.setState({ info: null }); + this.orgsRequest = $.get(`${this.props.apiURL}/organizations`) + .done(result => { + const orgs = result.map(org => ({ + label: org.name !== org.slug ? `${org.name} (${org.slug})` : org.slug, + value: org.slug + })); + this.setState({ organizations: orgs, loadingOrganizations: false }); + if (orgs.length > 0) { + this.handleSelectOrganization(orgs[0]); + } + }) + .fail((jqXHR2) => { + if (jqXHR2.statusText === 'abort') return; + const detail = jqXHR2.responseJSON?.detail || jqXHR2.statusText || 'Unknown error'; + this.setState({ + error: `Cannot load organizations: ${detail}`, + loadingOrganizations: false + }); + }); + }); + }; + + handleSelectOrganization = (e) => { + if (!e) return; + if (this.state.selectedOrganization?.value === e.value) return; + + // Abort previous datasets request if any + if (this.datasetsRequest) this.datasetsRequest.abort(); + + this.setState({ + selectedOrganization: e, + selectedDataset: null, + datasets: [], + loadingDatasets: true + }); + + this.datasetsRequest = $.get(`${this.props.apiURL}/organizations/${e.value}/datasets`) + .done(result => { + const datasets = result.map(ds => ({ + label: ds.name !== ds.slug ? + `${ds.name} (${ds.slug})` : ds.slug, + value: ds.slug, + name: ds.name + })); + + // Add "Create new" option at the beginning + datasets.unshift({ + label: '+ Create new dataset', + value: '__new__', + isNew: true + }); + + this.setState({ + datasets, + loadingDatasets: false, + selectedDataset: datasets[0], + createNewDataset: true + }); + }) + .fail((jqXHR) => { + if (jqXHR.statusText === 'abort') return; + const detail = jqXHR.responseJSON?.detail || jqXHR.statusText || 'Unknown error'; + this.setState({ + error: `Cannot load datasets: ${detail}`, + loadingDatasets: false + }); + }); + }; + + handleSelectDataset = (e) => { + if (!e) return; + + const createNewDataset = e.value === '__new__'; + this.setState({ + selectedDataset: e, + createNewDataset + }); + }; + + handleModeChange = (mode) => { + this.setState({ shareMode: mode }); + }; + + handleNewDatasetNameChange = (e) => { + this.setState({ newDatasetName: e.target.value }); + }; + + handleSubmit = () => { + const { shareMode, selectedOrganization, selectedDataset, newDatasetName, createNewDataset } = this.state; + + let tag = null; + let datasetName = null; + + if (shareMode === SHARE_MODE_SELECT && selectedOrganization) { + if (createNewDataset) { + // Will create new dataset in selected org + // tag format: "org/" means create new dataset in org + tag = selectedOrganization.value; + datasetName = newDatasetName || this.props.taskName || null; + } else if (selectedDataset && selectedDataset.value !== '__new__') { + // Use existing dataset + tag = `${selectedOrganization.value}/${selectedDataset.value}`; + } + } + // If SHARE_MODE_QUICK, tag remains null (backend creates personal org + random dataset) + + this.props.onShare({ tag, datasetName }); + }; + + getTotalSize() { + return this.props.filesToShare.reduce((sum, f) => sum + (f.size || 0), 0); + } + + render() { + const { onHide, show, filesToShare } = this.props; + const { + shareMode, + organizations, + datasets, + loadingOrganizations, + loadingDatasets, + selectedOrganization, + selectedDataset, + createNewDataset, + newDatasetName, + error + } = this.state; + + const canShare = shareMode === SHARE_MODE_QUICK || + (shareMode === SHARE_MODE_SELECT && selectedOrganization && + (createNewDataset || (selectedDataset && selectedDataset.value !== '__new__'))); + + return ( + + + + Share to DroneDB + + + + {error && ( +
+ {error} +
+ )} + + +
+ this.handleModeChange(SHARE_MODE_QUICK)} + > + Quick share +
+ Creates a new dataset with auto-generated name in your personal space +
+
+
+
+ this.handleModeChange(SHARE_MODE_SELECT)} + disabled={organizations.length === 0 && !loadingOrganizations} + > + Choose destination +
+ Select organization and dataset +
+
+
+
+ + {shareMode === SHARE_MODE_SELECT && ( +
+ + + + + + {createNewDataset && ( + + + + + )} +
+ )} + +
+
Files to share
+
+ {filesToShare.length} files + {this.formatBytes(this.getTotalSize())} +
+
    + {filesToShare.slice(0, 5).map((f, i) => ( +
  • {f.name}
  • + ))} + {filesToShare.length > 5 && ( +
  • ...and {filesToShare.length - 5} more
  • + )} +
+
+
+ + + + +
+ ); + } +} diff --git a/coreplugins/dronedb/public/components/ShareDialog.scss b/coreplugins/dronedb/public/components/ShareDialog.scss new file mode 100644 index 000000000..2ab3167f2 --- /dev/null +++ b/coreplugins/dronedb/public/components/ShareDialog.scss @@ -0,0 +1,89 @@ +.share-dialog { + .share-mode-option { + margin-bottom: 10px; + padding: 10px; + border: 1px solid #eee; + border-radius: 4px; + + &:hover { + background-color: #f9f9f9; + } + + .help-text { + margin-left: 20px; + color: #777; + font-size: 12px; + } + + input[type="radio"] { + margin-right: 8px; + } + } + + .destination-select { + margin-top: 15px; + padding: 15px; + background: #f5f5f5; + border-radius: 4px; + + label { + font-weight: 600; + margin-bottom: 5px; + display: block; + } + + .form-group { + margin-bottom: 15px; + + &:last-child { + margin-bottom: 0; + } + } + } + + .files-summary { + margin-top: 20px; + padding: 15px; + background: #f0f7ff; + border-radius: 4px; + border: 1px solid #d0e3f7; + + h5 { + margin: 0 0 10px 0; + font-weight: 600; + } + + .files-info { + margin-bottom: 10px; + + .badge { + margin-right: 8px; + background: #337ab7; + } + } + + .files-list { + list-style: none; + padding: 0; + margin: 0; + max-height: 120px; + overflow-y: auto; + + li { + padding: 3px 0; + font-size: 12px; + color: #555; + + i { + margin-right: 5px; + color: #999; + } + + &.more { + color: #999; + font-style: italic; + } + } + } + } +}