Jump to content

User:ZKang123/TitleCaseConverter.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
// titlecaseconverter.js
/* eslint-disable no-alert, no-console */

$( () => {
    // Expanded list of common abbreviations and special cases
    const SPECIAL_CASES = [
        // Common abbreviations
        'MRT', 'LTA', 'S$', 'US$', 'NASA', 'FBI', 'CIA', 'MP3', 'PDF', 'HTML', 
        'HTTP', 'URL', 'CEO', 'GPS', 'DVD', 'WiFi', 'PhD', 'ATM', 'ASAP', 'DIY',
        'FAQ', 'ID', 'IQ', 'OK', 'PC', 'TV', 'UK', 'USA', 'USB', 'VIP',
        // Singapore-specific
        'HDB', 'URA', 'NUS', 'NTU', 'SBS', 'SMRT', 'COE', 'ERP', 'CPF',
        // Technical terms
        'iOS', 'macOS', 'iPhone', 'iPad', 'iMac', 'eBay', 'DataMall'
    ];

    // Words that should always be capitalized
    const ALWAYS_CAPITALIZE = [ 
        'Me', 'It', 'His', 'If', 'Be', 'Am', 'Is', 'Are', 'Being', 'Was', 
        'Were', 'Been', 'During', 'Through', 'About', 'Until', 'Below', 'Under'
    ];

    // Words that shouldn't be capitalized (unless first/last word)
    const DO_NOT_CAPITALIZE = [
        'a', 'an', 'the', 'and', 'by', 'at', 'but', 'or', 'nor', 'for', 
        'yet', 'so', 'as', 'in', 'of', 'on', 'to', 'from', 'into', 'like', 
        'over', 'with', 'till', 'upon', 'off', 'per', 'up', 'out', 'via'
    ];

    /**
     * Convert titles to title case with options
     */
    function toTitleCase(title, options = {}) {
        const {
            changeExistingCapitalization = true,
            keepAllCaps = false
        } = options;

        const isAllCaps = title.toUpperCase() === title;
        if (isAllCaps && !keepAllCaps) {
            title = title.toLowerCase();
        }

        title = title.split(' ').map((word, index, array) => {
            // Retain words that are already in uppercase or are special cases
            if ((keepAllCaps && word.toUpperCase() === word) || isSpecialCase(word)) {
                return word;
            }

            // Retain capitalization for words following certain punctuation marks
            if (index > 0 && /[/;\-,]/.test(array[index - 1])) {
                return word.charAt(0).toUpperCase() + word.slice(1);
            }
            
            // Check for mixed capitalization
            const hasMixedCase = /[a-z]/.test(word) && /[A-Z]/.test(word);
            if (hasMixedCase && !changeExistingCapitalization) {
                return word;
            }

            // If there's already a capital letter in the word, we probably don't want to change it
            const hasUpperCaseLetter = /[A-Z]/.test(word);
            if (!changeExistingCapitalization && hasUpperCaseLetter) {
                return word;
            } else if (shouldCapitalize(word, index, array)) {
                return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
            } else {
                return word.toLowerCase();
            }
        }).join(' ');

        // Capitalize first letters that occur after punctuation
        title = title.replace(/ [^A-Za-z][a-z]/g, (match) => ' ' + match.slice(1, 2) + match.slice(2).toUpperCase());

        // Capitalize anything after a semicolon
        title = title.replace(/;[a-z]/g, (match) => ';' + match.slice(1).toUpperCase());

        // Capitalize letters mid-word that occur after hyphens or slashes
        title = title.replace(/-[a-z]/g, (match) => '-' + match.slice(1).toUpperCase());
        title = title.replace(/\/[a-z]/g, (match) => '/' + match.slice(1).toUpperCase());

        return title;
    }

    /**
     * Check if a word is an abbreviation or an exception
     */
    function isSpecialCase(word) {
        // Check against our list of special cases
        if (SPECIAL_CASES.includes(word)) {
            return true;
        }
        
        // Check for patterns like S$50, US$100
        if (/^[A-Z]+\$[\d,]+$/.test(word)) {
            return true;
        }
        
        // Check for Roman numerals (basic check)
        if (/^[IVXLCDM]+$/.test(word)) {
            return true;
        }
        
        // Check for all-caps acronyms
        return /^[A-Z0-9]+$/.test(word);
    }

    function shouldCapitalize(word, index, array) {
        const punctuationMarks = ['.', ',', ';', ':', '?', '!'];

        const isAbbr = isSpecialCase(word);
        const isProperNoun = ALWAYS_CAPITALIZE.includes(word);
        const isShortWord = DO_NOT_CAPITALIZE.includes(word.toLowerCase());
        const isFirstOrLastWord = index === 0 || index === array.length - 1;
        const isLongPreposition = word.length >= 5;
        const isVerb = ['be', 'am', 'is', 'are', 'being', 'was', 'were', 'been'].includes(word.toLowerCase());

        // Preserve capitalization after punctuation marks
        if (index > 0) {
            const prevWord = array[index - 1];
            const lastChar = prevWord.charAt(prevWord.length - 1);
            if (punctuationMarks.includes(lastChar)) {
                return true;
            }
        }

        return isAbbr || isFirstOrLastWord || isProperNoun || isLongPreposition || !isShortWord || isVerb;
    }

    /**
     * Convert reference titles in the HTML content
     */
    function convertReferenceTitles(htmlString, options = {}) {
        const citationRegex = /<ref[^>]*>.*?<\/ref>/gi;
        const titleRegex = /(\|title=)([^|]+)(\|)/i;

        return htmlString.replace(citationRegex, (match) => match.replace(titleRegex, (titleMatch, p1, p2, p3) => {
            const originalTitle = p2.trim();
            const titleCaseTitle = toTitleCase(originalTitle, options);
            // Ensure space is retained at the end
            const endingSpace = p2.endsWith(' ') ? ' ' : '';
            return `${p1}${titleCaseTitle}${endingSpace}${p3}`;
        }));
    }

    /**
     * Show preview of changes in a modal dialog
     */
    function showPreview(originalText, convertedText) {
        const modal = document.createElement('div');
        modal.style.cssText = `
            position: fixed;
            top: 50px;
            left: 50%;
            transform: translateX(-50%);
            width: 80%;
            max-width: 800px;
            background: white;
            padding: 20px;
            border: 1px solid #aaa;
            box-shadow: 0 4px 8px rgba(0,0,0,0.2);
            z-index: 1000;
            max-height: 80vh;
            overflow: auto;
        `;

        const closeButton = document.createElement('button');
        closeButton.textContent = 'Close';
        closeButton.style.cssText = `
            position: absolute;
            top: 10px;
            right: 10px;
            padding: 5px 10px;
        `;
        closeButton.onclick = () => modal.remove();

        const title = document.createElement('h3');
        title.textContent = 'Title Case Conversion Preview';
        title.style.marginTop = '0';

        const originalTitle = document.createElement('h4');
        originalTitle.textContent = 'Original:';

        const originalContent = document.createElement('div');
        originalContent.style.cssText = `
            background: #f8f9fa;
            padding: 10px;
            margin-bottom: 20px;
            border: 1px solid #eaecf0;
            white-space: pre-wrap;
        `;
        originalContent.textContent = originalText;

        const convertedTitle = document.createElement('h4');
        convertedTitle.textContent = 'Converted:';

        const convertedContent = document.createElement('div');
        convertedContent.style.cssText = `
            background: #f8f9fa;
            padding: 10px;
            border: 1px solid #eaecf0;
            white-space: pre-wrap;
        `;
        convertedContent.textContent = convertedText;

        const applyButton = document.createElement('button');
        applyButton.textContent = 'Apply Changes';
        applyButton.style.cssText = `
            margin-top: 20px;
            padding: 8px 16px;
            background: #36c;
            color: white;
            border: none;
            border-radius: 2px;
            cursor: pointer;
        `;
        applyButton.onclick = () => {
            const textArea = document.querySelector('#wpTextbox1');
            if (textArea) {
                textArea.value = convertedText;
                const summaryInput = document.querySelector('#wpSummary');
                if (summaryInput && !summaryInput.value.trim()) {
                    summaryInput.value = 'Converted reference titles to title case per [[MOS:CT]] using [[User:ZKang123/TitleCaseConverter|TitleCaseConverter]]';
                }
            }
            modal.remove();
        };

        modal.appendChild(closeButton);
        modal.appendChild(title);
        modal.appendChild(originalTitle);
        modal.appendChild(originalContent);
        modal.appendChild(convertedTitle);
        modal.appendChild(convertedContent);
        modal.appendChild(applyButton);

        document.body.appendChild(modal);
    }

    /**
     * Load the script and add the sidebar links
     */
    function loadTitleCaseConverter() {
        const sidebar = document.getElementById('p-tb');
        if (!sidebar) {
            alert('Error: Sidebar section not found!');
            return;
        }

        const ul = sidebar.querySelector('ul') || document.createElement('ul');
        
        // Create menu item for each option
        const options = [
            {
                text: 'Convert Ref Titles (Preserve Capitals)',
                options: { changeExistingCapitalization: false, keepAllCaps: false }
            },
            {
                text: 'Convert Ref Titles (Change Capitals)',
                options: { changeExistingCapitalization: true, keepAllCaps: false }
            },
            {
                text: 'Convert Ref Titles (Keep ALL CAPS)',
                options: { changeExistingCapitalization: true, keepAllCaps: true }
            }
        ];

        options.forEach((option) => {
            const sidebarLink = document.createElement('li');
            const link = document.createElement('a');
            link.innerText = option.text;
            link.href = '#';
            link.style.cssText = 'cursor: pointer; color: #0645ad; display: block; padding: 2px 0;';

            link.addEventListener('click', (event) => {
                event.preventDefault();
                const textArea = document.querySelector('#wpTextbox1');
                if (!textArea) {
                    alert('Error: Editing area not found!');
                    return;
                }

                const convertedText = convertReferenceTitles(textArea.value, option.options);
                showPreview(textArea.value, convertedText);
            });

            sidebarLink.appendChild(link);
            ul.appendChild(sidebarLink);
        });

        if (!sidebar.querySelector('ul')) {
            sidebar.appendChild(ul);
        }
    }

    // Load the script when the page is ready
    if (document.readyState !== 'loading') {
        loadTitleCaseConverter();
    } else {
        document.addEventListener('DOMContentLoaded', loadTitleCaseConverter);
    }

    // Unit tests can be run from console with: window.TitleCaseConverterUnitTests = true;
    if (window.TitleCaseConverterUnitTests) {
        runUnitTests();
    }

    function runUnitTests() {
        const tests = [
            // Normal cases
            { old: 'The South and West lines', new: 'The South and West Lines' },
            { old: 'Work on second phase of MRT system ahead of schedule', new: 'Work on Second Phase of MRT System Ahead of Schedule' },
            
            // Abbreviations and special cases
            { old: 'NASA and FBI report on UFOs', new: 'NASA and FBI Report on UFOs' },
            { old: 'New iPhone and iPad releases', new: 'New iPhone and iPad Releases' },
            { old: 'Payment via ATM and online banking', new: 'Payment via ATM and Online Banking' },
            
            // ALL CAPS handling
            { 
                old: 'PHASE 2 GETS GO-AHEAD TO ENSURE CONTINUITY', 
                new: 'Phase 2 Gets Go-Ahead To Ensure Continuity',
                options: { keepAllCaps: false }
            },
            { 
                old: 'PHASE 2 GETS GO-AHEAD TO ENSURE CONTINUITY', 
                new: 'PHASE 2 GETS GO-AHEAD TO ENSURE CONTINUITY',
                options: { keepAllCaps: true }
            },
            
            // Mixed case and existing capitalization
            { 
                old: 'DataMall and eBay sales figures', 
                new: 'DataMall and eBay Sales Figures',
                options: { changeExistingCapitalization: false }
            },
            
            // Punctuation
            { old: 'Revived, re-opened, newly appreciated', new: 'Revived, Re-Opened, Newly Appreciated' },
            { old: "Streetscapes/eldridge street Synagogue;a prayer-filled time capsule", new: "Streetscapes/Eldridge Street Synagogue;A Prayer-Filled Time Capsule" }
        ];

        let failures = 0;
        tests.forEach((test, i) => {
            const actual = toTitleCase(test.old, test.options || {});
            if (actual !== test.new) {
                console.log(`[Titlecaseconverter.js] Failed test ${i + 1}. Input: "${test.old}"`);
                console.log(`  Expected: "${test.new}"`);
                console.log(`  Actual:   "${actual}"`);
                failures++;
            }
        });

        if (!failures) {
            console.log('[Titlecaseconverter.js] All unit tests passed successfully.');
        } else {
            console.log(`[Titlecaseconverter.js] ${failures} test(s) failed.`);
        }
    }
});