MediaWiki:Gadget-verify.js: Difference between revisions

From PickiPedia: A knowledge base of bluegrass, old time, and other traditional and public domain music
Jump to navigationJump to search
No edit summary
Fix matching - strip wikitext formatting (bold/italic) when comparing to DOM text (via update-page on MediaWiki MCP Server)
 
(2 intermediate revisions by 2 users not shown)
Line 115: Line 115:


         $form.append(
         $form.append(
             $('<label>').attr('for', 'verify-source').text('Source (URL or description):'),
             $('<label>').attr('for', 'verify-source').text('Source (optional):'),
             $('<br>'),
             $('<br>'),
             $('<input>')
             $('<input>')
Line 122: Line 122:
                     id: 'verify-source',
                     id: 'verify-source',
                     name: 'source',
                     name: 'source',
                     placeholder: 'https://... or "I was there" or "per venue website"'
                     placeholder: 'https://... or "I was there" or leave blank'
                 })
                 })
                 .val(existingSource || '')
                 .val(existingSource || '')
Line 218: Line 218:
         var userName = mw.config.get('wgUserName');
         var userName = mw.config.get('wgUserName');
         var timestamp = new Date().toISOString().split('T')[0];
         var timestamp = new Date().toISOString().split('T')[0];
        // ***Do we want to force verification? I kinda think 'no' - sometimes a particular user's say-so is enough. ***
        // Require at least a source OR a note
        // if (!source && !note) {
        //    mw.notify('Please provide a source or a note explaining your verification', { type: 'error' });
        //    return;
        // }


         // Disable form while processing
         // Disable form while processing
Line 278: Line 271:
             $form.find('button[type="submit"]').text('Confirm Verification');
             $form.find('button[type="submit"]').text('Confirm Verification');
         });
         });
    }
    /**
    * Strip wikitext formatting for comparison purposes.
    * Removes bold ('''), italic (''), and common markup.
    */
    function stripWikitext(text) {
        return text
            .replace(/'{2,}/g, '')  // Remove '' and '''
            .replace(/\[\[([^\]|]+\|)?([^\]]+)\]\]/g, '$2')  // [[link|text]] -> text
            .replace(/\{\{!\}\}/g, '|')  // {{!}} -> |
            .replace(/\s+/g, ' ')
            .trim()
            .toLowerCase();
    }
    /**
    * Find a Bot_proposes or claim template in wikitext that contains the given text.
    * Returns {start, end, content} or null if not found.
    * Properly handles nested templates like {{!}}.
    */
    function findTemplateContaining(wikitext, searchText) {
        // Normalize searchText for comparison - collapse whitespace
        var normalizedSearch = stripWikitext(searchText).substring(0, 40);
       
        // Find all {{Bot_proposes or {{claim occurrences
        var templateRegex = /\{\{(Bot_proposes|claim)\s*\|/gi;
        var match;
       
        while ((match = templateRegex.exec(wikitext)) !== null) {
            var startIndex = match.index;
            var depth = 0;
            var i = startIndex;
           
            // Find the matching closing }}
            while (i < wikitext.length - 1) {
                if (wikitext[i] === '{' && wikitext[i + 1] === '{') {
                    depth++;
                    i += 2;
                } else if (wikitext[i] === '}' && wikitext[i + 1] === '}') {
                    depth--;
                    if (depth === 0) {
                        var endIndex = i + 2;
                        var templateContent = wikitext.substring(startIndex, endIndex);
                       
                        // Extract the actual content (first parameter, before |by= or |source=)
                        var inner = templateContent.replace(/^\{\{(Bot_proposes|claim)\s*\|\s*/i, '').replace(/\}\}$/, '');
                        // Remove |by=... and |source=... parameters
                        inner = inner.replace(/\|by=[^|}]*/gi, '').replace(/\|source=[^|}]*/gi, '');
                       
                        // Strip wikitext formatting for comparison
                        var normalizedInner = stripWikitext(inner);
                       
                        // Check if this template contains our search text
                        if (normalizedInner.indexOf(normalizedSearch) !== -1) {
                            return {
                                start: startIndex,
                                end: endIndex,
                                fullMatch: templateContent,
                                content: inner.trim()
                            };
                        }
                        break;
                    }
                    i += 2;
                } else {
                    i++;
                }
            }
        }
        return null;
     }
     }


Line 286: Line 350:
         var userName = mw.config.get('wgUserName');
         var userName = mw.config.get('wgUserName');
         var timestamp = new Date().toISOString().split('T')[0];
         var timestamp = new Date().toISOString().split('T')[0];
        // Require at least a source OR a note
        if (!source && !note) {
            mw.notify('Please provide a source or a note explaining your verification', { type: 'error' });
            return;
        }


         // Disable form while processing
         // Disable form while processing
Line 310: Line 368:
             var content = page.revisions[0].slots.main.content;
             var content = page.revisions[0].slots.main.content;


             // Build pattern to find the claim/bot_proposes template
             // Find the template containing this claim text
             var searchText = claimText.substring(0, 40).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
             var found = findTemplateContaining(content, claimText);
            var claimPattern = new RegExp(
           
                '\\{\\{(claim|bot_proposes)\\|[^}]*?' + searchText + '[^}]*?\\}\\}',
            if (!found) {
                 'i'
                mw.notify('Could not find the claim in page source. It may have been modified.', { type: 'error' });
             );
                $form.find('button').prop('disabled', false);
                $form.find('button[type="submit"]').text('Confirm Verification');
                 return;
             }


             // Build replacement - use {{source|...}} for URLs, {{verified|...}} for text
             // Build replacement - use {{source|...}} for URLs, {{verified|...}} for text
             var replacement;
             var replacement;
            var cleanContent = found.content;
           
             if (source && source.match(/^https?:\/\//)) {
             if (source && source.match(/^https?:\/\//)) {
                 replacement = claimText + '{{source|' + source + '}}';
                 replacement = cleanContent + '{{source|' + source + '}}';
             } else if (source) {
             } else if (source) {
                 replacement = claimText + '{{verified|' + source + '}}';
                 replacement = cleanContent + '{{verified|' + source + '}}';
             } else {
             } else {
                 replacement = claimText + '{{verified|by ' + userName + '}}';
                 replacement = cleanContent + '{{verified|by ' + userName + '}}';
             }
             }
              
              
             var newContent = content.replace(claimPattern, replacement);
             var newContent = content.substring(0, found.start) + replacement + content.substring(found.end);
 
            if (newContent === content) {
                mw.notify('Could not find the claim in page source. It may have been modified.', { type: 'error' });
                $form.find('button').prop('disabled', false);
                $form.find('button[type="submit"]').text('Confirm Verification');
                return;
            }


             var editSummary = 'Verified: "' + claimText.substring(0, 50) + (claimText.length > 50 ? '...' : '') + '"';
             var editSummary = 'Verified: "' + claimText.substring(0, 50) + (claimText.length > 50 ? '...' : '') + '"';

Latest revision as of 21:17, 7 January 2026

/**
 * Gadget-verify.js - Verification gadget for Pickipedia claims
 * 
 * Adds "Verify" buttons to {{claim}}, {{bot_proposes}}, and {{Show}} templates,
 * allowing logged-in users to verify content and record attestations.
 */
(function() {
    'use strict';

    // Only run for logged-in users
    if (mw.config.get('wgUserName') === null) {
        return;
    }

    // Wait for page to be ready
    mw.hook('wikipage.content').add(function($content) {
        // Find all claim and bot_proposes elements (inline)
        var $claims = $content.find('.claim-unverified, span.bot-proposal');
        
        $claims.each(function() {
            var $claim = $(this);
            
            // Don't add button twice
            if ($claim.find('.verify-btn').length > 0) {
                return;
            }

            var $btn = $('<button>')
                .addClass('verify-btn')
                .text('✓ Verify')
                .css({
                    'margin-left': '0.5em',
                    'font-size': '0.8em',
                    'padding': '1px 6px',
                    'cursor': 'pointer',
                    'background': '#f8f9fa',
                    'border': '1px solid #a2a9b1',
                    'border-radius': '3px',
                    'vertical-align': 'middle'
                })
                .on('click', function(e) {
                    e.preventDefault();
                    e.stopPropagation();
                    openVerifyDialog($claim, false);
                });

            $claim.append(' ').append($btn);
        });

        // Find Show infoboxes that need verification
        var $shows = $content.find('.show-infobox.bot-proposal');
        
        $shows.each(function() {
            var $show = $(this);
            
            // Don't add button twice
            if ($show.find('.verify-show-btn').length > 0) {
                return;
            }

            var $btn = $('<button>')
                .addClass('verify-show-btn')
                .text('✓ Verify This Show')
                .css({
                    'display': 'block',
                    'width': '100%',
                    'margin-top': '0.5em',
                    'padding': '6px 12px',
                    'cursor': 'pointer',
                    'background': '#f8f9fa',
                    'border': '1px solid #a2a9b1',
                    'border-radius': '3px',
                    'font-size': '0.9em'
                })
                .on('click', function(e) {
                    e.preventDefault();
                    e.stopPropagation();
                    openVerifyDialog($show, true);
                });

            $show.append($btn);
        });
    });

    function openVerifyDialog($element, isShow) {
        var claimText;
        if (isShow) {
            // For shows, build a summary from the infobox content
            var artists = $element.find('div:contains("Artists:")').text().replace('Artists:', '').trim();
            var venue = $element.find('div:contains("Venue:")').text().replace('Venue:', '').trim();
            var block = $element.find('div:contains("Block:")').text().replace('Block:', '').trim();
            claimText = artists + ' @ ' + venue + ' (block ' + block + ')';
        } else {
            claimText = $element.clone().children('.verify-btn').remove().end().text().trim();
        }
        
        var existingSource = $element.data('source') || '';
        var isBotProposal = $element.hasClass('bot-proposal');
        
        // Create dialog content
        var $dialog = $('<div>').addClass('verify-dialog');
        
        var $form = $('<form>').on('submit', function(e) {
            e.preventDefault();
            if (isShow) {
                submitShowVerification($element, $form, claimText, $overlay, $dialog);
            } else {
                submitVerification($element, $form, claimText, $overlay, $dialog);
            }
        });

        $form.append(
            $('<p>').css('margin-top', '0').html('<strong>Verifying:</strong> ' + mw.html.escape(claimText))
        );

        $form.append(
            $('<label>').attr('for', 'verify-source').text('Source (optional):'),
            $('<br>'),
            $('<input>')
                .attr({
                    type: 'text',
                    id: 'verify-source',
                    name: 'source',
                    placeholder: 'https://... or "I was there" or leave blank'
                })
                .val(existingSource || '')
                .css({ width: '100%', marginBottom: '1em', padding: '6px', boxSizing: 'border-box' })
        );

        if (isBotProposal && existingSource) {
            $form.find('label[for="verify-source"]').after(
                $('<div>').css({ fontSize: '0.9em', color: '#666', marginBottom: '0.5em' })
                    .text('Bot suggested source: ' + existingSource)
            );
        }

        $form.append(
            $('<label>').attr('for', 'verify-note').text('Note (optional):'),
            $('<br>'),
            $('<textarea>')
                .attr({
                    id: 'verify-note',
                    name: 'note',
                    rows: 3,
                    placeholder: 'Additional context about your verification...'
                })
                .css({ width: '100%', marginBottom: '1em', padding: '6px', boxSizing: 'border-box' })
        );

        $form.append(
            $('<button>')
                .attr('type', 'submit')
                .text('Confirm Verification')
                .css({
                    padding: '8px 16px',
                    background: '#36c',
                    color: 'white',
                    border: 'none',
                    borderRadius: '3px',
                    cursor: 'pointer',
                    marginRight: '8px'
                }),
            $('<button>')
                .attr('type', 'button')
                .text('Cancel')
                .css({
                    padding: '8px 16px',
                    background: '#f8f9fa',
                    border: '1px solid #a2a9b1',
                    borderRadius: '3px',
                    cursor: 'pointer'
                })
                .on('click', function() {
                    $overlay.remove();
                    $dialog.remove();
                })
        );

        $dialog.append($form);
        
        // Style the dialog
        $dialog.css({
            position: 'fixed',
            top: '50%',
            left: '50%',
            transform: 'translate(-50%, -50%)',
            background: 'white',
            padding: '20px',
            borderRadius: '8px',
            boxShadow: '0 4px 20px rgba(0,0,0,0.3)',
            zIndex: 10000,
            minWidth: '400px',
            maxWidth: '90vw'
        });

        // Add overlay
        var $overlay = $('<div>').css({
            position: 'fixed',
            top: 0,
            left: 0,
            right: 0,
            bottom: 0,
            background: 'rgba(0,0,0,0.5)',
            zIndex: 9999
        }).on('click', function() {
            $overlay.remove();
            $dialog.remove();
        });

        $('body').append($overlay).append($dialog);
        $dialog.find('#verify-source').focus();
    }

    function submitShowVerification($show, $form, claimText, $overlay, $dialog) {
        var source = $form.find('#verify-source').val().trim();
        var note = $form.find('#verify-note').val().trim();
        var pageName = mw.config.get('wgPageName');
        var userName = mw.config.get('wgUserName');
        var timestamp = new Date().toISOString().split('T')[0];

        // Disable form while processing
        $form.find('button').prop('disabled', true);
        $form.find('button[type="submit"]').text('Verifying...');

        var api = new mw.Api();
        
        api.get({
            action: 'query',
            titles: pageName,
            prop: 'revisions',
            rvprop: 'content',
            rvslots: 'main',
            formatversion: 2
        }).then(function(data) {
            var page = data.query.pages[0];
            var content = page.revisions[0].slots.main.content;

            // For Show template, change status=proposed to status=verified
            var newContent = content
                .replace(/\|status=proposed/g, '|status=verified')
                .replace(/\|by=[^\n|}]*/g, '|by=' + userName)
                .replace(/\|source=[^\n|}]*/g, '|source=' + (source || 'verified by ' + userName));

            if (newContent === content) {
                mw.notify('Could not update show status. It may have already been verified.', { type: 'error' });
                $form.find('button').prop('disabled', false);
                $form.find('button[type="submit"]').text('Confirm Verification');
                return;
            }

            var editSummary = 'Verified show: ' + claimText.substring(0, 50);
            
            return api.postWithToken('csrf', {
                action: 'edit',
                title: pageName,
                text: newContent,
                summary: editSummary
            }).then(function() {
                // Add to talk page
                return addTalkPageEntry(api, pageName, claimText, source, note, userName, timestamp);
            }).then(function() {
                mw.notify('Show verified!', { type: 'success' });
                $overlay.remove();
                $dialog.remove();
                // Reload to show changes
                location.reload();
            });
        }).catch(function(err) {
            mw.notify('Error: ' + err, { type: 'error' });
            $form.find('button').prop('disabled', false);
            $form.find('button[type="submit"]').text('Confirm Verification');
        });
    }

    /**
     * Strip wikitext formatting for comparison purposes.
     * Removes bold ('''), italic (''), and common markup.
     */
    function stripWikitext(text) {
        return text
            .replace(/'{2,}/g, '')  // Remove '' and '''
            .replace(/\[\[([^\]|]+\|)?([^\]]+)\]\]/g, '$2')  // [[link|text]] -> text
            .replace(/\{\{!\}\}/g, '|')  // {{!}} -> |
            .replace(/\s+/g, ' ')
            .trim()
            .toLowerCase();
    }

    /**
     * Find a Bot_proposes or claim template in wikitext that contains the given text.
     * Returns {start, end, content} or null if not found.
     * Properly handles nested templates like {{!}}.
     */
    function findTemplateContaining(wikitext, searchText) {
        // Normalize searchText for comparison - collapse whitespace
        var normalizedSearch = stripWikitext(searchText).substring(0, 40);
        
        // Find all {{Bot_proposes or {{claim occurrences
        var templateRegex = /\{\{(Bot_proposes|claim)\s*\|/gi;
        var match;
        
        while ((match = templateRegex.exec(wikitext)) !== null) {
            var startIndex = match.index;
            var depth = 0;
            var i = startIndex;
            
            // Find the matching closing }}
            while (i < wikitext.length - 1) {
                if (wikitext[i] === '{' && wikitext[i + 1] === '{') {
                    depth++;
                    i += 2;
                } else if (wikitext[i] === '}' && wikitext[i + 1] === '}') {
                    depth--;
                    if (depth === 0) {
                        var endIndex = i + 2;
                        var templateContent = wikitext.substring(startIndex, endIndex);
                        
                        // Extract the actual content (first parameter, before |by= or |source=)
                        var inner = templateContent.replace(/^\{\{(Bot_proposes|claim)\s*\|\s*/i, '').replace(/\}\}$/, '');
                        // Remove |by=... and |source=... parameters
                        inner = inner.replace(/\|by=[^|}]*/gi, '').replace(/\|source=[^|}]*/gi, '');
                        
                        // Strip wikitext formatting for comparison
                        var normalizedInner = stripWikitext(inner);
                        
                        // Check if this template contains our search text
                        if (normalizedInner.indexOf(normalizedSearch) !== -1) {
                            return {
                                start: startIndex,
                                end: endIndex,
                                fullMatch: templateContent,
                                content: inner.trim()
                            };
                        }
                        break;
                    }
                    i += 2;
                } else {
                    i++;
                }
            }
        }
        return null;
    }

    function submitVerification($claim, $form, claimText, $overlay, $dialog) {
        var source = $form.find('#verify-source').val().trim();
        var note = $form.find('#verify-note').val().trim();
        var pageName = mw.config.get('wgPageName');
        var userName = mw.config.get('wgUserName');
        var timestamp = new Date().toISOString().split('T')[0];

        // Disable form while processing
        $form.find('button').prop('disabled', true);
        $form.find('button[type="submit"]').text('Verifying...');

        var api = new mw.Api();
        
        api.get({
            action: 'query',
            titles: pageName,
            prop: 'revisions',
            rvprop: 'content',
            rvslots: 'main',
            formatversion: 2
        }).then(function(data) {
            var page = data.query.pages[0];
            var content = page.revisions[0].slots.main.content;

            // Find the template containing this claim text
            var found = findTemplateContaining(content, claimText);
            
            if (!found) {
                mw.notify('Could not find the claim in page source. It may have been modified.', { type: 'error' });
                $form.find('button').prop('disabled', false);
                $form.find('button[type="submit"]').text('Confirm Verification');
                return;
            }

            // Build replacement - use {{source|...}} for URLs, {{verified|...}} for text
            var replacement;
            var cleanContent = found.content;
            
            if (source && source.match(/^https?:\/\//)) {
                replacement = cleanContent + '{{source|' + source + '}}';
            } else if (source) {
                replacement = cleanContent + '{{verified|' + source + '}}';
            } else {
                replacement = cleanContent + '{{verified|by ' + userName + '}}';
            }
            
            var newContent = content.substring(0, found.start) + replacement + content.substring(found.end);

            var editSummary = 'Verified: "' + claimText.substring(0, 50) + (claimText.length > 50 ? '...' : '') + '"';
            
            return api.postWithToken('csrf', {
                action: 'edit',
                title: pageName,
                text: newContent,
                summary: editSummary
            }).then(function() {
                // Add to talk page
                return addTalkPageEntry(api, pageName, claimText, source, note, userName, timestamp);
            }).then(function() {
                mw.notify('Verification recorded!', { type: 'success' });
                $overlay.remove();
                $dialog.remove();
                // Reload to show changes
                location.reload();
            });
        }).catch(function(err) {
            mw.notify('Error: ' + err, { type: 'error' });
            $form.find('button').prop('disabled', false);
            $form.find('button[type="submit"]').text('Confirm Verification');
        });
    }

    function addTalkPageEntry(api, pageName, claimText, source, note, userName, timestamp) {
        var talkPage = 'Talk:' + pageName.replace(/^Talk:/, '').replace(/_/g, ' ');

        var entry = '\n== Verification by ' + userName + ' (' + timestamp + ') ==\n';
        entry += "'''Claim:''' " + claimText + '\n\n';
        if (source) {
            entry += "'''Source:''' " + source + '\n';
        }
        if (note) {
            entry += "\n'''Note:''' " + note + '\n';
        }

        return api.postWithToken('csrf', {
            action: 'edit',
            title: talkPage,
            appendtext: entry,
            summary: 'Recorded verification of claim'
        });
    }
})();