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'));