Jump to content

User:Polygnotus/Scripts/DraftCategories.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.
// <nowiki>
// Script to remove {{Draft categories}} template while preserving categories
//https://en.wikipedia.org/wiki/Category:Articles_using_draft_categories

//According to https://en.wikipedia.org/wiki/Wikipedia:Categorization#Categorizing_draft_pages
// there are 4 methods: nowiki, html comment, [[:Category and {{Draft categories}}

//Currently https://en.wikipedia.org/wiki/Wikipedia:Categorization#Categorizing_draft_pages doesn't even mention Tolbot and lists 2 scripts that could be merged into one 
//the scripts listed dont even support all 4 methods, dont support all redirects to the template (if draft then cat, if mainspace uncat)

//deduplicate then insert at MOS:ORDER

//Can't find Tolbot code, not sure how well it does

//How do I find [[:Category in mainspace? Ideally without parsing the dump?

// Searching for insource:/\[\[:Category:/  returns 20k articles...

//i don't see a restriction on linking to another namespace in the MOS, although it violates least astonishment
//api returns only 10k results oof. 
//check if [[:Category is near the bottom

//https://en.wikipedia.org/wiki/User:DannyS712/Draft_no_cat
//https://en.wikipedia.org/wiki/Category:AfC_submissions_with_categories
//https://en.wikipedia.org/wiki/Category:AfC_submissions_with_categories


(function() {
    'use strict';

    // ============ CONFIGURATION ============
    const TESTING_MODE = false; // Set to false to actually edit pages
    const MAX_ARTICLES = null; // Maximum number of articles to process (set to null for unlimited)
    const DELAY_BETWEEN_REQUESTS = 5000; // milliseconds between API calls (5 seconds)
    const DELAY_BETWEEN_EDITS = 5000; // milliseconds between actual edits (10 seconds)
    const MAX_RETRIES = 3;
    const BATCH_SIZE = 50; // Number of pages to fetch at once
    
    // ============ MAIN EXECUTION ============
    
    // Add a link to the toolbox to run the script
    if (mw.config.get('wgCanonicalSpecialPageName') === false) {
        mw.loader.using(['mediawiki.util'], function() {
            mw.util.addPortletLink(
                'p-tb',
                '#',
                'Remove Draft Categories',
                't-remove-draft-cats',
                'Remove Draft categories template from articles'
            );
            $('#t-remove-draft-cats').click(function(e) {
                e.preventDefault();
                if (confirm('This will process all articles in Category:Articles using draft categories. Continue?')) {
                    processDraftCategories();
                }
            });
        });
    }
    
    // ============ API FUNCTIONS ============
    
    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    async function getCategoryMembers(category, cmcontinue) {
        const api = new mw.Api({
            ajax: {
                headers: {
                    'Api-User-Agent': 'DraftCategoriesRemover/1.0 (User:' + mw.config.get('wgUserName') + ')'
                }
            }
        });
        
        const params = {
            action: 'query',
            list: 'categorymembers',
            cmtitle: category,
            cmlimit: BATCH_SIZE,
            cmnamespace: 0, // Main namespace only
            format: 'json'
        };
        
        if (cmcontinue) {
            params.cmcontinue = cmcontinue;
        }
        
        let retries = 0;
        while (retries < MAX_RETRIES) {
            try {
                const result = await api.get(params);
                return result;
            } catch (error) {
                retries++;
                console.error(`API error (attempt ${retries}/${MAX_RETRIES}):`, error);
                if (retries >= MAX_RETRIES) {
                    throw error;
                }
                await sleep(DELAY_BETWEEN_REQUESTS * retries);
            }
        }
    }
    
    async function getPageContent(title) {
        const api = new mw.Api({
            ajax: {
                headers: {
                    'Api-User-Agent': 'DraftCategoriesRemover/1.0 (User:' + mw.config.get('wgUserName') + ')'
                }
            }
        });
        
        let retries = 0;
        while (retries < MAX_RETRIES) {
            try {
                const result = await api.get({
                    action: 'query',
                    titles: title,
                    prop: 'revisions',
                    rvprop: 'content|timestamp',
                    rvslots: 'main',
                    format: 'json'
                });
                
                const pages = result.query.pages;
                const pageId = Object.keys(pages)[0];
                const page = pages[pageId];
                
                if (page.revisions && page.revisions[0]) {
                    return {
                        content: page.revisions[0].slots.main['*'],
                        timestamp: page.revisions[0].timestamp
                    };
                }
                return null;
            } catch (error) {
                retries++;
                console.error(`API error fetching ${title} (attempt ${retries}/${MAX_RETRIES}):`, error);
                if (retries >= MAX_RETRIES) {
                    throw error;
                }
                await sleep(DELAY_BETWEEN_REQUESTS * retries);
            }
        }
    }
    
    async function editPage(title, newContent, timestamp, summary) {
        if (TESTING_MODE) {
            console.log(`[TESTING MODE] Would edit ${title} with summary: ${summary}`);
            return { success: true, testing: true };
        }
        
        const api = new mw.Api({
            ajax: {
                headers: {
                    'Api-User-Agent': 'DraftCategoriesRemover/1.0 (User:' + mw.config.get('wgUserName') + ')'
                }
            }
        });
        
        let retries = 0;
        while (retries < MAX_RETRIES) {
            try {
                const result = await api.postWithToken('csrf', {
                    action: 'edit',
                    title: title,
                    text: newContent,
                    basetimestamp: timestamp,
                    summary: summary,
                    minor: true,
                    format: 'json'
                });
                return result;
            } catch (error) {
                retries++;
                console.error(`Edit error for ${title} (attempt ${retries}/${MAX_RETRIES}):`, error);
                if (retries >= MAX_RETRIES) {
                    throw error;
                }
                await sleep(DELAY_BETWEEN_REQUESTS * retries);
            }
        }
    }
    
    // ============ PROCESSING FUNCTIONS ============
    
    function findCategoryInsertionPoint(content) {
        // Categories go at position 8 in End matter (MOS:ORDER)
        // After: defaultsort, authority control, taxonbar, portal bar, navboxes
        // Before: improve categories, uncategorized, stub templates
        
        // Look for stub templates (these come AFTER categories)
        const stubMatch = content.match(/\{\{[^}]*stub\}\}/i);
        if (stubMatch) {
            return stubMatch.index;
        }
        
        // Look for {{Improve categories}} or {{Uncategorized}}
        const improveCatMatch = content.match(/\{\{\s*(Improve categories|Uncategorized)\s*\}\}/i);
        if (improveCatMatch) {
            return improveCatMatch.index;
        }
        
        // Look for existing categories (if any) to insert near them
        const existingCatMatch = content.match(/\[\[Category:[^\]]+\]\]/i);
        if (existingCatMatch) {
            // Find the last category
            const allCats = Array.from(content.matchAll(/\[\[Category:[^\]]+\]\]/gi));
            if (allCats.length > 0) {
                const lastCat = allCats[allCats.length - 1];
                return lastCat.index + lastCat[0].length;
            }
        }
        
        // Look for defaultsort (categories come AFTER this)
        const defaultsortMatch = content.match(/\{\{\s*DEFAULTSORT\s*:[^}]+\}\}/i);
        if (defaultsortMatch) {
            return defaultsortMatch.index + defaultsortMatch[0].length;
        }
        
        // Look for authority control templates (categories come AFTER these)
        const authorityMatch = content.match(/\{\{\s*Authority control[^}]*\}\}/i);
        if (authorityMatch) {
            return authorityMatch.index + authorityMatch[0].length;
        }
        
        // Look for {{Taxonbar}}
        const taxonbarMatch = content.match(/\{\{\s*Taxonbar[^}]*\}\}/i);
        if (taxonbarMatch) {
            return taxonbarMatch.index + taxonbarMatch[0].length;
        }
        
        // Look for {{Portal bar}} or {{Subject bar}}
        const portalBarMatch = content.match(/\{\{\s*(Portal bar|Subject bar)[^}]*\}\}/i);
        if (portalBarMatch) {
            return portalBarMatch.index + portalBarMatch[0].length;
        }
        
        // Look for navboxes (categories come AFTER these)
        const allNavboxes = Array.from(content.matchAll(/\{\{\s*[Nn]avbox[^}]*\}\}/g));
        if (allNavboxes.length > 0) {
            const lastNavbox = allNavboxes[allNavboxes.length - 1];
            return lastNavbox.index + lastNavbox[0].length;
        }
        
        // If nothing found, put at the very end
        return content.length;
    }
    
    function removeDraftCategoriesTemplate(content) {
        const templateNames = [
            'Draft categories',
            'Draft cats',
            'Draftcat',
            'Draft Categories',
            'Draft category',
            'Draft cat',
            'Afc categories',
            'Draftcats'
        ];
        
        const templatePattern = templateNames.map(name => 
            name.replace(/\s/g, '\\s*')
        ).join('|');
        
        // Find the template start
        const startRegex = new RegExp(
            '\\{\\{\\s*(?:' + templatePattern + ')\\s*\\|',
            'gi'
        );
        
        let modified = false;
        let newContent = content;
        let match;
        
        while ((match = startRegex.exec(content)) !== null) {
            const startPos = match.index;
            const contentStart = startPos + match[0].length;
            
            // Count braces to find the matching closing braces
            let braceCount = 2; // We start with {{
            let pos = contentStart;
            let endPos = -1;
            
            while (pos < content.length && braceCount > 0) {
                if (content.substr(pos, 2) === '{{') {
                    braceCount += 2;
                    pos += 2;
                } else if (content.substr(pos, 2) === '}}') {
                    braceCount -= 2;
                    pos += 2;
                    if (braceCount === 0) {
                        endPos = pos - 2; // Position of the final }}
                    }
                } else {
                    pos++;
                }
            }
            
            if (endPos !== -1) {
                // Extract the content between the pipes and the closing braces
                let categories = content.substring(contentStart, endPos).trim();
                
                // Fix [[:Category: to [[Category:
                categories = categories.replace(/\[\[:Category:/gi, '[[Category:');
                
                // Unwrap categories inside HTML comments: <!-- [[Category:...]] -->
                categories = categories.replace(/<!--\s*(\[\[Category:[^\]]+\]\])\s*-->/gi, '$1');
                
                // Unwrap categories inside nowiki tags: <nowiki>[[Category:...]]</nowiki>
                categories = categories.replace(/<nowiki>\s*(\[\[Category:[^\]]+\]\])\s*<\/nowiki>/gi, '$1');
                
                // Now deduplicate all categories
                const categoryRegex = /\[\[Category:[^\]]+\]\]/gi;
                const foundCategories = categories.match(categoryRegex) || [];
                
                // Normalize and deduplicate (case-insensitive)
                const uniqueCategories = [];
                const seen = new Set();
                
                for (const cat of foundCategories) {
                    const normalized = cat.toLowerCase();
                    if (!seen.has(normalized)) {
                        seen.add(normalized);
                        uniqueCategories.push(cat);
                    }
                }
                
                // Reconstruct with unique categories
                const deduplicatedCategories = uniqueCategories.join('\n');
                
                if (deduplicatedCategories) {
                    // Find insertion point based on MOS:ORDER
                    const insertionPoint = findCategoryInsertionPoint(content);
                    
                    // Remove the template
                    const beforeTemplate = content.substring(0, startPos);
                    const afterTemplate = content.substring(endPos + 2);
                    
                    // Determine where to insert categories
                    if (insertionPoint <= startPos) {
                        // Insertion point is before the template
                        const beforeInsertion = content.substring(0, insertionPoint).trimEnd();
                        const betweenInsertionAndTemplate = content.substring(insertionPoint, startPos);
                        const afterTemplateContent = afterTemplate.trimStart();
                        
                        newContent = beforeInsertion + '\n\n' + deduplicatedCategories + '\n' + betweenInsertionAndTemplate.trimStart() + afterTemplateContent;
                    } else if (insertionPoint > endPos + 2) {
                        // Insertion point is after the template
                        const adjustedInsertionPoint = insertionPoint - (endPos + 2 - startPos);
                        const beforeTemplateContent = beforeTemplate.trimEnd();
                        const betweenTemplateAndInsertion = afterTemplate.substring(0, adjustedInsertionPoint).trimEnd();
                        const afterInsertion = afterTemplate.substring(adjustedInsertionPoint).trimStart();
                        
                        newContent = beforeTemplateContent + betweenTemplateAndInsertion + '\n\n' + deduplicatedCategories + '\n' + afterInsertion;
                    } else {
                        // Insertion point is within the template area, just replace
                        newContent = beforeTemplate.trimEnd() + '\n\n' + deduplicatedCategories + '\n' + afterTemplate.trimStart();
                    }
                    
                    // Clean up excessive whitespace
                    newContent = newContent.replace(/\n{3,}/g, '\n\n').trim() + '\n';
                    
                    modified = true;
                } else {
                    // No categories found, just remove the template
                    newContent = content.substring(0, startPos) + content.substring(endPos + 2);
                    newContent = newContent.replace(/\n{3,}/g, '\n\n').trim() + '\n';
                    modified = true;
                }
                
                // Update content for next iteration
                content = newContent;
                // Reset regex
                startRegex.lastIndex = 0;
            }
        }
        
        return {
            modified: modified,
            content: newContent
        };
    }
    
    async function processPage(title) {
        console.log(`Processing: ${title}`);
        
        try {
            const pageData = await getPageContent(title);
            if (!pageData) {
                console.error(`Could not fetch content for ${title}`);
                return { success: false, error: 'Could not fetch content' };
            }
            
            const result = removeDraftCategoriesTemplate(pageData.content);
            
            if (!result.modified) {
                console.log(`No Draft categories template found in ${title}`);
                return { success: true, skipped: true };
            }
            
            if (TESTING_MODE) {
                console.log(`[TESTING MODE] Would remove Draft categories template from ${title}`);
                console.log('Original excerpt:', pageData.content.substring(0, 500));
                console.log('Modified excerpt:', result.content.substring(0, 500));
            } else {
                await editPage(
                    title,
                    result.content,
                    pageData.timestamp,
                    'Removing [[Template:Draft categories]] wrapper (categories preserved and deduplicated per [[MOS:ORDER]])'
                );
                console.log(`Successfully edited ${title}`);
                // Extra delay after actual edits
                await sleep(DELAY_BETWEEN_EDITS);
            }
            
            return { success: true };
        } catch (error) {
            console.error(`Error processing ${title}:`, error);
            return { success: false, error: error };
        }
    }
    
    async function processDraftCategories() {
        console.log('=== Draft Categories Removal Script ===');
        console.log('Testing mode:', TESTING_MODE);
        console.log('Max articles to process:', MAX_ARTICLES === null ? 'unlimited' : MAX_ARTICLES);
        console.log('Delay between API reads:', DELAY_BETWEEN_REQUESTS + 'ms');
        console.log('Delay between edits:', DELAY_BETWEEN_EDITS + 'ms');
        
        const stats = {
            total: 0,
            processed: 0,
            skipped: 0,
            failed: 0
        };
        
        try {
            let cmcontinue = null;
            
            do {
                console.log('\n--- Fetching batch of pages ---');
                const result = await getCategoryMembers('Category:Articles using draft categories', cmcontinue);
                
                if (!result.query || !result.query.categorymembers) {
                    console.log('No more pages found');
                    break;
                }
                
                const pages = result.query.categorymembers;
                console.log(`Found ${pages.length} pages in this batch`);
                
                for (const page of pages) {
                    // Check if we've hit the limit
                    if (MAX_ARTICLES !== null && stats.total >= MAX_ARTICLES) {
                        console.log(`\nReached maximum article limit (${MAX_ARTICLES}). Stopping.`);
                        break;
                    }
                    
                    stats.total++;
                    const processResult = await processPage(page.title);
                    
                    if (processResult.success) {
                        if (processResult.skipped) {
                            stats.skipped++;
                        } else {
                            stats.processed++;
                        }
                    } else {
                        stats.failed++;
                    }
                    
                    // Use standard delay for read operations
                    await sleep(DELAY_BETWEEN_REQUESTS);
                }
                
                // Break outer loop if we hit the limit
                if (MAX_ARTICLES !== null && stats.total >= MAX_ARTICLES) {
                    break;
                }
                
                cmcontinue = result.continue ? result.continue.cmcontinue : null;
                
            } while (cmcontinue);
            
            console.log('\n=== Processing Complete ===');
            console.log('Total pages checked:', stats.total);
            console.log('Successfully processed:', stats.processed);
            console.log('Skipped (no template found):', stats.skipped);
            console.log('Failed:', stats.failed);
            
            if (TESTING_MODE) {
                console.log('\n*** TESTING MODE WAS ENABLED - NO ACTUAL EDITS WERE MADE ***');
            }
            
        } catch (error) {
            console.error('Fatal error:', error);
        }
    }
    
})();
// </nowiki>