modules/ui/commit.js (429 lines of code) (raw):
import { select as d3_select } from 'd3-selection';
import deepEqual from 'fast-deep-equal';
import { t } from '../util/locale';
import { modeSelect } from '../modes/select';
import { modeBrowse } from '../modes/browse';
import { osmChangeset } from '../osm';
import { services } from '../services';
import { uiChangesetEditor } from './changeset_editor';
import { uiCommitChanges } from './commit_changes';
import { uiCommitWarnings } from './commit_warnings';
import { uiRawTagEditor } from './raw_tag_editor';
import { utilArrayGroupBy } from '../util';
import { utilDetect } from '../util/detect';
var _changeset;
var readOnlyTags = [
/^changesets_count$/,
/^created_by$/,
/^ideditor:/,
/^imagery_used$/,
/^host$/,
/^locale$/,
/^warnings:/,
/^resolved:/
];
// treat most punctuation (except -, _, +, &) as hashtag delimiters - #4398
// from https://stackoverflow.com/a/25575009
var hashtagRegex = /(#[^\u2000-\u206F\u2E00-\u2E7F\s\\'!"#$%()*,.\/:;<=>?@\[\]^`{|}~]+)/g;
export function uiCommit(context) {
var _userDetails;
var _selection;
var changesetEditor = uiChangesetEditor(context)
.on('change', changeTags);
var rawTagEditor = uiRawTagEditor(context)
.on('change', changeTags);
var commitChanges = uiCommitChanges(context);
var commitWarnings = uiCommitWarnings(context);
function commit(selection) {
_selection = selection;
var osm = context.connection();
if (!osm) return;
// expire stored comment, hashtags, source after cutoff datetime - #3947 #4899
var commentDate = +context.storage('commentDate') || 0;
var currDate = Date.now();
var cutoff = 2 * 86400 * 1000; // 2 days
if (commentDate > currDate || currDate - commentDate > cutoff) {
context.storage('comment', null);
context.storage('hashtags', null);
context.storage('source', null);
}
var tags;
// Initialize changeset if one does not exist yet.
// Also pull values from local storage.
if (!_changeset) {
// load in the URL hash values, if any
var hash = context.ui().hash;
if (hash.comment) {
context.storage('comment', hash.comment);
context.storage('commentDate', Date.now());
}
if (hash.source) {
context.storage('source', hash.source);
context.storage('commentDate', Date.now());
}
if (hash.hashtags) {
context.storage('hashtags', hash.hashtags);
}
var detected = utilDetect();
tags = {
comment: context.storage('comment') || '',
created_by: ('iD ' + context.version).substr(0, 255),
host: detected.host.substr(0, 255),
locale: detected.locale.substr(0, 255)
};
// call findHashtags initially - this will remove stored
// hashtags if any hashtags are found in the comment - #4304
findHashtags(tags, true);
var hashtags = context.storage('hashtags');
if (hashtags) {
tags.hashtags = hashtags;
}
var source = context.storage('source');
if (source) {
tags.source = source;
}
var photoOverlaysUsed = context.history().photoOverlaysUsed();
if (photoOverlaysUsed.length) {
var sources = (tags.source || '').split(';');
// include this tag for any photo layer
if (sources.indexOf('streetlevel imagery') === -1) {
sources.push('streetlevel imagery');
}
// add the photo overlays used during editing as sources
photoOverlaysUsed.forEach(function(photoOverlay) {
if (sources.indexOf(photoOverlay) === -1) {
sources.push(photoOverlay);
}
});
tags.source = sources.join(';').substr(0, 255);
}
_changeset = new osmChangeset({ tags: tags });
}
tags = Object.assign({}, _changeset.tags); // shallow copy
// assign tags for imagery used
var imageryUsed = context.history().imageryUsed().join(';').substr(0, 255);
tags.imagery_used = imageryUsed || 'None';
// assign tags for closed issues and notes
var osmClosed = osm.getClosedIDs();
if (osmClosed.length) {
tags['closed:note'] = osmClosed.join(';').substr(0, 255);
}
if (services.keepRight) {
var krClosed = services.keepRight.getClosedIDs();
if (krClosed.length) {
tags['closed:keepright'] = krClosed.join(';').substr(0, 255);
}
}
if (services.improveOSM) {
var iOsmClosed = services.improveOSM.getClosedIDs();
if (iOsmClosed.length) {
tags['closed:improveosm'] = iOsmClosed.join(';').substr(0, 255);
}
}
// remove existing issue counts
for (var key in tags) {
if (key.match(/(^warnings:)|(^resolved:)/)) {
delete tags[key];
}
}
function addIssueCounts(issues, prefix) {
var issuesByType = utilArrayGroupBy(issues, 'type');
for (var issueType in issuesByType) {
var issuesOfType = issuesByType[issueType];
if (issuesOfType[0].subtype) {
var issuesBySubtype = utilArrayGroupBy(issuesOfType, 'subtype');
for (var issueSubtype in issuesBySubtype) {
var issuesOfSubtype = issuesBySubtype[issueSubtype];
tags[prefix + ':' + issueType + ':' + issueSubtype] = issuesOfSubtype.length.toString().substr(0, 255);
}
} else {
tags[prefix + ':' + issueType] = issuesOfType.length.toString().substr(0, 255);
}
}
}
// add counts of warnings generated by the user's edits
var warnings = context.validator()
.getIssuesBySeverity({ what: 'edited', where: 'all', includeIgnored: true, includeDisabledRules: true }).warning;
addIssueCounts(warnings, 'warnings');
// add counts of issues resolved by the user's edits
var resolvedIssues = context.validator().getResolvedIssues();
addIssueCounts(resolvedIssues, 'resolved');
_changeset = _changeset.update({ tags: tags });
var body = selection.selectAll('.inspector-body')
.data([0]);
body = body.enter()
.append('div')
.attr('class', 'inspector-body sep-top')
.merge(body);
var footer = selection.selectAll('.inspector-footer')
.data([0]);
footer = footer.enter()
.append('div')
.attr('class', 'inspector-footer save-footer fillL')
.merge(footer);
// footer buttons section
var saveSection = footer.selectAll('.save-section')
.data([0]);
saveSection = saveSection.enter()
.append('div')
.attr('class','modal-section save-section')
.merge(saveSection);
var uploadBlockerText = getUploadBlockerMessage();
var blockerMessage = saveSection.selectAll('.blocker-message')
.data([0]);
blockerMessage = blockerMessage.enter()
.append('div')
.attr('class','blocker-message')
.merge(blockerMessage);
blockerMessage
.text(uploadBlockerText || '');
// Buttons
var buttonSection = saveSection.selectAll('.buttons')
.data([0]);
// enter
var buttonEnter = buttonSection.enter()
.append('div')
.attr('class', 'buttons');
buttonEnter
.append('button')
.attr('class', 'secondary-action button cancel-button')
.append('span')
.attr('class', 'label')
.text(t('commit.cancel'));
var uploadButton = buttonEnter
.append('button')
.attr('class', 'action button save-button');
uploadButton.append('span')
.attr('class', 'label')
.text(t('commit.save'));
// update
buttonSection = buttonSection
.merge(buttonEnter);
buttonSection.selectAll('.cancel-button')
.on('click.cancel', function() {
var selectedID = commitChanges.entityID();
if (selectedID) {
context.enter(modeSelect(context, [selectedID]));
} else {
context.enter(modeBrowse(context));
}
});
buttonSection.selectAll('.save-button')
.classed('disabled', uploadBlockerText !== null)
.on('click.save', function() {
if (!d3_select(this).classed('disabled')) {
this.blur(); // avoid keeping focus on the button - #4641
var mode = context.mode();
if (mode.id === 'save' && mode.save) {
mode.save(_changeset);
}
}
});
var overviewSection = body.selectAll('.overview-section')
.data([0]);
// Enter
overviewSection = overviewSection.enter()
.append('div')
.attr('class', 'overview-section modal-section')
.merge(overviewSection);
var prose = overviewSection.selectAll('.commit-info')
.data([0]);
if (prose.enter().size()) { // first time, make sure to update user details in prose
_userDetails = null;
}
prose = prose.enter()
.append('p')
.attr('class', 'commit-info')
.text(t('commit.upload_explanation'))
.merge(prose);
// always check if this has changed, but only update prose.html()
// if needed, because it can trigger a style recalculation
osm.userDetails(function(err, user) {
if (err) return;
if (_userDetails === user) return; // no change
_userDetails = user;
var userLink = d3_select(document.createElement('div'));
if (user.image_url) {
userLink
.append('img')
.attr('src', user.image_url)
.attr('class', 'icon pre-text user-icon');
}
userLink
.append('a')
.attr('class', 'user-info')
.text(user.display_name)
.attr('href', osm.userURL(user.display_name))
.attr('target', '_blank');
prose
.html(t('commit.upload_explanation_with_user', { user: userLink.html() }));
});
// Request Review
var requestReview = overviewSection.selectAll('.request-review')
.data([0]);
// Enter
var requestReviewEnter = requestReview.enter()
.append('div')
.attr('class', 'request-review');
var labelEnter = requestReviewEnter
.append('label')
.attr('for', 'commit-input-request-review');
labelEnter
.append('input')
.attr('type', 'checkbox')
.attr('id', 'commit-input-request-review');
labelEnter
.append('span')
.text(t('commit.request_review'));
// Update
requestReview = requestReview
.merge(requestReviewEnter);
var requestReviewInput = requestReview.selectAll('input')
.property('checked', isReviewRequested(_changeset.tags))
.on('change', toggleRequestReview);
// Changeset Section
var changesetSection = body.selectAll('.changeset-editor')
.data([0]);
changesetSection = changesetSection.enter()
.append('div')
.attr('class', 'modal-section changeset-editor')
.merge(changesetSection);
changesetSection
.call(changesetEditor
.changesetID(_changeset.id)
.tags(tags)
);
// Warnings
body.call(commitWarnings);
// Raw Tag Editor
var tagSection = body.selectAll('.tag-section.raw-tag-editor')
.data([0]);
tagSection = tagSection.enter()
.append('div')
.attr('class', 'modal-section tag-section raw-tag-editor')
.merge(tagSection);
var expanded = !tagSection.selectAll('a.hide-toggle.expanded').empty();
tagSection
.call(rawTagEditor
.expanded(expanded)
.readOnlyTags(readOnlyTags)
.tags(Object.assign({}, _changeset.tags)) // shallow copy
);
// Change summary
body.call(commitChanges);
function toggleRequestReview() {
var rr = requestReviewInput.property('checked');
updateChangeset({ review_requested: (rr ? 'yes' : undefined) });
var expanded = !tagSection.selectAll('a.hide-toggle.expanded').empty();
tagSection
.call(rawTagEditor
.expanded(expanded)
.readOnlyTags(readOnlyTags)
.tags(Object.assign({}, _changeset.tags)) // shallow copy
);
}
}
function getUploadBlockerMessage() {
var errors = context.validator()
.getIssuesBySeverity({ what: 'edited', where: 'all' }).error;
if (errors.length) {
return t('commit.blocker_message.outstanding_errors', { count: errors.length });
} else {
var n = d3_select('#preset-input-comment').node();
var hasChangesetComment = n && n.value.length > 0;
if (!hasChangesetComment) {
return t('commit.blocker_message.comment_needed');
}
}
return null;
}
function changeTags(changed, onInput) {
if (changed.hasOwnProperty('comment')) {
if (changed.comment === undefined) {
changed.comment = '';
}
if (!onInput) {
context.storage('comment', changed.comment);
context.storage('commentDate', Date.now());
}
}
if (changed.hasOwnProperty('source')) {
if (changed.source === undefined) {
context.storage('source', null);
} else if (!onInput) {
context.storage('source', changed.source);
context.storage('commentDate', Date.now());
}
}
updateChangeset(changed, onInput);
if (_selection) {
_selection.call(commit);
}
}
function findHashtags(tags, commentOnly) {
var inComment = commentTags();
var inHashTags = hashTags();
if (inComment !== null) { // when hashtags are detected in comment...
context.storage('hashtags', null); // always remove stored hashtags - #4304
if (commentOnly) { inHashTags = []; } // optionally override hashtags field
}
// keep only one copy of the tags
var all = new Set();
var keepTags = [];
inComment.forEach(checkTag);
inHashTags.forEach(checkTag);
return keepTags;
// Compare tags as lowercase strings, but keep original case tags
function checkTag(s) {
var compare = s.toLowerCase();
if (!all.has(compare)) {
all.add(compare);
keepTags.push(s);
}
}
// Extract hashtags from `comment`
function commentTags() {
var matches = (tags.comment || '')
.replace(/http\S*/g, '') // drop anything that looks like a URL - #4289
.match(hashtagRegex);
return (matches || []);
}
// Extract and clean hashtags from `hashtags`
function hashTags() {
var matches = (tags.hashtags || '')
.split(/[,;\s]+/)
.map(function (s) {
if (s[0] !== '#') { s = '#' + s; } // prepend '#'
var matched = s.match(hashtagRegex);
return matched && matched[0];
}).filter(Boolean); // exclude falsy
return (matches || []);
}
}
function isReviewRequested(tags) {
var rr = tags.review_requested;
if (rr === undefined) return false;
rr = rr.trim().toLowerCase();
return !(rr === '' || rr === 'no');
}
function updateChangeset(changed, onInput) {
var tags = Object.assign({}, _changeset.tags); // shallow copy
Object.keys(changed).forEach(function(k) {
var v = changed[k];
k = k.trim().substr(0, 255);
if (readOnlyTags.indexOf(k) !== -1) return;
if (k !== '' && v !== undefined) {
if (onInput) {
tags[k] = v;
} else {
tags[k] = v.trim().substr(0, 255);
}
} else {
delete tags[k];
}
});
if (!onInput) {
// when changing the comment, override hashtags with any found in comment.
var commentOnly = changed.hasOwnProperty('comment') && (changed.comment !== '');
var arr = findHashtags(tags, commentOnly);
if (arr.length) {
tags.hashtags = arr.join(';').substr(0, 255);
context.storage('hashtags', tags.hashtags);
} else {
delete tags.hashtags;
context.storage('hashtags', null);
}
}
// always update userdetails, just in case user reauthenticates as someone else
if (_userDetails && _userDetails.changesets_count !== undefined) {
var changesetsCount = parseInt(_userDetails.changesets_count, 10) + 1; // #4283
tags.changesets_count = String(changesetsCount);
// first 100 edits - new user
if (changesetsCount <= 100) {
var s;
s = context.storage('walkthrough_completed');
if (s) {
tags['ideditor:walkthrough_completed'] = s;
}
s = context.storage('walkthrough_progress');
if (s) {
tags['ideditor:walkthrough_progress'] = s;
}
s = context.storage('walkthrough_started');
if (s) {
tags['ideditor:walkthrough_started'] = s;
}
}
} else {
delete tags.changesets_count;
}
if (!deepEqual(_changeset.tags, tags)) {
_changeset = _changeset.update({ tags: tags });
}
}
return commit;
}