From 0e14241fcfa907ed3c906701e2dd29c4cd65ee21 Mon Sep 17 00:00:00 2001 From: Quincy Morgan Date: Wed, 5 Feb 2020 15:28:23 -0500 Subject: [PATCH] Move some upload code from modeSave to new coreUploader object (re: #7247) --- modules/core/context.js | 8 + modules/core/index.js | 1 + modules/core/uploader.js | 381 +++++++++++++++++++++++++++++++++++++++ modules/modes/save.js | 355 +++--------------------------------- modules/ui/commit.js | 4 +- modules/ui/init.js | 15 ++ 6 files changed, 431 insertions(+), 333 deletions(-) create mode 100644 modules/core/uploader.js diff --git a/modules/core/context.js b/modules/core/context.js index 5a54985a1..e33c9529b 100644 --- a/modules/core/context.js +++ b/modules/core/context.js @@ -9,6 +9,7 @@ import { t, currentLocale, addTranslation, setLocale } from '../util/locale'; import { coreData } from './data'; import { coreHistory } from './history'; import { coreValidator } from './validator'; +import { coreUploader } from './uploader'; import { dataLocales, dataEn } from '../../data'; import { geoRawMercator } from '../geo/raw_mercator'; import { modeSelect } from '../modes/select'; @@ -96,10 +97,12 @@ export function coreContext() { let _data; let _history; let _validator; + let _uploader; context.connection = () => _connection; context.data = () => _data; context.history = () => _history; context.validator = () => _validator; + context.uploader = () => _uploader; /* Connection */ context.preauth = (options) => { @@ -466,6 +469,10 @@ export function coreContext() { _validator.reset(); _features.reset(); _history.reset(); + _uploader.reset(); + + // don't leave stale state in the inspector + d3_select('.inspector-wrap *').remove(); return context; }; @@ -484,6 +491,7 @@ export function coreContext() { _data = coreData(context); _history = coreHistory(context); _validator = coreValidator(context); + _uploader = coreUploader(context); context.graph = _history.graph; context.changes = _history.changes; diff --git a/modules/core/index.js b/modules/core/index.js index 597d97589..ed3748884 100644 --- a/modules/core/index.js +++ b/modules/core/index.js @@ -4,4 +4,5 @@ export { coreDifference } from './difference'; export { coreGraph } from './graph'; export { coreHistory } from './history'; export { coreTree } from './tree'; +export { coreUploader } from './uploader'; export { coreValidator } from './validator'; diff --git a/modules/core/uploader.js b/modules/core/uploader.js new file mode 100644 index 000000000..9b2c9354a --- /dev/null +++ b/modules/core/uploader.js @@ -0,0 +1,381 @@ +import { dispatch as d3_dispatch } from 'd3-dispatch'; + +import { actionDiscardTags } from '../actions/discard_tags'; +import { actionMergeRemoteChanges } from '../actions/merge_remote_changes'; +import { actionNoop } from '../actions/noop'; +import { actionRevert } from '../actions/revert'; +import { coreGraph } from '../core/graph'; +import { t } from '../util/locale'; +import { utilArrayUnion, utilArrayUniq, utilDisplayName, utilDisplayType, utilRebind } from '../util'; + + +export function coreUploader(context) { + + var dispatch = d3_dispatch( + // Start and end events are dispatched exactly once each per legitimate outside call to `save` + 'saveStarted', // dispatched as soon as a call to `save` has been deemed legitimate + 'saveEnded', // dispatched after the result event has been dispatched + + 'willAttemptUpload', // dispatched before the actual upload call occurs, if it will + 'progressChanged', + + // Each save results in one of these outcomes: + 'resultNoChanges', // upload wasn't attempted since there were no edits + 'resultErrors', // upload failed due to errors + 'resultConflicts', // upload failed due to data conflicts + 'resultSuccess' // upload completed without errors + ); + + var _isSaving = false; + + var _toCheck = []; + var _toLoad = []; + var _loaded = {}; + var _toLoadCount = 0; + var _toLoadTotal = 0; + + var _conflicts = []; + var _errors = []; + var _origChanges; + + var _discardTags = {}; + context.data().get('discarded') + .then(function(d) { _discardTags = d; }) + .catch(function() { /* ignore */ }); + + var uploader = utilRebind({}, dispatch, 'on'); + + uploader.isSaving = function() { + return _isSaving; + }; + + uploader.save = function(changeset, tryAgain, checkConflicts) { + // Guard against accidentally entering save code twice - #4641 + if (_isSaving && !tryAgain) { + return; + } + + var osm = context.connection(); + if (!osm) { + return; + } + + // If user somehow got logged out mid-save, try to reauthenticate.. + // This can happen if they were logged in from before, but the tokens are no longer valid. + if (!osm.authenticated()) { + osm.authenticate(function(err) { + if (!err) { + uploader.save(changeset, tryAgain, checkConflicts); // continue where we left off.. + } + }); + return; + } + + if (!_isSaving) { + _isSaving = true; + dispatch.call('saveStarted', this); + } + + var history = context.history(); + var localGraph = context.graph(); + var remoteGraph = coreGraph(history.base(), true); + + _conflicts = []; + _errors = []; + + // Store original changes, in case user wants to download them as an .osc file + _origChanges = history.changes(actionDiscardTags(history.difference(), _discardTags)); + + // First time, `history.perform` a no-op action. + // Any conflict resolutions will be done as `history.replace` + if (!tryAgain) { + history.perform(actionNoop()); + } + + // Attempt a fast upload.. If there are conflicts, re-enter with `checkConflicts = true` + if (!checkConflicts) { + upload(changeset); + + // Do the full (slow) conflict check.. + } else { + var summary = history.difference().summary(); + _toCheck = []; + for (var i = 0; i < summary.length; i++) { + var item = summary[i]; + if (item.changeType === 'modified') { + _toCheck.push(item.entity.id); + } + } + + _toLoad = withChildNodes(_toCheck, localGraph); + _loaded = {}; + _toLoadCount = 0; + _toLoadTotal = _toLoad.length; + + if (_toCheck.length) { + dispatch.call('progressChanged', this, _toLoadCount, _toLoadTotal); + _toLoad.forEach(function(id) { _loaded[id] = false; }); + osm.loadMultiple(_toLoad, loaded); + } else { + upload(changeset); + } + } + + return; + + + function withChildNodes(ids, graph) { + var s = new Set(ids); + ids.forEach(function(id) { + var entity = graph.entity(id); + if (entity.type !== 'way') return; + + graph.childNodes(entity).forEach(function(child) { + if (child.version !== undefined) { + s.add(child.id); + } + }); + }); + + return Array.from(s); + } + + + // Reload modified entities into an alternate graph and check for conflicts.. + function loaded(err, result) { + if (_errors.length) return; + + if (err) { + _errors.push({ + msg: err.message || err.responseText, + details: [ t('save.status_code', { code: err.status }) ] + }); + hasErrors(); + + } else { + var loadMore = []; + + result.data.forEach(function(entity) { + remoteGraph.replace(entity); + _loaded[entity.id] = true; + _toLoad = _toLoad.filter(function(val) { return val !== entity.id; }); + + if (!entity.visible) return; + + // Because loadMultiple doesn't download /full like loadEntity, + // need to also load children that aren't already being checked.. + var i, id; + if (entity.type === 'way') { + for (i = 0; i < entity.nodes.length; i++) { + id = entity.nodes[i]; + if (_loaded[id] === undefined) { + _loaded[id] = false; + loadMore.push(id); + } + } + } else if (entity.type === 'relation' && entity.isMultipolygon()) { + for (i = 0; i < entity.members.length; i++) { + id = entity.members[i].id; + if (_loaded[id] === undefined) { + _loaded[id] = false; + loadMore.push(id); + } + } + } + }); + + _toLoadCount += result.data.length; + _toLoadTotal += loadMore.length; + dispatch.call('progressChanged', this, _toLoadCount, _toLoadTotal); + + if (loadMore.length) { + _toLoad.push.apply(_toLoad, loadMore); + osm.loadMultiple(loadMore, loaded); + } + + if (!_toLoad.length) { + detectConflicts(); + } + } + } + + + function detectConflicts() { + function choice(id, text, action) { + return { + id: id, + text: text, + action: function() { + history.replace(action); + } + }; + } + function formatUser(d) { + return '' + d + ''; + } + function entityName(entity) { + return utilDisplayName(entity) || (utilDisplayType(entity.id) + ' ' + entity.id); + } + + function sameVersions(local, remote) { + if (local.version !== remote.version) return false; + + if (local.type === 'way') { + var children = utilArrayUnion(local.nodes, remote.nodes); + for (var i = 0; i < children.length; i++) { + var a = localGraph.hasEntity(children[i]); + var b = remoteGraph.hasEntity(children[i]); + if (a && b && a.version !== b.version) return false; + } + } + + return true; + } + + _toCheck.forEach(function(id) { + var local = localGraph.entity(id); + var remote = remoteGraph.entity(id); + + if (sameVersions(local, remote)) return; + + var merge = actionMergeRemoteChanges(id, localGraph, remoteGraph, _discardTags, formatUser); + + history.replace(merge); + + var mergeConflicts = merge.conflicts(); + if (!mergeConflicts.length) return; // merged safely + + var forceLocal = actionMergeRemoteChanges(id, localGraph, remoteGraph, _discardTags).withOption('force_local'); + var forceRemote = actionMergeRemoteChanges(id, localGraph, remoteGraph, _discardTags).withOption('force_remote'); + var keepMine = t('save.conflict.' + (remote.visible ? 'keep_local' : 'restore')); + var keepTheirs = t('save.conflict.' + (remote.visible ? 'keep_remote' : 'delete')); + + _conflicts.push({ + id: id, + name: entityName(local), + details: mergeConflicts, + chosen: 1, + choices: [ + choice(id, keepMine, forceLocal), + choice(id, keepTheirs, forceRemote) + ] + }); + }); + + upload(changeset); + } + }; + + + function upload(changeset) { + var osm = context.connection(); + if (!osm) { + _errors.push({ msg: 'No OSM Service' }); + } + + if (_conflicts.length) { + + _conflicts.sort(function(a, b) { return b.id.localeCompare(a.id); }); + + dispatch.call('resultConflicts', this, changeset, _conflicts, _origChanges); + + endSave(); + + } else if (_errors.length) { + hasErrors(); + + } else { + var history = context.history(); + var changes = history.changes(actionDiscardTags(history.difference(), _discardTags)); + if (changes.modified.length || changes.created.length || changes.deleted.length) { + // fire off some async work that we want to be ready later + dispatch.call('willAttemptUpload', this); + osm.putChangeset(changeset, changes, uploadCallback); + } else { // changes were insignificant or reverted by user + + dispatch.call('resultNoChanges', this); + + endSave(); + + // reset iD + context.flush(); + } + } + } + + + function uploadCallback(err, changeset) { + if (err) { + if (err.status === 409) { // 409 Conflict + uploader.save(changeset, true, true); // tryAgain = true, checkConflicts = true + } else { + _errors.push({ + msg: err.message || err.responseText, + details: [ t('save.status_code', { code: err.status }) ] + }); + hasErrors(); + } + + } else { + context.history().clearSaved(); + dispatch.call('resultSuccess', this, changeset); + // Add delay to allow for postgres replication #1646 #2678 + window.setTimeout(function() { + endSave(); + + // reset iD + context.flush(); + }, 2500); + } + } + + + function hasErrors() { + + context.history().pop(); + + dispatch.call('resultErrors', this, _errors); + + endSave(); + } + + + function endSave() { + _isSaving = false; + + dispatch.call('saveEnded', this); + } + + + uploader.cancelConflictResolution = function() { + context.history().pop(); + }; + + + uploader.processResolvedConflicts = function(changeset) { + var history = context.history(); + + for (var i = 0; i < _conflicts.length; i++) { + if (_conflicts[i].chosen === 1) { // user chose "keep theirs" + var entity = context.hasEntity(_conflicts[i].id); + if (entity && entity.type === 'way') { + var children = utilArrayUniq(entity.nodes); + for (var j = 0; j < children.length; j++) { + history.replace(actionRevert(children[j])); + } + } + history.replace(actionRevert(_conflicts[i].id)); + } + } + + uploader.save(changeset, true, false); // tryAgain = true, checkConflicts = false + }; + + + uploader.reset = function() { + + }; + + + return uploader; +} diff --git a/modules/modes/save.js b/modules/modes/save.js index b80dc30c1..8fe5ee7e4 100644 --- a/modules/modes/save.js +++ b/modules/modes/save.js @@ -1,53 +1,38 @@ import { event as d3_event, select as d3_select } from 'd3-selection'; import { t } from '../util/locale'; -import { actionDiscardTags } from '../actions/discard_tags'; -import { actionMergeRemoteChanges } from '../actions/merge_remote_changes'; -import { actionNoop } from '../actions/noop'; -import { actionRevert } from '../actions/revert'; -import { coreGraph } from '../core/graph'; import { modeBrowse } from './browse'; import { modeSelect } from './select'; import { services } from '../services'; import { uiConflicts } from '../ui/conflicts'; import { uiConfirm } from '../ui/confirm'; import { uiCommit } from '../ui/commit'; -import { uiLoading } from '../ui/loading'; import { uiSuccess } from '../ui/success'; -import { utilArrayUnion, utilArrayUniq, utilDisplayName, utilDisplayType, utilKeybinding } from '../util'; - - -var _isSaving = false; +import { utilKeybinding } from '../util'; export function modeSave(context) { var mode = { id: 'save' }; var keybinding = utilKeybinding('modeSave'); - var loading = uiLoading(context) - .message(t('save.uploading')) - .blocking(true); - var commit = uiCommit(context) - .on('cancel', cancel) - .on('save', save); + .on('cancel', cancel); - var _toCheck = []; - var _toLoad = []; - var _loaded = {}; - var _toLoadCount = 0; - var _toLoadTotal = 0; - - var _conflicts = []; - var _errors = []; - var _origChanges; var _location; var _success; - var _discardTags = {}; - context.data().get('discarded') - .then(function(d) { _discardTags = d; }) - .catch(function() { /* ignore */ }); + var uploader = context.uploader() + .on('saveStarted.modeSave', function() { + keybindingOff(); + }) + .on('willAttemptUpload.modeSave', prepareForSuccess) + .on('progressChanged.modeSave', showProgress) + .on('resultNoChanges.modeSave', function() { + cancel(); + }) + .on('resultErrors.modeSave', showErrors) + .on('resultConflicts.modeSave', showConflicts) + .on('resultSuccess.modeSave', showSuccess); function cancel(selectedID) { @@ -59,280 +44,6 @@ export function modeSave(context) { } - function save(changeset, tryAgain, checkConflicts) { - // Guard against accidentally entering save code twice - #4641 - if (_isSaving && !tryAgain) { - return; - } - - var osm = context.connection(); - if (!osm) { - cancel(); - return; - } - - // If user somehow got logged out mid-save, try to reauthenticate.. - // This can happen if they were logged in from before, but the tokens are no longer valid. - if (!osm.authenticated()) { - osm.authenticate(function(err) { - if (err) { - cancel(); // quit save mode.. - } else { - save(changeset, tryAgain, checkConflicts); // continue where we left off.. - } - }); - return; - } - - if (!_isSaving) { - keybindingOff(); - context.container().call(loading); // block input - _isSaving = true; - } - - var history = context.history(); - var localGraph = context.graph(); - var remoteGraph = coreGraph(history.base(), true); - - _conflicts = []; - _errors = []; - - // Store original changes, in case user wants to download them as an .osc file - _origChanges = history.changes(actionDiscardTags(history.difference(), _discardTags)); - - // First time, `history.perform` a no-op action. - // Any conflict resolutions will be done as `history.replace` - if (!tryAgain) { - history.perform(actionNoop()); - } - - // Attempt a fast upload.. If there are conflicts, re-enter with `checkConflicts = true` - if (!checkConflicts) { - upload(changeset); - - // Do the full (slow) conflict check.. - } else { - var summary = history.difference().summary(); - _toCheck = []; - for (var i = 0; i < summary.length; i++) { - var item = summary[i]; - if (item.changeType === 'modified') { - _toCheck.push(item.entity.id); - } - } - - _toLoad = withChildNodes(_toCheck, localGraph); - _loaded = {}; - _toLoadCount = 0; - _toLoadTotal = _toLoad.length; - - if (_toCheck.length) { - showProgress(_toLoadCount, _toLoadTotal); - _toLoad.forEach(function(id) { _loaded[id] = false; }); - osm.loadMultiple(_toLoad, loaded); - } else { - upload(changeset); - } - } - - return; - - - function withChildNodes(ids, graph) { - var s = new Set(ids); - ids.forEach(function(id) { - var entity = graph.entity(id); - if (entity.type !== 'way') return; - - graph.childNodes(entity).forEach(function(child) { - if (child.version !== undefined) { - s.add(child.id); - } - }); - }); - - return Array.from(s); - } - - - // Reload modified entities into an alternate graph and check for conflicts.. - function loaded(err, result) { - if (_errors.length) return; - - if (err) { - _errors.push({ - msg: err.message || err.responseText, - details: [ t('save.status_code', { code: err.status }) ] - }); - showErrors(); - - } else { - var loadMore = []; - - result.data.forEach(function(entity) { - remoteGraph.replace(entity); - _loaded[entity.id] = true; - _toLoad = _toLoad.filter(function(val) { return val !== entity.id; }); - - if (!entity.visible) return; - - // Because loadMultiple doesn't download /full like loadEntity, - // need to also load children that aren't already being checked.. - var i, id; - if (entity.type === 'way') { - for (i = 0; i < entity.nodes.length; i++) { - id = entity.nodes[i]; - if (_loaded[id] === undefined) { - _loaded[id] = false; - loadMore.push(id); - } - } - } else if (entity.type === 'relation' && entity.isMultipolygon()) { - for (i = 0; i < entity.members.length; i++) { - id = entity.members[i].id; - if (_loaded[id] === undefined) { - _loaded[id] = false; - loadMore.push(id); - } - } - } - }); - - _toLoadCount += result.data.length; - _toLoadTotal += loadMore.length; - showProgress(_toLoadCount, _toLoadTotal); - - if (loadMore.length) { - _toLoad.push.apply(_toLoad, loadMore); - osm.loadMultiple(loadMore, loaded); - } - - if (!_toLoad.length) { - detectConflicts(); - } - } - } - - - function detectConflicts() { - function choice(id, text, action) { - return { id: id, text: text, action: function() { history.replace(action); } }; - } - function formatUser(d) { - return '' + d + ''; - } - function entityName(entity) { - return utilDisplayName(entity) || (utilDisplayType(entity.id) + ' ' + entity.id); - } - - function sameVersions(local, remote) { - if (local.version !== remote.version) return false; - - if (local.type === 'way') { - var children = utilArrayUnion(local.nodes, remote.nodes); - for (var i = 0; i < children.length; i++) { - var a = localGraph.hasEntity(children[i]); - var b = remoteGraph.hasEntity(children[i]); - if (a && b && a.version !== b.version) return false; - } - } - - return true; - } - - _toCheck.forEach(function(id) { - var local = localGraph.entity(id); - var remote = remoteGraph.entity(id); - - if (sameVersions(local, remote)) return; - - var action = actionMergeRemoteChanges; - var merge = action(id, localGraph, remoteGraph, _discardTags, formatUser); - - history.replace(merge); - - var mergeConflicts = merge.conflicts(); - if (!mergeConflicts.length) return; // merged safely - - var forceLocal = action(id, localGraph, remoteGraph, _discardTags).withOption('force_local'); - var forceRemote = action(id, localGraph, remoteGraph, _discardTags).withOption('force_remote'); - var keepMine = t('save.conflict.' + (remote.visible ? 'keep_local' : 'restore')); - var keepTheirs = t('save.conflict.' + (remote.visible ? 'keep_remote' : 'delete')); - - _conflicts.push({ - id: id, - name: entityName(local), - details: mergeConflicts, - chosen: 1, - choices: [ - choice(id, keepMine, forceLocal), - choice(id, keepTheirs, forceRemote) - ] - }); - }); - - upload(changeset); - } - } - - - function upload(changeset) { - var osm = context.connection(); - if (!osm) { - _errors.push({ msg: 'No OSM Service' }); - } - - if (_conflicts.length) { - _conflicts.sort(function(a, b) { return b.id.localeCompare(a.id); }); - showConflicts(changeset); - - } else if (_errors.length) { - showErrors(); - - } else { - var history = context.history(); - var changes = history.changes(actionDiscardTags(history.difference(), _discardTags)); - if (changes.modified.length || changes.created.length || changes.deleted.length) { - // fire off some async work that we want to be ready later - prepareForSuccess(); // geocode current location and load community index - osm.putChangeset(changeset, changes, uploadCallback); - } else { // changes were insignificant or reverted by user - d3_select('.inspector-wrap *').remove(); - loading.close(); - _isSaving = false; - context.flush(); - cancel(); - } - } - } - - - function uploadCallback(err, changeset) { - if (err) { - if (err.status === 409) { // 409 Conflict - save(changeset, true, true); // tryAgain = true, checkConflicts = true - } else { - _errors.push({ - msg: err.message || err.responseText, - details: [ t('save.status_code', { code: err.status }) ] - }); - showErrors(); - } - - } else { - context.history().clearSaved(); - showSuccess(changeset); - // Add delay to allow for postgres replication #1646 #2678 - window.setTimeout(function() { - d3_select('.inspector-wrap *').remove(); - loading.close(); - _isSaving = false; - context.flush(); - }, 2500); - } - } - - function showProgress(num, total) { var modal = context.container().select('.loading-modal .modal-section'); var progress = modal.selectAll('.progress') @@ -347,51 +58,34 @@ export function modeSave(context) { } - function showConflicts(changeset) { - var history = context.history(); + function showConflicts(changeset, conflicts, origChanges) { + var selection = context.container() .select('#sidebar') .append('div') .attr('class','sidebar-component'); - loading.close(); - _isSaving = false; - var ui = uiConflicts(context) - .conflictList(_conflicts) - .origChanges(_origChanges) + .conflictList(conflicts) + .origChanges(origChanges) .on('cancel', function() { - history.pop(); selection.remove(); keybindingOn(); + + uploader.cancelConflictResolution(); }) .on('save', function() { - for (var i = 0; i < _conflicts.length; i++) { - if (_conflicts[i].chosen === 1) { // user chose "keep theirs" - var entity = context.hasEntity(_conflicts[i].id); - if (entity && entity.type === 'way') { - var children = utilArrayUniq(entity.nodes); - for (var j = 0; j < children.length; j++) { - history.replace(actionRevert(children[j])); - } - } - history.replace(actionRevert(_conflicts[i].id)); - } - } - selection.remove(); - save(changeset, true, false); // tryAgain = true, checkConflicts = false + + uploader.processResolvedConflicts(changeset); }); selection.call(ui); } - function showErrors() { + function showErrors(errors) { keybindingOn(); - context.history().pop(); - loading.close(); - _isSaving = false; var selection = uiConfirm(context.container()); selection @@ -399,7 +93,7 @@ export function modeSave(context) { .append('h3') .text(t('save.error')); - addErrors(selection, _errors); + addErrors(selection, errors); selection.okButton(); } @@ -533,7 +227,6 @@ export function modeSave(context) { mode.exit = function() { - _isSaving = false; keybindingOff(); diff --git a/modules/ui/commit.js b/modules/ui/commit.js index dc2af6da2..3adf1cabb 100644 --- a/modules/ui/commit.js +++ b/modules/ui/commit.js @@ -38,7 +38,7 @@ var hashtagRegex = /(#[^\u2000-\u206F\u2E00-\u2E7F\s\\'!"#$%()*,.\/:;<=>?@\[\]^` export function uiCommit(context) { - var dispatch = d3_dispatch('cancel', 'save'); + var dispatch = d3_dispatch('cancel'); var _userDetails; var _selection; @@ -372,7 +372,7 @@ export function uiCommit(context) { .on('click.save', function() { if (!d3_select(this).classed('disabled')) { this.blur(); // avoid keeping focus on the button - #4641 - dispatch.call('save', this, _changeset); + context.uploader().save(_changeset); } }); diff --git a/modules/ui/init.js b/modules/ui/init.js index dce38af71..3aa7dd7c8 100644 --- a/modules/ui/init.js +++ b/modules/ui/init.js @@ -484,5 +484,20 @@ export function uiInit(context) { } }; + + var _saveLoading = d3_select(null); + + context.uploader() + .on('saveStarted.ui', function() { + _saveLoading = uiLoading(context) + .message(t('save.uploading')) + .blocking(true); + context.container().call(_saveLoading); // block input during upload + }) + .on('saveEnded.ui', function() { + _saveLoading.close(); + _saveLoading = d3_select(null); + }); + return ui; }