Move some upload code from modeSave to new coreUploader object (re: #7247)

This commit is contained in:
Quincy Morgan
2020-02-05 15:28:23 -05:00
parent 4194194e14
commit 0e14241fcf
6 changed files with 431 additions and 333 deletions

View File

@@ -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;

View File

@@ -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';

381
modules/core/uploader.js Normal file
View File

@@ -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 '<a href="' + osm.userURL(d) + '" target="_blank">' + d + '</a>';
}
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;
}

View File

@@ -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 '<a href="' + osm.userURL(d) + '" target="_blank">' + d + '</a>';
}
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();

View File

@@ -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);
}
});

View File

@@ -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;
}