MediaWiki:Gadget-updates-core.js

From [N8]
Revision as of 19:24, 9 August 2021 by Banri (talk | contribs)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search

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.
/*jshint bitwise: true, eqeqeq: true, esversion: 6, forin: true, freeze: true,
         futurehostile: true, immed: true, latedef: true, noarg: true, nonew: true,
         strict: true, undef: true, unused: true, browser: true, devel: true,
         jquery: true, laxbreak: true
*/
/*globals mw: false, OO: false */
(function() {
    "use strict";

    var UPD_SCRIPT = "MediaWiki:Gadget-updates-core.js", /// "MediaWiki:Gadget-updates-core.js",
          UPD_MAX_RETRIES = 5,
          UPD_IMAGES_ENABLED = true,
          UPD_ERR_REPORT_ENABLED = true,
          UPD_ERR_REPORT_PAGE = UPD_SCRIPT.replace( ":", " talk:" ),
          UPD_TITLE_FALSE_SUBPAGE = "false subpage",
          UPD_LS_KEY = "gsw-updates",
          UPD_HASH = "Gadget-updates";

    var UPD = {
        _initData: function() {
            UPD.api = UPD.api || new mw.Api();
            UPD.post = {
                title: null,
                restrictedTitleReasons: [],
                url: null,
                corsUrl: null,
                qfc: null,
                cgID: -1,
                hasNavbar: false,
                headersNum: 0,
                headerSizes: {},
                media: [],
                imgsNum: 0,
                vidsNum: 0,
                mediaTitle: null,
                hasHeader: false,
                headerMediaType: "",
                patchnotes: false,
                content: null,
            };
            UPD.ooui = UPD.ooui || {
                wm: OO.ui.getWindowManager(),
                md: new OO.ui.MessageDialog(),
            };
            UPD.retries = 0;
            UPD.$menu = UPD.$menu || null;
        },

        /**
         * Gets a CORS-friendly variant of the given URL
         *
         * @param {string} url the URL to convert
         * @returns {string|boolean} CORS-compliant URL if successful, false otherwise
         */
        toCORS: function( url ) {
            var temp;
            try {
                temp = new URL( url );
            } catch( _ ) {
                return false;
            }

            if( temp.hostname.search( /^((www|services|secure)\.)?gem\.com$/i ) === -1 ) {
                return false;
            }

            // rs modules supported by wiki's /cors/ endpoint
            if( temp.pathname.search( /^\/m=news\//i ) !== -1 ) {
                return "https://chisel.weirdgloop.org/cors/m=" + temp.pathname.substring( 3 );
            }

            // all other rs modules
            else {
                //TODO: allorigins is not allowed by CSP; all other URLs will fail for now(?)
                //      will either need to add support to /cors/ for more modules (possible?) or modify CSP (seems unlikely)
                ///return "//api.allorigins.win/get?url=" + encodeURIComponent( temp.href ) + "&callback=?";
                return false;
            }
        },

        /**
         * Converts QFC to URLs or removes these unnecessary/user-specific components from URLs:
         *         /c=.../ (for logged-in users)
         *         /sl=0/ (for logged-out users)
         *         /a=.../ (unknown - found in comment from previous script version)
         * @param {string} url the URL/QFC to be cleaned
         * @returns {string} the cleaned version of the URL
         */
        checkURL: function( url ) {
            if( url.search( /^([0-9]+[,-])+[0-9]+$/ ) !== -1 )
                return "https://secure.gem.com/m=forum/forums?" + url.replace( /-/g, "," );
            return url.replace( /\/(c|a|sl)=[^/]+\//gi, "/" );
        },

        /**
         * Displays a status message using MediaWiki's notification API
         * @param {string} msg message to display
         * @param {string} [type] MediaWiki notification type
         * @returns {void}
         */
		statusMsg: function( msg, type ) {
            type = type || "";
            if( ["error", "warn", "info"].includes( type ) )
                console[type]( msg );
            else
                console.log( msg );

            mw.notify( msg, {
                tag: "updates",
                title: "Update Script",
                type: type,
                autoHide: false,
            } );
        },

        /**
         * Fetches a remote URL or QFC and begins the parsing of its contents
         * @param {string} url the URL or QFC to fetch
         * @param {boolean} retry false if this is the first attempt, true otherwise
         * @returns {void}
         */
        getPost: function( url, retry ) {
        	retry = retry || false;
            debugger;
            if( !retry ) {
                UPD._initData();

                UPD.post.url = UPD.checkURL( url );
                UPD.post.corsUrl = UPD.toCORS( UPD.post.url );
                if( !UPD.post.corsUrl ) {
                    OO.ui.alert( "Invalid URL or QFC given; please try again." );
                    $( "a", UPD.$menu ).click();
                    return;
                }

                UPD.statusMsg( "Fetching post data from " + UPD.post.url );
            }

            $.get( UPD.post.corsUrl, function( data ) {
                UPD.retries = 0;
                localStorage.removeItem( UPD_LS_KEY );
                UPD.statusMsg( "Parsing data" );
                UPD.parsePost( $( data ).find( "article.c-news-article__main, section.thread-view" ).first() );
            } ).fail( function() {
                if( ++UPD.retries <= UPD_MAX_RETRIES ) {
                    UPD.statusMsg( "Failed to fetch post, retrying... ( " + UPD.retries + " )" );
                    UPD.getPost( UPD.post.url, true );
                } else {
                    OO.ui.alert( "Max retries exceeded, stopping. Check your connection, "
                        + "ensure you are using a valid link/QFC, refresh, and try again." );
                }
            } );
        },

        /**
         * Parse the retrieved data of an external news/forum post
         * @param {jQuery} $post the external news/forum post as a jQuery object
         * @returns {void}
         */
        parsePost: function( $post ) {
            var $content,
                originalTitle = "",
                divspancss = false;

            // detect forum vs. news post, find $content container accordingly
            var $threadTitle = $post.find( ".thread-view__heading" ),
                isForumPost = $threadTitle.length !== 0,
                $firstPost = isForumPost && $post.find( "article.forum-post:first" ) || null;
            UPD.post.patchnotes = !isForumPost && $post.find( ".articleContent > h2" ).text().search( /patch notes/i ) !== -1;
            if( isForumPost ) {
                $content = $firstPost.find( ".forum-post__body" );

                if( !$content.length ) {
                    UPD.statusMsg( "Unable to find post. Check the URL and try again.", "error" );
                    return;
                }
                if( $threadTitle.text().search( /patch notes/i ) === -1 ) {
                    UPD.statusMsg( "Forum thread does not appear to be about patch notes."
                        + " Check the URL and try again.", "error" );
                    return;
                } else {
                	UPD.post.patchnotes = true;
                }
                if( !$firstPost.hasClass( "forum-post--jmod" ) ) {
                    UPD.statusMsg( "Forum thread was not started by a JMod."
                        + " Check the URL and try again.", "error" );
                    return;
                }

            } else {
                $content = $post.find( "#article-content" );
                if ( $content.length === 0 ) {
                	$content = $post.find( ".articleContentText" );
                }
                // console.log($content[0].innerText);
            }

            // meta info
            if( UPD.post.patchnotes ) {
            	if( isForumPost ) {
	                var postTime = $firstPost.find( ".forum-post__time-below" ).text().substr( 0, 11 ).replace( /-/g, " " );
	                UPD.post.date = ( new Date( postTime ) ).toLocaleDateString( "en-GB", {
	                    year: "numeric", month: "long", day: "numeric" } );
                    UPD.post.qfc = $post.find( ".thread-view__qfc-number" ).first().text();
            	} else {
            		UPD.post.date = $post.find( ".articleMeta time" ).text().replace( /\b0(?=\d)/g, "" );
            	}
                UPD.post.title = "Patch Notes (" + UPD.post.date + ")";
            } else {
                UPD.post.title = $post.find( "#article-title" ).text().replace( /[‘’]/g, "\'" );
                if( UPD.post.title.includes( "|" ) ) {
                    originalTitle = UPD.post.title.replace( /\|/g, "{{!}}" );
                    UPD.post.title = UPD.post.title.replace( /\|/g, "-" );
                    UPD.post.restrictedTitleReasons.push( "Cannot use pipe characters in titles" );
                }
                if( UPD.post.title.includes( "#" ) ) {
                    originalTitle = UPD.post.title;
                    UPD.post.title = UPD.post.title.replace( /#/g, "" );
                    UPD.post.restrictedTitleReasons.push( "Cannot use # in titles" );
                }
                if( UPD.post.title.includes( "/" ) ) {
                    UPD.post.restrictedTitleReasons.push( UPD_TITLE_FALSE_SUBPAGE );
                }
                if( UPD.post.title.charAt(0) !== UPD.post.title.charAt(0).toUpperCase() ) {
                	originalTitle = ( originalTitle ? originalTitle : UPD.post.title );
                }
                var colonPrefix = UPD.post.title.match( /^([^:]+): *(.*)/ );
                if( colonPrefix && colonPrefix.length ) {
                    UPD.post.mediaTitle = colonPrefix[2];

                    //TODO: Is this necessary? Aren't we always prepending "Update:" to the title, forcing the namespace?
                    if( Object.values( mw.config.get( "wgFormattedNamespaces" ) ).includes( colonPrefix[1] ) ) {
                        originalTitle = UPD.post.title;
                        UPD.post.title = colonPrefix[1] + " - " + colonPrefix[2];
                        UPD.post.restrictedTitleReasons.push( "actual name begins with '" + colonPrefix[1]
                            + ":', putting it in the wrong namespace; displaytitle used" );
                    }
                } else {
                    UPD.post.mediaTitle = UPD.post.title;
                }
                UPD.post.mediaTitle = UPD.post.mediaTitle.charAt(0).toUpperCase() + UPD.post.mediaTitle.replace( /\//g, '-' ).slice(1);
				UPD.post.date = $post.find( ".c-news-article__date" ).text().replace( /\b0(?=\d)/g, "" );
                UPD.post.category = $post.find( ".c-news-article__category" ).html();
                UPD.post.cgID = parseInt( $post.find( ".c-news-article__category" ).attr( "href" ).match( /cat=(\d+)/ )[1] );
            }

            if( UPD.post.patchnotes )
                UPD.statusMsg( "Article detected as patch notes" );

            //TODO: verify destination page name doesn't already exist
            //      if it does... OOUI input modal to request new page, loop until not exist or canceled?

            /**
             * Captures an image to be uploaded and returns wiki markup to display it
             * @param {string} [url] remote URL of the image
             * @param {string} [opts] extra options to pass in the [[File:...]] wiki markup
             * @param {string} [linkText] non-empty value causes hyperlinked text to be inserted rather than the image
             * @param {boolean} [isHeader] true if this is the header image, false otherwise
             * @param {number} [width] width of the image
             * @returns {string} the wiki markup to display the image or an empty string on failure
             */
            function insertMedia( uncleanedUrl, opts, linkText, isHeader, width ) {
            	if(uncleanedUrl === undefined || uncleanedUrl === "") return "";
            	if(uncleanedUrl.match("https://cdn.gem.com/assets/img/external/news/Template_assets/scroll/")) {
            		console.log( "Ignoring style template assets: " + url);
            		return "";
            	}
            	var urlObj = new URL(uncleanedUrl);
            	var url = urlObj.origin + urlObj.pathname,
            		skip = false;
            	UPD.post.media.forEach( function(file) {
            		if ( file.url === url ) {
            			skip = true;
            			return;
            		}
            	});
            	if ( skip ) {
            		console.log( "Ignoring duplicate media URL: " + url );
            		return "";
            	}
            	opts = opts || "";
            	linkText = linkText || "";
            	isHeader = isHeader || false;
            	
                if( !UPD_IMAGES_ENABLED ) {
                    console.log( "Media disabled; ignoring media URL: " + url );
                    return "";
                }

                if( isHeader ) {
                    if( UPD.post.hasHeader ) {
                        UPD.statusMsg( "Post has multiple header media; ignoring all but the first...", "warn" );
                        return "";
                    } else {
                        UPD.post.hasHeader = true;
                    }
                }
                
                var imgExt = url.match( /\.(png|jpe?g|gif)$/i );
                var vidExt = url.match( /\.(webm)$/i );
                if( urlObj.host === "pbs.twimg.com" ) {
                	var twitterExt = urlObj.search.match( /format=(jpg|png)/i )
                	if( twitterExt !== null ) {
                		url += "." + twitterExt[1] + ":orig";
                		imgExt = twitterExt;
                	}
                } else if(urlObj.host === "steamuserimages-a.akamaihd.net" ) {
            		url += "?output-format=jpg";
            		imgExt = [null,'jpg'];
                } else if(urlObj.host === "preview.redd.it" ) {
            		url += urlObj.search;
                }
                var destName = UPD.post.mediaTitle + " ";
                if( imgExt ) {
                	UPD.post.imgsNum += 1;
                	if( isHeader ) UPD.post.headerMediaType = 'image';
                	var imgNum = UPD.post.imgsNum + (UPD.post.headerMediaType === 'image' ? -1 : 0);
	                destName += ( isHeader ? "update header" : "(" + imgNum + ") update image" )
	                    + "." + imgExt[1].replace( "jpeg", "jpg" );
                } else if( vidExt ) {
                	UPD.post.vidsNum += 1;
                	if( isHeader ) UPD.post.headerMediaType = 'video';
                	var vidNum = UPD.post.vidsNum + (UPD.post.headerMediaType === 'video' ? -1 : 0);
	                destName += ( isHeader ? "update header" : "(" + vidNum + ") update video" )
	                    + "." + vidExt[1];
                } else {
                    UPD.statusMsg( "Unsupported media extension for URL: " + url );
                    return "";
                }

                UPD.post.media.push( {
                    url: url,
                    name: destName
                } );

                return "[[" + ( linkText ? ":" : "" ) + "File:" + destName
                    + ( linkText ? "|" + linkText : ( opts ? "|" + opts : "" ) )
                    + ( width >= 580 ? "|580px" : "" )
                    + "|center]]";
            }

            // header image/video
            // NOTE: ignored for patch notes to keep those wiki pages more uniform and compact
            if( !UPD.post.patchnotes ) {
                var $header = $post.find( ".c-news-article__figure iframe[src*='www.youtube.com']" ).first();
                if( $header.length ) {
                    var embedURL = new URL( $header.attr( "src" ) );
                    $content.prepend( "{{Youtube|" + embedURL.pathname.match( /\/embed\/([^\/]+)/ )[1] + "}}\n" );
                }

                $header = $post.find( ".c-news-article__image" ).first();
                if( $header.length ) {
                    var img = insertMedia( $header.attr( "src" ), "", "", true );
                    if( img ) {
                        $content.prepend( img + "\n\n" );
                    }
                }
            }

            // ---------------------- begin HTML to wiki markup transformations ----------------------
            // <style> <script> <meta> --> remove
            $content.find( "style, script, meta" ).remove();
            
            // remove useless divs
            $content.find( "#article-top" ).replaceWith( function() {
                return this.innerHTML;
            } );
            $content.find( ".category:not(details)" ).replaceWith( function() {
                return this.innerHTML;
            } );
            $content.find( ".outro" ).replaceWith( function() {
                return this.innerHTML;
            } );
            
            // If the newspost has a navbar, create level 1 headers for
            // its sections
            $content.find( ".news-tabs" ).parents( "nav" ).replaceWith( function() {
            	// remove parent NAV tag if it has one
            	return this.innerHTML;
            });
            var navbarLinks = {};
            $content.find( ".news-tabs" ).replaceWith( function() {
            	UPD.post.hasNavbar = true;
            	$(this).find( "a" ).each( function() {
            		if ( !this.hash || !this.textContent ) return;
            		navbarLinks[this.hash] = this.textContent;
            		UPD.post.headersNum += 1;
            	});
                return '';
            } );
            $content.find( ".news-tabs-content" ).replaceWith( function() {
            	const topHeaderText = navbarLinks['#' + this.id];
            	if ( !this.id || !topHeaderText ) return;
            	const topHeader = '{{UB|' + topHeaderText + '|header=1}}';
            	return topHeader + this.innerHTML;
            } );
            
            // Patch Notes styling
            if( UPD.post.patchnotes ) {
            	var icons = [ 'achievements', 'mobile' ];
            	function getIconId( imgEl ) {
            		if ( !imgEl ) {	return 'achievements'; }
	            	var iconName = imgEl.src.match( /(\/([^\/]+?)\.png)$/ )[2];
	            	if ( icons.indexOf( iconName.toLowerCase() ) !== -1 ) {
	            		return iconName.toLowerCase();
	            	} else {
	            		return 'achievements';
	            	}
            	}
	            
	            // nav links
            	$content.find( ".legenda-item" ).replaceWith( function() {
            		var imgEl = $( this ).find( 'img' )[0];
            		return "{{UBLink|" + this.innerText + "|icons=" + getIconId(imgEl) + "}}";
            	});
            	
            	// icon headers
	            $content.find( ".category-heading" ).replaceWith( function() {
	            	var $header = $( this ).find( 'h1, h2, h3, h4, h5 ,h6' )[0],
	            		imgEl = $( this ).find( 'img' )[0];
	            	UPD.post.headersNum += 1;
	            	var headerLevel = ( $header.style.fontSize === "1em" ? "3" : false );
	                return "\n{{UB|" + $header.innerText + ( headerLevel ? "|header=" + headerLevel : "" ) + "|icons=" + getIconId(imgEl) + "}}";
	            } );
	            
	            // other headers
	            $content.find( "h4" ).replaceWith( function() {
	            	UPD.post.headersNum += 1;
	                return "\n{{UB|" + this.innerText + "|header=3}}";
	            });
            }
            
            // remove empty tags
            $content.find( "embed, link" ).replaceWith( function() {
            	if ( this.attributes.length === 0 && $(this).is(":empty") ) {
            		return "";
            	} else {
            		return this;
            	}
            });
			
			// collapsible sections
            $content.find('details.category').replaceWith( function() {
                // Summary with preserved italics and UB title
                var $summary = $(this).find('summary').first()
                $summary.find( "i, em" ).replaceWith( function() {
                    return "''" + this.innerHTML + "''";
                } );
                var summaryText = $summary.text().trim().replace('|', '<nowiki>|</nowiki>');
                // Use UB template
                var summaryTitle = summaryText.split(/''|<nowiki>/)[0].replace(/\n$/, '');
                summaryText = summaryText.replace(summaryTitle, '{{UB|'+summaryTitle+'}}')
                // Header image?
                if ( $(this).find('summary').first().css('background-image').indexOf('url') >= 0 ) {
                    var imgurl = $(this).find('summary').first().css('background-image').replace(/^url\("|"\)$/g, '')
                    summaryText += '\n' + insertMedia( imgurl, "", "", false, 800 );
                }
                // Content
                var htmlstr = "";
                $(this).children(':not(summary)').each( function(){
                    htmlstr += this.outerHTML;
                });
                return "<div class=\"mw-collapsible mw-collapsed rs-update-section\">\n<div>\n" + summaryText + "\n</div>\n<div class=\"mw-collapsible-content\">\n" + htmlstr + "\n</div>\n</div>"
            })

			$content.find('details').replaceWith( function() {
				var summaryText = $(this).find('summary')[0].textContent.trim().replace('|', '<nowiki>|</nowiki>');
				var htmlstr = "";
				$(this).children(':not(summary)').each( function(){
    				htmlstr += this.outerHTML;
				});
				return "{| class=\"mw-collapsible mw-collapsed\"\n! " + summaryText + "\n|-\n| " + htmlstr + "\n|}"
			})
			
            // replace artstation gallery container with plain file links
            $content.find('div.container > .mySlides').parent().replaceWith( function() {
            	return $content.find('div.container > .mySlides').contents();
            });
            
            // first div, if it contains a numbered list --> remove (we have TOCs for that)
            {
                var $firstChild = $content.children().first();
                if( $firstChild.prop( "tagName" ) === "DIV" && $firstChild.find( "> ol" ).length ) {
                    $firstChild.remove();
                }
            }

            // YouTube videos --> {{Youtube}}
            $content.find( "iframe[src*='www.youtube.com']" ).replaceWith( function() {
                return "{{Youtube|" + this.src.match( /\/embed\/([^\/?&]+)/ )[1] + "}}\n";
            } );

            // all other <iframe>s --> centred hyperlinks
            $content.find( "iframe" ).replaceWith( function() {
                var srcUrl = new URL( this.src, UPD.post.url );
                return "<div style=\"text-align: center;\">" + srcUrl.href + "</div>";
            } );
			
			// Reddit post embed styling
			$content.find( ".reddit-card" ).replaceWith( function() {
				return '<div class="news-embed reddit">' + this.innerHTML + '\n</div>';
			} );
			
			// Tweet embed styling
			$content.find( ".twitter-tweet" ).replaceWith( function() {
				$(this).find( "p" ).replaceWith( function() {
					return this.innerHTML + "\n\n";
				} );
				return '<div class="news-embed twitter">' + this.innerHTML + '\n</div>';
			} );
			
            // <p> <blockquote> --> line breaks
            // remove empty elements
            $content.find( "p, blockquote" ).replaceWith( function() {
            	if ( this.innerHTML.trim() !== "" ) {
                	return "\n\n" + this.innerHTML;
            	}
            	else {
            		return "";
            	}
            } );

            // <q> --> inline quotations
            //TODO: would like to see an example of an update that uses this; might be unused in newer format
            $content.find( "q" ).replaceWith( function() {
                return "''\"" + this.innerText + "\"''";
            } );

            // remove HTML comments
            $content.contents().filter( function() {
                return this.nodeType === Node.COMMENT_NODE;
            } ).remove();

            // get header font sizes and assign them a standard header level
            $content.find( "h1, h2, h3, h4, h5, h6" ).each( function() {
            	var fsize;
            	switch( this.style.fontSize ) {
            		case "":
            			fsize = 2;
            			break;
            		case "1.25em":
            			fsize = 3;
            			break;
            		case "1em":
            			fsize = 4;
            			break;
            		default:
            			fsize = 0;
            	}
            	UPD.post.headerSizes[this.style.fontSize] = fsize;
            } );
            
            // transform header level dynamically based on the headers present
        	var hvalues = Object.values(UPD.post.headerSizes).sort();
        	var newhsizes = {};
        	for (var k in UPD.post.headerSizes) {
        		newhsizes[k] = hvalues.indexOf(UPD.post.headerSizes[k]) + 2;
        	}
            
            // headers --> {{UB}}
            $content.find( "h1, h2, h3, h4, h5, h6" ).replaceWith( function() {
            	UPD.post.headersNum += 1;
            	var headerLevel = newhsizes[this.style.fontSize] || false;
            	if (headerLevel === 2) headerLevel = false;
                return "\n{{UB|" + this.innerText + ( headerLevel ? "|header=" + headerLevel : "" ) + "}}";
            } );

            // headers nested in collabsible sections
            $content.find( "div.mw-collapsible.rs-update-section .mw-collapsible-content" ).replaceWith( function() {
                var htmlstr = this.outerHTML;
                htmlstr = htmlstr.replace(/(\|header=)(\d+)(}})/g, function($1,$2,$3,$4) {
                    return $2 + (parseInt($3)+1) + $4;
                } );
                htmlstr = htmlstr.replace( /({{UB\|[^\|}]+)(}})/g, '$1|header=3$2' );
                return htmlstr;
            } );

            // bold text --> '''bold text'''
            $content.find( "b, strong, span[style*='trajan-pro-3']" ).replaceWith( function() {
                return "'''" + this.innerHTML + "'''";
            } );

            // italic text --> ''italic text''
            $content.find( "i, em" ).replaceWith( function() {
                return "''" + this.innerHTML + "''";
            } );
            
            // <font> --> {{Colour}}
            $content.find( "font" ).replaceWith( function () {
            	if ( this.color ) {
            		return "{{Colour|#" + this.color + "|" + this.innerHTML + "}}";
            	} else {
            		return this.innerHTML;
            	}
            } );

            // horizontal lines --> ----
            $content.find( "hr" ).replaceWith( "\n----\n" );
			
            // unordered list items --> * list item
            // weird pointless ul nesting
            $content.find( "ul" ).each( function () {
                var $ul = $( this );
                if ( $ul.children(":first").is("ul") && $ul.html().trim() == $ul.children(":first")[0].outerHTML.trim() ) {
                    $ul.replaceWith( $ul.children(":first") );
                }
            } );
            // weird ul ul ul li
            $content.find( "ul ul ul li" ).replaceWith( function() {
                return "*** " + this.innerHTML;
            } );
            // weird ul ul li
            $content.find( "li ul li, ul ul li" ).replaceWith( function() {
                return "** " + this.innerHTML;
            } );
            //TODO: does this work with multiple levels? answer: no
            $content.find( "ul li" ).replaceWith( function() {
                return "* " + this.innerHTML;
            } );

            // ordered list items --> # list item
            $content.find( "li ol li" ).replaceWith( function() {
                return "## " + this.innerHTML;
            } );
            //TODO: does this work with multiple levels?
            $content.find( "ol li" ).replaceWith( function() {
                return "# " + this.innerHTML;
            } );

            // remaining list items (likely invalid HTML) --> * list item
            $content.find( "li" ).replaceWith( function() {
                return "* " + this.innerHTML;
            } );

            // (un)ordered list elements --> their already-parsed contents
            $content.find( "ul, ol" ).replaceWith( function() {
                return '\n' + this.innerHTML;
            } );
            // handle weird ul ul and ul ul ul lists
            $content.find( "ul" ).replaceWith( function() {
                return this.innerHTML;
            } );
            $content.find( "ul" ).replaceWith( function() {
                return this.innerHTML;
            } );
            // Fix line breaks in lists
            var contStr = $content.html();
            contStr = contStr.replace(/[\r\n]{2,}\*/g, '\n*');
            $content.html( contStr );

            $content.find( "u" ).replaceWith( function() {
                return '&lt;span style="text-decoration: underline;"&gt;' + this.innerHTML + '&lt;/span&gt;';
            } );

            $content.find( "span, div" ).each( function() {
                var tag = this.tagName.toLowerCase();

                // keep tags that have inline styles, else strip the tags
                if( this.hasAttribute( "style" ) ) {
                    divspancss = true;
                    return '&lt;' + tag + ' style="' + this.getAttribute( 'style' ) + '"&gt;'
                        + ( this.innerHTML + ' ' ).trim() + '&lt;/' + tag + '&gt;';
                } else {
                    return this.innerHTML;
                }
            } );
            $content.find( "center, .feedback" ).replaceWith( function() {
                return '&lt;div style="text-align: center;"&gt;' + this.innerHTML + "&lt;/div&gt;";
            } );

            //TODO: forum posts need these stripped if direct descendent of post container
            //TODO:     maybe try selecting repeated siblings and preserving those before removing the others?
            $content.find( "br" ).replaceWith( "&lt;br&gt;" );

            // links to images
            $content.find( "a[href*='://services.gem.com/'], a[href*='://cdn.gem.com/']" ).filter( function() {
                return ( this.href.search( /\.(jpe?g|png|gif|webm)$/ ) !== -1 );
            } ).replaceWith( function() {
                return insertMedia( this.href, "", this.innerText );
            } );

            // other links
            $content.find( "a" ).replaceWith( function() {
                var hrefAttr = this.getAttribute( "href" ),
                	trailSpace = (this.innerText.slice(-1) === ' ' ? ' ' : ''),
                	linkText = this.innerText.trim(),
                	url;
				try { url = new URL(hrefAttr); } catch (_) {}
                if( hrefAttr && hrefAttr[0] === "#" && this.innerText.includes( "Top" ) )
                    return "{{top}}";
                else if( url && url.host === "gem.wiki" && url.pathname.startsWith("/w/") ) {
                	var wikiLink = url.pathname.replace('/w/', '').replaceAll('_', ' ').trim();
                	wikiLink = ((wikiLink.charAt(0).toLowerCase() + wikiLink.slice(1)) === linkText ? (wikiLink.charAt(0).toLowerCase() + wikiLink.slice(1)) : wikiLink);
                	return "{{WUL|" + wikiLink + ( wikiLink !== linkText ? "|" + linkText : "" ) + "}}" + trailSpace;
                } else
                    return "[" + UPD.checkURL( this.href ) + " " + linkText + "]" + trailSpace;
            } );

            // images
            $content.find( "img" ).replaceWith( function() {
                return insertMedia( this.src, "", "", false, this.width );
            } );
            
            // videos
            $content.find( "video" ).replaceWith( function() {
                return insertMedia( this.getElementsByTagName('source')[0].src );
            } );
            
            // tables
            $content.find( "td" ).replaceWith( function() {
				const colspan = (this.colSpan > 1 ? 'colspan="' + this.colSpan + '" ' : '');
				const rowspan = (this.rowSpan > 1 ? 'rowspan="' + this.rowSpan + '" ' : '');
				const cellspan = ( colspan + rowspan !== '' ? colspan + rowspan + '| ' : '' );
				return "| " + cellspan + this.innerHTML;
            } );
            $content.find( "th" ).replaceWith( function() {
				const colspan = (this.colSpan > 1 ? 'colspan="' + this.colSpan + '" ' : '');
				const rowspan = (this.rowSpan > 1 ? 'rowspan="' + this.rowSpan + '" ' : '');
				const cellspan = ( colspan + rowspan !== '' ? colspan + rowspan + '| ' : '' );
				return "! " + cellspan + this.innerHTML;
            } );
            $content.find( "tr" ).replaceWith( function() {
                return "|-" + this.innerHTML.trimEnd();
            } );
            $content.find( "tbody" ).replaceWith( function() {
                return this.innerHTML;
            } );
            $content.find( "table" ).replaceWith( function() {
                return '{| class="wikitable"' + this.innerHTML + '|}';
            } );
            
            // ---------------------- end HTML to wiki markup transformations ----------------------

            var htmlEncoding = {
                    lt:"<",
                    gt:">",
                    quot:"\"", rdquo:"\"", ldquo: "\"",
                    apos:"'", rsquo:"'", lsquo: "'",
                    hellip:"…",
                    ndash:"–"
                },
                htmlRegexp = new RegExp( "&(" + Object.keys( htmlEncoding ).join( "|" ) + ");", "g" );

            var content = $content.html()
                .replace( /\[\[:File:([^|]*)\|\s*\[\[File:([^|]*)\]\]\s*\]\]/gi, "[[File:$2|link=File:$1|center]]" )
                // TODO: what?
                .replace( htmlRegexp, function( match, type ) { return htmlEncoding[type]; } )
                // decode certain HTML entities
                .replace( / ?<br> ?<br>/g,  "\n\n"  )   // replace double-br with two newlines
                .replace( / ?<br> ?/g,      "\n"    )   // replace single brs with just a newline
                .replace( /[“”]/g,          '"'     )   // replace curly double quotes with standard ones
                .replace( /[‘’]/g,          "'"     )   // repalce curly single quotes with standard ones
                .replace( /\n{3,}/g,        "\n\n"  )   // fixing 3x+ enters caused by 2 paragraphs below each other
                .replace( /&amp;/g,         "&"     )   // replace "&amp;" appearing in links and headers with just "&"
                .replace( /[ \t]+$/gm,      ""      )   // trim excess whitespace from the end of a line
                .replace( /^[ \t]+/gm,      ""      )   // trim excess whitespace from the beginning of a line
                .replace( / {2,}/g,         " "     )   // condense spaces
            ;

            if( UPD.post.patchnotes ) {
                content = "{{Patch Notes"
                    + "|date=" + UPD.post.date
                    + "|qfc=" + ( UPD.post.qfc ? UPD.post.qfc : "|url=" + UPD.post.url )
                    + "|notoc=yes}}\n\n" + content;
            } else {
            	// console.log(UPD.post.headersNum);
                content = "{{Update"
                    + "|date=" + UPD.post.date
                    + "|category=" + UPD.post.category
                    + (UPD.post.headersNum >= 4 ? "|toc=t" : "")
                    + (UPD.post.hasNavbar ? "|toclimit=2" : "")
                    + "|link=" + UPD.post.url
                    + ( originalTitle ? "|title=Update:" + originalTitle : "" )
                    +  "}}\n\n" + content;
            }

            if( UPD.post.restrictedTitleReasons.length ) {
                content += "\n{{Restricted title"
                    + "|" + UPD.post.restrictedTitleReasons.join( ", " )
                    + ( UPD.post.restrictedTitleReasons.includes( UPD_TITLE_FALSE_SUBPAGE ) ? "|subpage=1" : "" )
                    + "}}";
            }

            if( divspancss ) {
                UPD.statusMsg( "Some inline CSS in a span/div tag was detected and brought in. "
                    + "Check that it looks OK - text/background colour, borders, etc." );
            }

            UPD.post.content = content;
			console.log(content);
			
            // verification that everything has been parsed
            var allowedTags = ["div", "span", "sub", "sup", "nowiki"],
                $nonTextNodes = $content.contents().filter( function() {
                    return this.nodeType !== Node.TEXT_NODE && !allowedTags.includes( this.tagName.toLowerCase() );
                } );
			
            if( $nonTextNodes.length ) {
                var unparsed = [];
                $nonTextNodes.each( function() {
                    unparsed.push( "&lt;" + this.tagName + "&gt; of nodeType=" + this.nodeType );
                } );

                UPD.statusMsg( "Warning: Some HTML tags found in the remote post have not been parsed. "
                    + "This might not have any consequences, but please do verify every edit made is correct.<br><br>"
                    + "Debug info:<br><ul>"
                    + "<li> " + unparsed.join( "</li>\n<li> " ) + "</li></ul>\n"
                    + ( UPD_ERR_REPORT_ENABLED ? "<br>An error report is being sent for further review." : "" ) );
				
                // if error reporting is enabled, call the report handler (which will then start the upload process)
                // otherwise, start the upload process now
                if( UPD_ERR_REPORT_ENABLED )
                    UPD.errorReport( unparsed );
                else
                    UPD.uploadFiles();
            } else {
                // no unhandled non-text nodes found; start upload process now
                UPD.uploadFiles();
            }
        },

        /**
         * Uploads media used in the post (if any) then begins the page creation
         * @returns {void}
         */
        uploadFiles: function() {
            if( !UPD.post.media.length )
                return UPD.previewPage();

            UPD.ooui.wm.openWindow( UPD.ooui.md, {
                    title: "Upload media?",
                    message: "Images and/or videos were found in the post. Would you like to upload them automatically?",
                    actions: [
                        {
                            action: "yes",
                            label: "Yes",
                        },
                        {
                            action: "no",
                            label: "No",
                        },
                        {
                            label: "Cancel",
                        },
                    ],
                } )
                .closing.done( function( confirmed ) {
                    if( confirmed )
                        confirmed = confirmed.action;
                    
                    if( confirmed === "yes" ) {
                        var comment = "Automatically uploading news post image for [[Update:" + UPD.post.title + "]] "
                            + "using [[" + UPD_SCRIPT + "|updates script]].";

                        // use a recursive local function to upload media synchronously.
                        // looping this way prevents overloading
                        ( function uploadFile( i, opts ) {
                            UPD.statusMsg( "Uploading file #" + ( i + 1 ) + "/" + UPD.post.media.length );
                            
							var postreq = {
                                action: "upload",
                                filename: UPD.post.media[i].name,
                                comment: comment + "\nSource: " + UPD.post.media[i].url,
                                text: "{{Update image|" + UPD.post.title + "|link=" + UPD.post.media[i].url + "}}",
                                ignorewarnings: "true",
                                url: UPD.post.media[i].url,
                            }
                            
                            if ( opts && opts.mismatch ) {
                            	if ( opts.mismatch[2] === "image/jpeg" ) {
                            		var newExt = ".jpg";
                            		postreq.filename = postreq.filename.replace(/(\.[A-z]+)$/, newExt);
                            	} else {
                            		console.log( "MIME type: " + opts.mismatch[2] + " is not supported.");
                            	}
                            }

                            UPD.api.postWithToken( "csrf", postreq ).done( function() {
                                //TODO: does the Deferred obj resolve even if errors occur?
                                //      if so, might need to parse response to detect errors

                                // recurse if more images remain; else start page creation process
                                if( UPD.post.media[i + 1] )
                                    uploadFile( i + 1 );
                                else
                                    UPD.previewPage();
                            } ).fail( function( textStatus, jqXHR ) {
                                //TODO: verify params (induce an error elsewhere and see how mw API responds?)
								if ( textStatus === "fileexists-no-change") {
									UPD.statusMsg( "Skipping file #" + ( i + 1 ) + "/" + UPD.post.media.length
										+ "; already exists and has not changed." );
									if( UPD.post.media[i + 1] )
										uploadFile( i + 1 );
									else
										UPD.previewPage();
								} else if ( textStatus === "verification-error" && jqXHR.error.details[0] === "filetype-mime-mismatch" ) {
									UPD.statusMsg( jqXHR.error.info + " uploading file #" + ( i + 1 ) + "/" + UPD.post.media.length
										+ " to appropriate extension." );
									uploadFile( i, { mismatch: jqXHR.error.details } );
								} else {
									UPD.statusMsg( "Uploading failed for media URL: " + UPD.post.media[i].url + "\n"
										+ "API error: " + textStatus, "warn" );
                    			}
                            } );
                        } )( 0 ); //initiate first upload
                } else if( confirmed === "no" ) {
                    // user does not want media uploaded automatically; skip to page creation
                    return UPD.previewPage();
                } else {
                    // user canceled request; do nothing
                }
            } );
        },

        /**
         * Saves new page's contents to localStorage and redirects user to a preview
         * NOTE: This ends the initial chain of the script; preview page's entry point is UPD.loadPreview()
         * @returns {void}
         */
        previewPage: function() {
            window.localStorage.setItem( UPD_LS_KEY, UPD.post.content );
            UPD.statusMsg( "Generating new page preview, redirecting..." );

            window.location.href = "/w/Update:" + encodeURIComponent( UPD.post.title ) + "?action=edit#" + UPD_HASH;
        },

        /**
         * Entry point for creation page; loads contents from localStorage and submits preview
         * @returns {void}
         */
        loadPreview: function() {
            var $ef = $( "#editform" ),
                action = $ef.attr( "action" ),
                $ta = $( "textarea:eq(0)", $ef ),
                $summ = $( "#wpSummary" ),
                $prev = $( "#wpPreview" ),
                content = window.localStorage.getItem( UPD_LS_KEY );

            if( !content ) {
                UPD.statusMsg( "Could not find new page contents in localStorage. Oops!", "error" );
                debugger;
                return;
            }
            if( !$ta.length || !$prev.length ) {
                UPD.statusMsg( "Can't find editor page elements. Oops!", "error" );
                debugger;
                return;
            }

            localStorage.removeItem( UPD_LS_KEY );
            $ta.val( content );
            $summ.val( "Automated creation using [[" + UPD_SCRIPT + "|updates script]]." );
            $ef.attr( "action", action + "#" + UPD_HASH );
            $prev.click();
        },

        hookSubmit: function() {
            //TODO: new entry point for action=submit page, hook submit button and call UPD.purgePages() on click
        },

        purgePages: function() {
            UPD.statusMsg( "Page editing finished. Preparing to purge indexes..." );
            var d = new Date( UPD.post.date ),
                pagesToPurge = [];

            pagesToPurge.push( d.getFullYear() );
            pagesToPurge.push( d.toLocaleDateString( "en-GB", { day: "numeric", month: "long" } ) );
            pagesToPurge.push( "Template:Updates" );
            pagesToPurge.push( "Gem Wiki" );
            switch( UPD.post.cgID ) {
                case 1:  pagesToPurge.push( "Game updates" ); break;
                case 2:  pagesToPurge.push( "Website updates" ); break;
                case 3:  pagesToPurge.push( "Customer Support updates" ); break;
                case 4:  pagesToPurge.push( "Technical updates" ); break;
                case 5:  pagesToPurge.push( "Community updates" ); break;
                case 6:  pagesToPurge.push( "Behind the Scenes" ); break;
                case 9:  pagesToPurge.push( "Shop updates" ); break;
                case 12: pagesToPurge.push( "Future updates" ); break;
                case 13: pagesToPurge.push( "Solomon's Store updates" ); break;
                case 14: pagesToPurge.push( "Treasure Hunter updates" ); break;
                case 15: pagesToPurge.push( "Feedback updates" ); break;
                case 16: pagesToPurge.push( "Event updates" ); break;
            }

            //TODO: this was a null edit previously. why? isn't a purge enough?
            //      and if not, will specifying `forcerecursivelinkupdate: true` work?
            if( pagesToPurge.length ) {
                UPD.api.post( {
                        format: "json",
                        action: "purge",
                        titles: pagesToPurge.join( "|" ),
                    } )
                    .done( function( data ) {
                        // noinspection JSUnresolvedVariable - API response, too lazy to document it
                        data.purge.forEach( function(page) {
                            if( "purged" in page ) {
                                UPD.statusMsg( "Purge of " + page.title + " successful!" );
                            } else {
                                var reason;
                                if( "missing" in page )
                                    reason = "Page appears to be missing";
                                else if( "invalidreason" in page )
                                    reason = page.invalidreason;
                                else
                                    reason = "Unknown reason";

                                UPD.statusMsg( "Failed to purge " + page.title + ": " + reason
                                    + '\n<br>Please manually purge this page: <a href="/w/' + page.title + '" target="_blank">here</a>' );
                            }
                        });
                    } )
                    .fail( function( _, data ) {
                        var msg = "Failed to purge pages, API returned error: " + data.error.info
                            + "\n<br>Please manually purge these pages: "
                            + "\n<br><ul>\n";
                        pagesToPurge.forEach( function(page) {
                            msg += '<li><a href="/w/' + page + '" target="_blank">' + page + '</a></li>\n';
                        });
                        msg += "</ul>";

                        UPD.statusMsg( msg, "error" );
                    } );
            }

            return UPD.success();
        },

        success: function() {
            UPD.statusMsg( "Updating complete!"
                + '\n<br>Click <a href="/w/Update:' + UPD.post.title + '" target="_blank">here</a> to view the new post.' );
        },

        errorReport: function(unparsed) {
            var message = "While updating the page [[Update:" + UPD.post.title + "]] "
                + "the following tag(s) occurred which were not parsed correctly:\n"
                + "<pre>" + unparsed.join( "\n" ) + "</pre>\n~~" + "~~";
            //<pre> -- might work

            //-----------------------------------------------------------------------------------------------
            //TODO: remove after testing
            var DEBUG_HALT = false;
            var DEBUG_STUB = false;
            
            if( DEBUG_HALT ) {
                console.log( "DEBUG_HALT is true, will not actually submit error report..." );
                return;
            } else if( DEBUG_STUB ) {
                console.log( "DEBUG_STUB is true, pretending report was submitted successfully..." );
                return UPD.uploadFiles();
            }
            //-----------------------------------------------------------------------------------------------

            UPD.api.postWithToken( "csrf", {
                    action: "edit",
                    section: "new",
                    sectiontitle: "Automated error report for " + UPD.post.title,
                    title: UPD_ERR_REPORT_PAGE,
                    summary: "Error report for [[" + UPD_SCRIPT + "|updates script]].",
                    text: message,
                    notminor: true,
                } )
                .done( UPD.uploadFiles )
                .fail( function( _, data ) {
                    UPD.statusMsg( "Failed to submit error report, API error: " + data.error.info, "error" );

                    OO.ui.confirm( "Failed to submit error report. Something is seriously broken. "
                        + "Please note errors in the status area and manually report them on [[" + UPD_ERR_REPORT_PAGE + "]]. "
                        + "Press OK to try to continue with the post anyway." )
                        .done( function( confirmed ) {
                            if( confirmed )
                                UPD.uploadFiles();
                            else
                                UPD.statusMsg( "Post aborted." );
                        } );
                } );
        },

        init: function() {
            // init internal data
            UPD._initData();

            // add our OOUI MessageDialog to the WindowManager
            //noinspection JSCheckFunctionSignatures - matches example in source docs
            UPD.ooui.wm.addWindows( [ UPD.ooui.md ] );

            // add button to vector skin
            UPD.$menu = $( mw.util.addPortletLink(
                "p-views",
                "#",
                "Create update",
                "ca-update",
                "Creates a new update"
            ) )
                .on( "click", function() {
                    new OO.ui.prompt( "Enter update page URL or forum QFC", {
                        textInput: {
                            placeholder: "http://geministation.com/patch-notes",
                        },
                    } )
                        .done( function ( result ) {
                            result = result.trim();
                            if ( result.length ) {
                                UPD.statusMsg( "Started the update script." );
                                UPD.getPost( result );
                            }
                        } );
                } );
        }
    };

    // save UPD object in window object for external references
    window.UPD = UPD;
} )();

// </nowiki>