Jump to content

User:Polygnotus/Scripts/SourceTable5.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>
// AfD Source Assessment Tool

// Note: mw.Api() running on-wiki handles User-Agent automatically.
// The User-Agent requirement applies to off-wiki external API consumers.

// Only run on AfD edit pages
var currentAction = mw.config.get('wgAction');
if (mw.config.get('wgPageName').startsWith('Wikipedia:Articles_for_deletion/') &&
    (currentAction === 'edit' || currentAction === 'submit')) {
    $(function() {
        console.log('AfD Source Assessment script running...');

        // Fix #9: simplified button placement — try #firstHeading, fall back to #editform
        var $target = $('#firstHeading');
        if (!$target.length) {
            $target = $('#editform');
        }

        if ($target.length) {
            $('<button>')
                .attr('id', 'assessSourcesButton')
                .text('Assess Sources')
                .css({
                    'margin-left': '10px',
                    'margin-bottom': '5px',
                    'display': 'inline-block',
                    'vertical-align': 'middle',
                    'font-size': '15px',
                    'padding': '4px 8px'
                })
                .click(function(e) {
                    e.preventDefault();
                    showSourceAssessmentPopup();
                })
                .appendTo($target);

            console.log('Assess Sources button added to', $target.attr('id'));
        } else {
            console.error('Could not find a suitable element to attach the button to');
        }
    });
}

// Global state
var assessmentState = {
    sources: [],
    assessments: []
};

function showSourceAssessmentPopup() {
    var afdPageName = mw.config.get('wgPageName');
    var articleName = extractArticleName(afdPageName);

    var editTextarea = document.getElementById('wpTextbox1');
    if (!editTextarea) {
        mw.notify('Error: Could not find edit textarea.', {type: 'error'});
        return;
    }

    var existingState = parseExistingAssessment(editTextarea.value);

    if (existingState) {
        assessmentState = existingState;
        console.log('Loaded existing state with', existingState.sources.length, 'sources');
        displayPopup();
    } else {
        // Fix #4: loading indicator via button state
        var $button = $('#assessSourcesButton');
        $button.text('Loading...').prop('disabled', true);

        new mw.Api().get({
            action: 'query',
            prop: 'revisions',
            rvprop: 'content',
            titles: articleName,
            formatversion: 2
        }).done(function(data) {
            $button.text('Assess Sources').prop('disabled', false);

            try {
                if (!data.query || !data.query.pages || data.query.pages.length === 0) {
                    mw.notify('Error: Could not retrieve article data.', {type: 'error'});
                    return;
                }

                var page = data.query.pages[0];
                if (page.missing) {
                    mw.notify('The article "' + articleName + '" could not be found.', {type: 'error'});
                    return;
                }

                if (!page.revisions || page.revisions.length === 0) {
                    mw.notify('No revisions found for the article.', {type: 'error'});
                    return;
                }

                var pageContent = page.revisions[0].content;
                var references = extractReferences(pageContent);

                if (references.length === 0) {
                    mw.notify('No references found in the article.', {type: 'warning'});
                    return;
                }

                assessmentState.sources = references;
                assessmentState.assessments = references.map(function() {
                    return {
                        independent: '',
                        reliable: '',
                        significant: '',
                        gng: '',
                        comments: ''
                    };
                });

                displayPopup();
            } catch (error) {
                mw.notify('An error occurred: ' + error.message, {type: 'error'});
                console.error('Error:', error);
            }
        }).fail(function(error) {
            $button.text('Assess Sources').prop('disabled', false);
            mw.notify('Failed to fetch article content.', {type: 'error'});
            console.error('API error:', error);
        });
    }
}

function displayPopup() {
    $('#sourceAssessmentPopup').remove();

    var popup = $('<div>')
        .attr('id', 'sourceAssessmentPopup')
        .css({
            'position': 'fixed',
            'top': '0',
            'left': '0',
            'width': '100%',
            'height': '100%',
            'background': 'rgba(0,0,0,0.5)',
            'z-index': '10000',
            'display': 'flex',
            'align-items': 'center',
            'justify-content': 'center',
            'overflow': 'auto'
        });

    var content = $('<div>')
        .css({
            'background': 'white',
            'padding': '20px',
            'border-radius': '5px',
            'width': '90%',
            'max-height': '90%',
            'overflow': 'auto',
            'position': 'relative'
        });

    var closeButton = $('<button>')
        .text('×')
        .css({
            'position': 'absolute',
            'top': '10px',
            'right': '10px',
            'border': 'none',
            'background': '#f0f0f0',
            'font-size': '24px',
            'width': '30px',
            'height': '30px',
            'border-radius': '50%',
            'cursor': 'pointer',
            'line-height': '1',
            'padding': '0'
        })
        .hover(
            function() { $(this).css('background', '#e0e0e0'); },
            function() { $(this).css('background', '#f0f0f0'); }
        )
        .click(function() {
            popup.remove();
        });
    content.append(closeButton);

    content.append($('<h2>').text('Source Assessment for WP:GNG').css('margin-top', '0'));

    var table = $('<table>')
        .attr('id', 'sourceAssessmentTable')
        .css({
            'width': '100%',
            'border-collapse': 'collapse',
            'margin': '20px 0'
        });

    var thead = $('<thead>');
    var headerRow = $('<tr>');
    ['#', 'Source', 'Independent', 'Reliable', 'Significant Coverage', 'Count Toward GNG', 'Comments'].forEach(function(header) {
        var th = $('<th>').text(header).css({
            'border': '1px solid #ccc',
            'padding': '8px',
            'background': '#f0f0f0',
            'text-align': 'left'
        });
        if (header === 'Count Toward GNG') {
            th.css('width', '20px');
        }
        headerRow.append(th);
    });
    thead.append(headerRow);
    table.append(thead);

    var tbody = $('<tbody>').attr('id', 'sourceAssessmentTbody');
    assessmentState.sources.forEach(function(source, index) {
        tbody.append(createAssessmentRow(source, index));
    });
    table.append(tbody);

    content.append(table);

    var buttonDiv = $('<div>').css({'text-align': 'right', 'margin-top': '10px'});

    // Fix #7: button to manually add a source row
    $('<button>')
        .text('Add Source Manually')
        .css({'margin-right': '10px', 'padding': '8px 16px'})
        .click(function() {
            var newIndex = assessmentState.sources.length;
            assessmentState.sources.push('');
            assessmentState.assessments.push({
                independent: '',
                reliable: '',
                significant: '',
                gng: '',
                comments: ''
            });
            var row = createAssessmentRow('', newIndex, true);
            $('#sourceAssessmentTbody').append(row);
        })
        .appendTo(buttonDiv);

    $('<button>')
        .text('Generate Wikicode')
        .css({'margin-right': '10px', 'padding': '8px 16px'})
        .click(function() {
            generateWikicode();
        })
        .appendTo(buttonDiv);

    $('<button>')
        .text('Close')
        .css({'padding': '8px 16px'})
        .click(function() {
            popup.remove();
        })
        .appendTo(buttonDiv);

    content.append(buttonDiv);
    popup.append(content);
    $('body').append(popup);
}

// Fix #7: added optional isManual parameter — manual rows get an editable source textarea
function createAssessmentRow(source, index, isManual) {
    var assessment = assessmentState.assessments[index];
    var row = $('<tr>').attr('data-index', index);

    // Number cell
    var numberCell = $('<td>').css({
        'border': '1px solid #ccc',
        'padding': '8px',
        'text-align': 'center',
        'font-weight': 'bold'
    }).text(index + 1);
    row.append(numberCell);

    // Source cell — editable textarea for manually added rows
    var sourceCell = $('<td>').css({
        'border': '1px solid #ccc',
        'padding': '8px',
        'max-width': '300px',
        'overflow': 'auto'
    });

    if (isManual) {
        var sourceTextarea = $('<textarea>')
            .css({'width': '100%', 'min-height': '60px'})
            .attr('placeholder', 'Paste citation wikicode here')
            .on('input', function() {
                assessmentState.sources[index] = $(this).val();
            });
        sourceCell.append(sourceTextarea);
    } else {
        sourceCell.html(source);
    }
    row.append(sourceCell);

    // Radio button columns: Independent, Reliable, Significant Coverage
    ['independent', 'reliable', 'significant'].forEach(function(field) {
        var cell = $('<td>').css({
            'border': '1px solid #ccc',
            'padding': '8px'
        });

        var radioGroup = $('<div>').css({'display': 'flex', 'flex-direction': 'column', 'gap': '4px'});

        [{value: 'yes', text: 'Yes'},
         {value: 'no', text: 'No'},
         {value: 'unknown', text: 'Unknown'},
         {value: 'partial', text: 'Partial'}
        ].forEach(function(option) {
            var radioId = 'radio_' + index + '_' + field + '_' + option.value;
            var label = $('<label>').css({'display': 'flex', 'align-items': 'center', 'cursor': 'pointer'});

            var radio = $('<input>')
                .attr('type', 'radio')
                .attr('name', 'radio_' + index + '_' + field)
                .attr('id', radioId)
                .val(option.value)
                .css({'margin-right': '4px'});

            if (assessment[field] === option.value) {
                radio.attr('checked', 'checked');
            }

            radio.change(function() {
                assessmentState.assessments[index][field] = $(this).val().trim();
                updateRowStatus(row, index);
            });

            label.append(radio);
            label.append($('<span>').text(option.text));
            radioGroup.append(label);
        });

        cell.append(radioGroup);
        row.append(cell);
    });

    // Count Toward GNG cell
    var gngCell = $('<td>').css({
        'border': '1px solid #ccc',
        'padding': '8px',
        'text-align': 'center',
        'width': '20px'
    }).attr('data-gng', '');
    row.append(gngCell);

    // Comments cell
    var commentsCell = $('<td>').css({
        'border': '1px solid #ccc',
        'padding': '8px'
    });
    var commentsTextarea = $('<textarea>')
        .css({'width': '100%', 'min-height': '40px'})
        .val(assessment.comments)
        .on('input', function() {
            assessmentState.assessments[index].comments = $(this).val().trim();
        });
    commentsCell.append(commentsTextarea);
    row.append(commentsCell);

    updateRowStatus(row, index);

    return row;
}

function updateRowStatus(row, index) {
    var assessment = assessmentState.assessments[index];
    var gngCell = row.find('td[data-gng]');

    var allYes = assessment.independent === 'yes' &&
                 assessment.reliable === 'yes' &&
                 assessment.significant === 'yes';

    if (allYes) {
        row.css('background-color', '#d4edda');
        gngCell.html('<img alt="Yes" src="//upload.wikimedia.org/wikipedia/commons/thumb/2/26/Check-green.svg/20px-Check-green.svg.png" width="13" height="13">');
        gngCell.attr('data-gng', 'yes');
        assessmentState.assessments[index].gng = 'yes'; // Fix #1: write gng back to state
    } else if (assessment.independent || assessment.reliable || assessment.significant) {
        row.css('background-color', '#f8d7da');
        gngCell.html('<img alt="No" src="//upload.wikimedia.org/wikipedia/commons/thumb/4/48/Dark_Red_x.svg/20px-Dark_Red_x.svg.png" width="13" height="13">');
        gngCell.attr('data-gng', 'no');
        assessmentState.assessments[index].gng = 'no'; // Fix #1
    } else {
        row.css('background-color', 'white');
        gngCell.html('');
        gngCell.attr('data-gng', '');
        assessmentState.assessments[index].gng = ''; // Fix #1
    }
}

function generateWikicode() {
    var wikicode = '\n\n{{User:Polygnotus/Templates/SourceAssessTable\n';

    assessmentState.sources.forEach(function(source, index) {
        var assessment = assessmentState.assessments[index];
        var num = index + 1;

        wikicode += '|src' + num + '=' + source + '\n';
        wikicode += '|num' + num + '=' + num + '\n';
        wikicode += '|ind' + num + '=' + assessment.independent + '\n';
        wikicode += '|rel' + num + '=' + assessment.reliable + '\n';
        wikicode += '|sig' + num + '=' + assessment.significant + '\n';
        wikicode += '|gng' + num + '=' + assessment.gng + '\n';
        wikicode += '|comments' + num + '=' + assessment.comments + '\n';
    });

    wikicode += '}}\n';

    var editTextarea = document.getElementById('wpTextbox1');
    if (editTextarea) {
        // Fix #8: use brace-counting removal instead of a lazy regex
        var currentContent = removeExistingAssessmentBlock(editTextarea.value);
        editTextarea.value = currentContent + wikicode;
        $(editTextarea).trigger('change');

        mw.notify('Source assessment added to edit area.', {type: 'success'});
        $('#sourceAssessmentPopup').remove();
    } else {
        mw.notify('Failed to find edit textarea.', {type: 'error'});
    }
}

// Fix #8: removes the existing assessment block using brace counting,
// so a }} inside a citation template cannot prematurely end the match
function removeExistingAssessmentBlock(content) {
    var startPattern = /\{\{User:Polygnotus\/Templates\/SourceAssessTable/;
    var startMatch = content.search(startPattern);

    if (startMatch === -1) {
        return content;
    }

    // Also strip any leading newlines before the template
    var blockStart = startMatch;
    while (blockStart > 0 && content[blockStart - 1] === '\n') {
        blockStart--;
    }

    var braceCount = 0;
    var templateEnd = -1;

    for (var i = startMatch; i < content.length - 1; i++) {
        if (content[i] === '{' && content[i + 1] === '{') {
            braceCount++;
            i++;
        } else if (content[i] === '}' && content[i + 1] === '}') {
            braceCount--;
            if (braceCount === 0) {
                templateEnd = i + 2;
                break;
            }
            i++;
        }
    }

    if (templateEnd === -1) {
        console.warn('removeExistingAssessmentBlock: could not find end of template, leaving content unchanged');
        return content;
    }

    // Also strip trailing newlines after the template
    while (templateEnd < content.length && content[templateEnd] === '\n') {
        templateEnd++;
    }

    return content.substring(0, blockStart) + content.substring(templateEnd);
}

function parseExistingAssessment(content) {
    var startPattern = /\{\{User:Polygnotus\/Templates\/SourceAssessTable/;
    var startMatch = content.search(startPattern);

    if (startMatch === -1) {
        console.log('No existing assessment template found');
        return null;
    }

    var braceCount = 0;
    var inTemplate = false;
    var templateEnd = -1;

    for (var i = startMatch; i < content.length - 1; i++) {
        if (content[i] === '{' && content[i + 1] === '{') {
            braceCount++;
            inTemplate = true;
            i++;
        } else if (content[i] === '}' && content[i + 1] === '}') {
            braceCount--;
            if (braceCount === 0 && inTemplate) {
                templateEnd = i + 2;
                break;
            }
            i++;
        }
    }

    if (templateEnd === -1) {
        console.log('Could not find end of template');
        return null;
    }

    var fullTemplate = content.substring(startMatch, templateEnd);
    var templateNameEnd = fullTemplate.indexOf('\n');
    if (templateNameEnd === -1) {
        return null;
    }

    var templateContent = fullTemplate.substring(templateNameEnd + 1, fullTemplate.length - 2);

    // Parse parameters, supporting multiline values
    var params = {};
    var currentParam = null;
    var currentValue = '';
    var lines = templateContent.split('\n');

    for (var i = 0; i < lines.length; i++) {
        var line = lines[i];
        var paramMatch = line.match(/^\|([a-zA-Z]+\d+)=(.*)$/);
        if (paramMatch) {
            if (currentParam) {
                params[currentParam] = currentValue;
            }
            currentParam = paramMatch[1];
            currentValue = paramMatch[2];
        } else if (currentParam && line.trim() !== '') {
            currentValue += '\n' + line;
        }
    }
    if (currentParam) {
        params[currentParam] = currentValue;
    }

    // Determine number of sources from highest numbered parameter
    var maxNum = 0;
    Object.keys(params).forEach(function(key) {
        var numMatch = key.match(/\d+$/);
        if (numMatch) {
            var num = parseInt(numMatch[0]);
            if (num > maxNum) maxNum = num;
        }
    });

    var sources = [];
    var assessments = [];

    for (var i = 1; i <= maxNum; i++) {
        if (params['src' + i] !== undefined) {
            sources.push(params['src' + i].trim());
            assessments.push({
                independent: (params['ind' + i] || '').trim(),
                reliable: (params['rel' + i] || '').trim(),
                significant: (params['sig' + i] || '').trim(),
                gng: (params['gng' + i] || '').trim(),
                comments: (params['comments' + i] || '').trim()
            });
        }
    }

    if (sources.length === 0) {
        console.log('No sources found in template');
        return null;
    }

    console.log('Successfully parsed', sources.length, 'sources with assessments');
    return { sources: sources, assessments: assessments };
}

function extractArticleName(afdPageName) {
    var articleName = afdPageName.replace('Wikipedia:Articles_for_deletion/', '');
    var nominationMatch = articleName.match(/(.*) \((\d+)(st|nd|rd|th) nomination\)$/);
    if (nominationMatch) {
        articleName = nominationMatch[1];
    }
    return articleName;
}

function extractReferences(content) {
    var references = [];
    var seenNames = {}; // Fix #3: track named refs to avoid duplicates
    var index = 0;

    while (index < content.length) {
        var startIndex = content.indexOf('<ref', index);
        if (startIndex === -1) break;

        var endIndex = content.indexOf('>', startIndex);
        if (endIndex === -1) break;

        var openingTag = content.substring(startIndex, endIndex + 1);

        // Fix #6: use regex instead of endsWith to handle whitespace before />
        if (/\/\s*>$/.test(openingTag)) {
            index = endIndex + 1;
            continue;
        }

        // Fix #5: skip refs with a group attribute — these are footnotes, not sources
        if (/\bgroup\s*=/.test(openingTag)) {
            var skipEnd = content.indexOf('</ref>', endIndex);
            index = (skipEnd === -1) ? content.length : skipEnd + 6;
            continue;
        }

        // Fix #3: deduplicate by named ref — if name= already seen, skip this definition
        var nameMatch = openingTag.match(/\bname\s*=\s*["']?([^"'\s>\/]+)["']?/);
        if (nameMatch) {
            var refName = nameMatch[1];
            if (seenNames[refName]) {
                var skipEnd = content.indexOf('</ref>', endIndex);
                index = (skipEnd === -1) ? content.length : skipEnd + 6;
                continue;
            }
            seenNames[refName] = true;
        }

        var refEndIndex = content.indexOf('</ref>', endIndex);
        if (refEndIndex === -1) break;

        var refContent = content.substring(endIndex + 1, refEndIndex).trim();

        // Fix #2: escape pipe characters from {{!}} so they don't break wikicode output
        refContent = refContent.replace(/\{\{!\}\}/g, '&#x7c;');

        if (refContent) {
            references.push(refContent);
        }

        index = refEndIndex + 6;
    }

    console.log('Extracted', references.length, 'references');
    return references;
}
// </nowiki>