diff --git a/data/core.yaml b/data/core.yaml index c74f1a977..a06894368 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -405,7 +405,6 @@ en: inspector: no_documentation_combination: There is no documentation available for this tag combination no_documentation_key: There is no documentation available for this key - documentation_redirect: This documentation has been redirected to a new page show_more: Show More view_on_osm: View on openstreetmap.org all_fields: All fields @@ -418,6 +417,7 @@ en: choose: Select feature type results: "{n} results for {search}" reference: View on OpenStreetMap Wiki + edit_reference: Edit or translate on OSM Wiki back_tooltip: Change feature remove: Remove search: Search diff --git a/dist/locales/en.json b/dist/locales/en.json index a484a711d..8260d3a4f 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -499,7 +499,6 @@ "inspector": { "no_documentation_combination": "There is no documentation available for this tag combination", "no_documentation_key": "There is no documentation available for this key", - "documentation_redirect": "This documentation has been redirected to a new page", "show_more": "Show More", "view_on_osm": "View on openstreetmap.org", "all_fields": "All fields", @@ -512,6 +511,7 @@ "choose": "Select feature type", "results": "{n} results for {search}", "reference": "View on OpenStreetMap Wiki", + "edit_reference": "Edit or translate on OSM Wiki", "back_tooltip": "Change feature", "remove": "Remove", "search": "Search", diff --git a/modules/services/index.js b/modules/services/index.js index 59c9d9524..cc2a3d729 100644 --- a/modules/services/index.js +++ b/modules/services/index.js @@ -3,6 +3,7 @@ import serviceMapRules from './maprules'; import serviceNominatim from './nominatim'; import serviceOpenstreetcam from './openstreetcam'; import serviceOsm from './osm'; +import serviceOsmWikibase from './osm_wikibase'; import serviceStreetside from './streetside'; import serviceTaginfo from './taginfo'; import serviceVectorTile from './vector_tile'; @@ -15,6 +16,7 @@ export var services = { mapillary: serviceMapillary, openstreetcam: serviceOpenstreetcam, osm: serviceOsm, + osmWikibase: serviceOsmWikibase, maprules: serviceMapRules, streetside: serviceStreetside, taginfo: serviceTaginfo, @@ -29,6 +31,7 @@ export { serviceNominatim, serviceOpenstreetcam, serviceOsm, + serviceOsmWikibase, serviceStreetside, serviceTaginfo, serviceVectorTile, diff --git a/modules/services/osm_wikibase.js b/modules/services/osm_wikibase.js new file mode 100644 index 000000000..366f6c5fc --- /dev/null +++ b/modules/services/osm_wikibase.js @@ -0,0 +1,201 @@ +import _debounce from 'lodash-es/debounce'; +import _forEach from 'lodash-es/forEach'; + +import { json as d3_json } from 'd3-request'; + +import { utilQsString } from '../util'; + + +var apibase = 'https://wiki.openstreetmap.org/w/api.php'; +var _inflight = {}; +var _wikibaseCache = {}; +var _localeIds = { en: false }; + + +var debouncedRequest = _debounce(request, 500, { leading: false }); + +function request(url, callback) { + if (_inflight[url]) return; + + _inflight[url] = d3_json(url, function (err, data) { + delete _inflight[url]; + callback(err, data); + }); +} + + +/** + * Get the best string value from the descriptions/labels result + * Note that if mediawiki doesn't recognize language code, it will return all values. + * In that case, fallback to use English. + * @param values object - either descriptions or labels + * @param langCode String + * @returns localized string + */ +function localizedToString(values, langCode) { + if (values) { + values = values[langCode] || values.en; + } + return values ? values.value : ''; +} + + +export default { + + init: function() { + _inflight = {}; + _wikibaseCache = {}; + _localeIds = {}; + }, + + + reset: function() { + _forEach(_inflight, function(req) { req.abort(); }); + _inflight = {}; + }, + + + /** + * Get the best value for the property, or undefined if not found + * @param entity object from wikibase + * @param property string e.g. 'P4' for image + * @param langCode string e.g. 'fr' for French + */ + claimToValue: function(entity, property, langCode) { + if (!entity.claims[property]) return undefined; + var locale = _localeIds[langCode]; + var preferredPick, localePick; + _forEach(entity.claims[property], function(stmt) { + // If exists, use value limited to the needed language (has a qualifier P26 = locale) + // Or if not found, use the first value with the "preferred" rank + if (!preferredPick && stmt.rank === 'preferred') { + preferredPick = stmt; + } + if (locale && stmt.qualifiers && stmt.qualifiers.P26 && + stmt.qualifiers.P26[0].datavalue.value.id === locale + ) { + localePick = stmt; + } + }); + var result = localePick || preferredPick; + + if (result) { + var datavalue = result.mainsnak.datavalue; + return datavalue.type === 'wikibase-entityid' ? datavalue.value.id : datavalue.value; + } else { + return undefined; + } + }, + + + toSitelink: function(key, value) { + var result = value ? 'Tag:' + key + '=' + value : 'Key:' + key; + return result.replace(/_/g, ' ').trim(); + }, + + + getEntity: function(params, callback) { + var doRequest = params.debounce ? debouncedRequest : request; + var self = this; + var titles = []; + var result = {}; + var keySitelink = this.toSitelink(params.key); + var tagSitelink = params.value ? this.toSitelink(params.key, params.value) : false; + var localeSitelink; + + if (params.langCode && _localeIds[params.langCode] === undefined) { + // If this is the first time we are asking about this locale, + // fetch corresponding entity (if it exists), and cache it. + // If there is no such entry, cache `false` value to avoid re-requesting it. + localeSitelink = ('Locale:' + params.langCode).replace(/_/g, ' ').trim(); + titles.push(localeSitelink); + } + + if (_wikibaseCache[keySitelink]) { + result.key = _wikibaseCache[keySitelink]; + } else { + titles.push(keySitelink); + } + + if (tagSitelink) { + if (_wikibaseCache[tagSitelink]) { + result.tag = _wikibaseCache[tagSitelink]; + } else { + titles.push(tagSitelink); + } + } + + if (!titles.length) { + // Nothing to do, we already had everything in the cache + return callback(null, result); + } + + // Requesting just the user language code + // If backend recognizes the code, it will perform proper fallbacks, + // and the result will contain the requested code. If not, all values are returned: + // {"zh-tw":{"value":"...","language":"zh-tw","source-language":"zh-hant"} + // {"pt-br":{"value":"...","language":"pt","for-language":"pt-br"}} + var obj = { + action: 'wbgetentities', + sites: 'wiki', + titles: titles.join('|'), + languages: params.langCode, + languagefallback: 1, + origin: '*', + format: 'json', + // There is an MW Wikibase API bug https://phabricator.wikimedia.org/T212069 + // We shouldn't use v1 until it gets fixed, but should switch to it afterwards + // formatversion: 2, + }; + + var url = apibase + '?' + utilQsString(obj); + doRequest(url, function(err, d) { + if (err) { + callback(err); + } else if (!d.success || d.error) { + callback(d.error.messages.map(function(v) { return v.html['*']; }).join('
')); + } else { + var localeId = false; + _forEach(d.entities, function(res) { + if (res.missing !== '') { + var title = res.sitelinks.wiki.title; + // Simplify access to the localized values + res.description = localizedToString(res.descriptions, params.langCode); + res.label = localizedToString(res.labels, params.langCode); + if (title === keySitelink) { + _wikibaseCache[keySitelink] = res; + result.key = res; + } else if (title === tagSitelink) { + _wikibaseCache[tagSitelink] = res; + result.tag = res; + } else if (title === localeSitelink) { + localeId = res.id; + } else { + console.log('Unexpected title ' + title); + } + } + }); + + if (localeSitelink) { + // If locale ID is not found, store false to prevent repeated queries + self.addLocale(params.langCode, localeId); + } + + callback(null, result); + } + }); + }, + + + addLocale: function(langCode, qid) { + // Makes it easier to unit test + _localeIds[langCode] = qid; + }, + + apibase: function(_) { + if (!arguments.length) return apibase; + apibase = _; + return this; + } + +}; diff --git a/modules/ui/tag_reference.js b/modules/ui/tag_reference.js index 03f625019..6215250b9 100644 --- a/modules/ui/tag_reference.js +++ b/modules/ui/tag_reference.js @@ -1,6 +1,3 @@ -import _find from 'lodash-es/find'; -import _omit from 'lodash-es/omit'; - import { event as d3_event, select as d3_select @@ -10,10 +7,11 @@ import { t } from '../util/locale'; import { utilDetect } from '../util/detect'; import { services } from '../services'; import { svgIcon } from '../svg'; +import { utilQsString } from '../util'; export function uiTagReference(tag) { - var taginfo = services.taginfo; + var wikibase = services.osmWikibase; var tagReference = {}; var _button = d3_select(null); @@ -21,42 +19,49 @@ export function uiTagReference(tag) { var _loaded; var _showing; - + /** + * @returns {{itemTitle: String, description: String, image: String|null}|null} + **/ function findLocal(data) { - var locale = utilDetect().locale.toLowerCase(); - var localized; + var entity = data.tag || data.key; + if (!entity) return null; - if (locale !== 'pt-br') { // see #3776, prefer 'pt' over 'pt-br' - localized = _find(data, function(d) { - return d.lang.toLowerCase() === locale; - }); - if (localized) return localized; + var result = { + title: entity.title, + description: entity.description, + }; + + if (entity.claims) { + var langCode = utilDetect().locale.toLowerCase(); + var url; + var image = wikibase.claimToValue(entity, 'P4', langCode); + if (image) { + url = 'https://commons.wikimedia.org/w/index.php'; + } else { + image = wikibase.claimToValue(entity, 'P28', langCode); + if (image) { + url = 'https://wiki.openstreetmap.org/w/index.php'; + } + } + if (image) { + result.image = { + url: url, + title: 'Special:Redirect/file/' + image + }; + } } - // try the non-regional version of a language, like - // 'en' if the language is 'en-US' - if (locale.indexOf('-') !== -1) { - var first = locale.split('-')[0]; - localized = _find(data, function(d) { - return d.lang.toLowerCase() === first; - }); - if (localized) return localized; - } - - // finally fall back to english - return _find(data, function(d) { - return d.lang.toLowerCase() === 'en'; - }); + return result; } function load(param) { - if (!taginfo) return; + if (!wikibase) return; _button .classed('tag-reference-loading', true); - taginfo.docs(param, function show(err, data) { + wikibase.getEntity(param, function show(err, data) { var docs; if (!err && data) { docs = findLocal(data); @@ -65,23 +70,25 @@ export function uiTagReference(tag) { _body.html(''); if (!docs || !docs.title) { - if (param.hasOwnProperty('value')) { - load(_omit(param, 'value')); // retry with key only - } else { - _body - .append('p') - .attr('class', 'tag-reference-description') - .text(t('inspector.no_documentation_key')); - done(); - } + _body + .append('p') + .attr('class', 'tag-reference-description') + .text(t('inspector.no_documentation_key')); + done(); return; } - if (docs.image && docs.image.thumb_url_prefix) { + if (docs.image) { + var imageUrl = docs.image.url + '?' + utilQsString({ + title: docs.image.title, + width: 100, + height: 100, + }); + _body .append('img') .attr('class', 'tag-reference-wiki-image') - .attr('src', docs.image.thumb_url_prefix + '100' + docs.image.thumb_url_suffix) + .attr('src', imageUrl) .on('load', function() { done(); }) .on('error', function() { d3_select(this).remove(); done(); }); } else { @@ -91,7 +98,7 @@ export function uiTagReference(tag) { _body .append('p') .attr('class', 'tag-reference-description') - .text(docs.description || t('inspector.documentation_redirect')); + .text(docs.description || t('inspector.no_documentation_key')); _body .append('a') @@ -101,7 +108,7 @@ export function uiTagReference(tag) { .attr('href', 'https://wiki.openstreetmap.org/wiki/' + docs.title) .call(svgIcon('#iD-icon-out-link', 'inline')) .append('span') - .text(t('inspector.reference')); + .text(t('inspector.edit_reference')); // Add link to info about "good changeset comments" - #2923 if (param.key === 'comment') { @@ -171,6 +178,7 @@ export function uiTagReference(tag) { } else if (_loaded) { done(); } else { + tag.langCode = utilDetect().locale.toLowerCase(); load(tag); } }); diff --git a/test/index.html b/test/index.html index b455624b3..8e78ab96d 100644 --- a/test/index.html +++ b/test/index.html @@ -110,6 +110,7 @@ + diff --git a/test/spec/services/osm_wikibase.js b/test/spec/services/osm_wikibase.js new file mode 100644 index 000000000..61951abba --- /dev/null +++ b/test/spec/services/osm_wikibase.js @@ -0,0 +1,322 @@ +describe('iD.serviceOsmWikibase', function () { + var server, wikibase; + + before(function () { + iD.services.osmWikibase = iD.serviceOsmWikibase; + }); + + after(function () { + delete iD.services.osmWikibase; + }); + + beforeEach(function () { + wikibase = iD.services.osmWikibase; + wikibase.init(); + server = sinon.fakeServer.create(); + }); + + afterEach(function () { + server.restore(); + }); + + + function query(url) { + return iD.utilStringQs(url.substring(url.indexOf('?') + 1)); + } + + function adjust(params, data) { + if (params) { + if (params.norm) { + data.description = data.descriptions.fr.value; + data.label = data.labels.fr.value; + } + } + return data; + } + + function keyData(params) { + return adjust(params, { + pageid: 205725, + ns: 120, + title: 'Item:Q42', + lastrevid: 1721242, + modified: '2018-12-18T07:00:43Z', + type: 'item', + id: 'Q42', + labels: { + fr: {language: 'en', value: 'amenity', 'for-language': 'fr'} + }, + descriptions: { + fr: {language: 'en', value: 'English description', 'for-language': 'fr'} + }, + aliases: {}, + claims: { + P2: [ // instance of + { + mainsnak: { + snaktype: 'value', + datatype: 'wikibase-item', + datavalue: {value: {'entity-type': 'item', id: 'Q7'}, type: 'wikibase-entityid'} + }, + type: 'statement', + rank: 'normal' + } + ], + P16: [ + { + mainsnak: { + snaktype: 'value', + datatype: 'string', + datavalue: {value: 'amenity', type: 'string'} + }, + type: 'statement', + rank: 'normal' + } + ], + P25: [ + { + mainsnak: { + snaktype: 'value', + datatype: 'wikibase-item', + datavalue: {value: {'entity-type': 'item', id: 'Q4679'}, type: 'wikibase-entityid'} + }, + type: 'statement', + rank: 'normal' + } + ], + P9: [ + { + mainsnak: { + snaktype: 'value', + datatype: 'wikibase-item', + datavalue: {value: {'entity-type': 'item', id: 'Q8'}, type: 'wikibase-entityid'} + }, + type: 'statement', + rank: 'normal' + } + ], + P6: [ + { + mainsnak: { + snaktype: 'value', + datatype: 'wikibase-item', + datavalue: {value: {'entity-type': 'item', id: 'Q15'}, type: 'wikibase-entityid'} + }, + type: 'statement', + rank: 'preferred' + }, + { + mainsnak: { + snaktype: 'value', + datatype: 'wikibase-item', + datavalue: {value: {'entity-type': 'item', id: 'Q14'}, type: 'wikibase-entityid'} + }, + type: 'statement', + qualifiers: { + P26: [ + { + snaktype: 'value', + datatype: 'wikibase-item', + datavalue: {value: {'entity-type': 'item', id: 'Q6994'}, type: 'wikibase-entityid'} + } + ] + }, + rank: 'normal' + } + ], + P28: [ + { + mainsnak: { + snaktype: 'value', + datatype: 'string', + datavalue: {value: 'Mapping-Features-Parking-Lot.png', type: 'string'} + }, + type: 'statement', + rank: 'normal' + } + ] + }, + sitelinks: { + wiki: { + site: 'wiki', + title: 'Key:amenity', + badges: [] + } + } + }); + } + + function tagData(params) { + return adjust(params, { + pageid: 210934, + ns: 120, + title: 'Item:Q13', + lastrevid: 1718041, + modified: '2018-12-18T03:51:05Z', + type: 'item', + id: 'Q13', + labels: { + fr: {language: 'en', value: 'amenity=parking', 'for-language': 'fr'} + }, + descriptions: { + fr: {language: 'fr', value: 'French description'} + }, + aliases: {}, + claims: { + P2: [ // instance of = Q2 (tag) + { + mainsnak: { + snaktype: 'value', + datatype: 'wikibase-item', + datavalue: {value: {'entity-type': 'item', id: 'Q2'}, type: 'wikibase-entityid'} + }, + type: 'statement', + rank: 'normal' + } + ], + P19: [ + { + mainsnak: { + snaktype: 'value', + datatype: 'string', + datavalue: {value: 'amenity=parking', type: 'string'} + }, + type: 'statement', + rank: 'normal' + } + ], + P10: [ + { + mainsnak: { + snaktype: 'value', + datatype: 'wikibase-item', + datavalue: {value: {'entity-type': 'item', id: 'Q42'}, type: 'wikibase-entityid'} + }, + type: 'statement', + rank: 'normal' + } + ], + P4: [ + { + mainsnak: { + snaktype: 'value', + datatype: 'commonsMedia', + datavalue: {value: 'Primary image.jpg', type: 'string'} + }, + type: 'statement', + rank: 'preferred' + } + ], + P6: [ + { + mainsnak: { + snaktype: 'value', + datatype: 'wikibase-item', + datavalue: {value: {'entity-type': 'item', id: 'Q14'}, type: 'wikibase-entityid'} + }, + type: 'statement', + rank: 'preferred' + }, + { + mainsnak: { + snaktype: 'value', + datatype: 'wikibase-item', + datavalue: {value: {'entity-type': 'item', id: 'Q13'}, type: 'wikibase-entityid'} + }, + type: 'statement', + qualifiers: { + P26: [ + { + snaktype: 'value', + datatype: 'wikibase-item', + datavalue: {value: {'entity-type': 'item', id: 'Q6994'}, type: 'wikibase-entityid'} + } + ] + }, + rank: 'normal' + } + ], + P25: [ + { + mainsnak: { + snaktype: 'value', + datatype: 'wikibase-item', + datavalue: {value: {'entity-type': 'item', id: 'Q4679'}, type: 'wikibase-entityid'} + }, + type: 'statement', + rank: 'normal' + } + ] + }, + sitelinks: { + wiki: { + site: 'wiki', + title: 'Tag:amenity=parking', + badges: [] + } + } + }); + } + + + var localeData = { + id: 'Q7792', + sitelinks: {wiki: {site: 'wiki', title: 'Locale:fr'}} + }; + + describe('#getEntity', function () { + it('calls the given callback with the results of the getEntity data item query', function () { + var callback = sinon.spy(); + wikibase.getEntity({key: 'amenity', value: 'parking', langCode: 'fr'}, callback); + + server.respondWith('GET', /action=wbgetentities/, + [200, {'Content-Type': 'application/json'}, JSON.stringify({ + entities: { + Q42: keyData(), + Q13: tagData(), + Q7792: localeData, + }, + success: 1 + })] + ); + server.respond(); + + expect(query(server.requests[0].url)).to.eql( + { + action: 'wbgetentities', + sites: 'wiki', + titles: 'Locale:fr|Key:amenity|Tag:amenity=parking', + languages: 'fr', + languagefallback: '1', + origin: '*', + format: 'json', + } + ); + expect(callback).to.have.been.calledWith(null, { + key: keyData({norm: true}), + tag: tagData({norm: true}) + }); + }); + }); + + + it('creates correct sitelinks', function () { + expect(wikibase.toSitelink('amenity')).to.eql('Key:amenity'); + expect(wikibase.toSitelink('amenity_')).to.eql('Key:amenity'); + expect(wikibase.toSitelink('_amenity_')).to.eql('Key: amenity'); + expect(wikibase.toSitelink('amenity or_not_')).to.eql('Key:amenity or not'); + expect(wikibase.toSitelink('amenity', 'parking')).to.eql('Tag:amenity=parking'); + expect(wikibase.toSitelink(' amenity_', '_parking_')).to.eql('Tag: amenity = parking'); + expect(wikibase.toSitelink('amenity or_not', '_park ing_')).to.eql('Tag:amenity or not= park ing'); + }); + + it('gets correct value from entity', function () { + wikibase.addLocale('de', 'Q6994'); + wikibase.addLocale('fr', 'Q7792'); + expect(wikibase.claimToValue(tagData(), 'P4', 'en')).to.eql('Primary image.jpg'); + expect(wikibase.claimToValue(keyData(), 'P6', 'en')).to.eql('Q15'); + expect(wikibase.claimToValue(keyData(), 'P6', 'fr')).to.eql('Q15'); + expect(wikibase.claimToValue(keyData(), 'P6', 'de')).to.eql('Q14'); + }); + +});