Hinweis: Leere nach dem Veröffentlichen den Browser-Cache, um die Änderungen sehen zu können.
- Firefox/Safari: Umschalttaste drücken und gleichzeitig Aktualisieren anklicken oder entweder Strg+F5 oder Strg+R (⌘+R auf dem Mac) drücken
- Google Chrome: Umschalttaste+Strg+R (⌘+Umschalttaste+R auf dem Mac) drücken
- Edge: Strg+F5 drücken oder Strg drücken und gleichzeitig Aktualisieren anklicken
// <nowiki> /** * VoyageData * Durchsucht den aktuellen Artikel nach Markern und vCards ohne Wikidata-ID und listet mögliche Kandidaten auf. * * Dokumentation: [[m:User:Nw520/Gadgets#VoyageData]] * Maintainer: [[voy:de:User:nw520]] * * Entwicklungsversion: [[voy:de:User:Nw520/VoyageData.js]] * Produktiv-Version: [[voy:de:MediaWiki:Gadget-VoyageData.js]] */ /* eslint-disable mediawiki/class-doc */ $.when( mw.loader.using( [ 'mediawiki.notification', 'mediawiki.util' ] ), $.ready ).then( function () { const strings = { 'voy-voyagedata-advancedsettings': { de: 'Fortgeschrittene Einstellungen', en: 'Advanced settings' }, 'voy-voyagedata-bybbox-description': { de: 'Im in der Karte sichtbaren Ausschnitt alle Wikidata-Datenobjekte laden / [+Shift] Wikidata Query Service öffnen', en: 'In the bounding box visible in the map, load all Wikidata data objects / [+Shift] Open Wikidata Query Service' }, 'voy-voyagedata-bybbox-label': { de: 'Bbox', en: 'Bbox' }, 'voy-voyagedata-bynamequery-description': { de: 'Anhand des Namens jedes Markers Wikidata-Datenobjekte suchen. Die hier verwendete Schnittstelle ist gröber als die Standardmethode / [+Shift] Suffix für Namen festlegen', en: 'Search wikidata data objects by the name of each marker. The used interface is more relaxed than the default one / [+Shift] Specify suffix for names' }, 'voy-voyagedata-bynamequery-label': { de: 'Name (grob)', en: 'Name (coarse)' }, 'voy-voyagedata-byradius-description': { de: 'In einem bestimmen Radius um jeden Marker mit Koordinaten alle Wikidata-Datenobjekte laden / [+Shift] Wikidata Query Service öffnen', en: 'Load all Wikidata data objects in a specified radius around each marker with coordinates / [+Shift] Open Wikidata Query Service' }, 'voy-voyagedata-byradius-label': { de: 'Radius', en: 'Radius' }, 'voy-voyagedata-byradius-prompt-radius': { de: 'Bitte gib einen Radius in Kilometern ein.', en: 'Please enter a radius in kilometres.' }, 'voy-voyagedata-bynamestrict-description': { de: 'Anhand des Namens jedes Markers Wikidata-Datenobjekte suchen / [+Shift] Suffix für Namen festlegen', en: 'Search wikidata data objects by the name of each marker / [+Shift] Specify suffix for names' }, 'voy-voyagedata-bynamestrict-label': { de: 'Name', en: 'Name' }, 'voy-voyagedata-clipboard-name-fail': { de: 'vCard-Name konnte nicht in Zwischenablage kopiert werden.', en: 'Failed to copy name of vCard to clipboard.' }, 'voy-voyagedata-clipboard-name-success': { de: 'vCard-Name in Zwischenablage kopiert.', en: 'Name of vCard copied to clipboard!' }, 'voy-voyagedata-clipboard-wdid-fail': { de: 'Wikidata-ID konnte nicht in Zwischenablage kopiert werden.', en: 'Failed to copy Wikidata ID to clipboard.' }, 'voy-voyagedata-clipboard-wdid-success': { de: 'Wikidata-ID in Zwischenablage kopiert.', en: 'Wikidata ID copied to clipboard!' }, 'voy-voyagedata-config-title': { de: 'VoyageData-Einstellungen', en: 'VoyageData config' }, 'voy-voyagedata-cornerlayout-label': { de: 'VoyageData in Fensterecke anzeigen', en: 'Display VoyageData in window corner' }, 'voy-voyagedata-discard': { de: 'Verwerfen', en: 'Discard' }, 'voy-voyagedata-initialradius-label': { de: 'Vorgeschlagener Radius', en: 'Initial radius' }, 'voy-voyagedata-kill-description': { de: 'VoyageData schließen', en: 'Close VoyageData' }, 'voy-voyagedata-minimise-description': { de: 'VoyageData minimieren', en: 'Minimise VoyageData' }, 'voy-voyagedata-please-wait': { de: 'Nur eine Sekunde, bitte, VoyageData wird geladen.', en: 'Just a second, please, VoyageData is loading.' }, 'voy-voyagedata-nameparameterchain-label': { de: 'Parameter-Reihenfolge für Namenssuche', en: 'Order of parameters for lookup by name' }, 'voy-voyagedata-no-name-placeholder': { de: '⟨kein Name⟩', en: '⟨no name⟩' }, 'voy-voyagedata-orphans': { de: 'Verwaiste Einträge', en: 'Orphans' }, 'voy-voyagedata-pin-label': { de: 'VoyageData an- bzw. abpinnen / [+Shift] VoyageData beenden', en: '(Un)pin VoyageData / [+Shift] Exit VoyageData' }, 'voy-voyagedata-portlet-load': { de: 'Wikidata-IDs mit VoyageData', en: 'Wikidata IDs via VoyageData' }, 'voy-voyagedata-save': { de: 'Speichern', en: 'Save' }, 'voy-voyagedata-search-failed': { de: 'Bei der Suche trat ein Fehler auf', en: 'An error occurred during the search' }, 'voy-voyagedata-settings-label': { de: 'Erweiterte Einstellungen', en: 'Advanced settings' }, 'voy-voyagedata-wdclassblacklist-label': { de: 'Klassen bei Wikidata-Datenobjekten ausschließen (ODER)', en: 'Exclude classes for wikidata items (OR)' }, 'voy-voyagedata-wdclasswhitelist-label': { de: 'Klassen bei Wikidata-Datenobjekten erfordern (ODER)', en: 'Require classes for wikidata items (OR)' } }; /** * @type {string} */ let cachedLangLocal = null; /** * @typedef {string} ItemManagerEvent */ class VoyageData { /** * @readonly */ static COORDINATES_MAX_LENGTH = 8; /** * @readonly */ static INITIAL_ZOOM = 12; /** * @readonly */ static SEARCH_LIMIT = 15; /** * @readonly */ static SPARQL_LIMIT = 1500; static WIKIDATA_CLASS_BLACKLIST = null; static WIKIDATA_CLASS_SUGGESTIONS = [ [ 'Befestigungen', 'Q57821' ], [ 'Friedhöfe', 'Q39614' ], [ 'militärische Gebäude', 'Q6852233' ], [ 'Mühle', 'Q44494' ], [ 'Paläste', 'Q16560' ], [ 'Parks', 'Q22698' ], [ 'Plätze', 'Q174782' ], [ 'Rathäuser', 'Q543654' ], [ 'Schlösser', 'Q751876' ], [ 'Theater', 'Q11635' ], 'GLAM', [ 'GLAM: Galerien, Bibliotheken, Archive und Museen', 'Q1030034' ], [ 'Bibliotheken', 'Q7075' ], [ 'Galerien', 'Q164419' ], [ 'Museen', 'Q33506' ], 'Mobilität', [ 'Bahnhöfe', 'Q55488' ], [ 'Haltestellen', 'Q548662' ], 'religiöses Gebäude', [ 'religiöses Gebäude', 'Q24398318' ], [ 'Kirchengebäude', 'Q16970' ], [ 'Moscheen', 'Q32815' ], [ 'Synagogen', 'Q34627' ], [ 'Tempel', 'Q44539' ] ]; static WIKIDATA_CLASS_WHITELIST = null; /** * @property {VoyageData.ItemManager} */ itemManager = null; /** * @property {VoyageData.Queue} */ queue = null; /** * @property {[EventTarget, string, EventListenerOrEventListenerObject][]} */ #eventListeners = []; /** * @property {VoyageData.Settings} */ #settings = null; /** * @property {number} */ #taskCounter = 0; /** * @property {OO.ui.ProgressBarWidget} */ #taskProgressBar = null; /** * @readonly * @property {Set<string>} */ wikidataIdsInMap = new Set(); /** * @readonly * @property {Set<string>} */ wikidataIdsLoaded = new Set(); /** * @typedef Coordinate * @property {number} lat * @property {number} long */ constructor() { this.#settings = new VoyageData.Settings(); } /** * @typedef Feature * @property {string} type * @property {Object} properties * (property {string} properties.marker-color) # Causes errors * (property {string} properties.marker-size) # Causes errors * (property {string} properties.marker-symbol) # Causes errors * (property {string} properties.title) # Causes errors * @property {Object} geometry * @property {string} geometry.type * @property {number[]} geometry.coordinates */ static async copyFromMarkerToClipboard( e, wdId ) { if ( e.shiftKey ) { return; } e.preventDefault(); try { await navigator.clipboard.writeText( wdId ); mw.notify( mw.msg( 'voy-voyagedata-clipboard-wdid-success' ), { tag: 'voy-voyagedata-clipboard', title: 'VoyageData', type: 'success' } ); } catch ( ex ) { console.error( ex ); mw.notify( mw.msg( 'voy-voyagedata-clipboard-wdid-fail' ), { tag: 'voy-voyagedata-clipboard', title: 'VoyageData', type: 'error' } ); } } static setupCss() { mw.util.addCSS( ` .voy-voyagedata-only-banner, .voy-voyagedata-only-corner { display: none; } .voy-voyagedata, .voy-voyagedata * { box-sizing: border-box; } .voy-voyagedata--banner .voy-voyagedata-only-banner { display: unset; } .voy-voyagedata--banner .voy-voyagedata-sticky:not(.voy-voyagedata-nosticky) { position: fixed; z-index: 10; } .voy-voyagedata--banner .voy-voyagedata-wrapper { height: 400px; max-height: 100vh; resize: vertical; } .voy-voyagedata--corner { bottom: 0; position: fixed; right: 0; transform: none; transition: transform .25s ease-in-out; z-index: 10; } .voy-voyagedata--corner .voy-voyagedata-only-corner { display: unset; } .voy-voyagedata--corner .voy-voyagedata-page-main { flex-direction: column; } .voy-voyagedata--corner .voy-voyagedata-page-main > div { overflow: hidden; } .voy-voyagedata--corner .voy-voyagedata-wrapper { border: 1px solid #c8ccd1; box-shadow: 0 2px 2px 0 rgba(0,0,0,0.25); height: calc(100vh - 6em); width: max(40vw, 40em); } .voy-voyagedata-list { flex: 1; overflow-y: auto; } .voy-voyagedate-listpanel { display: flex; flex-direction: column; } .voy-voyagedata--minimised { transform: translateY(calc(100% - 2.4em)); } .voy-voyagedata--minimised .voy-voyagedata-wrapper { border: none; } .voy-voyagedata-msgbox { border-style: solid; color: #000; font-weight: bold; margin: 2em 0 1em; margin-top: 2em; padding: 0.5em 1em; } .voy-voyagedata-noresults { color: #888; } .voy-voyagedata-notice { background-color: #f8f8f8; border-color: #ccc; display: flex; margin-top: 1em; } .voy-voyagedata-notice-text { flex: 1; margin-bottom: 0; } .voy-voyagedata-page-main { display: flex; flex: 1; overflow: hidden; } .voy-voyagedata-page-main > div { flex: 1; } .voy-voyagedata-resolved { background-color: lightgreen; } .voy-voyagedata-wrapper { background-color: white; display: flex; flex-direction: column; overflow: hidden; } .voy-voyagedata-wrapper h3 { margin-top: .45em; padding: 0; } .voy-voyagedata-wrapper .oo-ui-progressBarWidget { max-width: unset; } .skin-timeless .voy-voyagedata--banner { margin: 0 -2em; } .skin-timeless .voy-voyagedata--banner .voy-voyagedata-wrapper { border-bottom: .5em solid #eaecf0; border-top: thin solid #eaecf0; padding: 0 0 0 2em; } .skin-vector-legacy .voy-voyagedata-wrapper { border-bottom: 1px solid #a7d7f9; } @media screen and (max-width: 850px) { .skin-timeless .voy-voyagedata { margin: 0 !important; } .skin-timeless .voy-voyagedata-wrapper { padding: 0 0 0 .45em; } } ` ); } /** * @param {Coordinate} sw * @param {Coordinate} ne * @param {string} langUser * @param {string} langLocal * @param {string[]} blacklistItem * @param {string[]} whitelistClass * @param {number} limit * @param {string} customRules * @return {string} */ static sparqlQueryBox( sw, ne, langUser, langLocal, blacklistItem, whitelistClass, limit, customRules ) { return `SELECT DISTINCT ?item ?location ?labelUser ?labelEn ?labelLocal ?description ?ref WHERE { SERVICE wikibase:box { ?item wdt:P625 ?location . bd:serviceParam wikibase:cornerSouthWest ${coordinateToGeoLiteral( sw )}. bd:serviceParam wikibase:cornerNorthEast ${coordinateToGeoLiteral( ne )}. } OPTIONAL {?item rdfs:label ?labelUser. FILTER(LANG(?labelUser)="${langUser}")} OPTIONAL {?item rdfs:label ?labelEn. FILTER(LANG(?labelEn)="en")} OPTIONAL {?item rdfs:label ?labelLocal. FILTER(LANG(?labelLocal)="${langLocal}")} OPTIONAL {?item schema:description ?description. FILTER(LANG(?description)="${langUser}")} ${VoyageData.#sparqlBlackWhiteListFragment( blacklistItem, whitelistClass, customRules )} } LIMIT ${limit}`; } /** * @param {Array<any>} claims * @return {any} */ static #bestClaim( claims ) { const preferred = []; const normal = []; for ( const claim of claims ) { if ( claim.mainsnak.snaktype !== 'value' ) { // Skip novalue and somevalue } // TODO: Check for end qualifier if ( claim.rank === 'preferred' ) { preferred.push( claim ); } else if ( claim.rank === 'normal' ) { normal.push( claim ); } } if ( preferred.length > 0 ) { return preferred[ 0 ]; } else if ( normal.length > 0 ) { return normal[ 0 ]; } else { return null; } } /** * @param {string} lemma * @param {string|number} offset * @param {string|number} searchLimit * @return {Promise<[Array<VoyageData.ItemResult>, number]>} Search results and new offset */ static async #querySearch( lemma, offset, searchLimit ) { const data = await ( await VoyageData.#fetchGet( 'https://www.wikidata.org/w/api.php', { action: 'query', format: 'json', list: 'search', origin: '*', srenablerewrites: 1, srlimit: searchLimit, srnamespace: 0, sroffset: offset, srsearch: lemma }, { cache: 'no-cache', headers: { Accept: 'application/json, text/plain, */*' }, mode: 'cors' } ) ).json(); const results = data.query.search; const newOffset = data?.continue?.sroffset ?? 0; const items = await VoyageData.#wdItems( results.map( ( searchResult ) => { return searchResult.title; } ) ); return [ items.filter( ( entity ) => { return entity !== null; } ).map( ( entity ) => { const coordinateClaim = ( entity.claims?.P625 ?? null ) !== null ? VoyageData.#bestClaim( entity.claims?.P625 ) : null; const coordinates = coordinateClaim !== null ? { lat: coordinateClaim.mainsnak.datavalue.value.latitude, long: coordinateClaim.mainsnak.datavalue.value.longitude } : null; return new VoyageData.ItemResult( entity.id, entity.labels?.de?.value, entity.labels?.en?.value, null, entity.description?.de?.value ?? entity.description?.en?.value, coordinates ); } ), newOffset ]; } /** * @param {VoyageData.Item[]} referenceItems * @param {string} langUser * @param {string} langLocal * @param {string[]} blacklistItem * @param {string[]} whitelistClass * @param {number} radius * @param {number} limit * @param {string} customRules * @return {string} */ static sparqlQueryRadius( referenceItems, langUser, langLocal, blacklistItem, whitelistClass, radius, limit, customRules ) { const referenceList = referenceItems.map( ( item ) => { return `(${coordinateToGeoLiteral( item.coordinates )} ${item.uid})`; } ).join( ' ' ); return `SELECT DISTINCT ?item ?location (geof:distance(?reference, ?location) AS ?locationRefDist) ?labelUser ?labelEn ?labelLocal ?description ?ref WHERE { VALUES (?reference ?ref) {${referenceList}} SERVICE wikibase:around { ?item wdt:P625 ?location. bd:serviceParam wikibase:center ?reference. bd:serviceParam wikibase:radius "${String( radius )}". } OPTIONAL {?item rdfs:label ?labelUser. FILTER(LANG(?labelUser)="${langUser}")} OPTIONAL {?item rdfs:label ?labelEn. FILTER(LANG(?labelEn)="en")} OPTIONAL {?item rdfs:label ?labelLocal. FILTER(LANG(?labelLocal)="${langLocal}")} OPTIONAL {?item schema:description ?description. FILTER(LANG(?description)="${langUser}")} ${VoyageData.#sparqlBlackWhiteListFragment( blacklistItem, whitelistClass, customRules )} } ORDER BY ?locationRefDist LIMIT ${limit}`; } static #sparqlBlackWhiteListFragment( blacklistItem, whitelistClass, customRules ) { const blacklistItemList = blacklistItem.map( ( item ) => { return `wd:${item}`; } ).join( ',' ); const whitelistClassList = whitelistClass.map( ( classification ) => { return `wd:${classification}`; } ).join( ' ' ); const blacklistFragment = blacklistItem.length > 0 ? `FILTER (?item NOT IN (${blacklistItemList})).` : ''; const whitelistClassFragment = whitelistClass.length > 0 ? `VALUES ?whitelistClass {${whitelistClassList}}. ?item wdt:P31/wdt:P279* ?whitelistClass.` : ''; return `${blacklistFragment} ${whitelistClassFragment} ${customRules}`; } /** * @param {Array<string>} entities * @return {Promise<Array<any>>} */ static async #wdItems( entities ) { if ( entities.length === 0 ) { return []; } const data = await ( await VoyageData.#fetchGet( 'https://www.wikidata.org/w/api.php', { action: 'wbgetentities', format: 'json', ids: entities.join( '|' ), languages: [ 'de', 'en' ].join( '|' ), origin: '*', props: [ 'claims', 'descriptions', 'info', 'labels' ].join( '|' ) }, { cache: 'no-cache', headers: { Accept: 'application/json, text/plain, */*' }, mode: 'cors' } ) ).json(); if ( data.success !== 1 ) { throw new Error( 'Invalid response from API' ); } return entities.map( ( entity ) => { return data.entities[ entity ]; } ); } /** * @param {string} lemma * @param {number|string} offset * @param {number|string} searchLimit * @return {Promise<[Array<VoyageData.ItemResult>, number]>} Search results and new offset */ static async #wbsearchentities( lemma, offset, searchLimit ) { const data = await ( await VoyageData.#fetchGet( 'https://www.wikidata.org/w/api.php', { action: 'wbsearchentities', continue: offset, format: 'json', language: mw.config.get( 'wgUserLanguage' ), limit: searchLimit, origin: '*', search: lemma, type: 'item' }, { cache: 'no-cache', headers: { Accept: 'application/json, text/plain, */*' }, mode: 'cors' } ) ).json(); if ( data.success !== 1 ) { throw new Error( 'Request failed' ); } const newOffset = ( data[ 'search-continue' ] ?? null ) !== null ? parseInt( data[ 'search-continue' ] ) : 0; return [ data.search.map( ( searchResult ) => { const labelUser = searchResult.match.language === mw.config.get( 'wgUserLanguage' ) ? searchResult.label : null; const labelEn = searchResult.match.language === 'en' ? searchResult.label : null; const labelLocal = searchResult.match.language === getLangLocal() ? searchResult.label : null; return new VoyageData.ItemResult( searchResult.id, labelUser, labelEn, labelLocal, searchResult.description ?? null, null ); } ), newOffset ]; } /** * @param {HTMLElement} mainElement */ destroy( mainElement ) { mainElement.remove(); for ( const [ subject, event, listener ] of this.#eventListeners ) { ( /** @type {EventTarget} */ subject ).removeEventListener( event, listener ); } } /** * @return {[Coordinate, number]} */ determineMapCenter() { let coordinatesInArticle = this.itemManager.getItemsInArticle( true, true ); let center = null; if ( coordinatesInArticle.length !== 0 ) { // vCards, that need to be resolved but have coordinates const bboxCoordinatesInArticle = VoyageData.Bbox.containCoordinates( coordinatesInArticle.map( ( item ) => { return item.coordinates; } ) ); center = bboxCoordinatesInArticle.center(); return [ center, VoyageData.INITIAL_ZOOM ]; } else if ( document.querySelector( '.voy-coord-indicator[data-lat][data-lon]' ) !== null ) { // From Indicator /** * @type {HTMLElement} */ const indicatorElement = document.querySelector( '.voy-coord-indicator[data-lat][data-lon]' ); let zoom = VoyageData.INITIAL_ZOOM; center = { lat: parseFloat( indicatorElement.dataset.lat ), long: parseFloat( indicatorElement.dataset.lon ) }; if ( indicatorElement.dataset.zoom !== undefined ) { zoom = parseInt( indicatorElement.dataset.zoom ); } return [ center, zoom ]; } else { // vCards, that have coordinates including vCards with Wikidata-IDs coordinatesInArticle = this.itemManager.getItemsInArticle( false, true ); if ( coordinatesInArticle.length !== 0 ) { const bboxCoordinatesInArticle = VoyageData.Bbox.containCoordinates( coordinatesInArticle.map( ( item ) => { return item.coordinates; } ) ); return [ bboxCoordinatesInArticle.center(), VoyageData.INITIAL_ZOOM ]; } else { // Null-Island return [ { lat: 0, long: 0 }, VoyageData.INITIAL_ZOOM ]; } } } reportTaskFinished() { this.#taskCounter -= 1; if ( this.#taskCounter > 0 ) { this.#taskProgressBar?.$element.stop().show( 250 ); } else { this.#taskProgressBar?.$element.stop().hide( 250 ); } } reportTaskStarted() { this.#taskCounter += 1; if ( this.#taskCounter > 0 ) { this.#taskProgressBar?.$element.stop().show( 250 ); } else { this.#taskProgressBar?.$element.stop().hide( 250 ); } } /** * @param {number} itemUid * @param {string} lemma * @param {number} offset * @param {boolean} useQuerySearch * @return {Promise} */ searchRequest( itemUid, lemma, offset, useQuerySearch ) { return this.queue.enqueue( async () => { let searchResults; let newOffset; try { if ( useQuerySearch ) { [ searchResults, newOffset ] = await VoyageData.#querySearch( lemma, offset, VoyageData.SEARCH_LIMIT ); } else { [ searchResults, newOffset ] = await VoyageData.#wbsearchentities( lemma, offset, VoyageData.SEARCH_LIMIT ); } } catch ( ex ) { console.error( ex ); mw.notify( mw.msg( 'voy-voyagedata-search-failed' ), { tag: 'voy-voyagedata-search-failed', title: 'VoyageData', type: 'error' } ); return; } this.itemManager.setOffset( itemUid, newOffset ); this.itemManager.registerItemResults( itemUid, searchResults ); } ); } async setup() { const waitNotification = mw.notification.notify( mw.msg( 'voy-voyagedata-please-wait' ), { autoHide: false, title: 'VoyageData', type: 'info' } ); await mw.loader.using( [ 'ext.kartographer.box', 'oojs-ui', 'oojs-ui.styles.icons-accessibility', 'oojs-ui.styles.icons-interactions', 'oojs-ui.styles.icons-location', 'oojs-ui.styles.icons-media', 'oojs-ui.styles.icons-moderation', 'oojs-ui.styles.icons-wikimedia' ] ); waitNotification.close(); this.itemManager = new VoyageData.ItemManager(); this.queue = new VoyageData.Queue(); const [ center, zoom ] = this.determineMapCenter(); const { map, taskProgressBar, wrapper } = await this.setupUi( center, zoom ); this.#taskProgressBar = taskProgressBar; const markersForVcardsInArticle = getMarkersForVcardsInArticle( true ); for ( const groupId of Object.keys( markersForVcardsInArticle ) ) { const group = markersForVcardsInArticle[ groupId ]; map.addGeoJSONLayer( group.items, { name: group.name } ); } } /** * @param {Coordinate} center * @param {number} [zoom=VoyageData.INITIAL_ZOOM] * @return {Promise<{map: any, taskProgressBar: OO.ui.ProgressBarWidget, wrapper: HTMLElement}>} */ async setupUi( center, zoom = VoyageData.INITIAL_ZOOM ) { await mw.loader.using( [ 'ext.kartographer.box', 'mediawiki.util', 'oojs-ui-core' ] ); VoyageData.setupCss(); const mainElement = document.createElement( 'div' ); mainElement.classList.add( 'voy-voyagedata' ); mainElement.classList.add( `voy-voyagedata--${this.#settings.getLayout()}` ); // # Controls const controls = document.createElement( 'div' ); controls.classList.add( 'voy-voyagedata-controls' ); controls.classList.add( 'voy-voyagedata-only-corner' ); // ## Minimise let minimised = false; const buttonMinimise = new OO.ui.ButtonWidget( { icon: 'eye', title: mw.msg( 'voy-voyagedata-minimise-description' ) } ); buttonMinimise.$element.on( 'click', ( /** @type {MouseEvent} */ e ) => { e.preventDefault(); if ( minimised ) { document.querySelector( '.voy-voyagedata' ).classList.remove( 'voy-voyagedata--minimised' ); buttonMinimise.setIcon( 'eye' ); } else { document.querySelector( '.voy-voyagedata' ).classList.add( 'voy-voyagedata--minimised' ); buttonMinimise.setIcon( 'eyeClosed' ); } minimised = !minimised; } ); // ## Kill const buttonKill = new OO.ui.ButtonWidget( { flags: [ 'destructive' ], icon: 'close', title: mw.msg( 'voy-voyagedata-kill-description' ) } ); buttonKill.$element.on( 'click', ( /** @type {MouseEvent} */ e ) => { e.preventDefault(); this.destroy( mainElement ); addPortlet(); } ); const controlsWrapper = new OO.ui.ButtonGroupWidget( { items: [ buttonMinimise, buttonKill ] } ); controlsWrapper.$element[ 0 ].style.float = 'right'; controls.appendChild( controlsWrapper.$element[ 0 ] ); mainElement.appendChild( controls ); // # Wrapper const wrapper = document.createElement( 'div' ); wrapper.classList.add( 'voy-voyagedata-wrapper' ); mainElement.appendChild( wrapper ); // ## ProgressBar const progressBar = new OO.ui.ProgressBarWidget(); progressBar.$element.hide(); wrapper.appendChild( progressBar.$element[ 0 ] ); // ## Page Main const pageMain = document.createElement( 'div' ); pageMain.classList.add( 'voy-voyagedata-page-main' ); wrapper.appendChild( pageMain ); // ## MapWrapper const mapWrapper = document.createElement( 'div' ); const kartoBox = mw.loader.require( 'ext.kartographer.box' ); const map = kartoBox.map( { allowFullScreen: true, alwaysInteractive: true, center: [ center.lat, center.long ], container: mapWrapper, toggleNearby: false, zoom: zoom } ); new ResizeObserver( () => { // Invalidate map, if wrapper was resized map.invalidateSize(); } ).observe( mapWrapper ); // ### ListPanel const listPanel = document.createElement( 'div' ); listPanel.classList.add( 'voy-voyagedate-listpanel' ); listPanel.insertAdjacentHTML( 'beforeend', '<h3>VoyageData</h3>' ); pageMain.appendChild( listPanel ); pageMain.appendChild( mapWrapper ); // #### ListWrapper const listWrapper = document.createElement( 'div' ); listWrapper.classList.add( 'voy-voyagedata-list' ); // ##### List /** * @type {Object.<string, HTMLElement>} */ const itemMap = {}; const list = document.createElement( 'ul' ); listWrapper.appendChild( list ); listPanel.appendChild( listWrapper ); this.itemManager.on( VoyageData.ItemManager.Event.itemAdded, ( /** @type {VoyageData.Item} */ item ) => { const node = item.getListNode( this.itemManager ); itemMap[ item.uid ] = node; list.appendChild( node ); } ); this.itemManager.on( VoyageData.ItemManager.Event.itemResolved, ( /** @type {VoyageData.Item} */ item ) => { const oldItemElement = itemMap[ item.uid ]; const newNode = item.getListNode( this.itemManager ); itemMap[ item.uid ] = newNode; oldItemElement.parentNode.replaceChild( newNode, oldItemElement ); } ); this.itemManager.on( VoyageData.ItemManager.Event.itemResultsUpdated, ( /** @type {VoyageData.Item} */ item ) => { const itemElement = itemMap[ item.uid ]; item.updateResultList( this.itemManager, itemElement ); } ); for ( const item of this.itemManager.getItemsInArticle( true, false ) ) { this.itemManager.appendItem( item ); } // #### Buttons // ##### buttonByRadius const buttonByRadius = new OO.ui.ButtonWidget( { icon: 'mapPin', label: mw.msg( 'voy-voyagedata-byradius-label' ), title: mw.msg( 'voy-voyagedata-byradius-description' ) } ); buttonByRadius.$element.on( 'click', async ( /** @type {MouseEvent} */ e ) => { e.preventDefault(); // TODO: Wert merken const radius = parseFloat( await OO.ui.prompt( mw.msg( 'voy-voyagedata-byradius-prompt-radius' ), { textInput: { value: String( this.#settings.getInitialRadius() ) } } ) ); if ( !isNaN( radius ) ) { const sparqlQuery = VoyageData.sparqlQueryRadius( this.itemManager.getItemsInArticle( true, true ), mw.config.get( 'wgUserLanguage' ), getLangLocal(), Array.from( new Set( getWikidataIdsArticle().concat( Array.from( this.wikidataIdsLoaded ) ) ) ), this.#settings.getWdClassWhitelist() ?? [], radius, VoyageData.SPARQL_LIMIT, '' ); this.wdqsRequest( map, sparqlQuery, e.shiftKey ); } } ); this.#registerEventListener( window, 'keydown', ( /** @type {KeyboardEvent} */ e ) => { if ( e.key === 'Shift' ) { buttonByRadius.setIcon( 'logoWikidata' ); } } ); this.#registerEventListener( window, 'keyup', ( /** @type {KeyboardEvent} */ e ) => { if ( e.key === 'Shift' ) { buttonByRadius.setIcon( 'mapPin' ); } } ); // ##### buttonByVisBbox const buttonByVisBbox = new OO.ui.ButtonWidget( { icon: 'fullScreen', label: mw.msg( 'voy-voyagedata-bybbox-label' ), title: mw.msg( 'voy-voyagedata-bybbox-description' ) } ); buttonByVisBbox.$element.on( 'click', ( /** @type {MouseEvent} */ e ) => { e.preventDefault(); const bounds = map.getBounds(); const sw = { lat: bounds.getSouth(), long: bounds.getWest() }; const ne = { lat: bounds.getNorth(), long: bounds.getEast() }; const sparqlQuery = VoyageData.sparqlQueryBox( sw, ne, mw.config.get( 'wgUserLanguage' ), getLangLocal(), Array.from( new Set( getWikidataIdsArticle().concat( Array.from( this.wikidataIdsLoaded ) ) ) ), this.#settings.getWdClassWhitelist() ?? [], VoyageData.SPARQL_LIMIT, '' ); this.wdqsRequest( map, sparqlQuery, e.shiftKey ); } ); this.#registerEventListener( window, 'keydown', ( /** @type {KeyboardEvent} */ e ) => { if ( e.key === 'Shift' ) { buttonByVisBbox.setIcon( 'logoWikidata' ); } } ); this.#registerEventListener( window, 'keyup', ( /** @type {KeyboardEvent} */ e ) => { if ( e.key === 'Shift' ) { buttonByVisBbox.setIcon( 'fullScreen' ); } } ); // ##### buttonNameStrict const buttonNameStrict = new OO.ui.ButtonWidget( { icon: 'largerText', label: mw.msg( 'voy-voyagedata-bynamestrict-label' ), title: mw.msg( 'voy-voyagedata-bynamestrict-description' ) } ); buttonNameStrict.$element.on( 'click', async ( /** @type {MouseEvent} */ e ) => { e.preventDefault(); const suffix = e.shiftKey ? ( prompt( 'Bitte gib eine Suffix an.', mw.config.get( 'wgTitle' ) ) ?? '' ) : ''; // TODO: l10n for ( const item of this.itemManager.getItemsInArticle( true, false ) ) { const bestNameParameter = this.#settings.getNameParameterChain().map( ( nameParameter ) => { return item[ nameParameter ]; } ).find( ( value ) => { return value !== undefined && value !== null; } ); if ( bestNameParameter !== undefined ) { // FIXME: Offset this.reportTaskStarted(); try { await this.searchRequest( item.uid, `${bestNameParameter}${suffix === '' ? '' : ` ${suffix}`}`, 0, false ); } catch ( ex ) { // TODO } finally { this.reportTaskFinished(); } } } } ); // ##### buttonNameQuery const buttonNameQuery = new OO.ui.ButtonWidget( { icon: 'largerText', label: mw.msg( 'voy-voyagedata-bynamequery-label' ), title: mw.msg( 'voy-voyagedata-bynamequery-description' ) } ); buttonNameQuery.$element.on( 'click', async ( /** @type {MouseEvent} */ e ) => { e.preventDefault(); const suffix = e.shiftKey ? ( prompt( 'Bitte gib eine Suffix an.', mw.config.get( 'wgTitle' ) ) ?? '' ) : ''; // TODO: l10n for ( const item of this.itemManager.getItemsInArticle( true, false ) ) { const bestNameParameter = this.#settings.getNameParameterChain().map( ( nameParameter ) => { return item[ nameParameter ]; } ).find( ( value ) => { return value !== undefined && value !== null; } ); if ( bestNameParameter !== undefined ) { // FIXME: Offset this.reportTaskStarted(); try { await this.searchRequest( item.uid, `${bestNameParameter}${suffix === '' ? '' : ` ${suffix}`}`, 0, true ); } catch ( ex ) { // TODO } finally { this.reportTaskFinished(); } } } } ); // ##### buttonConfig const buttonConfig = new OO.ui.ButtonWidget( { icon: 'settings', title: mw.msg( 'voy-voyagedata-settings-label' ) } ); buttonConfig.$element.on( 'click', ( /** @type {MouseEvent} */ e ) => { e.preventDefault(); this.#settings.openConfigWindow(); } ); // ##### buttonPin const buttonPin = new OO.ui.ButtonWidget( { classes: [ 'voy-voyagedata-only-banner' ], icon: 'pushPin', title: mw.msg( 'voy-voyagedata-pin-label' ) } ); buttonPin.$element.on( 'click', ( /** @type {MouseEvent} */ e ) => { e.preventDefault(); if ( e.shiftKey ) { this.destroy( mainElement ); addPortlet(); } else { if ( wrapper.classList.contains( 'voy-voyagedata-nosticky' ) ) { wrapper.classList.remove( 'voy-voyagedata-nosticky' ); } else { wrapper.classList.add( 'voy-voyagedata-nosticky' ); } } } ); this.#registerEventListener( window, 'keydown', ( /** @type {KeyboardEvent} */ e ) => { if ( e.key === 'Shift' ) { buttonPin.setFlags( { destructive: true } ); buttonPin.setIcon( 'close' ); } } ); this.#registerEventListener( window, 'keyup', ( /** @type {KeyboardEvent} */ e ) => { if ( e.key === 'Shift' ) { buttonPin.setFlags( { destructive: false } ); buttonPin.setIcon( 'pushPin' ); } } ); const buttonWrapper = new OO.ui.ButtonGroupWidget( { items: [ buttonByRadius, buttonByVisBbox, buttonNameStrict, buttonNameQuery, buttonConfig, buttonPin ] } ); listPanel.appendChild( buttonWrapper.$element[ 0 ] ); document.getElementById( 'bodyContent' ).insertAdjacentElement( 'beforebegin', mainElement ); this.#setupStickynessWatcher( mainElement, wrapper ); return { map: map, taskProgressBar: progressBar, wrapper: mainElement }; } /** * @param {any} map * @param {string} sparqlQuery * @param {boolean} openWdqs * @return {Promise<boolean>} */ async wdqsRequest( map, sparqlQuery, openWdqs ) { if ( openWdqs ) { openSparqlQuery( sparqlQuery ); return false; } else { this.reportTaskStarted(); try { const data = await ( await VoyageData.#fetchPost( 'https://query.wikidata.org/sparql', { query: sparqlQuery.replace( /\n\t*/g, ' ' ) }, { cache: 'no-cache', headers: { Accept: 'application/json, text/plain, */*' }, mode: 'cors' } ) ).json(); const markersWikidata = []; for ( const val of data.results.bindings ) { const coordinates = val.location?.value.match( /Point\(([^ ]+) ([^)]+)\)/i ) ?? null; const wdid = val.item.value.match( /(Q[0-9]+)$/ )[ 1 ]; const labelUser = val.labelUser?.value; const labelEn = val.labelEn?.value; const labelLocal = val.labelLocal?.value; const ref = val.ref !== undefined ? parseInt( val.ref.value ) : null; if ( coordinates !== null && !this.wikidataIdsInMap.has( wdid ) ) { this.wikidataIdsInMap.add( wdid ); markersWikidata.push( { type: 'Feature', properties: { 'marker-color': '#9a0000', 'marker-size': 'medium', 'marker-symbol': 'marker', title: labelUser ?? labelEn ?? labelLocal ?? mw.msg( 'voy-voyagedata-no-name-placeholder' ), description: `<a href="${val.item.value}" target="_blank" onclick="VoyageData.copyFromMarkerToClipboard( event, '${wdid}');">${wdid}</a>` }, geometry: { type: 'Point', coordinates: [ parseFloat( coordinates[ 1 ] ), parseFloat( coordinates[ 2 ] ) ] } } ); } this.itemManager.registerItemResult( ref, new VoyageData.ItemResult( wdid, labelUser, labelEn, labelLocal, val.description?.value, coordinates !== null ? { lat: parseFloat( coordinates[ 1 ] ), long: parseFloat( coordinates[ 2 ] ) } : null ) ); this.wikidataIdsLoaded.add( wdid ); } map.addGeoJSONLayer( markersWikidata, { name: 'WD' } ); return true; } catch ( ex ) { mw.notify( mw.msg( 'voy-voyagedata-search-failed' ), { tag: 'voy-voyagedata-search-failed', title: 'VoyageData', type: 'error' } ); throw ex; } finally { this.reportTaskFinished(); } } } /** * Executes a GET-request via `fetch`. * @param {string} urlString * @param {Object.<string, string>} searchParams * @returns URL-string. */ static async #fetchGet( urlString, searchParams = {}, config = {} ) { const url = new URL( urlString ); for ( const [ key, value ] of Object.entries( searchParams ?? {} ) ) { url.searchParams.append( key, value ); } const fullUrlString = url.href; return fetch( fullUrlString, { ...{ method: 'GET' }, ...(config ?? {}) } ); } /** * Executes a POST-request via `fetch`. * @param {string} urlString * @param {Object.<string, string>} searchParams * @returns URL-string. */ static async #fetchPost( urlString, searchParams = {}, config = {} ) { const urlLSearchParams = new URLSearchParams(); for ( const [ key, value ] of Object.entries( searchParams ?? {} ) ) { urlLSearchParams.append( key, value ); } return await fetch( urlString, { ...{ body: urlLSearchParams, method: 'POST' }, ...(config ?? {}) } ); } /** * @param {EventTarget} subject * @param {string} event * @param {EventListenerOrEventListenerObject} listener */ #registerEventListener( subject, event, listener ) { this.#eventListeners.push( [ subject, event, listener ] ); subject.addEventListener( event, listener ); } /** * @param {HTMLElement} mainElement * @param {HTMLElement} wrapperElement */ #setupStickynessWatcher( mainElement, wrapperElement ) { const that = this; window.addEventListener( 'resize', setStickyness ); document.addEventListener( 'scroll', setStickyness ); new ResizeObserver( setStickyness ).observe( wrapperElement ); setStickyness(); function setStickyness() { let topTriggerOffset = 0; if ( document.body.classList.contains( 'skin-timeless' ) ) { topTriggerOffset = Math.max( 0, document.getElementById( 'mw-header-hack' ).getBoundingClientRect().top ); } const mainClientRect = mainElement.getBoundingClientRect(); const maxHeight = window.innerHeight - topTriggerOffset; wrapperElement.style.maxHeight = `${maxHeight}px`; if ( mainClientRect.top <= topTriggerOffset && that.#settings.getLayout() === 'banner' ) { wrapperElement.classList.add( 'voy-voyagedata-sticky' ); wrapperElement.style.top = `${Math.floor( topTriggerOffset )}px`; wrapperElement.style.width = `${mainElement.clientWidth}px`; mainElement.style.height = `${wrapperElement.clientHeight}px`; } else { wrapperElement.classList.remove( 'voy-voyagedata-sticky' ); wrapperElement.style.top = null; wrapperElement.style.width = null; mainElement.style.height = null; } } } } /** * @class */ VoyageData.Bbox = class { /** * @property {number} */ n = null; /** * @property {number} */ e = null; /** * @property {number} */ s = null; /** * @property {number} */ w = null; /** * @param {number} n * @param {number} e * @param {number} s * @param {number} w */ constructor( n, e, s, w ) { this.n = n; this.e = e; this.s = s; this.w = w; } /** * @param {Coordinate[]} coordinates * @return {VoyageData.Bbox} */ static containCoordinates( coordinates ) { const bbox = new VoyageData.Bbox( null, null, null, null ); for ( const coordinate of coordinates ) { bbox.extend( coordinate ); } return bbox; } /** * @return {Coordinate} */ center() { return { lat: ( this.n + this.s ) / 2, long: ( this.w + this.e ) / 2 }; } /** * @param {Coordinate} coordinate */ extend( { lat, long } ) { if ( this.n === null || this.n < lat ) { this.n = lat; } if ( this.s === null || this.s > lat ) { this.s = lat; } if ( this.w === null || this.w > long ) { this.w = long; } if ( this.e === null || this.e < long ) { this.e = long; } } /** * @param {number} extendBy */ pad( extendBy ) { this.n += extendBy; this.e += extendBy; this.s -= extendBy; this.w -= extendBy; } }; /** * @class */ VoyageData.Item = class { /** * @property {Coordinate} */ coordinates = null; /** * @property {HTMLElement} */ element = null; /** * @property {string} */ name = null; /** * @property {offset} */ offset = 0; /** * @property {string} */ selectedWdid = null; /** * @property {number} */ uid = null; /** * @param {string} name * @param {string} nameLocal * @param {Coordinate} coordinates * @param {HTMLElement} element * @param {number} uid */ constructor( name, nameLocal, coordinates, element, uid ) { this.coordinates = coordinates; this.element = element; this.name = name; this.nameLocal = nameLocal; this.uid = uid; } /** * @param {VoyageData.ItemManager} itemManager * @param {Object.<string, VoyageData.ItemResult>} itemResults * @return {HTMLElement} */ getItemResultsNode( itemManager, itemResults ) { const that = this; const ul = document.createElement( 'ul' ); ul.classList.add( 'voy-voyagedata-itemresults' ); if ( this.selectedWdid !== null ) { ul.style.display = 'none'; } if ( itemResults !== null ) { for ( const wdid of Object.keys( itemResults ) ) { ul.appendChild( itemResults[ wdid ].getListNode( resultChosen ) ); } } return ul; /** * @param {VoyageData.ItemResult} itemResult */ async function resultChosen( itemResult ) { if ( that.uid !== -1 ) { // Orphan group cannot be resolved that.selectedWdid = itemResult.wdid; } itemManager.resolveItem( that ); try { await navigator.clipboard.writeText( itemResult.wdid ); mw.notify( mw.msg( 'voy-voyagedata-clipboard-wdid-success' ), { tag: 'voy-voyagedata-clipboard', title: 'VoyageData', type: 'success' } ); } catch ( ex ) { console.error( ex ); mw.notify( mw.msg( 'voy-voyagedata-clipboard-wdid-fail' ), { tag: 'voy-voyagedata-clipboard', title: 'VoyageData', type: 'error' } ); } } } /** * @param {VoyageData.ItemManager} itemManager * @return {HTMLElement} */ getListNode( itemManager ) { const li = document.createElement( 'li' ); const results = this.getResults( itemManager ); if ( this.selectedWdid !== null ) { li.classList.add( 'voy-voyagedata-resolved' ); } if ( results === null || Object.keys( results ).length <= 0 ) { li.classList.add( 'voy-voyagedata-noresults' ); } let nameFragment = null; if ( this.nameLocal !== null ) { nameFragment = `<span class="voy-voyagedata-term-name" title="name" style="font-size:.8em">${mw.html.escape( this.name ?? '-' )}</span> <span style="font-weight:bold">/</span><span class="voy-voyagedata-term-name-local" title="name-local">${mw.html.escape( this.nameLocal )}</span>`; } else if ( this.name === null && this.nameLocal === null ) { nameFragment = mw.msg( 'voy-voyagedata-no-name-placeholder' ); } else { nameFragment = `<span class="voy-voyagedata-term-name" title="name">${mw.html.escape( this.name )}</span> <span style="font-weight:bold">/</span><span title="name-local" style="font-size:.8em">-</span>`; } li.innerHTML = `<span class="voy-voyagedata-term">${nameFragment}</span> ${this.coordinates !== null ? '<span title="vCard hat Koordinaten">[🌐]</span>' : ''}[<a href="#jumpto" title="Zu Marker in Artikel springen">⚓</a>][<a href="#collapse" title="Suchergebnisse umschalten">👁️</a>]<span class="action-loadmore" style="display:none">[<a href="#loadmore" title="Nach weiteren Elementen anhand des Namens suchen">🔍</a>]</span>`; li.querySelector( '.voy-voyagedata-term-name' )?.addEventListener( 'click', async ( /** @type {MouseEvent} */ e ) => { e.preventDefault(); if ( e.ctrlKey ) { window.open( `https://www.wikidata.org/w/index.php?title=Special:Search&search=${encodeURIComponent( this.name )}&ns0=1`, '_blank' ); } else { try { await navigator.clipboard.writeText( this.name ); mw.notify( mw.msg( 'voy-voyagedata-clipboard-name-success' ), { tag: 'voy-voyagedata-clipboard', title: 'VoyageData', type: 'success' } ); } catch ( ex ) { console.error( ex ); mw.notify( mw.msg( 'voy-voyagedata-clipboard-name-fail' ), { tag: 'voy-voyagedata-clipboard', title: 'VoyageData', type: 'error' } ); } } } ); li.querySelector( '.voy-voyagedata-term-name-local' )?.addEventListener( 'click', async ( /** @type {MouseEvent} */ e ) => { e.preventDefault(); if ( e.ctrlKey ) { window.open( `https://www.wikidata.org/w/index.php?title=Special:Search&search=${encodeURIComponent( this.nameLocal )}&ns0=1`, '_blank' ); } else { try { await navigator.clipboard.writeText( this.nameLocal ); mw.notify( mw.msg( 'voy-voyagedata-clipboard-name-success' ), { tag: 'voy-voyagedata-clipboard', title: 'VoyageData', type: 'success' } ); } catch ( ex ) { console.error( ex ); mw.notify( mw.msg( 'voy-voyagedata-clipboard-name-fail' ), { tag: 'voy-voyagedata-clipboard', title: 'VoyageData', type: 'error' } ); } } } ); li.querySelector( 'a[href="#collapse"]' ).addEventListener( 'click', ( e ) => { e.preventDefault(); $( li ).find( '.voy-voyagedata-itemresults' ).toggle(); } ); li.querySelector( 'a[href="#jumpto"]' ).addEventListener( 'click', ( e ) => { e.preventDefault(); window.scrollTo( 0, this.element.offsetTop ); } ); li.querySelector( 'a[href="#loadmore"]' ).addEventListener( 'click', ( e ) => { e.preventDefault(); // TODO: } ); li.querySelector( '.voy-voyagedata-term' ).addEventListener( 'click', ( e ) => { e.preventDefault(); // TODO: } ); li.appendChild( this.getItemResultsNode( itemManager, results ) ); return li; } /** * @param {VoyageData.ItemManager} itemManager * @return {Object.<string, VoyageData.ItemResult>} */ getResults( itemManager ) { return itemManager.getResults( this.uid ); } /** * @param {VoyageData.ItemManager} itemManager * @param {HTMLElement} itemElement */ updateResultList( itemManager, itemElement ) { const results = this.getResults( itemManager ); const oldItemResults = itemElement.querySelector( '.voy-voyagedata-itemresults' ); const newItemResults = this.getItemResultsNode( itemManager, results ); oldItemResults.parentElement.replaceChild( newItemResults, oldItemResults ); if ( results === null || Object.keys( results ).length <= 0 ) { itemElement.classList.add( 'voy-voyagedata-noresults' ); } else { itemElement.classList.remove( 'voy-voyagedata-noresults' ); } } }; /** * @class */ VoyageData.ItemManager = class { /** * @enum {ItemManagerEvent} */ static Event = { itemAdded: 'item_added', itemResolved: 'item_resolved', itemResultsUpdated: 'item_results_updated' }; /** * @property {number} */ static itemCounter = 0; /** * @property {Object.<number, VoyageData.Item>} */ items = {}; /** * @property {Object.<string, Array<Function>>} */ subscriptions = {}; /** * @property {Object.<string, Object.<string, VoyageData.ItemResult>>} */ #results = {}; /** * @param {VoyageData.Item} item */ appendItem( item ) { this.items[ item.uid ] = item; this.#trigger( VoyageData.ItemManager.Event.itemAdded, item ); } /** * @param {boolean} filterNoWikidata * @param {boolean} filterCoordinates * @return {VoyageData.Item[]} */ getItemsInArticle( filterNoWikidata, filterCoordinates ) { let container = document; if ( document.querySelector( '.ext-WikiEditor-realtimepreview-preview' ) !== null ) { container = document.querySelector( '.ext-WikiEditor-realtimepreview-preview' ); } let vcards = Array.from( container.querySelectorAll( `.vcard${filterNoWikidata ? ':not([data-wikidata])' : ''}` ) ); if ( filterCoordinates ) { // Filter out vCards without coordinates vcards = vcards.filter( ( vcard ) => { return vcard.querySelector( 'a[data-lat][data-lon]' ); } ); } return vcards.map( ( /** @type {HTMLElement} */ vcard ) => { let uid = null; if ( 'wvVoyagedataId' in vcard.dataset ) { uid = parseInt( vcard.dataset.wvVoyagedataId ); } else { uid = VoyageData.ItemManager.itemCounter++; vcard.dataset.wvVoyagedataId = String( uid ); } let coordinates = null; const coordinateElement = vcard.querySelector( 'a[data-lat][data-lon]' ); if ( coordinateElement !== null ) { coordinates = { lat: parseFloat( /** @type {HTMLElement} */ ( coordinateElement ).dataset.lat ), long: parseFloat( /** @type {HTMLElement} */ ( coordinateElement ).dataset.lon ) }; } const item = new VoyageData.Item( /** @type {HTMLElement} */ ( vcard.closest( '[data-name]' ) )?.dataset.name ?? null, /** @type {HTMLElement} */ ( vcard.closest( '[data-name-local]' ) )?.dataset.nameLocal ?? null, coordinates, vcard, uid ); return item; } ); } /** * @param {number} itemUid * @return {Object.<string, VoyageData.ItemResult>} */ getResults( itemUid ) { if ( this.#results[ itemUid ] === undefined ) { return null; } else { return this.#results[ itemUid ]; } } /** * @param {ItemManagerEvent} event * @param {Function} callback */ on( event, callback ) { if ( this.subscriptions[ event ] === undefined ) { this.subscriptions[ event ] = []; } this.subscriptions[ event ].push( callback ); } /** * @param {number} itemUid * @param {number} newOffset */ setOffset( itemUid, newOffset ) { this.items[ itemUid ].offset = newOffset; } /** * @param {number} itemUid * @param {VoyageData.ItemResult} itemResult */ registerItemResult( itemUid, itemResult ) { this.registerItemResults( itemUid, [ itemResult ] ); } /** * @param {number} itemUid * @param {VoyageData.ItemResult[]} itemResults */ registerItemResults( itemUid, itemResults ) { const itemUidNum = itemUid === null ? -1 : itemUid; if ( this.#results[ itemUidNum ] === undefined ) { this.#results[ itemUidNum ] = {}; } for ( const itemResult of itemResults ) { if ( this.#results[ itemUidNum ][ itemResult.wdid ] === undefined ) { this.#results[ itemUidNum ][ itemResult.wdid ] = itemResult; } else { // FIXME: Merge this.#results[ itemUidNum ][ itemResult.wdid ] = itemResult; } } if ( itemUidNum === -1 && this.items[ itemUidNum ] === undefined ) { const orphansItem = new VoyageData.Item( mw.msg( 'voy-voyagedata-orphans' ), null, null, null, itemUidNum ); this.items[ itemUidNum ] = orphansItem; this.#trigger( VoyageData.ItemManager.Event.itemAdded, orphansItem ); } this.#trigger( VoyageData.ItemManager.Event.itemResultsUpdated, this.items[ itemUidNum ] ); } /** * @param {VoyageData.Item} item */ resolveItem( item ) { this.#trigger( VoyageData.ItemManager.Event.itemResolved, item ); } /** * @param {ItemManagerEvent} event * @param {any} args */ #trigger( event, ...args ) { if ( this.subscriptions[ event ] !== undefined ) { for ( const callback of this.subscriptions[ event ] ) { callback.call( this, ...args ); } } } }; /** * @class */ VoyageData.ItemResult = class { /** * @property {string} */ wdid = null; /** * @property {string} */ labelUser = null; /** * @property {string} */ labelEn = null; /** * @property {string} */ labelLocal = null; /** * @property {string} */ description = null; /** * @property {Coordinate} */ coordinates = null; /** * @param {string} wdid * @param {string} labelUser * @param {string} labelEn * @param {string} labelLocal * @param {string} description * @param {Coordinate} coordinates */ constructor( wdid, labelUser, labelEn, labelLocal, description, coordinates ) { this.wdid = wdid; this.labelUser = labelUser ?? null; this.labelEn = labelEn ?? null; this.labelLocal = labelLocal ?? null; this.description = description ?? null; this.coordinates = coordinates ?? null; } /** * @param {Function} selectionCallback * @return {HTMLElement} */ getListNode( selectionCallback ) { const labels = [ [ this.labelUser, mw.config.get( 'wgUserLanguage' ) ], [ this.labelEn, 'en' ], [ this.labelLocal, getLangLocal() ] ].map( ( [ label, lang ], i ) => { return `<span style="${i !== 0 ? 'font-size:.8em;' : ''}" title="${lang}">${label === null ? '-' : mw.html.escape( label )}</span>`; } ).join( ' <span style="font-weight:bold">/</span>' ); const li = document.createElement( 'li' ); li.innerHTML = `<li data-entity="${this.wdid}"><span class="voy-voyagedata-label">${labels}</span> (<a class="voy-voyagedata-wdid" href="https://www.wikidata.org/wiki/${this.wdid}" rel="nofollow noopener">${this.wdid}</a>)${this.description !== null ? `<br/><i class="voy-voyagedata-description">${mw.html.escape( this.description )}</i>` : ''}</li>`; li.querySelector( '.voy-voyagedata-wdid' ).addEventListener( 'click', ( /** @type {MouseEvent} */ e ) => { if ( !e.ctrlKey ) { e.preventDefault(); selectionCallback( this ); } } ); return li; } }; /** * @class */ VoyageData.Queue = class { /** * @property {number} */ max = 0; /** * @property {number} */ nextPointer = 0; /** * @property {number} */ performing = 0; /** * @property {[Promise, Function, Function][]} */ tasks = []; /** * @param {number} [max=5] */ constructor( max = 5 ) { this.max = max || 5; this.nextPointer = 0; this.performing = 0; } /** * @template T * @param {Promise<T>} task * @return {Promise<T>} */ enqueue( task ) { return new Promise( ( resolve, reject ) => { this.tasks.push( [ task, resolve, reject ] ); this.work(); } ); } work() { if ( this.max > this.performing ) { if ( this.tasks[ this.nextPointer ] === undefined ) { this.nextPointer = 0; this.tasks = []; this.performing = 0; } else { const next = this.tasks[ this.nextPointer ]; this.nextPointer += 1; this.#executeTask( next ); } } } #executeTask( [ task, resolve, reject ] ) { this.performing += 1; task().then( () => { resolve(); this.#finishTask(); }, () => { reject(); this.#finishTask(); } ); } #finishTask() { this.performing -= 1; this.work(); } }; /** * @class */ VoyageData.Settings = class { /** * @type {ConfigWindow} */ #configWindow = null; /** * @type {OO.ui.TextInputWidget} */ #initialRadius = null; /** * @type {OO.ui.MenuTagMultiselectWidget} */ #inputNameParameterChain = null; /** * @type {OO.ui.MenuTagMultiselectWidget} */ #inputWdClassBlacklist = null; /** * @type {OO.ui.MenuTagMultiselectWidget} */ #inputWdClassWhitelist = null; /** * @type {string} */ #settingInitialRadius = mw.user.options.get( 'userjs-voy-voyagedata-initialRadius' ) ?? '0.05'; /** * @type {string} */ #settingLayout = mw.user.options.get( 'userjs-voy-voyagedata-layout' ) === 'corner' ? 'corner' : 'banner'; /** * @type {string} */ #settingNameParameterChain = [ 'nameLocal', 'name' ]; /** * @type {string[]} */ #settingWdClassBlacklist = null; /** * @type {string[]} */ #settingWdClassWhitelist = null; /** * @type {OO.ui.WindowManager} */ #windowManager = null; constructor() { this.#settingWdClassBlacklist = VoyageData.WIKIDATA_CLASS_BLACKLIST ?? null; this.#settingWdClassWhitelist = VoyageData.WIKIDATA_CLASS_WHITELIST ?? null; } /** * @return {string} */ getInitialRadius() { return this.#settingInitialRadius; } /** * @return {string} */ getLayout() { return this.#settingLayout; } /** * @return {string} */ getNameParameterChain() { return this.#settingNameParameterChain; } /** * @return {string[]} */ getWdClassBlacklist() { return this.#settingWdClassBlacklist; } /** * @param {string[]} newValue */ setWdClassBlacklist( newValue ) { this.#settingWdClassBlacklist = newValue; } /** * @return {string[]} */ getWdClassWhitelist() { return this.#settingWdClassWhitelist; } /** * @param {string[]} newValue */ setWdClassWhitelist( newValue ) { this.#settingWdClassWhitelist = newValue; } openConfigWindow() { if ( this.#configWindow === null ) { this.#setupConfigWindow(); } this.#loadSettings(); this.#windowManager.openWindow( this.#configWindow ); } async #loadSettings() { this.#initialRadius.setValue( this.#settingInitialRadius ?? '0.05' ); this.#inputWdClassBlacklist.setValue( this.#settingWdClassBlacklist ?? [] ); this.#inputWdClassWhitelist.setValue( this.#settingWdClassWhitelist ?? [] ); } async #saveSettings() { if ( this.#initialRadius.getValue() !== this.#settingInitialRadius ) { this.#settingInitialRadius = this.#initialRadius.getValue(); ( new mw.Api() ).saveOption( 'userjs-voy-voyagedata-initialRadius', this.#settingInitialRadius ); mw.user.options.set( 'userjs-voy-voyagedata-initialRadius', this.#settingInitialRadius ); } this.#settingNameParameterChain = this.#inputNameParameterChain.getValue(); this.#settingWdClassBlacklist = this.#inputWdClassBlacklist.getValue(); this.#settingWdClassWhitelist = this.#inputWdClassWhitelist.getValue(); } #setupConfigWindow() { const that = this; const ConfigWindow = function ( config ) { ConfigWindow.super.call( this, config ); }; OO.inheritClass( ConfigWindow, OO.ui.ProcessDialog ); ConfigWindow.static.actions = [ { action: 'save', label: mw.msg( 'voy-voyagedata-save' ), flags: [ 'primary', 'progressive' ] }, { label: mw.msg( 'voy-voyagedata-discard' ), flags: 'safe' } ]; ConfigWindow.static.name = 'configWindow'; ConfigWindow.static.title = mw.msg( 'voy-voyagedata-config-title' ); ConfigWindow.prototype.initialize = function () { ConfigWindow.super.prototype.initialize.call( this ); this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } ); that.#inputNameParameterChain = new OO.ui.MenuTagMultiselectWidget( { label: mw.msg( 'voy-voyagedata-nameparameterchain-label' ), options: [ { data: 'name', label: 'name' }, { data: 'nameLocal', label: 'name-local' } ], selected: [ 'nameLocal', 'name' ], title: mw.msg( 'voy-voyagedata-nameparameterchain-label' ) } ); that.#inputWdClassBlacklist