diff --git a/.jshintrc b/.jshintrc index 5582195ae..74c9584db 100644 --- a/.jshintrc +++ b/.jshintrc @@ -13,6 +13,7 @@ "_": false, "t": false, "bootstrap": false, + "Diff3": false, "rbush": false, "JXON": false, "osmAuth": false, diff --git a/Makefile b/Makefile index e697a7544..3a36953af 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,7 @@ dist/iD.js: \ js/lib/d3.typeahead.js \ js/lib/d3.curtain.js \ js/lib/d3.value.js \ + js/lib/diff3.js \ js/lib/jxon.js \ js/lib/lodash.js \ js/lib/osmauth.js \ diff --git a/css/app.css b/css/app.css index 103ca75a8..8b9e5e6ec 100644 --- a/css/app.css +++ b/css/app.css @@ -140,10 +140,6 @@ strong { a:visited, a { color: #7092ff; - -webkit-transition: all 100ms; - -moz-transition: all 100ms; - -o-transition: all 100ms; - transition: all 100ms; } a:hover { @@ -172,10 +168,6 @@ input[type=email] { width: 100%; border-radius:4px; text-overflow: ellipsis; - -webkit-transition: all 200ms; - -moz-transition: all 200ms; - -o-transition: all 200ms; - transition: all 200ms; } textarea:focus, @@ -247,9 +239,6 @@ ul li { list-style: none;} background-color: white; color: #7092FF; cursor: pointer; - -moz-transition: all 100ms; - -o-transition: all 100ms; - transition: all 100ms; } .toggle-list > label:hover { @@ -358,11 +347,6 @@ button { display: inline-block; height:40px; border-radius:4px; - /* Crashes Safari: https://github.com/openstreetmap/iD/issues/1188 */ - /*-webkit-transition: all 100ms;*/ - -moz-transition: all 100ms; - -o-transition: all 100ms; - transition: all 100ms; } button:focus, @@ -418,6 +402,10 @@ button.minor:hover { border-right: 1px solid rgba(0,0,0,.5); } +.fillL .joined button { + border-right: 1px solid white; +} + .joined button:first-child { border-radius:4px 0 0 4px; } @@ -429,6 +417,13 @@ button.minor:hover { button.action { background: #7092ff; + color: white; +} + +button[disabled].action, +button[disabled].action:hover { + background: #cccccc; + color: #888; } button.action:focus, @@ -436,6 +431,15 @@ button.action:hover { background: #597BE7; } +button.secondary-action { + background: #ececec; +} + +button.secondary-action:focus, +button.secondary-action:hover { + background: #cccccc; +} + button.save.has-count { padding: 9px; } @@ -565,13 +569,13 @@ button[disabled] .icon.avatar { background-position: -320px -40px;} button[disabled] .icon.nearby { background-position: -340px -40px;} button[disabled] .icon.data { background-position: -600px -40px;} -.icon.point.deleted { background-position: -302px -80px;} -.icon.line.deleted { background-position: -320px -80px;} -.icon.area.deleted { background-position: -340px -80px;} +.icon.point.deleted { background-position: -480px -80px;} +.icon.line.deleted { background-position: -500px -80px;} +.icon.area.deleted { background-position: -520px -80px;} -.icon.point.created { background-position: -302px -100px;} -.icon.line.created { background-position: -320px -100px;} -.icon.area.created { background-position: -340px -100px;} +.icon.point.created { background-position: -480px -100px;} +.icon.line.created { background-position: -500px -100px;} +.icon.area.created { background-position: -520px -100px;} .icon.point.modified { background-position: -22px 0; } @@ -619,7 +623,7 @@ a:hover .icon.out-link { background-position: -500px -14px;} } .header h3 { - text-align: center; + text-align: left; margin-bottom: 0; white-space: nowrap; text-overflow: ellipsis; @@ -657,7 +661,7 @@ a:hover .icon.out-link { background-position: -500px -14px;} .footer { position: absolute; bottom: 0; - padding: 5px 30px 5px 30px; + padding: 5px 20px 5px 20px; border-top: 1px solid #ccc; background-color: #fafafa; width: 100%; @@ -816,9 +820,6 @@ a:hover .icon.out-link { background-position: -500px -14px;} text-overflow: ellipsis; overflow: hidden; border-left: 1px solid rgba(0, 0, 0, .1); - -moz-transition: all 100ms; - -o-transition: all 100ms; - transition: all 100ms; } .feature-list-item .label .icon { @@ -898,9 +899,6 @@ a:hover .icon.out-link { background-position: -500px -14px;} text-overflow: ellipsis; overflow: hidden; border-left: 1px solid rgba(0, 0, 0, .1); - -moz-transition: all 100ms; - -o-transition: all 100ms; - transition: all 100ms; border-radius: 0 3px 3px 0; } @@ -1051,6 +1049,10 @@ a:hover .icon.out-link { background-position: -500px -14px;} text-align: right; } +.form-label-button-wrap .tag-reference-button { + border-radius: 0 3px 0 0; +} + .form-label-button-wrap .icon { opacity: .5; } @@ -1137,7 +1139,7 @@ a:hover .icon.out-link { background-position: -500px -14px;} /* Styles for raw tag inspector on hover */ .inspector-hover .tag-row .key-wrap, -.inspector-hover .tag-row .input-wrap-position { +.inspector-hover .tag-row .form-field.input-wrap-position { width: 50%; } @@ -1190,69 +1192,6 @@ a:hover .icon.out-link { background-position: -500px -14px;} line-height: 0; display: block; overflow: hidden; - -webkit-transition: opacity 200ms, width 0 200ms, padding 0 200ms, line-height 0 200ms; - -moz-transition: opacity 200ms, width 0 200ms, padding 0 200ms, line-height 0 200ms; - -o-transition: opacity 200ms, width 0 200ms, padding 0 200ms, line-height 0 200ms; - transition: opacity 200ms, width 0 200ms, padding 0 200ms, line-height 0 200ms; -} - -/* first phase hover-to-active animations */ - -textarea, -.form-label, -.preset-input-wrap, -.preset-input-wrap .label { - -webkit-transition: all 200ms; - -moz-transition: all 200ms; - -o-transition: all 200ms; - transition: all 200ms; -} - -/* second phase hover-to-active animations */ - -input, -.checkselect label:last-of-type { - -webkit-transition: opacity 200ms 200ms, width 200ms 200ms, margin-right 200ms 200ms; - -moz-transition: opacity 200ms 200ms, width 200ms 200ms, margin-right 200ms 200ms; - -o-transition: opacity 200ms 200ms, width 200ms 200ms, margin-right 200ms 200ms; - transition: opacity 200ms 200ms, width 200ms 200ms, margin-right 200ms 200ms; -} - -.entity-editor-pane button.minor, -.combobox-caret, -.entity-editor-pane .header button, -.toggle-list label span, -.spin-control, -.more-fields, -.view-on-osm, -.hide-toggle:before, -.entity-editor-pane .toggle-list label::before, -.entity-editor-pane .toggle-list label.remove .icon { - -webkit-transition: opacity 200ms 200ms; - -moz-transition: opacity 200ms 200ms; - -o-transition: opacity 200ms 200ms; - transition: opacity 200ms 200ms; -} - -.entity-editor-pane a.hide-toggle { - -webkit-transition: padding-left 200ms 200ms, color 200ms 200ms; - -moz-transition: padding-left 200ms 200ms, color 200ms 200ms; - -o-transition: padding-left 200ms 200ms, color 200ms 200ms; - transition: padding-left 200ms 200ms, color 200ms 200ms; -} - -.entity-editor-pane .toggle-list label:not(.active) { - -webkit-transition: height 200ms 200ms, padding 200ms 200ms, border-width 100ms 300ms; - -moz-transition: height 200ms 200ms, padding 200ms 200ms, border-width 100ms 300ms; - -o-transition: height 200ms 200ms, padding 200ms 200ms, border-width 100ms 300ms; - transition: height 200ms 200ms, padding 200ms 200ms, border-width 100ms 300ms; -} - -.entity-editor-pane .toggle-list label { - -webkit-transition: border-width 100ms 300ms, padding 200ms 200ms, background-color 200ms 200ms, color 200ms 200ms; - -moz-transition: border-width 100ms 300ms, padding 200ms 200ms, background-color 200ms 200ms, color 200ms 200ms; - -o-transition: border-width 100ms 300ms, padding 200ms 200ms, background-color 200ms 200ms, color 200ms 200ms; - transition: border-width 100ms 300ms, padding 200ms 200ms, background-color 200ms 200ms, color 200ms 200ms; } /* adding additional preset fields */ @@ -1260,10 +1199,6 @@ input, .more-fields { padding: 0 20px 20px 20px; font-weight: bold; - -webkit-transition: padding 200ms 200ms, max-height 200ms 200ms; - -moz-transition: padding 200ms 200ms, max-height 200ms 200ms; - -o-transition: padding 200ms 200ms, max-height 200ms 200ms; - transition: padding 200ms 200ms, max-height 200ms 200ms; } .more-fields label { padding: 5px 10px 5px 0; } @@ -1601,10 +1536,6 @@ div.combobox { width: 40%; float: left; height: 30px; - -webkit-transition: width 200ms; - -moz-transition: width 200ms; - -o-transition: width 200ms; - transition: width 200ms; } .tag-row input.key { @@ -2187,10 +2118,14 @@ img.wiki-image { #attrib { width: 100%; height: 20px; + margin-bottom: 5px; float: left; clear: both; + pointer-events: none; } +#attrib * { pointer-events: all; } + .base-layer-attribution, .overlay-layer-attribution { position: absolute; @@ -2298,7 +2233,7 @@ img.wiki-image { clear: both; text-align: right; width: 100%; - padding: 0px 5px; + padding: 0px 10px; } .api-status.offline, @@ -2311,8 +2246,11 @@ img.wiki-image { ------------------------------------------------------- */ .modal { + top: 40px; display: inline-block; position:absolute; + border-radius: 3px; + overflow: hidden; left: 0; right: 0; margin: auto; @@ -2349,8 +2287,19 @@ img.wiki-image { border-bottom: 1px solid #CCC; } -.modal-section:last-child { - border-bottom: 0; +.modal-section.header h3 { + padding: 0; +} + +.modal-section.buttons { + text-align: center; +} + +.modal-section.buttons .action { + display: inline-block; + margin: 0 10px; + text-align: center; + vertical-align: middle; } .loading-modal { @@ -2379,7 +2328,8 @@ img.wiki-image { display: block; content: ''; height: 100px; - width: 100px; + width: 100%; + max-width: 100px; margin: auto; margin-bottom: 10px; background:transparent url(img/sprite.svg) no-repeat 0 -220px; @@ -2389,6 +2339,10 @@ img.wiki-image { border-right: 1px solid #CCC; } +.modal-section:last-child { + border-bottom: 0; +} + /* Restore Modal ------------------------------------------------------- */ @@ -2458,14 +2412,6 @@ img.wiki-image { margin-bottom: 0; } -.mode-save button.action { - float: none; - margin: auto; - display: block; - color: white; - font-size: 14px; -} - .mode-save .user-info img { float: left; } @@ -2528,6 +2474,56 @@ img.wiki-image { .changeset-list li:first-child { border-top: 0;} +/* Conflict resolution +------------------------------------------------------- */ + +.conflicts-help { + padding: 20px; + background-color: #ffffbb; + border-bottom: 1px solid #ccc; +} + +.conflicts-buttons { + padding: 20px; +} + +.mode-save button.conflicts-button { + float: left; +} + +.conflict-container { + border-bottom: 1px solid #ccc; +} + +.conflict-description { + padding: 5px 20px; + display: block; +} + +.conflicts-done { + padding: 20px 20px 0 20px; +} + +.conflict-detail-container { + padding: 10px 20px; +} + +.conflict-count { + padding: 10px 20px; +} + +.conflict-choices { + margin-top: 10px; +} + +.conflict-nav-buttons { + padding: 10px 0 20px 0; +} + +.conflict-nav-button { + height: 30px; +} + /* Notices ------------------------------------------------------- */ @@ -2708,28 +2704,7 @@ img.wiki-image { } .tooltip-inner .keyhint { - font-size: 10px; - padding: 0 7px; font-weight: bold; - display: inline-block; - border-radius: 2px; - border: 1px solid #CCC; - position: relative; - z-index: 1; - text-align: left; -} - -.tooltip-inner .keyhint::after { - content: ""; - position: absolute; - border-radius: 2px; - height: 10px; - width: 100%; - z-index: 0; - bottom: -4px; - left: -1px; - border: 1px solid #CCC; - border-top: 0; } /* Exceptions for tooltip layouts */ diff --git a/data/core.yaml b/data/core.yaml index dd002bfa0..dfceac898 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -313,10 +313,33 @@ en: title: Save help: "Save changes to OpenStreetMap, making them visible to other users." no_changes: No changes to save. - error: An error occurred while trying to save + error: Errors occurred while trying to save + status_code: "Server returned status code {code}" unknown_error_details: "Please ensure you are connected to the internet." uploading: Uploading changes to OpenStreetMap. unsaved_changes: You have unsaved changes + conflict: + header: Resolve conflicting edits + count: 'Conflict {num} of {total}' + previous: '< Previous' + next: 'Next >' + keep_local: Keep mine + keep_remote: Use theirs + restore: Restore + delete: Leave Deleted + download_changes: Or download your changes. + done: "All conflicts resolved!" + help: | + Another user changed some of the same map features you changed. + Click on each item below for more details about the conflict, and choose whether to keep + your changes or the other user's changes. + merge_remote_changes: + conflict: + deleted: 'This object has been deleted by {user}.' + location: 'This object was moved by both you and {user}.' + nodelist: 'Nodes were changed by both you and {user}.' + memberlist: 'Relation members were changed by both you and {user}.' + tags: 'You changed the {tag} tag to "{local}" and {user} changed it to "{remote}".' success: edited_osm: "Edited OSM!" just_edited: "You just edited OpenStreetMap!" @@ -329,6 +352,7 @@ en: (details). confirm: okay: "Okay" + cancel: "Cancel" splash: welcome: Welcome to the iD OpenStreetMap editor text: "iD is a friendly but powerful tool for contributing to the world's best free world map. This is version {version}. For more information see {website} and report bugs at {github}." diff --git a/dist/img/mini-loader.gif b/dist/img/mini-loader.gif index cbe6674b8..89250511d 100644 Binary files a/dist/img/mini-loader.gif and b/dist/img/mini-loader.gif differ diff --git a/dist/img/sprite.svg b/dist/img/sprite.svg index a59937bdf..b47a50a64 100644 --- a/dist/img/sprite.svg +++ b/dist/img/sprite.svg @@ -29,13 +29,13 @@ id="namedview392" showgrid="true" inkscape:zoom="1" - inkscape:cx="262.65678" - inkscape:cy="510.36274" + inkscape:cx="475.13394" + inkscape:cy="495.7147" inkscape:window-x="276" inkscape:window-y="71" inkscape:window-maximized="0" inkscape:current-layer="svg12393" - showguides="false" + showguides="true" inkscape:guide-bbox="true" inkscape:snap-global="true" inkscape:snap-bbox="true" @@ -78,6 +78,34 @@ orientation="-0.41576267,-0.90947315" position="646,553.53846" id="guide6219" /> + + + + + + + @@ -98,7 +126,7 @@ image/svg+xml - + @@ -1787,13 +1815,7 @@ inkscape:connector-curvature="0" style="color:#000000;fill:#e06d5f;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" id="path2997-7" - d="m 514,84 -1,1 0,1.59375 L 506.59375,93 505,93 l -1,1 0,2 1,1 2,0 1,-1 0,-1.59375 L 514.40625,88 516,88 l 1,-1 0,-2 -1,-1 z m -22,1 c -2.76142,0 -5,2.23858 -5,5 0,2.76143 5,7 5,7 0,0 5,-4.23857 5,-7 0,-2.76142 -2.23858,-5 -5,-5 z m 23,0 c 0.55228,0 1,0.44772 1,1 0,0.55229 -0.44772,1 -1,1 -0.25152,0 -0.48052,-0.0967 -0.65625,-0.25 -0.0344,-0.03002 -0.0638,-0.05934 -0.0937,-0.09375 -0.15335,-0.175731 -0.25,-0.404729 -0.25,-0.65625 0,-0.55228 0.44772,-1 1,-1 z m 10,0 -1,1 0,2 1,1 0,4 -1,1 0,2 1,1 2,0 1,-1 4,0 1,1 2,0 1,-1 0,-2 -1,-1 0,-4 1,-1 0,-2 -1,-1 -2,0 -1,1 -4,0 -1,-1 z m 1,1 c 0.55228,0 1,0.447715 1,1 0,0.552285 -0.44772,1 -1,1 -0.55228,0 -1,-0.447715 -1,-1 0,-0.552285 0.44772,-1 1,-1 z m 8,0 c 0.55228,0 1,0.447715 1,1 0,0.552285 -0.44772,1 -1,1 -0.55228,0 -1,-0.447715 -1,-1 0,-0.552285 0.44772,-1 1,-1 z m -41.84375,2 c 1.10457,0 2,0.89543 2,2 0,1.10457 -0.89543,2 -2,2 -1.10457,0 -2,-0.89543 -2,-2 0,-1.10457 0.89543,-2 2,-2 z M 528,88 l 4,0 1,1 0,4 -1,1 -4,0 -1,-1 0,-4 z m -22,6 c 0.25152,0 0.48052,0.0967 0.65625,0.25 l 0.0937,0.09375 c 0.15335,0.175731 0.25,0.404734 0.25,0.65625 0,0.55229 -0.44772,1 -1,1 -0.55228,0 -1,-0.44771 -1,-1 0,-0.55228 0.44772,-1 1,-1 z m 20,0 c 0.55228,0 1,0.447715 1,1 0,0.552285 -0.44772,1 -1,1 -0.55228,0 -1,-0.447715 -1,-1 0,-0.552285 0.44772,-1 1,-1 z m 8,0 c 0.55228,0 1,0.447715 1,1 0,0.552285 -0.44772,1 -1,1 -0.55228,0 -1,-0.447715 -1,-1 0,-0.552285 0.44772,-1 1,-1 z" /> - + d="m 514,83 -1,1 0,1.59375 L 506.59375,92 505,92 l -1,1 0,2 1,1 2,0 1,-1 0,-1.59375 L 514.40625,87 516,87 l 1,-1 0,-2 -1,-1 z m -24,1 c -2.76142,0 -5,2.23858 -5,5 0,2.76143 5,7 5,7 0,0 5,-4.23857 5,-7 0,-2.76142 -2.23858,-5 -5,-5 z m 25,0 c 0.55228,0 1,0.44772 1,1 0,0.55229 -0.44772,1 -1,1 -0.25152,0 -0.48052,-0.0967 -0.65625,-0.25 -0.0344,-0.03002 -0.0638,-0.05934 -0.0937,-0.09375 -0.15335,-0.175731 -0.25,-0.404729 -0.25,-0.65625 0,-0.55228 0.44772,-1 1,-1 z m 10,0 -1,1 0,2 1,1 0,4 -1,1 0,2 1,1 2,0 1,-1 4,0 1,1 2,0 1,-1 0,-2 -1,-1 0,-4 1,-1 0,-2 -1,-1 -2,0 -1,1 -4,0 -1,-1 z m 1,1 c 0.55228,0 1,0.447715 1,1 0,0.552285 -0.44772,1 -1,1 -0.55228,0 -1,-0.447715 -1,-1 0,-0.552285 0.44772,-1 1,-1 z m 8,0 c 0.55228,0 1,0.447715 1,1 0,0.552285 -0.44772,1 -1,1 -0.55228,0 -1,-0.447715 -1,-1 0,-0.552285 0.44772,-1 1,-1 z m -43.84375,2 c 1.10457,0 2,0.89543 2,2 0,1.10457 -0.89543,2 -2,2 -1.10457,0 -2,-0.89543 -2,-2 0,-1.10457 0.89543,-2 2,-2 z M 528,87 l 4,0 1,1 0,4 -1,1 -4,0 -1,-1 0,-4 z m -22,6 c 0.25152,0 0.48052,0.0967 0.65625,0.25 l 0.0937,0.09375 c 0.15335,0.175731 0.25,0.404734 0.25,0.65625 0,0.55229 -0.44772,1 -1,1 -0.55228,0 -1,-0.44771 -1,-1 0,-0.55228 0.44772,-1 1,-1 z m 20,0 c 0.55228,0 1,0.447715 1,1 0,0.552285 -0.44772,1 -1,1 -0.55228,0 -1,-0.447715 -1,-1 0,-0.552285 0.44772,-1 1,-1 z m 8,0 c 0.55228,0 1,0.447715 1,1 0,0.552285 -0.44772,1 -1,1 -0.55228,0 -1,-0.447715 -1,-1 0,-0.552285 0.44772,-1 1,-1 z" /> + diff --git a/dist/locales/en.json b/dist/locales/en.json index 869941454..ec87f211e 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -387,10 +387,33 @@ "title": "Save", "help": "Save changes to OpenStreetMap, making them visible to other users.", "no_changes": "No changes to save.", - "error": "An error occurred while trying to save", + "error": "Errors occurred while trying to save", + "status_code": "Server returned status code {code}", "unknown_error_details": "Please ensure you are connected to the internet.", "uploading": "Uploading changes to OpenStreetMap.", - "unsaved_changes": "You have unsaved changes" + "unsaved_changes": "You have unsaved changes", + "conflict": { + "header": "Resolve conflicting edits", + "count": "Conflict {num} of {total}", + "previous": "< Previous", + "next": "Next >", + "keep_local": "Keep mine", + "keep_remote": "Use theirs", + "restore": "Restore", + "delete": "Leave Deleted", + "download_changes": "Or download your changes.", + "done": "All conflicts resolved!", + "help": "Another user changed some of the same map features you changed.\nClick on each item below for more details about the conflict, and choose whether to keep\nyour changes or the other user's changes.\n" + } + }, + "merge_remote_changes": { + "conflict": { + "deleted": "This object has been deleted by {user}.", + "location": "This object was moved by both you and {user}.", + "nodelist": "Nodes were changed by both you and {user}.", + "memberlist": "Relation members were changed by both you and {user}.", + "tags": "You changed the {tag} tag to \"{local}\" and {user} changed it to \"{remote}\"." + } }, "success": { "edited_osm": "Edited OSM!", @@ -402,7 +425,8 @@ "help_html": "Your changes should appear in the \"Standard\" layer in a few minutes. Other layers, and certain features, may take longer\n(details).\n" }, "confirm": { - "okay": "Okay" + "okay": "Okay", + "cancel": "Cancel" }, "splash": { "welcome": "Welcome to the iD OpenStreetMap editor", diff --git a/index.html b/index.html index 474ea4563..3e71faba6 100644 --- a/index.html +++ b/index.html @@ -30,6 +30,7 @@ + @@ -81,6 +82,7 @@ + @@ -158,6 +160,7 @@ + @@ -259,6 +262,7 @@ "oauth_secret": "aMnOOCwExO2XYtRVWJ1bI9QOdqh1cay2UgpbhA6p" } ])); + }); diff --git a/js/id/actions/merge_remote_changes.js b/js/id/actions/merge_remote_changes.js new file mode 100644 index 000000000..2888c57b5 --- /dev/null +++ b/js/id/actions/merge_remote_changes.js @@ -0,0 +1,254 @@ +iD.actions.MergeRemoteChanges = function(id, localGraph, remoteGraph, formatUser) { + var option = 'safe', // 'safe', 'force_local', 'force_remote' + conflicts = []; + + function user(d) { + return _.isFunction(formatUser) ? formatUser(d) : d; + } + + + function mergeLocation(remote, target) { + function pointEqual(a, b) { + var epsilon = 1e-6; + return (Math.abs(a[0] - b[0]) < epsilon) && (Math.abs(a[1] - b[1]) < epsilon); + } + + if (option === 'force_local' || pointEqual(target.loc, remote.loc)) { + return target; + } + if (option === 'force_remote') { + return target.update({loc: remote.loc}); + } + + conflicts.push(t('merge_remote_changes.conflict.location', { user: user(remote.user) })); + return target; + } + + + function mergeNodes(base, remote, target) { + if (option === 'force_local' || _.isEqual(target.nodes, remote.nodes)) { + return target; + } + if (option === 'force_remote') { + return target.update({nodes: remote.nodes}); + } + + var ccount = conflicts.length, + o = base.nodes || [], + a = target.nodes || [], + b = remote.nodes || [], + nodes = [], + hunks = Diff3.diff3_merge(a, o, b, true); + + for (var i = 0; i < hunks.length; i++) { + var hunk = hunks[i]; + if (hunk.ok) { + nodes.push.apply(nodes, hunk.ok); + } else { + // for all conflicts, we can assume c.a !== c.b + // because `diff3_merge` called with `true` option to exclude false conflicts.. + var c = hunk.conflict; + if (_.isEqual(c.o, c.a)) { // only changed remotely + nodes.push.apply(nodes, c.b); + } else if (_.isEqual(c.o, c.b)) { // only changed locally + nodes.push.apply(nodes, c.a); + } else { // changed both locally and remotely + conflicts.push(t('merge_remote_changes.conflict.nodelist', { user: user(remote.user) })); + break; + } + } + } + + return (conflicts.length === ccount) ? target.update({nodes: nodes}) : target; + } + + + function mergeChildren(targetWay, children, updates, graph) { + function isUsed(node, targetWay) { + var parentWays = _.pluck(graph.parentWays(node), 'id'); + return node.hasInterestingTags() || + _.without(parentWays, targetWay.id).length > 0 || + graph.parentRelations(node).length > 0; + } + + var ccount = conflicts.length; + + for (var i = 0; i < children.length; i++) { + var id = children[i], + node = graph.hasEntity(id); + + // remove unused childNodes.. + if (targetWay.nodes.indexOf(id) === -1) { + if (node && !isUsed(node, targetWay)) { + updates.removeIds.push(id); + } + continue; + } + + // restore used childNodes.. + var local = localGraph.hasEntity(id), + remote = remoteGraph.hasEntity(id), + target; + + if (!remote) continue; + + if (option === 'force_remote' && remote.visible) { + updates.replacements.push(remote); + } + if (option === 'force_local' && local) { + target = iD.Entity(local, { version: remote.version }); + updates.replacements.push(target); + } + if (option === 'safe' && local && remote) { + target = iD.Entity(local, { version: remote.version }); + if (remote.visible) { + target = mergeLocation(remote, target); + } else { + conflicts.push(t('merge_remote_changes.conflict.deleted', { user: user(remote.user) })); + } + + if (conflicts.length !== ccount) break; + updates.replacements.push(target); + } + } + + return targetWay; + } + + + function updateChildren(updates, graph) { + for (var i = 0; i < updates.replacements.length; i++) { + graph = graph.replace(updates.replacements[i]); + } + if (updates.removeIds.length) { + graph = iD.actions.DeleteMultiple(updates.removeIds)(graph); + } + return graph; + } + + + function mergeMembers(remote, target) { + if (option === 'force_local' || _.isEqual(target.members, remote.members)) { + return target; + } + if (option === 'force_remote') { + return target.update({members: remote.members}); + } + + conflicts.push(t('merge_remote_changes.conflict.memberlist', { user: user(remote.user) })); + return target; + } + + + function mergeTags(base, remote, target) { + function ignoreKey(k) { + return _.contains(iD.data.discarded, k); + } + + if (option === 'force_local' || _.isEqual(target.tags, remote.tags)) { + return target; + } + if (option === 'force_remote') { + return target.update({tags: remote.tags}); + } + + var ccount = conflicts.length, + o = base.tags || {}, + a = target.tags || {}, + b = remote.tags || {}, + keys = _.reject(_.union(_.keys(o), _.keys(a), _.keys(b)), ignoreKey), + tags = _.clone(a), + changed = false; + + for (var i = 0; i < keys.length; i++) { + var k = keys[i]; + + if (o[k] !== b[k] && a[k] !== b[k]) { // changed remotely.. + if (o[k] !== a[k]) { // changed locally.. + conflicts.push(t('merge_remote_changes.conflict.tags', + { tag: k, local: a[k], remote: b[k], user: user(remote.user) })); + + } else { // unchanged locally, accept remote change.. + if (b.hasOwnProperty(k)) { + tags[k] = b[k]; + } else { + delete tags[k]; + } + changed = true; + } + } + } + + return (changed && conflicts.length === ccount) ? target.update({tags: tags}) : target; + } + + + // `graph.base()` is the common ancestor of the two graphs. + // `localGraph` contains user's edits up to saving + // `remoteGraph` contains remote edits to modified nodes + // `graph` must be a descendent of `localGraph` and may include + // some conflict resolution actions performed on it. + // + // --- ... --- `localGraph` -- ... -- `graph` + // / + // `graph.base()` --- ... --- `remoteGraph` + // + var action = function(graph) { + var updates = { replacements: [], removeIds: [] }, + base = graph.base().entities[id], + local = localGraph.entity(id), + remote = remoteGraph.entity(id), + target = iD.Entity(local, { version: remote.version }); + + // delete/undelete + if (!remote.visible) { + if (option === 'force_remote') { + return iD.actions.DeleteMultiple([id])(graph); + + } else if (option === 'force_local') { + if (target.type === 'way') { + target = mergeChildren(target, _.uniq(local.nodes), updates, graph); + graph = updateChildren(updates, graph); + } + return graph.replace(target); + + } else { + conflicts.push(t('merge_remote_changes.conflict.deleted', { user: user(remote.user) })); + return graph; // do nothing + } + } + + // merge + if (target.type === 'node') { + target = mergeLocation(remote, target); + + } else if (target.type === 'way') { + // pull in any child nodes that may not be present locally.. + graph.rebase(remoteGraph.childNodes(remote), [graph], false); + target = mergeNodes(base, remote, target); + target = mergeChildren(target, _.union(local.nodes, remote.nodes), updates, graph); + + } else if (target.type === 'relation') { + target = mergeMembers(remote, target); + } + + target = mergeTags(base, remote, target); + + if (!conflicts.length) { + graph = updateChildren(updates, graph).replace(target); + } + + return graph; + }; + + action.withOption = function(opt) { + option = opt; + return action; + }; + + action.conflicts = function() { + return conflicts; + }; + + return action; +}; diff --git a/js/id/behavior/hash.js b/js/id/behavior/hash.js index 979c470bf..e44c504ec 100644 --- a/js/id/behavior/hash.js +++ b/js/id/behavior/hash.js @@ -65,7 +65,7 @@ iD.behavior.Hash = function(context) { if (location.hash) { var q = iD.util.stringQs(location.hash.substring(1)); - if (q.id) context.loadEntity(q.id.split(',')[0], !q.map); + if (q.id) context.zoomToEntity(q.id.split(',')[0], !q.map); if (q.comment) context.storage('comment', q.comment); hashchange(); if (q.map) hash.hadHash = true; diff --git a/js/id/core/connection.js b/js/id/core/connection.js index ad7478e50..65cb5fbf7 100644 --- a/js/id/core/connection.js +++ b/js/id/core/connection.js @@ -1,6 +1,5 @@ iD.Connection = function() { - - var event = d3.dispatch('authenticating', 'authenticated', 'auth', 'loading', 'load', 'loaded'), + var event = d3.dispatch('authenticating', 'authenticated', 'auth', 'loading', 'loaded'), url = 'http://www.openstreetmap.org', connection = {}, inflight = {}, @@ -42,10 +41,10 @@ iD.Connection = function() { }; connection.loadFromURL = function(url, callback) { - function done(dom) { - return callback(null, parse(dom)); + function done(err, dom) { + return callback(err, parse(dom)); } - return d3.xml(url).get().on('load', done); + return d3.xml(url).get(done); }; connection.loadEntity = function(id, callback) { @@ -55,11 +54,34 @@ iD.Connection = function() { connection.loadFromURL( url + '/api/0.6/' + type + '/' + osmID + (type !== 'node' ? '/full' : ''), function(err, entities) { - event.load(err, {data: entities}); - if (callback) callback(err, entities && _.find(entities, function(e) { return e.id === id; })); + if (callback) callback(err, {data: entities}); }); }; + connection.loadMultiple = function(ids, callback) { + // TODO: upgrade lodash and just use _.chunk + function chunk(arr, chunkSize) { + var result = []; + for (var i = 0; i < arr.length; i += chunkSize) { + result.push(arr.slice(i, i + chunkSize)); + } + return result; + } + + _.each(_.groupBy(ids, iD.Entity.id.type), function(v, k) { + var type = k + 's', + osmIDs = _.map(v, iD.Entity.id.toOSM); + + _.each(chunk(osmIDs, 150), function(arr) { + connection.loadFromURL( + url + '/api/0.6/' + type + '?' + type + '=' + arr.join(), + function(err, entities) { + if (callback) callback(err, {data: entities}); + }); + }); + }); + }; + function authenticating() { event.authenticating(); } @@ -68,6 +90,12 @@ iD.Connection = function() { event.authenticated(); } + function getLoc(attrs) { + var lon = attrs.lon && attrs.lon.value, + lat = attrs.lat && attrs.lat.value; + return [parseFloat(lon), parseFloat(lat)]; + } + function getNodes(obj) { var elems = obj.getElementsByTagName(ndStr), nodes = new Array(elems.length); @@ -101,15 +129,20 @@ iD.Connection = function() { return members; } + function getVisible(attrs) { + return (!attrs.visible || attrs.visible.value !== 'false'); + } + var parsers = { node: function nodeData(obj) { var attrs = obj.attributes; return new iD.Node({ id: iD.Entity.id.fromOSM(nodeStr, attrs.id.value), - loc: [parseFloat(attrs.lon.value), parseFloat(attrs.lat.value)], + loc: getLoc(attrs), version: attrs.version.value, user: attrs.user && attrs.user.value, - tags: getTags(obj) + tags: getTags(obj), + visible: getVisible(attrs) }); }, @@ -120,7 +153,8 @@ iD.Connection = function() { version: attrs.version.value, user: attrs.user && attrs.user.value, tags: getTags(obj), - nodes: getNodes(obj) + nodes: getNodes(obj), + visible: getVisible(attrs) }); }, @@ -131,13 +165,14 @@ iD.Connection = function() { version: attrs.version.value, user: attrs.user && attrs.user.value, tags: getTags(obj), - members: getMembers(obj) + members: getMembers(obj), + visible: getVisible(attrs) }); } }; function parse(dom) { - if (!dom || !dom.childNodes) return new Error('Bad request'); + if (!dom || !dom.childNodes) return; var root = dom.childNodes[0], children = root.childNodes, @@ -292,7 +327,7 @@ iD.Connection = function() { return connection; }; - connection.loadTiles = function(projection, dimensions) { + connection.loadTiles = function(projection, dimensions, callback) { if (off) return; @@ -345,7 +380,7 @@ iD.Connection = function() { loadedTiles[id] = true; delete inflight[id]; - event.load(err, _.extend({data: parsed}, tile)); + if (callback) callback(err, _.extend({data: parsed}, tile)); if (_.isEmpty(inflight)) { event.loaded(); diff --git a/js/id/core/entity.js b/js/id/core/entity.js index f4f52274c..dcc825fef 100644 --- a/js/id/core/entity.js +++ b/js/id/core/entity.js @@ -56,6 +56,9 @@ iD.Entity.prototype = { if (!this.id && this.type) { this.id = iD.Entity.id(this.type); } + if (!this.hasOwnProperty('visible')) { + this.visible = true; + } if (iD.debug) { Object.freeze(this); diff --git a/js/id/core/graph.js b/js/id/core/graph.js index 9422acc73..2fa09a6c7 100644 --- a/js/id/core/graph.js +++ b/js/id/core/graph.js @@ -16,10 +16,7 @@ iD.Graph = function(other, mutable) { this.transients = {}; this._childNodes = {}; - - if (!mutable) { - this.freeze(); - } + this.frozen = !mutable; }; iD.Graph.prototype = { @@ -112,20 +109,19 @@ iD.Graph.prototype = { // is used only during the history operation that merges newly downloaded // data into each state. To external consumers, it should appear as if the // graph always contained the newly downloaded data. - rebase: function(entities, stack) { + rebase: function(entities, stack, force) { var base = this.base(), i, j, k, id; for (i = 0; i < entities.length; i++) { var entity = entities[i]; - if (base.entities[entity.id]) + if (!entity.visible || (!force && base.entities[entity.id])) continue; // Merging data into the base graph base.entities[entity.id] = entity; - this._updateCalculated(undefined, entity, - base.parentWays, base.parentRels); + this._updateCalculated(undefined, entity, base.parentWays, base.parentRels); // Restore provisionally-deleted nodes that are discovered to have an extant parent if (entity.type === 'way') { @@ -262,15 +258,9 @@ iD.Graph.prototype = { arguments[i].call(graph, graph); } - return this.frozen ? graph.freeze() : this; - }, + if (this.frozen) graph.frozen = true; - freeze: function() { - this.frozen = true; - - // No longer freezing entities here due to in-place updates needed in rebase. - - return this; + return graph; }, // Obliterates any existing entities diff --git a/js/id/core/history.js b/js/id/core/history.js index 87c9b0075..979d815a3 100644 --- a/js/id/core/history.js +++ b/js/id/core/history.js @@ -41,9 +41,13 @@ iD.History = function(context) { return stack[index].graph; }, + base: function() { + return stack[0].graph; + }, + merge: function(entities, extent) { - stack[0].graph.rebase(entities, _.pluck(stack, 'graph')); - tree.rebase(entities); + stack[0].graph.rebase(entities, _.pluck(stack, 'graph'), false); + tree.rebase(entities, false); dispatch.change(undefined, extent); }, @@ -195,6 +199,12 @@ iD.History = function(context) { if (id in base.graph.entities) { baseEntities[id] = base.graph.entities[id]; } + // get originals of parent entities too + _.forEach(base.graph._parentWays[id], function(parentId) { + if (parentId in base.graph.entities) { + baseEntities[parentId] = base.graph.entities[parentId]; + } + }); }); var x = {}; @@ -237,8 +247,8 @@ iD.History = function(context) { var baseEntities = h.baseEntities.map(function(entity) { return iD.Entity(entity); }); - stack[0].graph.rebase(baseEntities, _.pluck(stack, 'graph')); - tree.rebase(baseEntities); + stack[0].graph.rebase(baseEntities, _.pluck(stack, 'graph'), true); + tree.rebase(baseEntities, true); } stack = h.stack.map(function(d) { diff --git a/js/id/core/tree.js b/js/id/core/tree.js index d456436c5..538e9894e 100644 --- a/js/id/core/tree.js +++ b/js/id/core/tree.js @@ -39,13 +39,13 @@ iD.Tree = function(head) { var tree = {}; - tree.rebase = function(entities) { + tree.rebase = function(entities, force) { var insertions = {}; for (var i = 0; i < entities.length; i++) { var entity = entities[i]; - if (head.entities.hasOwnProperty(entity.id) || rectangles[entity.id]) + if (!entity.visible || (!force && (head.entities.hasOwnProperty(entity.id) || rectangles[entity.id]))) continue; insertions[entity.id] = entity; diff --git a/js/id/id.js b/js/id/id.js index 0a9586a52..acd000a9c 100644 --- a/js/id/id.js +++ b/js/id/id.js @@ -53,10 +53,6 @@ window.iD = function () { locale = locale.split('-')[0]; } - connection.on('load.context', function loadContext(err, result) { - history.merge(result.data, result.extent); - }); - context.preauth = function(options) { connection.switch(options); return context; @@ -86,6 +82,51 @@ window.iD = function () { context.connection = function() { return connection; }; context.history = function() { return history; }; + /* Connection */ + function entitiesLoaded(err, result) { + if (!err) history.merge(result.data, result.extent); + } + + context.loadTiles = function(projection, dimensions, callback) { + function done(err, result) { + entitiesLoaded(err, result); + if (callback) callback(err, result); + } + connection.loadTiles(projection, dimensions, done); + }; + + context.loadEntity = function(id, callback) { + function done(err, result) { + entitiesLoaded(err, result); + if (callback) callback(err, result); + } + connection.loadEntity(id, done); + }; + + context.zoomToEntity = function(id, zoomTo) { + if (zoomTo !== false) { + this.loadEntity(id, function(err, result) { + if (err) return; + var entity = _.find(result.data, function(e) { return e.id === id; }); + if (entity) { map.zoomTo(entity); } + }); + } + + map.on('drawn.zoomToEntity', function() { + if (!context.hasEntity(id)) return; + map.on('drawn.zoomToEntity', null); + context.on('enter.zoomToEntity', null); + context.enter(iD.modes.Select(context, [id])); + }); + + context.on('enter.zoomToEntity', function() { + if (mode.id !== 'browse') { + map.on('drawn.zoomToEntity', null); + context.on('enter.zoomToEntity', null); + } + }); + }; + /* History */ context.graph = history.graph; context.changes = history.changes; @@ -100,7 +141,7 @@ window.iD = function () { }; context.save = function() { - if (inIntro) return; + if (inIntro || (mode && mode.id === 'save')) return; history.save(); if (history.hasChanges()) return t('save.unsaved_changes'); }; @@ -170,31 +211,6 @@ window.iD = function () { } }; - context.loadEntity = function(id, zoomTo) { - if (zoomTo !== false) { - connection.loadEntity(id, function(error, entity) { - if (entity) { - map.zoomTo(entity); - } - }); - } - - map.on('drawn.loadEntity', function() { - if (!context.hasEntity(id)) return; - map.on('drawn.loadEntity', null); - context.on('enter.loadEntity', null); - context.enter(iD.modes.Select(context, [id])); - }); - - context.on('enter.loadEntity', function() { - if (mode.id !== 'browse') { - map.on('drawn.loadEntity', null); - context.on('enter.loadEntity', null); - } - }); - }; - - /* Behaviors */ context.install = function(behavior) { context.surface().call(behavior); diff --git a/js/id/modes/save.js b/js/id/modes/save.js index ab41f507d..6e97c670a 100644 --- a/js/id/modes/save.js +++ b/js/id/modes/save.js @@ -1,43 +1,249 @@ iD.modes.Save = function(context) { var ui = iD.ui.Commit(context) - .on('cancel', cancel) - .on('save', save); + .on('cancel', cancel) + .on('save', save); function cancel() { context.enter(iD.modes.Browse(context)); } - function save(e) { - var loading = iD.ui.Loading(context) - .message(t('save.uploading')) - .blocking(true); + function save(e, tryAgain) { + function withChildNodes(ids) { + return _.uniq(_.reduce(toCheck, function(result, id) { + var e = context.entity(id); + if (e.type === 'way') result.push.apply(result, e.nodes); + return result; + }, _.clone(ids))); + } - context.container() - .call(loading); + var loading = iD.ui.Loading(context).message(t('save.uploading')).blocking(true), + history = context.history(), + origChanges = history.changes(iD.actions.DiscardTags(history.difference())), + localGraph = context.graph(), + remoteGraph = iD.Graph(history.base(), true), + modified = _.filter(history.difference().summary(), {changeType: 'modified'}), + toCheck = _.pluck(_.pluck(modified, 'entity'), 'id'), + toLoad = withChildNodes(toCheck), + conflicts = [], + errors = []; - context.connection().putChangeset( - context.history().changes(iD.actions.DiscardTags(context.history().difference())), - e.comment, - context.history().imageryUsed(), - function(err, changeset_id) { - loading.close(); - if (err) { - var confirm = iD.ui.confirm(context.container()); - confirm - .select('.modal-section.header') - .append('h3') - .text(t('save.error')); - confirm - .select('.modal-section.message-text') - .append('p') - .text(err.responseText || t('save.unknown_error_details')); - } else { - context.flush(); - success(e, changeset_id); + if (!tryAgain) history.perform(iD.actions.Noop()); // checkpoint + context.container().call(loading); + + if (toCheck.length) { + context.connection().loadMultiple(toLoad, loaded); + } else { + finalize(); + } + + + // 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.responseText, + details: [ t('save.status_code', { code: err.status }) ] + }); + showErrors(); + + } else { + _.each(result.data, function(entity) { + remoteGraph.replace(entity); + toLoad = _.without(toLoad, entity.id); + }); + + if (!toLoad.length) { + checkConflicts(); } + } + } + + + function checkConflicts() { + function choice(id, text, action) { + return { id: id, text: text, action: function() { history.replace(action); } }; + } + function formatUser(d) { + return '' + d + ''; + } + function entityName(entity) { + return iD.util.displayName(entity) || (iD.util.displayType(entity.id) + ' ' + entity.id); + } + + function compareVersions(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]), + b = remoteGraph.hasEntity(children[i]); + + if (!a || !b || a.version !== b.version) return false; + } + } + + return true; + } + + _.each(toCheck, function(id) { + var local = localGraph.entity(id), + remote = remoteGraph.entity(id); + + if (compareVersions(local, remote)) return; + + var action = iD.actions.MergeRemoteChanges, + merge = action(id, localGraph, remoteGraph, formatUser), + diff = history.replace(merge); + + if (diff.length()) return; // merged safely + + var forceLocal = action(id, localGraph, remoteGraph).withOption('force_local'), + forceRemote = action(id, localGraph, remoteGraph).withOption('force_remote'), + keepMine = t('save.conflict.' + (remote.visible ? 'keep_local' : 'restore')), + keepTheirs = t('save.conflict.' + (remote.visible ? 'keep_remote' : 'delete')); + + conflicts.push({ + id: id, + name: entityName(local), + details: merge.conflicts(), + chosen: 1, + choices: [ + choice(id, keepMine, forceLocal), + choice(id, keepTheirs, forceRemote) + ] + }); }); + + finalize(); + } + + + function finalize() { + if (conflicts.length) { + conflicts.sort(function(a,b) { return b.id.localeCompare(a.id); }); + showConflicts(); + } else if (errors.length) { + showErrors(); + } else { + context.connection().putChangeset( + history.changes(iD.actions.DiscardTags(history.difference())), + e.comment, + history.imageryUsed(), + function(err, changeset_id) { + if (err) { + errors.push({ + msg: err.responseText, + details: [ t('save.status_code', { code: err.status }) ] + }); + showErrors(); + } else { + loading.close(); + context.flush(); + success(e, changeset_id); + } + }); + } + } + + + function showConflicts() { + var selection = context.container() + .select('#sidebar') + .append('div') + .attr('class','sidebar-component'); + + loading.close(); + + selection.call(iD.ui.Conflicts(context) + .list(conflicts) + .on('download', function() { + var data = JXON.stringify(context.connection().osmChangeJXON('CHANGEME', origChanges)), + win = window.open('data:text/xml,' + encodeURIComponent(data), '_blank'); + win.focus(); + }) + .on('cancel', function() { + history.pop(); + selection.remove(); + }) + .on('save', function() { + selection.remove(); + save(e, true); + }) + ); + } + + + function showErrors() { + var selection = iD.ui.confirm(context.container()); + + history.pop(); + loading.close(); + + 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() { + var error = d3.select(this), + detail = d3.select(this.nextElementSibling), + exp = error.classed('expanded'); + + detail.style('display', exp ? 'none' : 'block'); + error.classed('expanded', !exp); + + d3.event.preventDefault(); + }); + + 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(e, changeset_id) { context.enter(iD.modes.Browse(context) .sidebar(iD.ui.Success(context) @@ -54,27 +260,13 @@ iD.modes.Save = function(context) { id: 'save' }; - var behaviors = [ - iD.behavior.Hover(context), - iD.behavior.Select(context), - iD.behavior.Lasso(context), - iD.modes.DragNode(context).behavior]; - mode.enter = function() { - behaviors.forEach(function(behavior) { - context.install(behavior); - }); - context.connection().authenticate(function() { context.ui().sidebar.show(ui); }); }; mode.exit = function() { - behaviors.forEach(function(behavior) { - context.uninstall(behavior); - }); - context.ui().sidebar.hide(ui); }; diff --git a/js/id/renderer/background.js b/js/id/renderer/background.js index 8b74795e7..3e5d4b5f6 100644 --- a/js/id/renderer/background.js +++ b/js/id/renderer/background.js @@ -151,14 +151,16 @@ iD.Background = function(context) { background.zoomToGpxLayer = function() { if (background.hasGpxLayer()) { - var viewport = context.map().extent().polygon(), + var map = context.map(), + viewport = map.trimmedExtent().polygon(), coords = _.reduce(gpxLayer.geojson().features, function(coords, feature) { var c = feature.geometry.coordinates; return _.union(coords, feature.geometry.type === 'Point' ? [c] : c); }, []); if (!iD.geo.polygonIntersectsPolygon(viewport, coords)) { - context.map().extent(d3.geo.bounds(gpxLayer.geojson())); + var extent = iD.geo.Extent(d3.geo.bounds(gpxLayer.geojson())); + map.centerZoom(extent.center(), map.trimmedExtentZoom(extent)); } } }; diff --git a/js/id/renderer/map.js b/js/id/renderer/map.js index 84531e16a..3ff258a10 100644 --- a/js/id/renderer/map.js +++ b/js/id/renderer/map.js @@ -205,7 +205,7 @@ iD.Map = function(context) { } if (map.editable()) { - context.connection().loadTiles(projection, dimensions); + context.loadTiles(projection, dimensions); drawVector(difference, extent); } else { editOff(); @@ -341,8 +341,10 @@ iD.Map = function(context) { }; map.zoomTo = function(entity, zoomLimits) { - var extent = entity.extent(context.graph()), - zoom = map.extentZoom(extent); + var extent = entity.extent(context.graph()); + if (!isFinite(extent.area())) return; + + var zoom = map.trimmedExtentZoom(extent); zoomLimits = zoomLimits || [context.minEditableZoom(), 20]; map.centerZoom(extent.center(), Math.min(Math.max(zoom, zoomLimits[0]), zoomLimits[1])); }; @@ -391,19 +393,28 @@ iD.Map = function(context) { projection.invert([dimensions[0] - pad, headerY + pad])); }; - map.extentZoom = function(_) { - var extent = iD.geo.Extent(_), - tl = projection([extent[0][0], extent[1][1]]), + function calcZoom(extent, dim) { + var tl = projection([extent[0][0], extent[1][1]]), br = projection([extent[1][0], extent[0][1]]); // Calculate maximum zoom that fits extent - var hFactor = (br[0] - tl[0]) / dimensions[0], - vFactor = (br[1] - tl[1]) / dimensions[1], + var hFactor = (br[0] - tl[0]) / dim[0], + vFactor = (br[1] - tl[1]) / dim[1], hZoomDiff = Math.log(Math.abs(hFactor)) / Math.LN2, vZoomDiff = Math.log(Math.abs(vFactor)) / Math.LN2, newZoom = map.zoom() - Math.max(hZoomDiff, vZoomDiff); return newZoom; + } + + map.extentZoom = function(_) { + return calcZoom(iD.geo.Extent(_), dimensions); + }; + + map.trimmedExtentZoom = function(_) { + var trimY = 120, trimX = 40, + trimmed = [dimensions[0] - trimX, dimensions[1] - trimY]; + return calcZoom(iD.geo.Extent(_), trimmed); }; map.editable = function() { diff --git a/js/id/ui.js b/js/id/ui.js index 1513488a4..35ed98bbe 100644 --- a/js/id/ui.js +++ b/js/id/ui.js @@ -88,6 +88,10 @@ iD.ui = function(context) { .attr('id', 'footer') .attr('class', 'fillD'); + footer.append('div') + .attr('class', 'api-status') + .call(iD.ui.Status(context)); + footer.append('div') .attr('id', 'scale-block') .call(iD.ui.Scale(context)); @@ -132,10 +136,6 @@ iD.ui = function(context) { .attr('tabindex', -1) .call(iD.ui.Contributors(context)); - footer.append('div') - .attr('class', 'api-status') - .call(iD.ui.Status(context)); - window.onbeforeunload = function() { return context.save(); }; diff --git a/js/id/ui/commit.js b/js/id/ui/commit.js index 6b4cb364a..e92cf441d 100644 --- a/js/id/ui/commit.js +++ b/js/id/ui/commit.js @@ -6,6 +6,7 @@ iD.ui.Commit = function(context) { summary = context.history().difference().summary(); function zoomToEntity(change) { + var entity = change.entity; if (change.changeType !== 'deleted' && context.graph().entity(entity.id).geometry(context.graph()) !== 'vertex') { @@ -116,7 +117,7 @@ iD.ui.Commit = function(context) { // Confirm Button var saveButton = saveSection.append('button') - .attr('class', 'action col4 button') + .attr('class', 'action col6 button') .on('click.save', function() { event.save({ comment: commentField.node().value diff --git a/js/id/ui/confirm.js b/js/id/ui/confirm.js index d70612b87..88569470a 100644 --- a/js/id/ui/confirm.js +++ b/js/id/ui/confirm.js @@ -12,15 +12,20 @@ iD.ui.confirm = function(selection) { section.append('div') .attr('class', 'modal-section message-text'); - var buttonwrap = section.append('div') + var buttons = section.append('div') .attr('class', 'modal-section buttons cf'); - buttonwrap.append('button') - .attr('class', 'col2 action') - .on('click.confirm', function() { - modal.remove(); - }) - .text(t('confirm.okay')); + modal.okButton = function() { + buttons + .append('button') + .attr('class', 'action col4') + .on('click.confirm', function() { + modal.remove(); + }) + .text(t('confirm.okay')); + + return modal; + }; return modal; }; diff --git a/js/id/ui/conflicts.js b/js/id/ui/conflicts.js new file mode 100644 index 000000000..321dd9a0b --- /dev/null +++ b/js/id/ui/conflicts.js @@ -0,0 +1,237 @@ +iD.ui.Conflicts = function(context) { + var dispatch = d3.dispatch('download', 'cancel', 'save'), + list; + + function conflicts(selection) { + var header = selection + .append('div') + .attr('class', 'header fillL'); + + header + .append('button') + .attr('class', 'fr') + .on('click', function() { dispatch.cancel(); }) + .append('span') + .attr('class', 'icon close'); + + header + .append('h3') + .text(t('save.conflict.header')); + + var body = selection + .append('div') + .attr('class', 'body fillL'); + + body + .append('div') + .attr('class', 'conflicts-help') + .text(t('save.conflict.help')) + .append('a') + .attr('class', 'conflicts-download') + .text(t('save.conflict.download_changes')) + .on('click.download', function() { dispatch.download(); }); + + body + .append('div') + .attr('class', 'conflict-container fillL3') + .call(showConflict, 0); + + body + .append('div') + .attr('class', 'conflicts-done') + .attr('opacity', 0) + .style('display', 'none') + .text(t('save.conflict.done')); + + var buttons = body + .append('div') + .attr('class','buttons col12 joined conflicts-buttons'); + + buttons + .append('button') + .attr('disabled', list.length > 1) + .attr('class', 'action conflicts-button col6') + .text(t('save.title')) + .on('click.try_again', function() { dispatch.save(); }); + + buttons + .append('button') + .attr('class', 'secondary-action conflicts-button col6') + .text(t('confirm.cancel')) + .on('click.cancel', function() { dispatch.cancel(); }); + } + + + function showConflict(selection, index) { + if (index < 0 || index >= list.length) return; + + var parent = d3.select(selection.node().parentElement); + + // enable save button if this is the last conflict being reviewed.. + if (index === list.length - 1) { + window.setTimeout(function() { + parent.select('.conflicts-button') + .attr('disabled', null); + + parent.select('.conflicts-done') + .transition() + .attr('opacity', 1) + .style('display', 'block'); + }, 250); + } + + var item = selection + .selectAll('.conflict') + .data([list[index]]); + + var enter = item.enter() + .append('div') + .attr('class', 'conflict'); + + enter + .append('h4') + .attr('class', 'conflict-count') + .text(t('save.conflict.count', { num: index + 1, total: list.length })); + + enter + .append('a') + .attr('class', 'conflict-description') + .attr('href', '#') + .text(function(d) { return d.name; }) + .on('click', function(d) { + zoomToEntity(d.id); + d3.event.preventDefault(); + }); + + var details = enter + .append('div') + .attr('class', 'conflict-detail-container'); + + details + .append('ul') + .attr('class', 'conflict-detail-list') + .selectAll('li') + .data(function(d) { return d.details || []; }) + .enter() + .append('li') + .attr('class', 'conflict-detail-item') + .html(function(d) { return d; }); + + details + .append('div') + .attr('class', 'conflict-choices') + .call(addChoices); + + details + .append('div') + .attr('class', 'conflict-nav-buttons joined cf') + .selectAll('button') + .data(['previous', 'next']) + .enter() + .append('button') + .text(function(d) { return t('save.conflict.' + d); }) + .attr('class', 'conflict-nav-button action col6') + .attr('disabled', function(d, i) { + return (i === 0 && index === 0) || + (i === 1 && index === list.length - 1) || null; + }) + .on('click', function(d, i) { + var container = parent.select('.conflict-container'), + sign = (i === 0 ? -1 : 1); + + container + .selectAll('.conflict') + .remove(); + + container + .call(showConflict, index + sign); + + d3.event.preventDefault(); + }); + + item.exit() + .remove(); + + } + + function addChoices(selection) { + var choices = selection + .append('ul') + .attr('class', 'layer-list') + .selectAll('li') + .data(function(d) { return d.choices || []; }); + + var enter = choices.enter() + .append('li') + .attr('class', 'layer'); + + var label = enter + .append('label'); + + label + .append('input') + .attr('type', 'radio') + .attr('name', function(d) { return d.id; }) + .on('change', function(d, i) { + var ul = this.parentElement.parentElement.parentElement; + ul.__data__.chosen = i; + choose(ul, d); + }); + + label + .append('span') + .text(function(d) { return d.text; }); + + choices + .each(function(d, i) { + var ul = this.parentElement; + if (ul.__data__.chosen === i) choose(ul, d); + }); + } + + function choose(ul, datum) { + if (d3.event) d3.event.preventDefault(); + + d3.select(ul) + .selectAll('li') + .classed('active', function(d) { return d === datum; }) + .selectAll('input') + .property('checked', function(d) { return d === datum; }); + + datum.action(); + zoomToEntity(datum.id); + } + + function zoomToEntity(id) { + context.surface().selectAll('.hover') + .classed('hover', false); + + var entity = context.graph().hasEntity(id); + if (entity) { + context.map().zoomTo(entity); + context.surface().selectAll( + iD.util.entityOrMemberSelector([entity.id], context.graph())) + .classed('hover', true); + } + } + + + // The conflict list should be an array of objects like: + // { + // id: id, + // name: entityName(local), + // details: merge.conflicts(), + // chosen: 1, + // choices: [ + // choice(id, keepMine, forceLocal), + // choice(id, keepTheirs, forceRemote) + // ] + // } + conflicts.list = function(_) { + if (!arguments.length) return list; + list = _; + return conflicts; + }; + + return d3.rebind(conflicts, dispatch, 'on'); +}; diff --git a/js/id/ui/contributors.js b/js/id/ui/contributors.js index cca4acc2c..ce2cb518b 100644 --- a/js/id/ui/contributors.js +++ b/js/id/ui/contributors.js @@ -55,7 +55,7 @@ iD.ui.Contributors = function(context) { return function(selection) { update(selection); - context.connection().on('load.contributors', function() { + context.connection().on('loaded.contributors', function() { update(selection); }); diff --git a/js/id/ui/feature_list.js b/js/id/ui/feature_list.js index 0786ca9e7..2d29f9540 100644 --- a/js/id/ui/feature_list.js +++ b/js/id/ui/feature_list.js @@ -223,7 +223,7 @@ iD.ui.FeatureList = function(context) { else if (d.entity) { context.enter(iD.modes.Select(context, [d.entity.id])); } else { - context.loadEntity(d.id); + context.zoomToEntity(d.id); } } diff --git a/js/id/ui/modal.js b/js/id/ui/modal.js index 6d49c0e51..248bc7e07 100644 --- a/js/id/ui/modal.js +++ b/js/id/ui/modal.js @@ -52,15 +52,9 @@ iD.ui.modal = function(selection, blocking) { if (animate) { shaded.transition().style('opacity', 1); - modal - .style('top','0px') - .transition() - .duration(200) - .style('top','40px'); } else { shaded.style('opacity', 1); } - return shaded; }; diff --git a/js/id/util.js b/js/id/util.js index 660f164dd..273d40338 100644 --- a/js/id/util.js +++ b/js/id/util.js @@ -30,6 +30,14 @@ iD.util.displayName = function(entity) { return entity.tags[localeName] || entity.tags.name || entity.tags.ref; }; +iD.util.displayType = function(id) { + return { + n: t('inspector.node'), + w: t('inspector.way'), + r: t('inspector.relation') + }[id.charAt(0)]; +}; + iD.util.stringQs = function(str) { return str.split('&').reduce(function(obj, pair){ var parts = pair.split('='); diff --git a/js/lib/diff3.js b/js/lib/diff3.js new file mode 100644 index 000000000..a527d1e33 --- /dev/null +++ b/js/lib/diff3.js @@ -0,0 +1,431 @@ +// Copyright (c) 2006, 2008 Tony Garnock-Jones +// Copyright (c) 2006, 2008 LShift Ltd. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, +// including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, +// and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +// BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +// ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// source: https://bitbucket.org/lshift/synchrotron/src + +Diff3 = (function() { + 'use strict'; + + var diff3 = { + longest_common_subsequence: function(file1, file2) { + /* Text diff algorithm following Hunt and McIlroy 1976. + * J. W. Hunt and M. D. McIlroy, An algorithm for differential file + * comparison, Bell Telephone Laboratories CSTR #41 (1976) + * http://www.cs.dartmouth.edu/~doug/ + * + * Expects two arrays of strings. + */ + var equivalenceClasses; + var file2indices; + var newCandidate; + var candidates; + var line; + var c, i, j, jX, r, s; + + equivalenceClasses = {}; + for (j = 0; j < file2.length; j++) { + line = file2[j]; + if (equivalenceClasses[line]) { + equivalenceClasses[line].push(j); + } else { + equivalenceClasses[line] = [j]; + } + } + + candidates = [{file1index: -1, + file2index: -1, + chain: null}]; + + for (i = 0; i < file1.length; i++) { + line = file1[i]; + file2indices = equivalenceClasses[line] || []; + + r = 0; + c = candidates[0]; + + for (jX = 0; jX < file2indices.length; jX++) { + j = file2indices[jX]; + + for (s = 0; s < candidates.length; s++) { + if ((candidates[s].file2index < j) && + ((s == candidates.length - 1) || + (candidates[s + 1].file2index > j))) + break; + } + + if (s < candidates.length) { + newCandidate = {file1index: i, + file2index: j, + chain: candidates[s]}; + if (r == candidates.length) { + candidates.push(c); + } else { + candidates[r] = c; + } + r = s + 1; + c = newCandidate; + if (r == candidates.length) { + break; // no point in examining further (j)s + } + } + } + + candidates[r] = c; + } + + // At this point, we know the LCS: it's in the reverse of the + // linked-list through .chain of + // candidates[candidates.length - 1]. + + return candidates[candidates.length - 1]; + }, + + diff_comm: function(file1, file2) { + // We apply the LCS to build a "comm"-style picture of the + // differences between file1 and file2. + + var result = []; + var tail1 = file1.length; + var tail2 = file2.length; + var common = {common: []}; + + function processCommon() { + if (common.common.length) { + common.common.reverse(); + result.push(common); + common = {common: []}; + } + } + + for (var candidate = Diff3.longest_common_subsequence(file1, file2); + candidate !== null; + candidate = candidate.chain) + { + var different = {file1: [], file2: []}; + + while (--tail1 > candidate.file1index) { + different.file1.push(file1[tail1]); + } + + while (--tail2 > candidate.file2index) { + different.file2.push(file2[tail2]); + } + + if (different.file1.length || different.file2.length) { + processCommon(); + different.file1.reverse(); + different.file2.reverse(); + result.push(different); + } + + if (tail1 >= 0) { + common.common.push(file1[tail1]); + } + } + + processCommon(); + + result.reverse(); + return result; + }, + + diff_patch: function(file1, file2) { + // We apply the LCD to build a JSON representation of a + // diff(1)-style patch. + + var result = []; + var tail1 = file1.length; + var tail2 = file2.length; + + function chunkDescription(file, offset, length) { + var chunk = []; + for (var i = 0; i < length; i++) { + chunk.push(file[offset + i]); + } + return {offset: offset, + length: length, + chunk: chunk}; + } + + for (var candidate = Diff3.longest_common_subsequence(file1, file2); + candidate !== null; + candidate = candidate.chain) + { + var mismatchLength1 = tail1 - candidate.file1index - 1; + var mismatchLength2 = tail2 - candidate.file2index - 1; + tail1 = candidate.file1index; + tail2 = candidate.file2index; + + if (mismatchLength1 || mismatchLength2) { + result.push({file1: chunkDescription(file1, + candidate.file1index + 1, + mismatchLength1), + file2: chunkDescription(file2, + candidate.file2index + 1, + mismatchLength2)}); + } + } + + result.reverse(); + return result; + }, + + strip_patch: function(patch) { + // Takes the output of Diff3.diff_patch(), and removes + // information from it. It can still be used by patch(), + // below, but can no longer be inverted. + var newpatch = []; + for (var i = 0; i < patch.length; i++) { + var chunk = patch[i]; + newpatch.push({file1: {offset: chunk.file1.offset, + length: chunk.file1.length}, + file2: {chunk: chunk.file2.chunk}}); + } + return newpatch; + }, + + invert_patch: function(patch) { + // Takes the output of Diff3.diff_patch(), and inverts the + // sense of it, so that it can be applied to file2 to give + // file1 rather than the other way around. + + for (var i = 0; i < patch.length; i++) { + var chunk = patch[i]; + var tmp = chunk.file1; + chunk.file1 = chunk.file2; + chunk.file2 = tmp; + } + }, + + patch: function (file, patch) { + // Applies a patch to a file. + // + // Given file1 and file2, Diff3.patch(file1, + // Diff3.diff_patch(file1, file2)) should give file2. + + var result = []; + var commonOffset = 0; + + function copyCommon(targetOffset) { + while (commonOffset < targetOffset) { + result.push(file[commonOffset]); + commonOffset++; + } + } + + for (var chunkIndex = 0; chunkIndex < patch.length; chunkIndex++) { + var chunk = patch[chunkIndex]; + copyCommon(chunk.file1.offset); + for (var lineIndex = 0; lineIndex < chunk.file2.chunk.length; lineIndex++) { + result.push(chunk.file2.chunk[lineIndex]); + } + commonOffset += chunk.file1.length; + } + + copyCommon(file.length); + return result; + }, + + diff_indices: function(file1, file2) { + // We apply the LCS to give a simple representation of the + // offsets and lengths of mismatched chunks in the input + // files. This is used by diff3_merge_indices below. + + var result = []; + var tail1 = file1.length; + var tail2 = file2.length; + + for (var candidate = Diff3.longest_common_subsequence(file1, file2); + candidate !== null; + candidate = candidate.chain) + { + var mismatchLength1 = tail1 - candidate.file1index - 1; + var mismatchLength2 = tail2 - candidate.file2index - 1; + tail1 = candidate.file1index; + tail2 = candidate.file2index; + + if (mismatchLength1 || mismatchLength2) { + result.push({file1: [tail1 + 1, mismatchLength1], + file2: [tail2 + 1, mismatchLength2]}); + } + } + + result.reverse(); + return result; + }, + + diff3_merge_indices: function (a, o, b) { + // Given three files, A, O, and B, where both A and B are + // independently derived from O, returns a fairly complicated + // internal representation of merge decisions it's taken. The + // interested reader may wish to consult + // + // Sanjeev Khanna, Keshav Kunal, and Benjamin C. Pierce. "A + // Formal Investigation of Diff3." In Arvind and Prasad, + // editors, Foundations of Software Technology and Theoretical + // Computer Science (FSTTCS), December 2007. + // + // (http://www.cis.upenn.edu/~bcpierce/papers/diff3-short.pdf) + var i; + + var m1 = Diff3.diff_indices(o, a); + var m2 = Diff3.diff_indices(o, b); + + var hunks = []; + function addHunk(h, side) { + hunks.push([h.file1[0], side, h.file1[1], h.file2[0], h.file2[1]]); + } + for (i = 0; i < m1.length; i++) { addHunk(m1[i], 0); } + for (i = 0; i < m2.length; i++) { addHunk(m2[i], 2); } + hunks.sort(); + + var result = []; + var commonOffset = 0; + function copyCommon(targetOffset) { + if (targetOffset > commonOffset) { + result.push([1, commonOffset, targetOffset - commonOffset]); + commonOffset = targetOffset; + } + } + + for (var hunkIndex = 0; hunkIndex < hunks.length; hunkIndex++) { + var firstHunkIndex = hunkIndex; + var hunk = hunks[hunkIndex]; + var regionLhs = hunk[0]; + var regionRhs = regionLhs + hunk[2]; + while (hunkIndex < hunks.length - 1) { + var maybeOverlapping = hunks[hunkIndex + 1]; + var maybeLhs = maybeOverlapping[0]; + if (maybeLhs > regionRhs) break; + regionRhs = maybeLhs + maybeOverlapping[2]; + hunkIndex++; + } + + copyCommon(regionLhs); + if (firstHunkIndex == hunkIndex) { + // The "overlap" was only one hunk long, meaning that + // there's no conflict here. Either a and o were the + // same, or b and o were the same. + if (hunk[4] > 0) { + result.push([hunk[1], hunk[3], hunk[4]]); + } + } else { + // A proper conflict. Determine the extents of the + // regions involved from a, o and b. Effectively merge + // all the hunks on the left into one giant hunk, and + // do the same for the right; then, correct for skew + // in the regions of o that each side changed, and + // report appropriate spans for the three sides. + var regions = { + 0: [a.length, -1, o.length, -1], + 2: [b.length, -1, o.length, -1] + }; + for (i = firstHunkIndex; i <= hunkIndex; i++) { + hunk = hunks[i]; + var side = hunk[1]; + var r = regions[side]; + var oLhs = hunk[0]; + var oRhs = oLhs + hunk[2]; + var abLhs = hunk[3]; + var abRhs = abLhs + hunk[4]; + r[0] = Math.min(abLhs, r[0]); + r[1] = Math.max(abRhs, r[1]); + r[2] = Math.min(oLhs, r[2]); + r[3] = Math.max(oRhs, r[3]); + } + var aLhs = regions[0][0] + (regionLhs - regions[0][2]); + var aRhs = regions[0][1] + (regionRhs - regions[0][3]); + var bLhs = regions[2][0] + (regionLhs - regions[2][2]); + var bRhs = regions[2][1] + (regionRhs - regions[2][3]); + result.push([-1, + aLhs, aRhs - aLhs, + regionLhs, regionRhs - regionLhs, + bLhs, bRhs - bLhs]); + } + commonOffset = regionRhs; + } + + copyCommon(o.length); + return result; + }, + + diff3_merge: function (a, o, b, excludeFalseConflicts) { + // Applies the output of Diff3.diff3_merge_indices to actually + // construct the merged file; the returned result alternates + // between "ok" and "conflict" blocks. + + var result = []; + var files = [a, o, b]; + var indices = Diff3.diff3_merge_indices(a, o, b); + + var okLines = []; + function flushOk() { + if (okLines.length) { + result.push({ok: okLines}); + } + okLines = []; + } + function pushOk(xs) { + for (var j = 0; j < xs.length; j++) { + okLines.push(xs[j]); + } + } + + function isTrueConflict(rec) { + if (rec[2] != rec[6]) return true; + var aoff = rec[1]; + var boff = rec[5]; + for (var j = 0; j < rec[2]; j++) { + if (a[j + aoff] != b[j + boff]) return true; + } + return false; + } + + for (var i = 0; i < indices.length; i++) { + var x = indices[i]; + var side = x[0]; + if (side == -1) { + if (excludeFalseConflicts && !isTrueConflict(x)) { + pushOk(files[0].slice(x[1], x[1] + x[2])); + } else { + flushOk(); + result.push({conflict: {a: a.slice(x[1], x[1] + x[2]), + aIndex: x[1], + o: o.slice(x[3], x[3] + x[4]), + oIndex: x[3], + b: b.slice(x[5], x[5] + x[6]), + bIndex: x[5]}}); + } + } else { + pushOk(files[side].slice(x[1], x[1] + x[2])); + } + } + + flushOk(); + return result; + } + }; + return diff3; +})(); + +if (typeof module !== 'undefined') module.exports = Diff3; diff --git a/test/index.html b/test/index.html index c0dbc2970..4f4f6510c 100644 --- a/test/index.html +++ b/test/index.html @@ -31,6 +31,7 @@ + @@ -76,6 +77,7 @@ + @@ -117,9 +119,9 @@ + - @@ -137,8 +139,9 @@ - + + @@ -216,6 +219,7 @@ + @@ -239,6 +243,7 @@ + diff --git a/test/index_packaged.html b/test/index_packaged.html index 324ed0b69..fe590c099 100644 --- a/test/index_packaged.html +++ b/test/index_packaged.html @@ -25,11 +25,12 @@ + + - @@ -45,11 +46,16 @@ - + + + + + + diff --git a/test/spec/actions/copy_entity.js b/test/spec/actions/copy_entity.js index 14f2de0fb..15f0497a3 100644 --- a/test/spec/actions/copy_entity.js +++ b/test/spec/actions/copy_entity.js @@ -56,21 +56,21 @@ describe("iD.actions.CopyEntity", function () { expect(created[0]).to.be.an.instanceof(iD.Relation); }); - it("deep copies a Relation, member Ways, and child Nodes and adds them to the graph", function () { - var a = iD.Node({id: 'a'}), - b = iD.Node({id: 'b'}), - w = iD.Way({id: 'w', nodes: ['a', 'b']}), - r = iD.Relation({id: 'r', members: [{id: 'w'}]}), - base = iD.Graph([a, b, w, r]), - head = iD.actions.CopyEntity(r, true)(base), - diff = iD.Difference(base, head), - created = diff.created(); + it("deep copies a Relation, member Ways, and child Nodes and adds them to the graph");//, function () { + // var a = iD.Node({id: 'a'}), + // b = iD.Node({id: 'b'}), + // w = iD.Way({id: 'w', nodes: ['a', 'b']}), + // r = iD.Relation({id: 'r', members: [{id: 'w'}]}), + // base = iD.Graph([a, b, w, r]), + // head = iD.actions.CopyEntity(r, true)(base), + // diff = iD.Difference(base, head), + // created = diff.created(); - expect(head.hasEntity('r')).to.be.ok; - expect(created).to.have.length(4); - expect(created[0]).to.be.an.instanceof(iD.Relation); - expect(created[1]).to.be.an.instanceof(iD.Way); - expect(created[2]).to.be.an.instanceof(iD.Node); - expect(created[3]).to.be.an.instanceof(iD.Node); - }); + // expect(head.hasEntity('r')).to.be.ok; + // expect(created).to.have.length(4); + // expect(created[0]).to.be.an.instanceof(iD.Relation); + // expect(created[1]).to.be.an.instanceof(iD.Way); + // expect(created[2]).to.be.an.instanceof(iD.Node); + // expect(created[3]).to.be.an.instanceof(iD.Node); + // }); }); diff --git a/test/spec/actions/merge_remote_changes.js b/test/spec/actions/merge_remote_changes.js new file mode 100644 index 000000000..9c15289ee --- /dev/null +++ b/test/spec/actions/merge_remote_changes.js @@ -0,0 +1,545 @@ +describe("iD.actions.MergeRemoteChanges", function () { + var base = iD.Graph([ + iD.Node({id: 'a', loc: [1, 1], version: '1', tags: {foo: 'foo'}}), + + iD.Node({id: 'p1', loc: [ 10, 10], version: '1'}), + iD.Node({id: 'p2', loc: [ 10, -10], version: '1'}), + iD.Node({id: 'p3', loc: [-10, -10], version: '1'}), + iD.Node({id: 'p4', loc: [-10, 10], version: '1'}), + iD.Way({ + id: 'w1', + nodes: ['p1', 'p2', 'p3', 'p4', 'p1'], + version: '1', + tags: {foo: 'foo', area: 'yes'} + }), + + iD.Node({id: 'q1', loc: [ 5, 5], version: '1'}), + iD.Node({id: 'q2', loc: [ 5, -5], version: '1'}), + iD.Node({id: 'q3', loc: [-5, -5], version: '1'}), + iD.Node({id: 'q4', loc: [-5, 5], version: '1'}), + iD.Way({ + id: 'w2', + nodes: ['q1', 'q2', 'q3', 'q4', 'q1'], + version: '1', + tags: {foo: 'foo', area: 'yes'} + }), + + iD.Relation({ + id: 'r', + members: [{id: 'w1', role: 'outer'}, {id: 'w2', role: 'inner'}], + version: '1', + tags: {type: 'multipolygon', foo: 'foo'} + }) + ]), + + // some new objects not in the graph yet.. + r1 = iD.Node({id: 'r1', loc: [ 12, 12], version: '1'}), + r2 = iD.Node({id: 'r2', loc: [ 12, -12], version: '1'}), + r3 = iD.Node({id: 'r3', loc: [-12, -12], version: '1'}), + r4 = iD.Node({id: 'r4', loc: [-12, 12], version: '1'}), + w3 = iD.Way({ + id: 'w3', + nodes: ['r1', 'r2', 'r3', 'r4', 'r1'], + version: '1', + tags: {foo: 'foo_new', area: 'yes'} + }), + + s1 = iD.Node({id: 's1', loc: [ 6, 6], version: '1'}), + s2 = iD.Node({id: 's2', loc: [ 6, -6], version: '1'}), + s3 = iD.Node({id: 's3', loc: [-6, -6], version: '1'}), + s4 = iD.Node({id: 's4', loc: [-6, 6], version: '1'}), + w4 = iD.Way({ + id: 'w4', + nodes: ['s1', 's2', 's3', 's4', 's1'], + version: '1', + tags: {foo: 'foo_new', area: 'yes'} + }), + + saved, error; + + // setup mock locale object.. + beforeEach(function() { + saved = locale; + error = console.error; + console.error = function () {}; + locale = { + _current: 'en', + en: { + 'merge_remote_changes': { + "annotation": "Merged remote changes from server.", + "conflict": { + "deleted": "This object has been deleted by {user}.", + "location": "This object was moved by both you and {user}.", + "nodelist": "Nodes were changed by both you and {user}.", + "memberlist": "Relation members were changed by both you and {user}.", + "tags": "You changed the {tag} tag to \"{local}\" and {user} changed it to \"{remote}\"." + } + } + } + }; + }); + + afterEach(function() { + locale = saved; + console.error = error; + }); + + function makeGraph(entities) { + return _.reduce(entities, function(graph, entity) { + return graph.replace(entity); + }, iD.Graph(base)); + } + + describe("non-destuctive merging", function () { + describe("tags", function() { + it("doesn't merge tags if conflict (local change, remote change)", function () { + var localTags = {foo: 'foo_local'}, // changed foo + remoteTags = {foo: 'foo_remote'}, // changed foo + local = base.entity('a').update({tags: localTags}), + remote = base.entity('a').update({tags: remoteTags, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('a', localGraph, remoteGraph), + result = action(localGraph); + + expect(result).to.eql(localGraph); + }); + + it("doesn't merge tags if conflict (local change, remote delete)", function () { + var localTags = {foo: 'foo_local'}, // changed foo + remoteTags = {}, // deleted foo + local = base.entity('a').update({tags: localTags}), + remote = base.entity('a').update({tags: remoteTags, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('a', localGraph, remoteGraph), + result = action(localGraph); + + expect(result).to.eql(localGraph); + }); + + it("doesn't merge tags if conflict (local delete, remote change)", function () { + var localTags = {}, // deleted foo + remoteTags = {foo: 'foo_remote'}, // changed foo + local = base.entity('a').update({tags: localTags}), + remote = base.entity('a').update({tags: remoteTags, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('a', localGraph, remoteGraph), + result = action(localGraph); + + expect(result).to.eql(localGraph); + }); + + it("doesn't merge tags if conflict (local add, remote add)", function () { + var localTags = {foo: 'foo', bar: 'bar_local'}, // same foo, added bar + remoteTags = {foo: 'foo', bar: 'bar_remote'}, // same foo, added bar + local = base.entity('a').update({tags: localTags}), + remote = base.entity('a').update({tags: remoteTags, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('a', localGraph, remoteGraph), + result = action(localGraph); + + expect(result).to.eql(localGraph); + }); + + it("merges tags if no conflict (remote delete)", function () { + var localTags = {foo: 'foo', bar: 'bar_local'}, // same foo, added bar + remoteTags = {}, // deleted foo + mergedTags = {bar: 'bar_local'}, + local = base.entity('a').update({tags: localTags}), + remote = base.entity('a').update({tags: remoteTags, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('a', localGraph, remoteGraph), + result = action(localGraph); + + expect(result.entity('a').version).to.eql('2'); + expect(result.entity('a').tags).to.eql(mergedTags); + }); + + it("merges tags if no conflict (local delete)", function () { + var localTags = {}, // deleted foo + remoteTags = {foo: 'foo', bar: 'bar_remote'}, // same foo, added bar + mergedTags = {bar: 'bar_remote'}, + local = base.entity('a').update({tags: localTags}), + remote = base.entity('a').update({tags: remoteTags, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('a', localGraph, remoteGraph), + result = action(localGraph); + + expect(result.entity('a').version).to.eql('2'); + expect(result.entity('a').tags).to.eql(mergedTags); + }); + }); + + + describe("nodes", function () { + it("doesn't merge nodes if location is different", function () { + var localTags = {foo: 'foo_local'}, // changed foo + remoteTags = {foo: 'foo', bar: 'bar_remote'}, // same foo, added bar + localLoc = [2, 2], // moved node + remoteLoc = [3, 3], // moved node + local = base.entity('a').update({tags: localTags, loc: localLoc}), + remote = base.entity('a').update({tags: remoteTags, loc: remoteLoc, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('a', localGraph, remoteGraph), + result = action(localGraph); + + expect(result).to.eql(localGraph); + }); + + it("merges nodes if location is same", function () { + var localTags = {foo: 'foo_local'}, // changed foo + remoteTags = {foo: 'foo', bar: 'bar_remote'}, // same foo, added bar + mergedTags = {foo: 'foo_local', bar: 'bar_remote'}, + localLoc = [2, 2], // moved node + remoteLoc = [2, 2], // moved node + local = base.entity('a').update({tags: localTags, loc: localLoc}), + remote = base.entity('a').update({tags: remoteTags, loc: remoteLoc, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('a', localGraph, remoteGraph), + result = action(localGraph); + + expect(result.entity('a').version).to.eql('2'); + expect(result.entity('a').tags).to.eql(mergedTags); + expect(result.entity('a').loc).to.eql([2, 2]); + }); + }); + + + describe("ways", function () { + it("merges ways if nodelist is same", function () { + var localTags = {foo: 'foo_local', area: 'yes'}, // changed foo + remoteTags = {foo: 'foo', bar: 'bar_remote', area: 'yes'}, // same foo, added bar + mergedTags = {foo: 'foo_local', bar: 'bar_remote', area: 'yes'}, + local = base.entity('w1').update({tags: localTags}), + remote = base.entity('w1').update({tags: remoteTags, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('w1', localGraph, remoteGraph), + result = action(localGraph); + + expect(result.entity('w1').version).to.eql('2'); + expect(result.entity('w1').tags).to.eql(mergedTags); + }); + + it("merges ways if nodelist changed only remotely", function () { + var localTags = {foo: 'foo_local', area: 'yes'}, // changed foo + remoteTags = {foo: 'foo', bar: 'bar_remote', area: 'yes'}, // same foo, added bar + mergedTags = {foo: 'foo_local', bar: 'bar_remote', area: 'yes'}, + localNodes = ['p1', 'p2', 'p3', 'p4', 'p1'], // didn't change nodes + remoteNodes = ['p1', 'r2', 'r3', 'p4', 'p1'], // changed nodes + local = base.entity('w1').update({tags: localTags, nodes: localNodes}), + remote = base.entity('w1').update({tags: remoteTags, nodes: remoteNodes, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote, r2, r3]), + action = iD.actions.MergeRemoteChanges('w1', localGraph, remoteGraph), + result = action(localGraph); + + expect(result.entity('w1').version).to.eql('2'); + expect(result.entity('w1').tags).to.eql(mergedTags); + expect(result.entity('w1').nodes).to.eql(remoteNodes); + expect(result.hasEntity('r2')).to.eql(r2); + expect(result.hasEntity('r3')).to.eql(r3); + }); + + it("merges ways if nodelist changed only locally", function () { + var localTags = {foo: 'foo_local', area: 'yes'}, // changed foo + remoteTags = {foo: 'foo', bar: 'bar_remote', area: 'yes'}, // same foo, added bar + mergedTags = {foo: 'foo_local', bar: 'bar_remote', area: 'yes'}, + localNodes = ['p1', 'r2', 'r3', 'p4', 'p1'], // changed nodes + remoteNodes = ['p1', 'p2', 'p3', 'p4', 'p1'], // didn't change nodes + local = base.entity('w1').update({tags: localTags, nodes: localNodes}), + remote = base.entity('w1').update({tags: remoteTags, nodes: remoteNodes, version: '2'}), + localGraph = makeGraph([local, r2, r3]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('w1', localGraph, remoteGraph), + result = action(localGraph); + + expect(result.entity('w1').version).to.eql('2'); + expect(result.entity('w1').tags).to.eql(mergedTags); + expect(result.entity('w1').nodes).to.eql(localNodes); + }); + + it("merges ways if nodelist changes don't overlap", function () { + var localTags = {foo: 'foo_local', area: 'yes'}, // changed foo + remoteTags = {foo: 'foo', bar: 'bar_remote', area: 'yes'}, // same foo, added bar + mergedTags = {foo: 'foo_local', bar: 'bar_remote', area: 'yes'}, + localNodes = ['p1', 'r1', 'r2', 'p3', 'p4', 'p1'], // changed p2 -> r1, r2 + remoteNodes = ['p1', 'p2', 'p3', 'r3', 'r4', 'p1'], // changed p4 -> r3, r4 + mergedNodes = ['p1', 'r1', 'r2', 'p3', 'r3', 'r4', 'p1'], + local = base.entity('w1').update({tags: localTags, nodes: localNodes}), + remote = base.entity('w1').update({tags: remoteTags, nodes: remoteNodes, version: '2'}), + localGraph = makeGraph([local, r1, r2]), + remoteGraph = makeGraph([remote, r3, r4]), + action = iD.actions.MergeRemoteChanges('w1', localGraph, remoteGraph), + result = action(localGraph); + + expect(result.entity('w1').version).to.eql('2'); + expect(result.entity('w1').tags).to.eql(mergedTags); + expect(result.entity('w1').nodes).to.eql(mergedNodes); + expect(result.hasEntity('r3')).to.eql(r3); + expect(result.hasEntity('r4')).to.eql(r4); + }); + + it("doesn't merge ways if nodelist changes overlap", function () { + var localTags = {foo: 'foo_local', area: 'yes'}, // changed foo + remoteTags = {foo: 'foo', bar: 'bar_remote', area: 'yes'}, // same foo, added bar + localNodes = ['p1', 'r1', 'r2', 'p3', 'p4', 'p1'], // changed p2 -> r1, r2 + remoteNodes = ['p1', 'r3', 'r4', 'p3', 'p4', 'p1'], // changed p2 -> r3, r4 + local = base.entity('w1').update({tags: localTags, nodes: localNodes}), + remote = base.entity('w1').update({tags: remoteTags, nodes: remoteNodes, version: '2'}), + localGraph = makeGraph([local, r1, r2]), + remoteGraph = makeGraph([remote, r3, r4]), + action = iD.actions.MergeRemoteChanges('w1', localGraph, remoteGraph), + result = action(localGraph); + + expect(result).to.eql(localGraph); + }); + + it("merges ways if childNode location is same", function () { + var localLoc = [12, 12], // moved node + remoteLoc = [12, 12], // moved node + local = base.entity('p1').update({loc: localLoc}), + remote = base.entity('p1').update({loc: remoteLoc, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('w1', localGraph, remoteGraph), + result = action(localGraph); + + expect(result.entity('p1').version).to.eql('2'); + expect(result.entity('p1').loc).to.eql(remoteLoc); + }); + + it("doesn't merge ways if childNode location is different", function () { + var localLoc = [12, 12], // moved node + remoteLoc = [13, 13], // moved node + local = base.entity('p1').update({loc: localLoc}), + remote = base.entity('p1').update({loc: remoteLoc, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('w1', localGraph, remoteGraph), + result = action(localGraph); + + expect(result).to.eql(localGraph); + }); + }); + + + describe("relations", function () { + it("doesn't merge relations if members have changed", function () { + var localTags = {foo: 'foo_local', type: 'multipolygon'}, // changed foo + remoteTags = {foo: 'foo', bar: 'bar_remote', type: 'multipolygon'}, // same foo, added bar + localMembers = [{id: 'w1', role: 'outer'}, {id: 'w2', role: 'inner'}], // same members + remoteMembers = [{id: 'w1', role: 'outer'}, {id: 'w4', role: 'inner'}], // changed inner to w4 + local = base.entity('r').update({tags: localTags, members: localMembers}), + remote = base.entity('r').update({tags: remoteTags, members: remoteMembers, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote, s1, s2, s3, s4, w4]), + action = iD.actions.MergeRemoteChanges('r', localGraph, remoteGraph), + result = action(localGraph); + + expect(result).to.eql(localGraph); + }); + + it("merges relations if members are same and changed tags don't conflict", function () { + var localTags = {foo: 'foo_local', type: 'multipolygon'}, // changed foo + remoteTags = {foo: 'foo', bar: 'bar_remote', type: 'multipolygon'}, // same foo, added bar + mergedTags = {foo: 'foo_local', bar: 'bar_remote', type: 'multipolygon'}, + localMembers = [{id: 'w1', role: 'outer'}, {id: 'w2', role: 'inner'}], // same members + remoteMembers = [{id: 'w1', role: 'outer'}, {id: 'w2', role: 'inner'}], // same members + local = base.entity('r').update({tags: localTags, members: localMembers}), + remote = base.entity('r').update({tags: remoteTags, members: remoteMembers, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('r', localGraph, remoteGraph), + result = action(localGraph); + + expect(result.entity('r').version).to.eql('2'); + expect(result.entity('r').tags).to.eql(mergedTags); + }); + }); + + + describe("#conflicts", function () { + it("returns conflict details", function () { + var localTags = {foo: 'foo_local'}, // changed foo + remoteTags = {foo: 'foo', bar: 'bar_remote'}, // same foo, added bar + remoteLoc = [2, 2], // moved node + local = base.entity('a').update({tags: localTags}), + remote = base.entity('a').update({tags: remoteTags, loc: remoteLoc, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('a', localGraph, remoteGraph), + result = action(localGraph); + + expect(action.conflicts()).not.to.be.empty; + }); + }); + }); + + + describe("destuctive merging", function () { + describe("nodes", function () { + it("merges nodes with 'force_local' option", function () { + var localTags = {foo: 'foo_local'}, // changed foo + remoteTags = {foo: 'foo_remote'}, // changed foo + localLoc = [2, 2], // moved node + remoteLoc = [3, 3], // moved node + local = base.entity('a').update({tags: localTags, loc: localLoc}), + remote = base.entity('a').update({tags: remoteTags, loc: remoteLoc, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('a', localGraph, remoteGraph).withOption('force_local'), + result = action(localGraph); + + expect(result.entity('a').version).to.eql('2'); + expect(result.entity('a').tags).to.eql(localTags); + expect(result.entity('a').loc).to.eql(localLoc); + }); + + it("merges nodes with 'force_remote' option", function () { + var localTags = {foo: 'foo_local'}, // changed foo + remoteTags = {foo: 'foo_remote'}, // changed foo + localLoc = [2, 2], // moved node + remoteLoc = [3, 3], // moved node + local = base.entity('a').update({tags: localTags, loc: localLoc}), + remote = base.entity('a').update({tags: remoteTags, loc: remoteLoc, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('a', localGraph, remoteGraph).withOption('force_remote'), + result = action(localGraph); + + expect(result.entity('a').version).to.eql('2'); + expect(result.entity('a').tags).to.eql(remoteTags); + expect(result.entity('a').loc).to.eql(remoteLoc); + }); + }); + + + describe("ways", function () { + it("merges ways with 'force_local' option", function () { + var localTags = {foo: 'foo_local', area: 'yes'}, // changed foo + remoteTags = {foo: 'foo_remote', area: 'yes'}, // changed foo + localNodes = ['p1', 'r1', 'r2', 'p3', 'p4', 'p1'], // changed p2 -> r1, r2 + remoteNodes = ['p1', 'r3', 'r4', 'p3', 'p4', 'p1'], // changed p2 -> r3, r4 + local = base.entity('w1').update({tags: localTags, nodes: localNodes}), + remote = base.entity('w1').update({tags: remoteTags, nodes: remoteNodes, version: '2'}), + localGraph = makeGraph([local, r1, r2]), + remoteGraph = makeGraph([remote, r3, r4]), + action = iD.actions.MergeRemoteChanges('w1', localGraph, remoteGraph).withOption('force_local'), + result = action(localGraph); + + expect(result.entity('w1').version).to.eql('2'); + expect(result.entity('w1').tags).to.eql(localTags); + expect(result.entity('w1').nodes).to.eql(localNodes); + }); + + it("merges ways with 'force_remote' option", function () { + var localTags = {foo: 'foo_local', area: 'yes'}, // changed foo + remoteTags = {foo: 'foo_remote', area: 'yes'}, // changed foo + localNodes = ['p1', 'r1', 'r2', 'p3', 'p4', 'p1'], // changed p2 -> r1, r2 + remoteNodes = ['p1', 'r3', 'r4', 'p3', 'p4', 'p1'], // changed p2 -> r3, r4 + local = base.entity('w1').update({tags: localTags, nodes: localNodes}), + remote = base.entity('w1').update({tags: remoteTags, nodes: remoteNodes, version: '2'}), + localGraph = makeGraph([local, r1, r2]), + remoteGraph = makeGraph([remote, r3, r4]), + action = iD.actions.MergeRemoteChanges('w1', localGraph, remoteGraph).withOption('force_remote'), + result = action(localGraph); + + expect(result.entity('w1').version).to.eql('2'); + expect(result.entity('w1').tags).to.eql(remoteTags); + expect(result.entity('w1').nodes).to.eql(remoteNodes); + expect(result.hasEntity('r3')).to.eql(r3); + expect(result.hasEntity('r4')).to.eql(r4); + }); + + it("merges way childNodes with 'force_local' option", function () { + var localLoc = [12, 12], // moved node + remoteLoc = [13, 13], // moved node + local = base.entity('p1').update({loc: localLoc}), + remote = base.entity('p1').update({loc: remoteLoc, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('w1', localGraph, remoteGraph).withOption('force_local'), + result = action(localGraph); + + expect(result.entity('p1').version).to.eql('2'); + expect(result.entity('p1').loc).to.eql(localLoc); + }); + + it("merges way childNodes with 'force_remote' option", function () { + var localLoc = [12, 12], // moved node + remoteLoc = [13, 13], // moved node + local = base.entity('p1').update({loc: localLoc}), + remote = base.entity('p1').update({loc: remoteLoc, version: '2'}), + localGraph = makeGraph([local]), + remoteGraph = makeGraph([remote]), + action = iD.actions.MergeRemoteChanges('w1', localGraph, remoteGraph).withOption('force_remote'), + result = action(localGraph); + + expect(result.entity('p1').version).to.eql('2'); + expect(result.entity('p1').loc).to.eql(remoteLoc); + }); + + it("keeps only important childNodes when merging", function () { + var localNodes = ['p1', 'r1', 'r2', 'p3', 'p4', 'p1'], // changed p2 -> r1, r2 + remoteNodes = ['p1', 'r3', 'r4', 'p3', 'p4', 'p1'], // changed p2 -> r3, r4 + localr1 = r1.update({tags: {highway: 'traffic_signals'}}), // r1 has interesting tags + local = base.entity('w1').update({nodes: localNodes}), + remote = base.entity('w1').update({nodes: remoteNodes, version: '2'}), + localGraph = makeGraph([local, localr1, r2]), + remoteGraph = makeGraph([remote, r3, r4]), + action = iD.actions.MergeRemoteChanges('w1', localGraph, remoteGraph).withOption('force_remote'), + result = action(localGraph); + + expect(result.entity('w1').nodes).to.eql(remoteNodes); + expect(result.hasEntity('r1')).to.eql(localr1); + expect(result.hasEntity('r2')).to.be.not.ok; + }); + }); + + + describe("relations", function () { + it("merges relations with 'force_local' option", function () { + var localTags = {foo: 'foo_local', type: 'multipolygon'}, // changed foo + remoteTags = {foo: 'foo_remote', type: 'multipolygon'}, // changed foo + localMembers = [{id: 'w3', role: 'outer'}, {id: 'w2', role: 'inner'}], // changed outer to w3 + remoteMembers = [{id: 'w1', role: 'outer'}, {id: 'w4', role: 'inner'}], // changed inner to w4 + local = base.entity('r').update({tags: localTags, members: localMembers}), + remote = base.entity('r').update({tags: remoteTags, members: remoteMembers, version: '2'}), + localGraph = makeGraph([local, r1, r2, r3, r4, w3]), + remoteGraph = makeGraph([remote, s1, s2, s3, s4, w4]), + action = iD.actions.MergeRemoteChanges('r', localGraph, remoteGraph).withOption('force_local'), + result = action(localGraph); + + expect(result.entity('r').version).to.eql('2'); + expect(result.entity('r').tags).to.eql(localTags); + expect(result.entity('r').members).to.eql(localMembers); + }); + + it("merges relations with 'force_remote' option", function () { + var localTags = {foo: 'foo_local', type: 'multipolygon'}, // changed foo + remoteTags = {foo: 'foo_remote', type: 'multipolygon'}, // changed foo + localMembers = [{id: 'w3', role: 'outer'}, {id: 'w2', role: 'inner'}], // changed outer to w3 + remoteMembers = [{id: 'w1', role: 'outer'}, {id: 'w4', role: 'inner'}], // changed inner to w4 + local = base.entity('r').update({tags: localTags, members: localMembers}), + remote = base.entity('r').update({tags: remoteTags, members: remoteMembers, version: '2'}), + localGraph = makeGraph([local, r1, r2, r3, r4, w3]), + remoteGraph = makeGraph([remote, s1, s2, s3, s4, w4]), + action = iD.actions.MergeRemoteChanges('r', localGraph, remoteGraph).withOption('force_remote'), + result = action(localGraph); + + expect(result.entity('r').version).to.eql('2'); + expect(result.entity('r').tags).to.eql(remoteTags); + expect(result.entity('r').members).to.eql(remoteMembers); + }); + }); + }); + +}); diff --git a/test/spec/core/connection.js b/test/spec/core/connection.js index da4d3568a..770b7d981 100644 --- a/test/spec/core/connection.js +++ b/test/spec/core/connection.js @@ -90,9 +90,10 @@ describe('iD.Connection', function () { }); it('loads a node', function(done) { - c.loadEntity('n1', function(error, entity) { + var id = 'n1'; + c.loadEntity(id, function(err, result) { + var entity = _.find(result.data, function(e) { return e.id === id; }); expect(entity).to.be.an.instanceOf(iD.Node); - expect(entity.id).to.eql('n1'); done(); }); @@ -102,9 +103,10 @@ describe('iD.Connection', function () { }); it('loads a way', function(done) { - c.loadEntity('w1', function(error, entity) { + var id = 'w1'; + c.loadEntity(id, function(err, result) { + var entity = _.find(result.data, function(e) { return e.id === id; }); expect(entity).to.be.an.instanceOf(iD.Way); - expect(entity.id).to.eql('w1'); done(); }); @@ -112,20 +114,23 @@ describe('iD.Connection', function () { [200, { "Content-Type": "text/xml" }, wayXML]); server.respond(); }); - - it('emits a load event', function(done) { - c.loadEntity('n1'); - c.on('load', function(error, result) { - expect(result.data[0]).to.be.an.instanceOf(iD.Node); - done(); - }); - - server.respondWith("GET", "http://www.openstreetmap.org/api/0.6/node/1", - [200, { "Content-Type": "text/xml" }, nodeXML]); - server.respond(); - }); }); + describe('#loadMultiple', function () { + beforeEach(function() { + server = sinon.fakeServer.create(); + }); + + afterEach(function() { + server.restore(); + }); + + it('loads nodes'); + it('loads ways'); + + }); + + describe('#osmChangeJXON', function() { it('converts change data to JXON', function() { var jxon = c.osmChangeJXON('1234', {created: [], modified: [], deleted: []}); diff --git a/test/spec/core/graph.js b/test/spec/core/graph.js index e8d6aa83c..d5238270d 100644 --- a/test/spec/core/graph.js +++ b/test/spec/core/graph.js @@ -30,7 +30,7 @@ describe('iD.Graph', function() { }); it("remains mutable if passed true as second argument", function () { - expect(iD.Graph([], true).frozen).not.to.be.true; + expect(iD.Graph([], true).frozen).to.be.false; }); }); @@ -58,12 +58,6 @@ describe('iD.Graph', function() { }); }); - describe("#freeze", function () { - it("sets the frozen flag", function () { - expect(iD.Graph([], true).freeze().frozen).to.be.true; - }); - }); - describe("#rebase", function () { it("preserves existing entities", function () { var node = iD.Node({id: 'n'}), @@ -83,6 +77,15 @@ describe('iD.Graph', function() { expect(graph.entity('n')).to.equal(node); }); + it("doesn't rebase deleted entities", function () { + var node = iD.Node({id: 'n', visible: false}), + graph = iD.Graph(); + + graph.rebase([node], [graph]); + + expect(graph.hasEntity('n')).to.be.not.ok; + }); + it("gives precedence to existing entities", function () { var a = iD.Node({id: 'n'}), b = iD.Node({id: 'n'}), @@ -93,10 +96,20 @@ describe('iD.Graph', function() { expect(graph.entity('n')).to.equal(a); }); + it("gives precedence to new entities when force = true", function () { + var a = iD.Node({id: 'n'}), + b = iD.Node({id: 'n'}), + graph = iD.Graph([a]); + + graph.rebase([b], [graph], true); + + expect(graph.entity('n')).to.equal(b); + }); + it("inherits entities from base prototypally", function () { var graph = iD.Graph(); - graph.rebase([iD.Node()], [graph]); + graph.rebase([iD.Node({id: 'n'})], [graph]); expect(graph.entities).not.to.have.ownProperty('n'); }); diff --git a/test/spec/core/relation.js b/test/spec/core/relation.js index 8588f3781..119df2fa7 100644 --- a/test/spec/core/relation.js +++ b/test/spec/core/relation.js @@ -91,7 +91,7 @@ describe('iD.Relation', function () { expect(r1_copy.members[1].id).to.equal(r2_copy.members[0].id); }); - // it("deep copies cyclical relation graphs without issue", function () { + it("deep copies cyclical relation graphs without issue"); //, function () { // var r1 = iD.Relation({id: 'r1', members: [{id: 'r2'}]}), // r2 = iD.Relation({id: 'r2', members: [{id: 'r1'}]}), // graph = iD.Graph([r1, r2]), @@ -109,7 +109,7 @@ describe('iD.Relation', function () { // expect(r2_copy.members[0].id).to.equal(r1_copy.id, msg); // }); - // it("deep copies self-refrencing relations without issue", function () { + it("deep copies self-refrencing relations without issue"); //, function () { // var r1 = iD.Relation({id: 'r1', members: [{id: 'r1'}]}), // graph = iD.Graph([r1]), // result = r1.copy(true, graph), diff --git a/test/spec/lib/diff3.js b/test/spec/lib/diff3.js new file mode 100644 index 000000000..3cf741554 --- /dev/null +++ b/test/spec/lib/diff3.js @@ -0,0 +1,89 @@ +describe("diff3", function() { + function split(s) { + return s ? s.split(/ /) : []; + } + + it('performs diff3 merge', function() { + var o = split('AA ZZ 00 M 99'), + a = split('AA a b c ZZ new 00 a a M 99'), + b = split('AA a d c ZZ 11 M z z 99'), + res = Diff3.diff3_merge(a, o, b); + + /* + AA + <<<<<<< a + a + b + c + ||||||| o + ======= + a + d + c + >>>>>>> b + ZZ + <<<<<<< a + new + 00 + a + a + ||||||| o + 00 + ======= + 11 + >>>>>>> b + M + z + z + 99 + */ + + expect(res[0].ok).to.eql(['AA']); + expect(res[0].conflict).to.be.undefined; + + expect(res[1].ok).to.be.undefined; + expect(res[1].conflict.o).to.eql([]); + expect(res[1].conflict.a).to.eql(['a', 'b', 'c']); + expect(res[1].conflict.b).to.eql(['a', 'd', 'c']); + + expect(res[2].ok).to.eql(['ZZ']); + expect(res[2].conflict).to.be.undefined; + + expect(res[3].ok).to.be.undefined; + expect(res[3].conflict.o).to.eql(['00']); + expect(res[3].conflict.a).to.eql(['new', '00', 'a', 'a']); + expect(res[3].conflict.b).to.eql(['11']); + + expect(res[4].ok).to.eql(['M', 'z', 'z', '99']); + expect(res[4].conflict).to.be.undefined; + }); + + it('can include false conflicts', function() { + var o = split('AA ZZ'), + a = split('AA a b c ZZ'), + b = split('AA a b c ZZ'), + res = Diff3.diff3_merge(a, o, b, false); + + expect(res[0].ok).to.eql(['AA']); + expect(res[0].conflict).to.be.undefined; + + expect(res[1].ok).to.be.undefined; + expect(res[1].conflict.o).to.eql([]); + expect(res[1].conflict.a).to.eql(['a', 'b', 'c']); + expect(res[1].conflict.b).to.eql(['a', 'b', 'c']); + + expect(res[2].ok).to.eql(['ZZ']); + expect(res[2].conflict).to.be.undefined; + }); + + it('can exclude false conflicts', function() { + var o = split('AA ZZ'), + a = split('AA a b c ZZ'), + b = split('AA a b c ZZ'), + res = Diff3.diff3_merge(a, o, b, true); + + expect(res[0].ok).to.eql(['AA', 'a', 'b', 'c', 'ZZ']); + expect(res[0].conflict).to.be.undefined; + }); + +});