diff --git a/data/org.gnome.Maps.data.gresource.xml.in b/data/org.gnome.Maps.data.gresource.xml.in index 37423e02b5ef09511bf4db110afb69755353b512..a0234a3f543b57d7a96e1ac60fb6090188c8b430 100644 --- a/data/org.gnome.Maps.data.gresource.xml.in +++ b/data/org.gnome.Maps.data.gresource.xml.in @@ -16,6 +16,7 @@ ui/osm-account-dialog.ui ui/osm-edit-address.ui ui/osm-edit-dialog.ui + ui/osm-edit-wikipedia.ui ui/osm-type-list-row.ui ui/osm-type-search-entry.ui ui/osm-type-popover.ui diff --git a/data/ui/osm-edit-wikipedia.ui b/data/ui/osm-edit-wikipedia.ui new file mode 100644 index 0000000000000000000000000000000000000000..fecfff2636aa245be8ff6a8052af3e06431918bf --- /dev/null +++ b/data/ui/osm-edit-wikipedia.ui @@ -0,0 +1,50 @@ + + + + + diff --git a/po/POTFILES.in b/po/POTFILES.in index e840e653c35fe7a6c03cfc19ac2cd3ed7fa08c75..42d09049ebca3b8e649b489cdb1b36ea16fa73a4 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -14,6 +14,7 @@ data/ui/main-window.ui data/ui/osm-account-dialog.ui data/ui/osm-edit-address.ui data/ui/osm-edit-dialog.ui +data/ui/osm-edit-wikipedia.ui data/ui/place-popover.ui data/ui/route-entry.ui data/ui/send-to-dialog.ui diff --git a/src/osmEditDialog.js b/src/osmEditDialog.js index d93f1441b4736201cc1dc13f1c0b3d5794760c7d..aadb5e8dd7551b57476f262e732afbda22e7a27d 100644 --- a/src/osmEditDialog.js +++ b/src/osmEditDialog.js @@ -49,22 +49,12 @@ const EditFieldType = { INTEGER: 1, UNSIGNED_INTEGER: 2, COMBO: 3, - ADDRESS: 4 + ADDRESS: 4, + WIKIPEDIA: 5 }; const _WIKI_BASE = 'https://wiki.openstreetmap.org/wiki/Key:'; -var _osmWikipediaRewriteFunc = function(text) { - let wikipediaArticleFormatted = OSMUtils.getWikipediaOSMArticleFormatFromUrl(text); - - /* if the entered text is a Wikipedia link, - * substitute it with the OSM-formatted Wikipedia article tag */ - if (wikipediaArticleFormatted) - return wikipediaArticleFormatted; - else - return text; -}; - /* Reformat a phone number string if it looks like a tel: URI * strip off the leading tel: protocol string and trailing parameters, * following a ; @@ -157,12 +147,10 @@ const OSM_FIELDS = [ }, { name: _("Wikipedia"), - tag: 'wikipedia', - type: EditFieldType.TEXT, - validate: Wikipedia.isValidWikipedia, - rewriteFunc: _osmWikipediaRewriteFunc, - hint: _("The format used should include the language code " + - "and the article title like “en:Article title”.") + tag: 'wiki', + subtags: ['wikipedia', + 'wikidata'], + type: EditFieldType.WIKIPEDIA }, { name: _("Opening hours"), @@ -252,7 +240,7 @@ const OSM_FIELDS = [ hint: _("Information used to inform other mappers about non-obvious information about an element, the author’s intent when creating it, or hints for further improvement.") }]; -export class OSMEditAddress extends Gtk.Grid { +class OSMEditAddress extends Gtk.Grid { constructor({street, number, postCode, city, ...params}) { super(params); @@ -279,8 +267,39 @@ GObject.registerClass({ 'city' ], }, OSMEditAddress); -export class OSMEditDialog extends Gtk.Dialog { +class OSMEditWikipedia extends Gtk.Grid { + + constructor({article, wikidata, ...params}) { + super(params); + + if (article) + this.article.text = article; + + if (wikidata) + this.wikidata.text = wikidata; + + if (article && !Wikipedia.isValidWikipedia(article)) + this.article.get_style_context().add_class("warning"); + else + this.article.get_style_context().remove_class("warning"); + + if (wikidata && !Wikipedia.isValidWikidata(wikidata)) + this.wikidata.get_style_context().add_class("warning"); + else + this.wikidata.get_style_context().remove_class("warning"); + + this.refresh.sensitive = article !== ''; + } +} + +GObject.registerClass({ + Template: 'resource:///org/gnome/Maps/ui/osm-edit-wikipedia.ui', + Children: [ 'article', + 'wikidata', + 'refresh' ] +}, OSMEditWikipedia); +export class OSMEditDialog extends Gtk.Dialog { static Response = { UPLOADED: 0, DELETED: 1, @@ -753,13 +772,88 @@ export class OSMEditDialog extends Gtk.Dialog { addr.post.connect('changed', changedFunc.bind(this, addr.post, 2)); addr.city.connect('changed', changedFunc.bind(this, addr.city, 3)); - let rows = fieldSpec.rows || 1; + let rows = fieldSpec.rows ?? 1; this._editorGrid.attach(addr, 1, this._currentRow, 1, rows); addr.street.grab_focus(); this._addOSMEditDeleteButton(fieldSpec); this._currentRow += rows; } + _addOSMEditWikipediaEntry(fieldSpec, value) { + this._addOSMEditLabel(fieldSpec) + + let wiki = new OSMEditWikipedia({ article: value[0], + wikidata: value[1] }); + + wiki.article.connect('changed', () => { + let rewrittenText = + OMSUtils.getWikipediaOSMArticleFormatFromUrl(wiki.article.text); + + if (rewrittenText) + wiki.article.text = rewrittenText; + + if (wiki.article.text !== '' && + !Wikipedia.isValidWikipedia(wiki.article.text)) { + wiki.article.get_style_context().add_class("warning"); + } else { + wiki.article.get_style_context().remove_class("warning"); + } + + this._osmObject.set_tag(fieldSpec.subtags[0], wiki.article.text); + this._nextButton.sensitive = true; + wiki.refreshWikidata.sensitive = wiki.article.text !== ''; + }); + + wiki.wikidata.connect('changed', () => { + let rewrittenText = + OSMUtils.getWikidataFromUrl(wiki.wikidata.text); + + if (rewrittenText) + wiki.wikidata.text = rewrittenText; + + if (wiki.wikidata.text !== '' && + !Wikipedia.isValidWikidata(wiki.wikidata.text)) { + wiki.wikidata.get_style_context().add_class("warning"); + } else { + wiki.wikidata.get_style_context().remove_class("warning"); + } + + this._osmObject.set_tag(fieldSpec.subtags[1], wiki.wikidata.text); + this._nextButton.sensitive = true; + }); + + wiki.article.connect('icon-press', () => { + this._showHintPopover(wiki.article, + _("The format used should include the language code " + + "and the article title like “en:Article title”.")); + }); + + wiki.wikidata.connect('icon-press', () => { + this._showHintPopover(wiki.wikidata, + _("Use the reload button to load the Wikidata tag for the selected article")); + }); + + wiki.refresh.connect('clicked', () => { + Wikipedia.fetchWikidataForArticle(wiki.article.text, + this._cancellable, + (wikidata) => { + if (!wikidata) { + Utils.showDialog(_("Couldn't find Wikidata tag for article"), + Gtk.MessageType.ERROR, this); + return; + } + + wiki.wikidata.text = wikidata; + }); + }); + + let rows = fieldSpec.rows ?? 1; + this._editorGrid.attach(wiki, 1, this._currentRow, 1, rows); + wiki.article.grab_focus(); + this._addOSMEditDeleteButton(fieldSpec); + this._currentRow += rows; + } + /* update visible items in the "Add Field" popover */ _updateAddFieldMenu() { /* clear old items */ @@ -828,19 +922,22 @@ export class OSMEditDialog extends Gtk.Dialog { _addOSMField(fieldSpec, value) { switch (fieldSpec.type) { case EditFieldType.TEXT: - this._addOSMEditTextEntry(fieldSpec, value || ''); + this._addOSMEditTextEntry(fieldSpec, value ?? ''); break; case EditFieldType.INTEGER: - this._addOSMEditIntegerEntry(fieldSpec, value || 0, -1e9, 1e9); + this._addOSMEditIntegerEntry(fieldSpec, value ?? 0, -1e9, 1e9); break; case EditFieldType.UNSIGNED_INTEGER: - this._addOSMEditIntegerEntry(fieldSpec, value || 0, 0, 1e9); + this._addOSMEditIntegerEntry(fieldSpec, value ?? 0, 0, 1e9); break; case EditFieldType.COMBO: - this._addOSMEditComboEntry(fieldSpec, value || ''); + this._addOSMEditComboEntry(fieldSpec, value ?? ''); break; case EditFieldType.ADDRESS: - this._addOSMEditAddressEntry(fieldSpec, value || ''); + this._addOSMEditAddressEntry(fieldSpec, value ?? ''); + break; + case EditFieldType.WIKIPEDIA: + this._addOSMEditWikipediaEntry(fieldSpec, value ?? ''); break; } } @@ -901,3 +998,4 @@ GObject.registerClass({ 'recentTypesListBox', 'headerBar'], }, OSMEditDialog); + diff --git a/src/osmUtils.js b/src/osmUtils.js index fd245a767560d489dab06ee144781e912afaee7e..23a2bff26f7b4539514e730853e19d638a1a842f 100644 --- a/src/osmUtils.js +++ b/src/osmUtils.js @@ -43,6 +43,23 @@ export function getWikipediaOSMArticleFormatFromUrl(url) { } } +/* + * Gets a Wikidata tag from from a URL of the forms + * https://www.wikidata.org/wiki/Qnnnn, or + * https://www.wikidata.org/wiki/Special:EntityPage/Qnnnn + * or null if input doesn't match these formats + */ +export function getWikidataFromUrl(url) { + let regex = + /https?:\/\/www.\wikidata\.org\/wiki\/(?:Special:EntityPage\/)?(Q\d+)/; + let match = url.match(regex); + + if (match?.length === 3 || match?.length === 2) + return match.last(); + else + return null; +} + /** * Updates a Place object according to an OSMObject. * Will also update place in the place store. diff --git a/src/place.js b/src/place.js index f16eee5d2696b6b160ba265cca0c20f6bdf8a181..ab89bab2cee63bff1975ba10dc13933c59a7a062 100644 --- a/src/place.js +++ b/src/place.js @@ -58,6 +58,7 @@ export class Place extends GeocodeGlib.Place { delete params.email; delete params.phone; delete params.wiki; + delete params.wikidata; delete params.openingHours; delete params.internetAccess; delete params.religion; @@ -150,6 +151,8 @@ export class Place extends GeocodeGlib.Place { this.phone = tags.phone; if (wiki) this.wiki = wiki; + if (tags.wikidata) + this.wikidata = tags.wikidata; if (tags.wheelchair) this.wheelchair = tags.wheelchair; if (openingHours) @@ -226,6 +229,14 @@ export class Place extends GeocodeGlib.Place { return this._wiki; } + set wikidata(v) { + this._wikidata = v; + } + + get wikidata() { + return this._wikidata; + } + set openingHours(v) { this._openingHours = v; } @@ -377,6 +388,7 @@ export class Place extends GeocodeGlib.Place { email: this.email, phone: this.phone, wiki: this.wiki, + wikidata: this.wikidata, wheelchair: this.wheelchair, openingHours: this.openingHours, internetAccess: this.internetAccess, diff --git a/src/placeView.js b/src/placeView.js index c8bcdc4087f03645baa21e9dfd8b6d00eccf7607..37493cfe40dfa1cb42809a37e9b8b5be8e30aa20 100644 --- a/src/placeView.js +++ b/src/placeView.js @@ -465,7 +465,8 @@ export class PlaceView extends Gtk.Box { info: Translations.translateReligion(place.religion) }); } - if (place.wiki && Wikipedia.isValidWikipedia(place.wiki)) { + if (place.wiki && Wikipedia.isValidWikipedia(place.wiki) || + place.wikidata && Wikipedia.isValidWikidata(place.wikidata)) { content.push({ type: 'wikipedia', info: '' }); } @@ -577,7 +578,13 @@ export class PlaceView extends Gtk.Box { let content = this._createContent(place); this._attachContent(content); - if (place.wiki && Wikipedia.isValidWikipedia(place.wiki)) { + if (place.wikidata && Wikipedia.isValidWikidata(place.wikidata)) { + let defaultArticle = + place.wiki && Wikipedia.isValidWikipedia(place.wiki)) ? + place.wiki : null; + + this._requestWikidata(place.wikidata, defaultArticle); + } else if (place.wiki && Wikipedia.isValidWikipedia(place.wiki)) { this._requestWikipedia(place.wiki); } @@ -592,6 +599,13 @@ export class PlaceView extends Gtk.Box { this._onThumbnailComplete.bind(this)); } + _requestWikidata(wikidata, defaultArticle) { + Wikipedia.fetchArticleInfoForWikidata( + wikidata, defaultArticle, THUMBNAIL_FETCH_SIZE, + this._onWikiMetadataComplete.bind(this), + this._onThumbnailComplete.bind(this)); + } + _onThumbnailComplete(thumbnail) { this.thumbnail = thumbnail; } diff --git a/src/wikipedia.js b/src/wikipedia.js index 80ad3a7dac46f5497bc78bec1573240ac6505fde..90fe7bf3409c616abfa0924f3e68123bb4ea6a32 100644 --- a/src/wikipedia.js +++ b/src/wikipedia.js @@ -32,6 +32,16 @@ import * as Utils from './utils.js'; */ const WP_REGEX = /^[a-z][a-z][a-z]?(\-[a-z]+)?$|^simple$/; +/** + * Regex matching Wikidata tags + */ +const WIKIDATA_REGEX = /Q\d+/; + +/** + * Wikidata properties + */ +const WIKIDATA_PROPERTY_IMAGE = 'P18'; + let _soupSession = null; function _getSoupSession() { if (_soupSession === null) { @@ -43,6 +53,8 @@ function _getSoupSession() { let _thumbnailCache = {}; let _metadataCache = {}; +let _wikidataCache = {}; +let _wikidataImageSourceCache = {}; export function getLanguage(wiki) { return wiki.split(':')[0]; @@ -72,6 +84,13 @@ export function isValidWikipedia(wiki) { return wpCode.match(WP_REGEX) !== null; } +/** + * Determine if a Wikidata reference tag is valid (of the form Qnnn) + */ +export function isValidWikidata(wikidata) { + return wikidata.match(WIKIDATA_REGEX) !== null; +} + /* * Fetch various metadata about a Wikipedia article, given the wiki language * and article title. @@ -155,6 +174,187 @@ export function fetchArticleInfo(wiki, size, metadataCb, thumbnailCb) { }); } +/* + * Fetch various metadata about a Wikidata reference. + * + * @defaultArticle is the native Wikipedia article, if set, for the object + * when present, it used as a fallback if none of the references + * of the Wikidate tag matches a user's language + * @size is the maximum width of the thumbnail. + * + * Calls @metadataCb with the lang:title pair for the article and an object + * containing information about the article. For the keys/values of this + * object, see the relevant MediaWiki API documentation. + * + * Calls @thumbnailCb with the Gdk.Pixbuf of the icon when successful, otherwise + * null. + */ +export function fetchArticleInfoForWikidata(wikidata, defaultArticle, + size, metadataCb, thumbnailCb) { + let cachedWikidata = _wikidataCache[wikidata]; + + if (cachedWikidata) { + _onWikidataFetched(wikidata, defaultArticle, size, metadataCb, + thumbnailCb); + return; + } + + let uri = 'https://www.wikidata.org/w/api.php'; + let encodedForm = Soup.form_encode_hash({ action: 'wbgetentities', + ids: wikidata, + format: 'json' }); + let msg = Soup.Message.new_from_encoded_form('GET', uri, encodedForm); + let session = _getSoupSession(); + + session.send_and_read_async(msg, GLib.PRIORIRY_DEFAULT, null, + (source, res) => { + if (msg.get_status() !== Soup.Status.OK) { + log('Failed to request Wikidata entities: ' + msg.reason_phrase); + metadataCb(null, {}); + thumbnailCb(null); + return; + } + + let buffer = session.send_and_read_finish(res).get_data(); + let response = JSON.parse(Utils.getBufferText(buffer)); + + _wikidataCache[wikidata] = response; + _onWikidataFetched(wikidata, defaultArticle, response, size, + metadataCb, thumbnailCb); + }); +} + +export function fetchWikidataForArticle(wiki, cancellable, callback) { + let lang = getLanguage(wiki); + let title = getHtmlEntityEncodedArticle(wiki); + let uri = 'https://www.wikidata.org/w/api.php'; + let encodedForm = Soup.form_encode_hash({ action: 'wbgetentities', + sites: lang + 'wiki', + titles: title, + format: 'json' }); + let msg = Soup.Message.new_from_encoded_form('GET', uri, encodedForm); + let session = _getSoupSession(); + + session.send_and_read_async(msg, GLib.PRIORIRY_DEFAULT, cancellable, + (source, res) => { + if (msg.get_status() !== Soup.Status.OK) { + log(`Failed to request Wikidata entities: ${msg.reason_phrase}`); + callback(null); + return; + } + + let buffer = session.send_and_read_finish(res).get_data(); + let response = JSON.parse(Utils.getBufferText(buffer)); + let id = Object.values(response.entities ?? [])?.[0]?.id; + + callback(id); + }); +} + +function _onWikidataFetched(wikidata, defaultArticle, response, size, + metadataCb, thumbnailCb) { + let sitelinks = response?.entities?.[wikidata]?.sitelinks; + + if (!sitelinks) { + Utils.debug('No sitelinks element in response'); + metadataCb(null, {}); + if (thumbnailCb) + thumbnailCb(null); + return; + } + + let claims = response?.entities?.[wikidata]?.claims; + let imageName = + claims?.[WIKIDATA_PROPERTY_IMAGE]?.[0]?.mainsnak?.datavalue?.value; + + /* if the Wikidata metadata links to a title image, use that to fetch + * the thumbnail image + */ + if (imageName) { + _fetchWikidataThumbnail(imageName, size, thumbnailCb); + thumbnailCb = null; + } + + /* try to find articles in the order of the user's preferred + * languages + */ + for (let language of _getLanguages()) { + /* sitelinks appear under "sitelinks" in the form: + * langwiki, e.g. "enwiki" + */ + if (sitelinks[language + 'wiki']) { + let article = `${language}:${sitelinks[language + 'wiki'].title}`; + + fetchArticleInfo(article, size, metadataCb, thumbnailCb); + return; + } + } + + // if no article reference matches a preferred language + if (defaultArticle) { + // if there's a default article from the "wikipedia" tag, use it + fetchArticleInfo(defaultArticle, size, metadataCb, thumbnailCb); + } else { + /* if there's exactly one *wiki sitelink, use it, since it's + * probably the default (native) article + */ + let foundSitelink; + let numFoundSitelinks = 0; + + for (let sitelink in sitelinks) { + if (sitelink.endsWith('wiki') && sitelink !== 'commonswiki') { + foundSitelink = sitelink; + numFoundSitelinks++; + } + } + + if (numFoundSitelinks === 1) { + let language = foundSitelink.substring(0, foundSitelink.length - 4); + let article = `${language}:${sitelinks[foundSitelink].title}`; + + fetchArticleInfo(article, size, metadataCb, thumbnailCb); + } + } +} + +function _fetchWikidataThumbnail(imageName, size, thumbnailCb) { + let cachedImageUrl = _wikidataImageSourceCache[imageName + '/' + size]; + + if (cachedImageUrl) { + _fetchThumbnailImage(imageName, size, cachedImageUrl, thumbnailCb); + return; + } + + let uri = 'https://wikipedia.org/w/api.php'; + let encodedForm = Soup.form_encode_hash({ action: 'query', + prop: 'imageinfo', + iiprop: 'url', + iiurlwidth: size + '', + titles: 'Image:' + imageName, + format: 'json' }); + let msg = Soup.Message.new_from_encoded_form('GET', uri, encodedForm); + let session = _getSoupSession(); + + session.send_and_read_async(msg, GLib.PRIORIRY_DEFAULT, null, + (source, res) => { + if (msg.get_status() !== Soup.Status.OK) { + log('Failed to request Wikidata image thumbnail URL: ' + + msg.reason_phrase); + thumbnailCb(null); + return; + } + + let buffer = session.send_and_read_finish(res).get_data(); + let response = JSON.parse(Utils.getBufferText(buffer)); + let thumburl = response?.query?.pages?.[-1]?.imageinfo?.[0]?.thumburl; + + if (thumburl) { + _fetchThumbnailImage(imageName, size, thumburl, thumbnailCb); + _wikidataImageSourceCache[imageName + '/' + size] = thumburl; + } + }); +} + function _onMetadataFetched(wiki, page, size, metadataCb, thumbnailCb) { /* Try to get a thumbnail *before* following language links--the primary article probably has the best thumbnail image */ @@ -218,7 +418,7 @@ function _fetchThumbnailImage(wiki, size, source, callback) { the original article should be used. */ function _findLanguageLink(wiki, page) { let originalLang = getLanguage(wiki); - let languages = GLib.get_language_names().map((lang) => lang.split(/[\._\-]/)[0]); + let languages = _getLanguages(); if (!languages.includes(originalLang)) { let langlinks = {}; @@ -233,3 +433,7 @@ function _findLanguageLink(wiki, page) { } } } + +function _getLanguages() { + return GLib.get_language_names().map((lang) => lang.split(/[\._\-]/)[0]); +} diff --git a/tests/wikipediaTest.js b/tests/wikipediaTest.js index 29e6086a80a72c1b81af9d17c80be10c57b008df..8113d28ac3217c62607055eac28a7b71f95177e7 100644 --- a/tests/wikipediaTest.js +++ b/tests/wikipediaTest.js @@ -38,3 +38,14 @@ JsUnit.assertTrue(Wikipedia.isValidWikipedia('zh-yue:粵文維基百科')); // invalid references JsUnit.assertFalse(Wikipedia.isValidWikipedia('https://en.wikipedia.org/wiki/Article')); JsUnit.assertFalse(Wikipedia.isValidWikipedia('Article with no edition')); + +// valid wikidata references +JsUnit.assertTrue(Wikipedia.isValidWikidata('Q1234')); +JsUnit.assertTrue(Wikipedia.isValidWikidata('Q1')); +JsUnit.assertTrue(Wikipedia.isValidWikidata('Q100000000')); + +// invalid wikidata references +JsUnit.assertFalse(Wikipedia.isValidWikidata('1234')); +JsUnit.assertFalse(Wikipedia.isValidWikidata('AAAA')); +JsUnit.assertFalse(Wikipedia.isValidWikidata('Q')); +JsUnit.assertFalse(Wikipedia.isValidWikidata('en:Article'));