diff --git a/Makefile b/Makefile index 55ed971e2..c22d5a80a 100644 --- a/Makefile +++ b/Makefile @@ -67,6 +67,7 @@ dist/iD.js: \ js/id/services/mapillary.js \ js/id/services/nominatim.js \ js/id/services/taginfo.js \ + js/id/services/wikidata.js \ js/id/services/wikipedia.js \ js/id/util.js \ js/id/util/session_mutex.js \ diff --git a/data/presets.yaml b/data/presets.yaml index 4c0534523..137cddb63 100644 --- a/data/presets.yaml +++ b/data/presets.yaml @@ -1045,7 +1045,7 @@ en: # 'width=*' label: Width (Meters) wikipedia: - # 'wikipedia=*' + # 'wikipedia=*, wikidata=*' label: Wikipedia presets: address: diff --git a/data/presets/fields.json b/data/presets/fields.json index ee2a6a2f1..203c632a5 100644 --- a/data/presets/fields.json +++ b/data/presets/fields.json @@ -1414,6 +1414,10 @@ }, "wikipedia": { "key": "wikipedia", + "keys": [ + "wikipedia", + "wikidata" + ], "type": "wikipedia", "icon": "wikipedia", "universal": true, diff --git a/data/presets/fields/wikipedia.json b/data/presets/fields/wikipedia.json index 8f4a67d85..466c95520 100644 --- a/data/presets/fields/wikipedia.json +++ b/data/presets/fields/wikipedia.json @@ -1,5 +1,6 @@ { "key": "wikipedia", + "keys": ["wikipedia", "wikidata"], "type": "wikipedia", "icon": "wikipedia", "universal": true, diff --git a/index.html b/index.html index 80d184a2f..cb539b9cc 100644 --- a/index.html +++ b/index.html @@ -43,6 +43,7 @@ + diff --git a/js/id/services/wikidata.js b/js/id/services/wikidata.js new file mode 100644 index 000000000..0718e2fdd --- /dev/null +++ b/js/id/services/wikidata.js @@ -0,0 +1,22 @@ +iD.services.wikidata = function() { + var wiki = {}, + endpoint = 'https://www.wikidata.org/w/api.php?'; + + // Given a Wikipedia language and article title, return an array of + // corresponding Wikidata entities. + wiki.itemsByTitle = function(lang, title, callback) { + lang = lang || 'en'; + d3.jsonp(endpoint + iD.util.qsString({ + action: 'wbgetentities', + format: 'json', + sites: lang.replace(/-/g, '_') + 'wiki', + titles: title, + languages: 'en', // shrink response by filtering to one language + callback: '{callback}' + }), function(data) { + callback(title, data.entities || {}); + }); + }; + + return wiki; +}; diff --git a/js/id/ui/preset/localized.js b/js/id/ui/preset/localized.js index 657331fbe..f41d2c191 100644 --- a/js/id/ui/preset/localized.js +++ b/js/id/ui/preset/localized.js @@ -4,7 +4,7 @@ iD.ui.preset.localized = function(field, context) { input, localizedInputs, wikiTitles, entity; - function i(selection) { + function localized(selection) { input = selection.selectAll('.localized-main') .data([0]); @@ -203,7 +203,7 @@ iD.ui.preset.localized = function(field, context) { .value(function(d) { return d.value; }); } - i.tags = function(tags) { + localized.tags = function(tags) { // Fetch translations from wikipedia if (tags.wikipedia && !wikiTitles) { wikiTitles = {}; @@ -228,13 +228,15 @@ iD.ui.preset.localized = function(field, context) { localizedInputs.call(render, postfixed.reverse()); }; - i.focus = function() { + localized.focus = function() { input.node().focus(); }; - i.entity = function(_) { + localized.entity = function(_) { + if (!arguments.length) return entity; entity = _; + return localized; }; - return d3.rebind(i, dispatch, 'on'); + return d3.rebind(localized, dispatch, 'on'); }; diff --git a/js/id/ui/preset/textarea.js b/js/id/ui/preset/textarea.js index b609aed72..92d18aa63 100644 --- a/js/id/ui/preset/textarea.js +++ b/js/id/ui/preset/textarea.js @@ -2,7 +2,7 @@ iD.ui.preset.textarea = function(field) { var dispatch = d3.dispatch('change'), input; - function i(selection) { + function textarea(selection) { input = selection.selectAll('textarea') .data([0]); @@ -25,13 +25,13 @@ iD.ui.preset.textarea = function(field) { }; } - i.tags = function(tags) { + textarea.tags = function(tags) { input.value(tags[field.key] || ''); }; - i.focus = function() { + textarea.focus = function() { input.node().focus(); }; - return d3.rebind(i, dispatch, 'on'); + return d3.rebind(textarea, dispatch, 'on'); }; diff --git a/js/id/ui/preset/wikipedia.js b/js/id/ui/preset/wikipedia.js index b14ac9655..b129eb9ba 100644 --- a/js/id/ui/preset/wikipedia.js +++ b/js/id/ui/preset/wikipedia.js @@ -1,9 +1,10 @@ iD.ui.preset.wikipedia = function(field, context) { var dispatch = d3.dispatch('change'), wikipedia = iD.services.wikipedia(), + wikidata = iD.services.wikidata(), link, entity, lang, title; - function i(selection) { + function wiki(selection) { var langcombo = d3.combobox() .fetcher(function(value, cb) { var v = value.toLowerCase(); @@ -54,7 +55,7 @@ iD.ui.preset.wikipedia = function(field, context) { title .call(titlecombo) - .on('blur', change) + .on('blur', blur) .on('change', change); link = selection.selectAll('a.wiki-link') @@ -81,14 +82,19 @@ iD.ui.preset.wikipedia = function(field, context) { function changeLang() { lang.value(language()[1]); - change(); + change(true); } - function change() { + function blur() { + change(true); + } + + function change(skipWikidata) { var value = title.value(), m = value.match(/https?:\/\/([-a-z]+)\.wikipedia\.org\/(?:wiki|\1-[-a-z]+)\/([^#]+)(?:#(.+))?/), l = m && _.find(iD.data.wikipedia, function(d) { return m[1] === d[2]; }), - anchor; + anchor, + syncTags = {}; if (l) { // Normalize title http://www.mediawiki.org/wiki/API:Query#Title_normalization @@ -107,12 +113,47 @@ iD.ui.preset.wikipedia = function(field, context) { title.value(value); } - var t = {}; - t[field.key] = value ? language()[2] + ':' + value : undefined; - dispatch.change(t); + syncTags.wikipedia = value ? language()[2] + ':' + value : undefined; + if (!skipWikidata) { + syncTags.wikidata = undefined; + } + + dispatch.change(syncTags); + + + if (skipWikidata || !value || !language()[2]) return; + + // attempt asynchronous update of wikidata tag.. + var initEntityId = entity.id, + initWikipedia = context.entity(initEntityId).tags.wikipedia; + + wikidata.itemsByTitle(language()[2], value, function (title, data) { + // 1. most recent change was a tag change + var annotation = t('operations.change_tags.annotation'), + currAnnotation = context.history().undoAnnotation(); + if (currAnnotation !== annotation) return; + + // 2. same entity exists and still selected + var selectedIds = context.selectedIDs(), + currEntityId = selectedIds.length > 0 && selectedIds[0]; + if (currEntityId !== initEntityId) return; + + // 3. wikipedia value has not changed + var currTags = _.clone(context.entity(currEntityId).tags), + qids = data && Object.keys(data); + if (initWikipedia !== currTags.wikipedia) return; + + // ok to coalesce the update of wikidata tag into the previous tag change + currTags.wikidata = qids && _.find(qids, function (id) { + return id.match(/^Q\d+$/); + }); + + context.overwrite(iD.actions.ChangeTags(currEntityId, currTags), annotation); + dispatch.change(currTags); + }); } - i.tags = function(tags) { + wiki.tags = function(tags) { var value = tags[field.key] || '', m = value.match(/([^:]+):([^#]+)(?:#(.+))?/), l = m && _.find(iD.data.wikipedia, function(d) { return m[1] === d[2]; }), @@ -131,7 +172,7 @@ iD.ui.preset.wikipedia = function(field, context) { } } link.attr('href', 'https://' + m[1] + '.wikipedia.org/wiki/' + - m[2].replace(/ /g, '_') + (anchor ? ('#' + anchor) : '')); + m[2].replace(/ /g, '_') + (anchor ? ('#' + anchor) : '')); // unrecognized value format } else { @@ -143,13 +184,15 @@ iD.ui.preset.wikipedia = function(field, context) { } }; - i.entity = function(_) { + wiki.entity = function(_) { + if (!arguments.length) return entity; entity = _; + return wiki; }; - i.focus = function() { + wiki.focus = function() { title.node().focus(); }; - return d3.rebind(i, dispatch, 'on'); + return d3.rebind(wiki, dispatch, 'on'); }; diff --git a/test/index.html b/test/index.html index 15775fc36..24aa35963 100644 --- a/test/index.html +++ b/test/index.html @@ -48,6 +48,7 @@ + diff --git a/test/spec/ui/preset/wikipedia.js b/test/spec/ui/preset/wikipedia.js index f90ab6e09..b0d5764c6 100644 --- a/test/spec/ui/preset/wikipedia.js +++ b/test/spec/ui/preset/wikipedia.js @@ -1,13 +1,46 @@ describe('iD.ui.preset.wikipedia', function() { - var selection, field; + var entity, context, selection, field, wikiDelay, selectedId; + + function wikidataStub() { + wikidataStub.itemsByTitle = function(lang, title, callback) { + var data = {Q216353: {id: 'Q216353'}}; + if (wikiDelay) { + window.setTimeout(function () { callback(title, data); }, wikiDelay); + } + else { + callback(title, data); + } + } + return wikidataStub; + } + + function changeTags(changed) { + var annotation = t('operations.change_tags.annotation'); + var tags = _.extend({}, entity.tags, changed); + context.perform(iD.actions.ChangeTags(entity.id, tags), annotation); + } beforeEach(function() { + entity = iD.Node({id: 'n12345'}); + selectedId = entity.id; + context = iD(); + context.history().merge([entity]); selection = d3.select(document.createElement('div')); - field = iD().presets(iD.data.presets).presets().field('wikipedia'); + field = context.presets(iD.data.presets).presets().field('wikipedia'); + wikiDelay = 0; + + sinon.stub(iD.services, 'wikidata', wikidataStub); + sinon.stub(context, 'selectedIDs', function() { return [selectedId]; }); }); + afterEach(function() { + iD.services.wikidata.restore(); + context.selectedIDs.restore(); + }); + + it('recognizes lang:title format', function() { - var wikipedia = iD.ui.preset.wikipedia(field, {}); + var wikipedia = iD.ui.preset.wikipedia(field, context); selection.call(wikipedia); wikipedia.tags({wikipedia: 'en:Title'}); expect(selection.selectAll('.wiki-lang').value()).to.equal('English'); @@ -15,44 +48,108 @@ describe('iD.ui.preset.wikipedia', function() { expect(selection.selectAll('.wiki-link').attr('href')).to.equal('https://en.wikipedia.org/wiki/Title'); }); - it('sets a new value', function() { - var wikipedia = iD.ui.preset.wikipedia(field, {}); + it('sets language, value, wikidata', function() { + var wikipedia = iD.ui.preset.wikipedia(field, context).entity(entity); + wikipedia.on('change', changeTags); selection.call(wikipedia); - wikipedia.on('change', function(tags) { - expect(tags).to.eql({wikipedia: undefined}); - }); - + var spy = sinon.spy(); + wikipedia.on('change.spy', spy); selection.selectAll('.wiki-lang').value('Deutsch'); - happen.once(selection.selectAll('.wiki-lang').node(), {type: 'change'}); - - wikipedia.on('change', function(tags) { - expect(tags).to.eql({wikipedia: 'de:Title'}); - }); + happen.once(selection.selectAll('.wiki-lang').node(), { type: 'change' }); + happen.once(selection.selectAll('.wiki-lang').node(), { type: 'blur' }); + expect(spy.callCount).to.equal(2); + expect(spy.firstCall).to.have.been.calledWith({ wikipedia: undefined }); // on change + expect(spy.secondCall).to.have.been.calledWith({ wikipedia: undefined }); // on blur + spy = sinon.spy(); + wikipedia.on('change.spy', spy); selection.selectAll('.wiki-title').value('Title'); - happen.once(selection.selectAll('.wiki-title').node(), {type: 'change'}); + happen.once(selection.selectAll('.wiki-title').node(), { type: 'change' }); + happen.once(selection.selectAll('.wiki-title').node(), { type: 'blur' }); + expect(spy.callCount).to.equal(3); + expect(spy.firstCall).to.have.been.calledWith({ wikipedia: 'de:Title', wikidata: undefined }); // on change + expect(spy.secondCall).to.have.been.calledWith({ wikipedia: 'de:Title', wikidata: 'Q216353' }); // wikidata async + expect(spy.thirdCall).to.have.been.calledWith({ wikipedia: 'de:Title' }); // on blur }); it('recognizes pasted URLs', function() { - var wikipedia = iD.ui.preset.wikipedia(field, {}); + var wikipedia = iD.ui.preset.wikipedia(field, context).entity(entity); + wikipedia.on('change', changeTags); selection.call(wikipedia); selection.selectAll('.wiki-title').value('http://de.wikipedia.org/wiki/Title'); - happen.once(selection.selectAll('.wiki-title').node(), {type: 'change'}); - + happen.once(selection.selectAll('.wiki-title').node(), { type: 'change' }); expect(selection.selectAll('.wiki-lang').value()).to.equal('Deutsch'); expect(selection.selectAll('.wiki-title').value()).to.equal('Title'); }); it('preserves existing language', function() { - selection.call(iD.ui.preset.wikipedia(field, {})); + selection.call(iD.ui.preset.wikipedia(field, context)); selection.selectAll('.wiki-lang').value('Deutsch'); - var wikipedia = iD.ui.preset.wikipedia(field, {}); + var wikipedia = iD.ui.preset.wikipedia(field, context); selection.call(wikipedia); wikipedia.tags({}); expect(selection.selectAll('.wiki-lang').value()).to.equal('Deutsch'); }); + + it('does not set delayed wikidata tag if wikipedia field has changed', function(done) { + var wikipedia = iD.ui.preset.wikipedia(field, context).entity(entity); + wikipedia.on('change', changeTags); + selection.call(wikipedia); + wikiDelay = 20; + + var spy = sinon.spy(); + wikipedia.on('change.spy', spy); + selection.selectAll('.wiki-lang').value('Deutsch'); + selection.selectAll('.wiki-title').value('Skip'); + happen.once(selection.selectAll('.wiki-title').node(), { type: 'change' }); + happen.once(selection.selectAll('.wiki-title').node(), { type: 'blur' }); + + window.setTimeout(function() { + selection.selectAll('.wiki-title').value('Title'); + happen.once(selection.selectAll('.wiki-title').node(), { type: 'change' }); + happen.once(selection.selectAll('.wiki-title').node(), { type: 'blur' }); + }, 10); + + window.setTimeout(function() { + expect(spy.callCount).to.equal(5); + expect(spy.getCall(0)).to.have.been.calledWith({ wikipedia: 'de:Skip', wikidata: undefined }); // 'Skip' on change + expect(spy.getCall(1)).to.have.been.calledWith({ wikipedia: 'de:Skip' }); // 'Skip' on blur + expect(spy.getCall(2)).to.have.been.calledWith({ wikipedia: 'de:Title', wikidata: undefined }); // 'Title' on change +10ms + expect(spy.getCall(3)).to.have.been.calledWith({ wikipedia: 'de:Title' }); // 'Title' on blur +10ms + // skip delayed wikidata for 'Skip' // 'Skip' wikidata +20ms + expect(spy.getCall(4)).to.have.been.calledWith({ wikipedia: 'de:Title', wikidata: 'Q216353' }); // 'Title' wikidata +40ms + done(); + }, 50); + }); + + it('does not set delayed wikidata tag if selected entity has changed', function(done) { + var wikipedia = iD.ui.preset.wikipedia(field, context).entity(entity); + wikipedia.on('change', changeTags); + selection.call(wikipedia); + wikiDelay = 20; + + var spy = sinon.spy(); + wikipedia.on('change.spy', spy); + selection.selectAll('.wiki-lang').value('Deutsch'); + selection.selectAll('.wiki-title').value('Title'); + happen.once(selection.selectAll('.wiki-title').node(), { type: 'change' }); + happen.once(selection.selectAll('.wiki-title').node(), { type: 'blur' }); + + window.setTimeout(function() { + selectedId = 'w-123'; // user clicked on something else.. + }, 10); + + window.setTimeout(function() { + expect(spy.callCount).to.equal(2); + expect(spy.getCall(0)).to.have.been.calledWith({ wikipedia: 'de:Title', wikidata: undefined }); // 'Title' on change + expect(spy.getCall(1)).to.have.been.calledWith({ wikipedia: 'de:Title' }); // 'Title' on blur + // wikidata tag not changed because another entity is now selected + done(); + }, 50); + }); + });