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>