import '../css/ProjectListItem.scss'; import React from 'react'; import update from 'immutability-helper'; import TaskList from './TaskList'; import NewTaskPanel from './NewTaskPanel'; import ImportTaskPanel from './ImportTaskPanel'; import UploadProgressBar from './UploadProgressBar'; import ErrorMessage from './ErrorMessage'; import EditProjectDialog from './EditProjectDialog'; import Dropzone from '../vendor/dropzone'; import csrf from '../django/csrf'; import HistoryNav from '../classes/HistoryNav'; import PropTypes from 'prop-types'; import ResizeModes from '../classes/ResizeModes'; import exifr from '../vendor/exifr'; import { _, interpolate } from '../classes/gettext'; import $ from 'jquery'; class ProjectListItem extends React.Component { static propTypes = { history: PropTypes.object.isRequired, data: PropTypes.object.isRequired, // project json onDelete: PropTypes.func, onTaskMoved: PropTypes.func, } constructor(props){ super(props); this.historyNav = new HistoryNav(props.history); this.state = { showTaskList: this.historyNav.isValueInQSList("project_task_open", props.data.id), upload: this.getDefaultUploadState(), error: "", data: props.data, refreshing: false, importing: false, buttons: [] }; this.toggleTaskList = this.toggleTaskList.bind(this); this.closeUploadError = this.closeUploadError.bind(this); this.cancelUpload = this.cancelUpload.bind(this); this.handleTaskSaved = this.handleTaskSaved.bind(this); this.viewMap = this.viewMap.bind(this); this.handleDelete = this.handleDelete.bind(this); this.handleEditProject = this.handleEditProject.bind(this); this.updateProject = this.updateProject.bind(this); this.taskDeleted = this.taskDeleted.bind(this); this.taskMoved = this.taskMoved.bind(this); this.hasPermission = this.hasPermission.bind(this); } refresh(){ // Update project information based on server this.setState({refreshing: true}); this.refreshRequest = $.getJSON(`/api/projects/${this.state.data.id}/`) .done((json) => { this.setState({data: json}); }) .fail((_, __, e) => { this.setState({error: e.message}); }) .always(() => { this.setState({refreshing: false}); }); } componentWillUnmount(){ if (this.deleteProjectRequest) this.deleteProjectRequest.abort(); if (this.refreshRequest) this.refreshRequest.abort(); } getDefaultUploadState(){ return { uploading: false, editing: false, error: "", progress: 0, files: [], totalCount: 0, uploadedCount: 0, totalBytes: 0, totalBytesSent: 0, lastUpdated: 0 }; } resetUploadState(){ this.setUploadState(this.getDefaultUploadState()); } setUploadState(props){ this.setState(update(this.state, { upload: { $merge: props } })); } hasPermission(perm){ return this.state.data.permissions.indexOf(perm) !== -1; } componentDidMount(){ Dropzone.autoDiscover = false; if (this.hasPermission("add")){ this.dz = new Dropzone(this.dropzone, { paramName: "images", url : 'TO_BE_CHANGED', parallelUploads: 6, uploadMultiple: false, acceptedFiles: "image/*,text/*", autoProcessQueue: false, createImageThumbnails: false, clickable: this.uploadButton, chunkSize: 2147483647, timeout: 2147483647, headers: { [csrf.header]: csrf.token } }); this.dz.on("addedfiles", files => { let totalBytes = 0; for (let i = 0; i < files.length; i++){ totalBytes += files[i].size; files[i].deltaBytesSent = 0; files[i].trackedBytesSent = 0; files[i].retries = 0; } this.setUploadState({ editing: true, totalCount: this.state.upload.totalCount + files.length, files, totalBytes: this.state.upload.totalBytes + totalBytes }); }) .on("uploadprogress", (file, progress, bytesSent) => { const now = new Date().getTime(); if (bytesSent > file.size) bytesSent = file.size; if (progress === 100 || now - this.state.upload.lastUpdated > 500){ const deltaBytesSent = bytesSent - file.deltaBytesSent; file.trackedBytesSent += deltaBytesSent; const totalBytesSent = this.state.upload.totalBytesSent + deltaBytesSent; const progress = totalBytesSent / this.state.upload.totalBytes * 100; this.setUploadState({ progress, totalBytesSent, lastUpdated: now }); file.deltaBytesSent = bytesSent; } }) .on("complete", (file) => { // Retry const retry = () => { const MAX_RETRIES = 10; if (file.retries < MAX_RETRIES){ // Update progress const totalBytesSent = this.state.upload.totalBytesSent - file.trackedBytesSent; const progress = totalBytesSent / this.state.upload.totalBytes * 100; this.setUploadState({ progress, totalBytesSent, }); file.status = Dropzone.QUEUED; file.deltaBytesSent = 0; file.trackedBytesSent = 0; file.retries++; this.dz.processQueue(); }else{ throw new Error(interpolate(_('Cannot upload %(filename)s, exceeded max retries (%(max_retries)s)'), {filename: file.name, max_retries: MAX_RETRIES})); } }; try{ if (file.status === "error"){ retry(); }else{ // Check response let response = JSON.parse(file.xhr.response); if (response.success){ // Update progress by removing the tracked progress and // use the file size as the true number of bytes let totalBytesSent = this.state.upload.totalBytesSent + file.size; if (file.trackedBytesSent) totalBytesSent -= file.trackedBytesSent; const progress = totalBytesSent / this.state.upload.totalBytes * 100; this.setUploadState({ progress, totalBytesSent, uploadedCount: this.state.upload.uploadedCount + 1 }); this.dz.processQueue(); }else{ retry(); } } }catch(e){ this.setUploadState({error: `${e.message}`, uploading: false}); this.dz.cancelUpload(); } }) .on("queuecomplete", () => { const remainingFilesCount = this.state.upload.totalCount - this.state.upload.uploadedCount; if (remainingFilesCount === 0){ // All files have uploaded! this.setUploadState({uploading: false}); $.ajax({ url: `/api/projects/${this.state.data.id}/tasks/${this.dz._taskInfo.id}/commit/`, contentType: 'application/json', dataType: 'json', type: 'POST' }).done((task) => { if (task && task.id){ this.newTaskAdded(); }else{ this.setUploadState({error: interpolate(_('Cannot create new task. Invalid response from server: %(error)s'), { error: JSON.stringify(task) }) }); } }).fail(() => { this.setUploadState({error: _("Cannot create new task. Please try again later.")}); }); }else if (this.dz.getQueuedFiles() === 0){ // Done but didn't upload all? this.setUploadState({ totalCount: this.state.upload.totalCount - remainingFilesCount, uploading: false, error: interpolate(_('%(count)s files cannot be uploaded. As a reminder, only images (.jpg, .tif, .png) and GCP files (.txt) can be uploaded. Try again.'), { count: remainingFilesCount }) }); } }) .on("reset", () => { this.resetUploadState(); }) .on("dragenter", () => { if (!this.state.upload.editing){ this.resetUploadState(); } }); } PluginsAPI.Dashboard.triggerAddNewTaskButton({projectId: this.state.data.id, onNewTaskAdded: this.newTaskAdded}, (button) => { if (!button) return; this.setState(update(this.state, { buttons: {$push: [button]} })); }); } newTaskAdded = () => { this.setState({importing: false}); if (this.state.showTaskList){ this.taskList.refresh(); }else{ this.setState({showTaskList: true}); } this.resetUploadState(); this.refresh(); } setRef(prop){ return (domNode) => { if (domNode != null) this[prop] = domNode; } } toggleTaskList(){ const showTaskList = !this.state.showTaskList; this.historyNav.toggleQSListItem("project_task_open", this.state.data.id, showTaskList); this.setState({ showTaskList: showTaskList }); } closeUploadError(){ this.setUploadState({error: ""}); } cancelUpload(e){ this.dz.removeAllFiles(true); } taskDeleted(){ this.refresh(); } taskMoved(task){ this.refresh(); if (this.props.onTaskMoved) this.props.onTaskMoved(task); } handleDelete(){ return $.ajax({ url: `/api/projects/${this.state.data.id}/`, type: 'DELETE' }).done(() => { if (this.props.onDelete) this.props.onDelete(this.state.data.id); }); } handleTaskSaved(taskInfo){ this.dz._taskInfo = taskInfo; // Allow us to access the task info from dz this.setUploadState({uploading: true, editing: false}); // Create task const formData = { name: taskInfo.name, options: taskInfo.options, processing_node: taskInfo.selectedNode.id, auto_processing_node: taskInfo.selectedNode.key == "auto", partial: true }; if (taskInfo.resizeMode === ResizeModes.YES){ formData.resize_to = taskInfo.resizeSize; } $.ajax({ url: `/api/projects/${this.state.data.id}/tasks/`, contentType: 'application/json', data: JSON.stringify(formData), dataType: 'json', type: 'POST' }).done((task) => { if (task && task.id){ this.dz._taskInfo.id = task.id; this.dz.options.url = `/api/projects/${this.state.data.id}/tasks/${task.id}/upload/`; this.dz.processQueue(); }else{ this.setState({error: interpolate(_('Cannot create new task. Invalid response from server: %(error)s'), { error: JSON.stringify(task) }) }); this.handleTaskCanceled(); } }).fail(() => { this.setState({error: _("Cannot create new task. Please try again later.")}); this.handleTaskCanceled(); }); } handleTaskCanceled = () => { this.dz.removeAllFiles(true); this.resetUploadState(); } handleUpload = () => { // Not a second click for adding more files? if (!this.state.upload.editing){ this.handleTaskCanceled(); } } handleEditProject(){ this.editProjectDialog.show(); } updateProject(project){ return $.ajax({ url: `/api/projects/${this.state.data.id}/`, contentType: 'application/json', data: JSON.stringify({ name: project.name, description: project.descr, }), dataType: 'json', type: 'PATCH' }).done(() => { this.refresh(); }); } viewMap(){ location.href = `/map/project/${this.state.data.id}/`; } handleImportTask = () => { this.setState({importing: true}); } handleCancelImportTask = () => { this.setState({importing: false}); } handleTaskTitleHint = () => { return new Promise((resolve, reject) => { if (this.state.upload.files.length > 0){ // Find first image in list let f = null; for (let i = 0; i < this.state.upload.files.length; i++){ if (this.state.upload.files[i].type.indexOf("image") === 0){ f = this.state.upload.files[i]; break; } } if (!f){ reject(); return; } // Parse EXIF const options = { ifd0: false, exif: [0x9003], gps: [0x0001, 0x0002, 0x0003, 0x0004], interop: false, ifd1: false // thumbnail }; exifr.parse(f, options).then(gps => { if (!gps.latitude || !gps.longitude){ reject(); return; } let dateTime = gps["36867"]; // Try to parse the date from EXIF to JS const parts = dateTime.split(" "); if (parts.length == 2){ let [ d, t ] = parts; d = d.replace(/:/g, "-"); const tm = Date.parse(`${d} ${t}`); if (!isNaN(tm)){ dateTime = new Date(tm).toLocaleDateString(); } } // Fallback to file modified date if // no exif info is available if (!dateTime) dateTime = f.lastModifiedDate.toLocaleDateString(); // Query nominatim OSM $.ajax({ url: `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${gps.latitude}&lon=${gps.longitude}`, contentType: 'application/json', type: 'GET' }).done(json => { if (json.name) resolve(`${json.name} - ${dateTime}`); else if (json.address && json.address.road) resolve(`${json.address.road} - ${dateTime}`); else reject(new Error("Invalid json")); }).fail(reject); }).catch(reject); } }); } render() { const { refreshing, data } = this.state; const numTasks = data.tasks.length; return (
  • { this.editProjectDialog = domNode; }} title={_("Edit Project")} saveLabel={_("Save Changes")} savingLabel={_("Saving changes...")} saveIcon="far fa-edit" projectName={data.name} projectDescr={data.description} saveAction={this.updateProject} deleteAction={this.hasPermission("delete") ? this.handleDelete : undefined} />
    {this.hasPermission("add") ?
    {this.state.buttons.map((button, i) => {button})}
    : ""}
    {data.name}
    {data.description}
    {numTasks > 0 ? {interpolate(_("%(count)s Tasks"), { count: numTasks})} : ""} {_("Edit")}
    {this.state.upload.uploading ? : ""} {this.state.upload.error !== "" ?
    {this.state.upload.error}
    : ""} {this.state.upload.editing ? this.state.upload.files } /> : ""} {this.state.importing ? : ""} {this.state.showTaskList ? : ""}
  • ); } } export default ProjectListItem;