MediaWiki:Gadget-WishlistTranslation.js
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
// <nowiki>
mw.loader.using( ["vue","@wikimedia/codex","mediawiki.language.names","mediawiki.storage"] ).then( ( require ) => {
/**
* WishlistTranslation: A gadget for machine translation of pages within the Community Wishlist.
* From [[Community Tech]]
* Compiled from source at https://gitlab.wikimedia.org/repos/commtech/wishlist-intake
* Please submit code changes as a merge request to the source repository.
*/
'use strict';
function __$styleInject(css) {
if (!css) return;
if (typeof window == 'undefined') return;
var style = document.createElement('style');
style.setAttribute('media', 'screen');
style.innerHTML = css;
document.head.appendChild(style);
return css;
}
var vue = require('vue');
var codex = require('@wikimedia/codex');
var wishHomePage = "Community Wishlist";
var wishIntakePage = "Community Wishlist/Intake";
var wishEditParam = "editwish";
var voteRecordedStorageName = "wishlist-intake-vote-added";
var wishIndexTemplate = "Community Wishlist/Wishes";
var wishIndexTemplateAll = "Community Wishlist/Wishes/All";
var wishIndexTemplateRecent = "Community Wishlist/Wishes/Recent";
var wishIndexTemplateArchive = "Community Wishlist/Wishes/Archive";
var wishCategory = "Community Wishlist/Wishes";
var wishPagePrefix = "Community Wishlist/Wishes/";
var wishTemplate = "Community Wishlist/Wish";
var focusAreaPagePrefix = "Community Wishlist/Focus areas/";
var focusAreaVoteCountSuffix = "/Vote count";
var focusAreaTemplate = "Community Wishlist/Focus area";
var focusAreasTemplate = "Community Wishlist/Focus areas";
var focusAreaTemplateAll = "Community Wishlist/Focus areas/All";
var focusAreaTemplateTop = "Community Wishlist/Focus areas/Top";
var supportTemplate = "Community Wishlist/Support";
var messagesPage = "MediaWiki:Gadget-WishlistIntake/messages";
var interfaceMessageGroupId = "agg-Community_Wishlist_interface";
var wishesMessageGroupId = "agg-Community_Wishlist_wishes";
var maxRecentWishes = 5;
var gadgets = {
WishlistIntake: {
description: "A gadget for the intake and editing of Community Wishlist proposals.",
ResourceLoader: true,
"default": true,
hidden: true,
"package": true,
files: [
"WishlistIntake.js"
],
rights: [
"editmyusercss"
],
categories: [
"Community Wishlist/Intake"
],
dependencies: [
"vue",
"@wikimedia/codex",
"mediawiki.util",
"mediawiki.api",
"user.options",
"mediawiki.action.view.postEdit",
"mediawiki.confirmCloseWindow",
"mediawiki.jqueryMsg"
],
peers: [
"WishlistIntake-pagestyles"
]
},
"WishlistIntake-pagestyles": {
peer: true,
hidden: true,
files: [
"WishlistIntake-pagestyles.css"
],
filesOnWiki: true
},
WishlistManager: {
description: "A gadget helping with management tasks for the Community Wishlist.",
ResourceLoader: true,
"default": true,
hidden: true,
"package": true,
files: [
"WishlistManager.js"
],
rights: [
"editmyusercss"
],
dependencies: [
"mediawiki.api",
"mediawiki.util"
]
},
WishlistTranslation: {
description: "A gadget for machine translation of pages within the Community Wishlist.",
ResourceLoader: true,
"package": true,
files: [
"WishlistTranslation.js"
],
categories: [
"Community Wishlist/Intake"
],
dependencies: [
"vue",
"@wikimedia/codex",
"mediawiki.language.names",
"mediawiki.storage"
],
messages: [
"communityrequests-translation-translatable",
"communityrequests-translation-switch",
"communityrequests-translation-progress",
"communityrequests-translation-errors"
]
}
};
var messages = {
"communitywishlist-wish-loading-error": "There was an error while parsing the wish source text. It may contain invalid wikitext. Please [$1 refresh] and try again, use the [[$2|source editor]], or ask for help on the [[$3|talk page]].",
"communitywishlist-edit-with-form": "Edit with form",
"communitywishlist-form-subtitle": "Welcome to the new Community Wishlist. Please fill in the form below to submit your wish.",
"communitywishlist-form-error": "Something went wrong. Please try saving again, or ask for help on the [[$1|talk page]].",
"communitywishlist-description": "Describe your problem",
"communitywishlist-description-description": "Explain in detail the wish or problem you are addressing.",
"communitywishlist-title": "Wish title",
"communitywishlist-title-error": "Please enter a value for this field (between $1 and $2 {{PLURAL:$2|character|characters}}).",
"communitywishlist-title-description": "Make sure your title contains a brief description of the wish or problem.",
"communitywishlist-description-error": "Please enter a value for this field ($1 or more {{PLURAL:$1|character|characters}}).",
"communitywishlist-wishtype-label": "Which type best describes your wish?",
"communitywishlist-wishtype-description": "For submitting a policy change request, please consult the applicable project.",
"communitywishlist-wishtype-feature-label": "Feature request",
"communitywishlist-wishtype-feature-description": "You want new features and functions that do not exist yet.",
"communitywishlist-wishtype-bug-label": "Bug report",
"communitywishlist-wishtype-bug-description": "You want a problem or error fixed with existing features.",
"communitywishlist-wishtype-change-label": "System change",
"communitywishlist-wishtype-change-description": "You want a currently working feature or function to be changed.",
"communitywishlist-wishtype-unknown-label": "I'm not sure or I don't know",
"communitywishlist-wishtype-unknown-description": "After receiving your wish, we will assign a relevant type.",
"communitywishlist-wishtype-error": "Please select a wish type.",
"communitywishlist-project-intro": "Which projects is your wish related to?",
"communitywishlist-project-help": "Select all projects your wish will have an impact on.",
"communitywishlist-project-all-projects": "All projects",
"communitywishlist-project-show-less": "Show less",
"communitywishlist-project-show-all": "Show all",
"communitywishlist-project-other-label": "It's something else",
"communitywishlist-project-other-description": "e.g. gadgets, bots and external tools",
"communitywishlist-project-other-error": "Please enter a value for this field (greater than $1 {{PLURAL:$1|character|characters}}), or select a project checkbox.",
"communitywishlist-project-no-selection": "Please select at least $1 {{PLURAL:$1|project checkbox|project checkboxes}}, or enter a value for the \"$2\" field.",
"communitywishlist-audience-label": "Primary affected users",
"communitywishlist-audience-description": "Describe which user group and situation this will affect the most",
"communitywishlist-audience-error": "Please enter a value for this field (between $1 and $2 {{PLURAL:$2|characters}}).",
"communitywishlist-phabricator-label": "Phabricator tasks (optional)",
"communitywishlist-phabricator-desc": "Enter Phabricator task IDs or URLs.",
"communitywishlist-phabricator-chip-desc": "A list of Phabricator task IDs.",
"communitywishlist-create-success": "Your wish has been submitted.",
"communitywishlist-edit-success": "Your wish has been saved.",
"communitywishlist-view-all-wishes": "View all wishes.",
"communitywishlist-close": "Close",
"communitywishlist-publish": "Publish wish",
"communitywishlist-save": "Save changes",
"communitywishlist-support-focus-area": "Support focus area",
"communitywishlist-support-focus-area-dialog-title": "Support \"$1\"",
"communitywishlist-optional-comment": "Optional comment",
"communitywishlist-supported": "Already supported",
"communitywishlist-support-focus-area-confirmed": "You have voted in support of this focus area.",
"communitywishlist-unsupport-focus-area": "Remove your support vote"
};
var importedMessages = [
"communityrequests-status-draft",
"communityrequests-status-submitted",
"communityrequests-status-open",
"communityrequests-status-in-progress",
"communityrequests-status-delivered",
"communityrequests-status-blocked",
"communityrequests-status-archived",
"project-localized-name-commonswiki",
"project-localized-name-group-wikinews",
"project-localized-name-group-wikipedia",
"project-localized-name-group-wikiquote",
"project-localized-name-group-wikisource",
"project-localized-name-group-wikiversity",
"project-localized-name-group-wikivoyage",
"project-localized-name-group-wiktionary",
"project-localized-name-mediawikiwiki",
"project-localized-name-metawiki",
"project-localized-name-specieswiki",
"project-localized-name-wikidatawiki",
"project-localized-name-wikifunctionswiki",
"wikimedia-otherprojects-cloudservices",
"cancel",
"wikimedia-copyrightwarning"
];
var config = {
wishHomePage: wishHomePage,
wishIntakePage: wishIntakePage,
wishEditParam: wishEditParam,
voteRecordedStorageName: voteRecordedStorageName,
wishIndexTemplate: wishIndexTemplate,
wishIndexTemplateAll: wishIndexTemplateAll,
wishIndexTemplateRecent: wishIndexTemplateRecent,
wishIndexTemplateArchive: wishIndexTemplateArchive,
wishCategory: wishCategory,
wishPagePrefix: wishPagePrefix,
wishTemplate: wishTemplate,
focusAreaPagePrefix: focusAreaPagePrefix,
focusAreaVoteCountSuffix: focusAreaVoteCountSuffix,
focusAreaTemplate: focusAreaTemplate,
focusAreasTemplate: focusAreasTemplate,
focusAreaTemplateAll: focusAreaTemplateAll,
focusAreaTemplateTop: focusAreaTemplateTop,
supportTemplate: supportTemplate,
messagesPage: messagesPage,
interfaceMessageGroupId: interfaceMessageGroupId,
wishesMessageGroupId: wishesMessageGroupId,
maxRecentWishes: maxRecentWishes,
gadgets: gadgets,
messages: messages,
importedMessages: importedMessages
};
/**
* Utility functions for the gadget
*/
class WebUtil {
/**
* Get the full page name with underscores replaced by spaces.
* We use this instead of wgTitle because it's possible to set up
* the wishlist gadget for use outside the mainspace.
*
* @return {string}
*/
static getPageName() {
return mw.config.get( 'wgPageName' ).replaceAll( '_', ' ' );
}
/**
* Is the current page a wish page?
*
* @return {boolean}
*/
static isWishPage() {
return this.getPageName().startsWith( config.wishPagePrefix );
}
/**
* Are we currently creating a new wish?
*
* @return {boolean}
*/
static isNewWish() {
return this.getPageName().startsWith( config.wishIntakePage ) &&
mw.config.get( 'wgAction' ) === 'view' &&
!this.isWishEdit() &&
// Don't load on diff pages
!mw.config.get( 'wgDiffOldId' );
}
/**
* Are we currently viewing (but not editing) a wish page?
*
* @return {boolean}
*/
static isWishView() {
return this.isWishPage() && !this.isWishEdit() && mw.config.get( 'wgAction' ) === 'view';
}
/**
* Are we currently editing a wish page?
*
* @return {boolean}
*/
static isWishEdit() {
return this.isWishPage() && !!mw.util.getParamValue( config.wishEditParam );
}
/**
* Are we currently manually editing a wish page?
*
* @return {boolean}
*/
static isManualWishEdit() {
return this.isWishPage() &&
(
mw.config.get( 'wgAction' ) === 'edit' ||
document.documentElement.classList.contains( 've-active' )
);
}
/**
* Are we currently on a focus area page?
*
* @return {boolean}
*/
static isFocusAreaPage() {
return this.getPageName().startsWith( config.focusAreaPagePrefix );
}
/**
* Get the user's preferred language.
*
* @return {string}
*/
static userPreferredLang() {
if ( mw.config.get( 'wgArticleId' ) === 0 ) {
// Use interface language for new pages.
return mw.config.get( 'wgUserLanguage' );
}
// Use content language for existing pages.
return mw.config.get( 'wgContentLanguage' );
}
/**
* Is the user's preferred language right-to-left?
*
* @return {boolean}
*/
static isRtl() {
return $( 'body' ).css( 'direction' ) === 'rtl';
}
/**
* Are we on a page related to creating, editing, or viewing a wish?
* This can include viewing the revision history, manual editing of wishes, etc.
*
* @return {boolean}
*/
static isWishRelatedPage() {
return this.isNewWish() || this.isWishEdit() || this.isWishView() || this.isWishPage();
}
/**
* Should we show the intake form?
*
* @return {boolean}
*/
static shouldShowForm() {
// Prevent form from loading on i.e. action=history
return mw.config.get( 'wgAction' ) === 'view' &&
( this.isNewWish() || this.isWishEdit() );
}
/**
* Get the slug for the wish derived from the page title.
* This is the subpage title and not necessarily the wish title,
* which is stored in the proposal content.
*
* @return {string|null} null if not a wish-related page
*/
static getWishSlug() {
if ( this.isNewWish() ) {
// New wishes have no slug yet.
return '';
} else if ( this.isWishPage() ) {
// Existing wishes have the page prefix stripped.
const slugPortion = this.getPageName().slice( config.wishPagePrefix.length );
// Strip off language subpage. Slashes are disallowed in wish slugs.
return slugPortion.split( '/' )[ 0 ];
}
return null;
}
/**
* Get the slug for the focus area derived from the page title.
*
* @return {string|null} null if not a focus area page
*/
static getFocusAreaSlug() {
if ( this.isFocusAreaPage() ) {
const slugPortion = this.getPageName().slice( config.focusAreaPagePrefix.length );
// Strip off language subpage. Slashes are disallowed in focus area slugs.
return slugPortion.split( '/' )[ 0 ];
}
return null;
}
/**
* Get the full page title of the wish from the slug.
*
* @param {string} slug
* @return {string}
*/
static getWishPageTitleFromSlug( slug ) {
return config.wishPagePrefix + slug;
}
/**
* Is the user WMF staff?
*
* @todo WMF-specific
* @return {boolean}
*/
static isStaff() {
return /\s\(WMF\)$|-WMF$/.test( mw.config.get( 'wgUserName' ) );
}
/**
* Log an error to the console.
*
* @param {string} text
* @param {Error} error
*/
static logError( text, error ) {
mw.log.error( `[WishlistIntake] ${ text }`, error );
}
/**
* Get a CSS-only Codex Message component of the specified type.
* This is for use outside the Vue application.
*
* @param {mw.Message} message
* @param {string} type 'notice', 'warning', 'error' or 'success'
* @return {HTMLDivElement}
*/
static getMessageBox( message, type ) {
const messageBlock = document.createElement( 'div' );
// The following messages may be used here:
// * cdx-message--notice
// * cdx-message--warning
// * cdx-message--error
// * cdx-message--success
messageBlock.classList.add( 'cdx-message', 'cdx-message--block', `cdx-message--${ type }` );
if ( type === 'warning' ) {
messageBlock.role = 'alert';
} else {
messageBlock.ariaLive = 'polite';
}
const icon = document.createElement( 'span' );
icon.classList.add( 'cdx-message__icon' );
const content = document.createElement( 'div' );
content.classList.add( 'cdx-message__content' );
content.innerHTML = message.parse();
messageBlock.appendChild( icon );
messageBlock.appendChild( content );
return messageBlock;
}
/**
* Is the user viewing in mobile format?
*
* @return {boolean}
*/
static isMobile() {
return !!mw.config.get( 'wgMFMode' );
}
/**
* Fetch messages from the wiki and set them in mw.messages.
*
* @param {mw.Api} api
* @return {jQuery.Promise}
*/
static setOnWikiMessages( api ) {
const titles = [ config.messagesPage + '/en' ],
langPageLocal = config.messagesPage + '/' + mw.config.get( 'wgUserLanguage' );
if ( mw.config.get( 'wgUserLanguage' ) !== 'en' ) {
titles.push( langPageLocal );
}
return api.get( {
action: 'query',
prop: 'revisions',
titles,
rvprop: 'content',
rvslots: 'main',
format: 'json',
formatversion: 2,
// Cache for 30 minutes.
maxage: 1800,
smaxage: 1800
} ).then( ( resp ) => {
let messagesLocal = {},
messagesEn = {};
/**
* The content model of the messages page is wikitext so that it can be used with
* Extension:Translate. Consequently, it's possible to break things. This just does
* a try/catch and returns the default English messages if it fails.
*
* @param {string} title
* @param {string} content
* @return {Object}
*/
const parseJSON = ( title, content ) => {
try {
return JSON.parse( content );
} catch ( e ) {
WebUtil.logError( `Failed to parse JSON for ${ title }.`, e );
return { messages: config.messages };
}
};
resp.query.pages.forEach( ( page ) => {
if ( !page.revisions ) {
// Missing
return;
}
const pageObj = page.revisions[ 0 ].slots.main;
const parsedContent = parseJSON( config.messagesPage, pageObj.content );
if ( page.title === langPageLocal && mw.config.get( 'wgUserLanguage' ) !== 'en' ) {
messagesLocal = parsedContent.messages;
} else {
messagesEn = parsedContent.messages;
}
} );
mw.messages.set( Object.assign( messagesEn, messagesLocal ) );
} );
}
}
var script = vue.defineComponent( {
name: 'WishlistTranslationBanner',
components: {
CdxMessage: codex.CdxMessage,
CdxProgressBar: codex.CdxProgressBar,
CdxToggleSwitch: codex.CdxToggleSwitch
},
props: {
translatableNodes: { type: Array, default: () => [] },
targetLang: { type: String, default: '' },
targetLangDir: { type: String, default: 'ltr' }
},
setup() {
// eslint-disable-next-line n/no-missing-require
const storage = require( 'mediawiki.storage' ).local;
const storageName = 'wishlist-intake-translation-enabled';
// @todo Load these from Codex. T311099.
const cdxIconRobot = '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><!----><g><path d="M10.5 5h6.505C18.107 5 19 5.896 19 6.997V14h-7v2h5.005c1.102 0 1.995.888 1.995 2v2H1v-2c0-1.105.893-2 1.995-2H8v-2H1V6.997C1 5.894 1.893 5 2.995 5H9.5V2.915a1.5 1.5 0 111 0zm-4 6a1.5 1.5 0 100-3 1.5 1.5 0 000 3m7 0a1.5 1.5 0 100-3 1.5 1.5 0 000 3"></path></g></svg>';
return {
storage,
storageName,
cdxIconRobot
};
},
data() {
return {
enabled: this.storage.get( this.storageName ) === '1',
inprogress: 0,
translatedNodeCount: 0,
errors: []
};
},
computed: {
translatableNodeCount() {
return this.translatableNodes.length;
},
userLanguageName() {
const langNames = mw.language.getData( this.targetLang, 'languageNames' );
if ( langNames && langNames[ this.targetLang ] !== undefined ) {
return langNames[ this.targetLang ];
}
return this.targetLang;
}
},
methods: {
onToggle() {
this.storage.set( this.storageName, this.enabled ? '1' : '0' );
for ( const node of this.translatableNodes ) {
if ( !node.isConnected ) {
// May have been removed since being queried in wishlistTranslation.init.js
continue;
}
if ( !this.enabled ) {
// Disable by returning to untranslated values.
node.nodeValue = node.nodeValueUntranslated;
node.parentElement.lang = node.langOriginal;
node.parentElement.dir = node.dirOriginal;
continue;
} else {
if ( node.nodeValueTranslated !== undefined ) {
// If this node has been translated already, switch to that value.
node.nodeValue = node.nodeValueTranslated;
node.parentElement.lang = this.targetLang;
node.parentElement.dir = this.targetLangDir;
} else {
// Otherwise, get the translation.
node.nodeValueUntranslated = node.nodeValue;
node.parentElement.style.opacity = '0.6';
this.inprogress++;
// Note that node.lang has been set in the init script.
this.getTranslation( node.nodeValueUntranslated, node.lang )
.then( ( translatedHtml ) => {
node.parentElement.style.opacity = '';
this.inprogress--;
node.langOriginal = node.lang;
node.dirOriginal = node.dir;
if ( translatedHtml === '' ) {
return;
}
node.parentElement.lang = this.targetLang;
node.parentElement.dir = this.targetLangDir;
this.translatedNodeCount++;
node.nodeValueTranslated = translatedHtml;
node.nodeValue = node.nodeValueTranslated;
} );
}
}
}
},
/**
* @param {string} html
* @param {string} srcLang
* @return {Promise<string>}
*/
getTranslation( html, srcLang ) {
const url = `https://cxserver.wikimedia.org/v1/mt/${ srcLang }/${ this.targetLang }/MinT`;
return fetch( url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify( { html: html } )
} ).then( ( response ) => {
return response.text().then( ( body ) => {
// It is not always JSON that is returned. T373418.
// @todo i18n for error messages
let responseBody = '';
try {
responseBody = JSON.parse( body );
} catch ( e ) {
this.errors.push( 'Unable to decode MinT response: ' + body );
return '';
}
if ( !responseBody.contents ) {
this.errors.push( 'No MinT response contents. Response was: ' + body );
return '';
}
// Wrap output with spaces if the input was (MinT strips them).
return ( html.startsWith( ' ' ) ? ' ' : '' ) +
responseBody.contents +
( html.endsWith( ' ' ) ? ' ' : '' );
} );
} );
}
},
mounted() {
if ( this.enabled ) {
this.onToggle();
}
}
} );
const _hoisted_1 = ["innerHTML"];
const _hoisted_2 = ["innerHTML"];
const _hoisted_3 = { key: 0 };
const _hoisted_4 = {
key: 1,
class: "wishlist-translation-errors"
};
function render(_ctx, _cache, $props, $setup, $data, $options) {
const _component_cdx_toggle_switch = vue.resolveComponent("cdx-toggle-switch");
const _component_cdx_progress_bar = vue.resolveComponent("cdx-progress-bar");
const _component_cdx_message = vue.resolveComponent("cdx-message");
return (vue.openBlock(), vue.createElementBlock(vue.Fragment, null, [
vue.createCommentVNode(" eslint-disable vue/no-v-html "),
vue.createVNode(_component_cdx_message, {
"allow-user-dismiss": "",
icon: _ctx.cdxIconRobot
}, {
default: vue.withCtx(() => [
vue.createElementVNode("div", {
innerHTML: _ctx.$i18n(
'communityrequests-translation-translatable', _ctx.userLanguageName
).parse()
}, null, 8 /* PROPS */, _hoisted_1),
vue.createElementVNode("div", null, [
vue.createVNode(_component_cdx_toggle_switch, {
modelValue: _ctx.enabled,
"onUpdate:modelValue": [
_cache[0] || (_cache[0] = $event => ((_ctx.enabled) = $event)),
_ctx.onToggle
]
}, {
default: vue.withCtx(() => [
vue.createElementVNode("span", {
innerHTML: _ctx.$i18n( 'communityrequests-translation-switch' ).parse()
}, null, 8 /* PROPS */, _hoisted_2)
]),
_: 1 /* STABLE */
}, 8 /* PROPS */, ["modelValue", "onUpdate:modelValue"])
]),
(_ctx.enabled && _ctx.inprogress)
? (vue.openBlock(), vue.createElementBlock("div", _hoisted_3, [
vue.createVNode(_component_cdx_progress_bar, { "aria-hidden": "true" }),
vue.createTextVNode(" " + vue.toDisplayString(_ctx.$i18n( 'communityrequests-translation-progress' )
.params( [ _ctx.translatedNodeCount, _ctx.translatableNodeCount ] )
.text()), 1 /* TEXT */)
]))
: vue.createCommentVNode("v-if", true),
(_ctx.enabled && _ctx.errors.length > 0)
? (vue.openBlock(), vue.createElementBlock("div", _hoisted_4, [
vue.createElementVNode("strong", null, vue.toDisplayString(_ctx.$i18n( 'communityrequests-translation-errors' ).text()), 1 /* TEXT */),
vue.createElementVNode("ul", null, [
(vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(_ctx.errors, (error) => {
return (vue.openBlock(), vue.createElementBlock("li", { key: error }, vue.toDisplayString(error), 1 /* TEXT */))
}), 128 /* KEYED_FRAGMENT */))
])
]))
: vue.createCommentVNode("v-if", true)
]),
_: 1 /* STABLE */
}, 8 /* PROPS */, ["icon"])
], 2112 /* STABLE_FRAGMENT, DEV_ROOT_FRAGMENT */))
}
__$styleInject(".wishlist-translation-errors {\n color: #d73333;\n}\n");
script.render = render;
/**
* Entry point for the WishlistTranslation gadget.
*/
if ( WebUtil.getPageName().startsWith( config.wishHomePage ) ) {
mw.hook( 'wikipage.content' ).add( ( $content ) => {
const targetLang = mw.config.get( 'wgUserLanguage' );
getTranslatableNodes( $content[ 0 ], targetLang ).then( ( translatableNodes ) => {
// Do nothing if there's nothing to translate.
if ( translatableNodes.length === 0 ) {
return;
}
// Get the i18n messages, and mount the Vue app.
const messages = config.gadgets.WishlistTranslation.messages;
( new mw.Api() ).loadMessages( messages ).then( () => {
const appRoot = document.createElement( 'div' );
$content[ 0 ].before( appRoot );
const appData = {
targetLang: targetLang,
// @todo Get the lang dir in a better way.
targetLangDir: document.querySelector( 'html' ).dir,
translatableNodes: translatableNodes
};
const Vue = require( 'vue' );
Vue.createMwApp( script, appData ).mount( appRoot );
} );
} );
} );
}
/**
* Get all source languages supported by MinT for the given target language,
* caching the result in localStorage for a day to avoid re-querying on every\
* page load.
*
* @param {string} targetLang The target language code.
* @return {Array<string,Array<string>>}
*/
function getSupportedLangs( targetLang ) {
// eslint-disable-next-line n/no-missing-require
const storage = require( 'mediawiki.storage' ).local;
const localStorageKey = 'wishlist-intake-langlist-' + targetLang;
const stored = storage.get( localStorageKey );
if ( stored ) {
return Promise.resolve( JSON.parse( stored ) );
}
const url = 'https://cxserver.wikimedia.org/v1/list/mt';
return fetch( url ).then( ( response ) => {
return response.text().then( ( body ) => {
const sourceLangs = [];
try {
const mintLangs = JSON.parse( body ).MinT;
// The API maps each language to those that it can be translated to,
// but we want a list of all possible source langs for our target.
if ( mintLangs[ targetLang ] ) {
for ( const sourceLang of Object.keys( mintLangs ) ) {
if ( mintLangs[ sourceLang ].includes( targetLang ) ) {
sourceLangs.push( sourceLang );
}
}
}
} catch ( e ) {
// Unable to parse response.
}
// Store for 24 hours.
storage.set( localStorageKey, JSON.stringify( sourceLangs ), 60 * 60 * 24 );
return sourceLangs;
} );
} );
}
/**
* Get all DOM nodes that need to be translated.
*
* @todo More needs to be done here to select nodes and/or elements that are
* actually needing to be translated and that are of the most appropriate size
* and scope. Probably we should be collecting elements and not leaf nodes, but
* if we do that then in many cases we end up also having translations inside
* them, so more work is needed there.
*
* @param {Element} content DOM containing at least one .mw-parser-output element.
* @param {string} targetLang
* @return {Promise<Array<Node>>}
*/
function getTranslatableNodes( content, targetLang ) {
const parserOutput = content.querySelector( '.mw-parser-output' );
if ( parserOutput === null ) {
return Promise.resolve( [] );
}
return getSupportedLangs( targetLang ).then( ( supportedLangs ) => {
// Find all text nodes that are in a different language to the interface language.
const walker = document.createTreeWalker( parserOutput, NodeFilter.SHOW_TEXT, ( node ) => {
// Skip empty nodes, and everything in the <languages /> bar.
if ( node.nodeValue.trim() === '' ||
node.parentElement.closest( '.mw-pt-languages' )
) {
return NodeFilter.FILTER_SKIP;
}
const lang = node.parentElement.closest( '[lang]' ).lang;
// Skip if they're the same language.
if ( lang === targetLang ||
// Skip style elements.
node.parentElement instanceof HTMLStyleElement ||
// Skip if any parent has `.translate-no`. T161486.
// @todo Fix this to permit `.translate-yes` to be inside a `.translate-no`.
node.parentElement.closest( '.translate-no' )
) {
return NodeFilter.FILTER_SKIP;
}
// Check if the source lang can be translated to the target lang.
if ( !supportedLangs.includes( lang ) ) {
return NodeFilter.FILTER_SKIP;
}
// Save the parent lang on the node for easier access when sending it for translation.
node.lang = lang;
return NodeFilter.FILTER_ACCEPT;
} );
// Get all nodes.
let n = walker.nextNode();
const translatableNodes = [];
while ( n ) {
translatableNodes.push( n );
n = walker.nextNode();
}
return translatableNodes;
} );
}
} );
// </nowiki>