import _clone from 'lodash-es/clone'; import _filter from 'lodash-es/filter'; import _map from 'lodash-es/map'; import _reduce from 'lodash-es/reduce'; import _union from 'lodash-es/union'; import _uniq from 'lodash-es/uniq'; import _without from 'lodash-es/without'; import { event as d3_event, select as d3_select } from 'd3-selection'; import { t } from '../util/locale'; import { actionDiscardTags, actionMergeRemoteChanges, actionNoop, actionRevert } from '../actions'; import { coreGraph } from '../core'; import { modeBrowse, modeSelect } from './index'; import { services } from '../services'; import { uiConflicts, uiConfirm, uiCommit, uiLoading, uiSuccess } from '../ui'; import { utilDisplayName, utilDisplayType, utilKeybinding } from '../util'; var _isSaving = false; 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); var _toCheck = []; var _toLoad = []; var _loaded = {}; var _toLoadCount = 0; var _toLoadTotal = 0; var _conflicts = []; var _errors = []; var _origChanges; var _location; function cancel(selectedID) { if (selectedID) { context.enter(modeSelect(context, [selectedID])); } else { context.enter(modeBrowse(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())); // 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 modified = _filter(history.difference().summary(), { changeType: 'modified' }); _toCheck = _map(_map(modified, '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) { return _uniq(_reduce(ids, function(result, id) { var entity = graph.entity(id); if (entity.type === 'way') { try { var children = graph.childNodes(entity); result.push.apply(result, _map(_filter(children, 'version'), 'id')); } catch (err) { /* eslint-disable no-console */ if (typeof console !== 'undefined') console.error(err); /* eslint-enable no-console */ } } return result; }, _clone(ids))); } // 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 = _without(_toLoad, 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 = _union(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, formatUser); history.replace(merge); var mergeConflicts = merge.conflicts(); if (!mergeConflicts.length) return; // merged safely var forceLocal = action(id, localGraph, remoteGraph).withOption('force_local'); var forceRemote = action(id, localGraph, remoteGraph).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())); if (changes.modified.length || changes.created.length || changes.deleted.length) { loadLocation(); // so it is ready when we display the save screen 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(); success(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') .data([0]); // enter/update progress.enter() .append('div') .attr('class', 'progress') .merge(progress) .text(t('save.conflict_progress', { num: num, total: total })); } function showConflicts(changeset) { var history = context.history(); var selection = context.container() .select('#sidebar') .append('div') .attr('class','sidebar-component'); loading.close(); _isSaving = false; var ui = uiConflicts(context) .conflictList(_conflicts) .origChanges(_origChanges) .on('cancel', function() { history.pop(); selection.remove(); keybindingOn(); }) .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 = _uniq(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 }); selection.call(ui); } function showErrors() { keybindingOn(); context.history().pop(); loading.close(); _isSaving = false; var selection = uiConfirm(context.container()); selection .select('.modal-section.header') .append('h3') .text(t('save.error')); addErrors(selection, _errors); selection.okButton(); } function addErrors(selection, data) { var message = selection .select('.modal-section.message-text'); var items = message .selectAll('.error-container') .data(data); var enter = items.enter() .append('div') .attr('class', 'error-container'); enter .append('a') .attr('class', 'error-description') .attr('href', '#') .classed('hide-toggle', true) .text(function(d) { return d.msg || t('save.unknown_error_details'); }) .on('click', function() { d3_event.preventDefault(); var error = d3_select(this); var detail = d3_select(this.nextElementSibling); var exp = error.classed('expanded'); detail.style('display', exp ? 'none' : 'block'); error.classed('expanded', !exp); }); var details = enter .append('div') .attr('class', 'error-detail-container') .style('display', 'none'); details .append('ul') .attr('class', 'error-detail-list') .selectAll('li') .data(function(d) { return d.details || []; }) .enter() .append('li') .attr('class', 'error-detail-item') .text(function(d) { return d; }); items.exit() .remove(); } function success(changeset) { commit.reset(); var ui = uiSuccess(context) .changeset(changeset) .location(_location) .on('cancel', function() { context.ui().sidebar.hide(); }); context.enter(modeBrowse(context).sidebar(ui)); } function keybindingOn() { d3_select(document) .call(keybinding.on('⎋', cancel, true)); } function keybindingOff() { d3_select(document) .call(keybinding.unbind); } // Reverse geocode current map location so we can display a message on // the success screen like "Thank you for editing around city, state." function loadLocation() { _location = null; if (!services.geocoder) return; services.geocoder.reverse(context.map().center(), function(err, result) { if (err || !result || !result.address) return; var parts = []; var addr = result.address; var city = addr && (addr.town || addr.city || addr.county); if (city) parts.push(city); var region = addr && (addr.state || addr.country); if (region) parts.push(region); _location = parts.join(', '); }); } mode.enter = function() { // Show sidebar context.ui().sidebar.expand(); function done() { context.ui().sidebar.show(commit); } keybindingOn(); context.container().selectAll('#content') .attr('class', 'inactive'); var osm = context.connection(); if (!osm) { cancel(); return; } if (osm.authenticated()) { done(); } else { osm.authenticate(function(err) { if (err) { cancel(); } else { done(); } }); } }; mode.exit = function() { _isSaving = false; keybindingOff(); context.container().selectAll('#content') .attr('class', 'active'); context.ui().sidebar.hide(); }; return mode; }