MediaWiki:Gadget-CargoHelper.js

From Angelina Jordan Wiki
Revision as of 14:19, 16 November 2025 by Most2dot0 (talk | contribs) (Undo - had to go back because of issues with submitting the templates in the refactored version I missed before)

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.
/* MediaWiki:Gadget-CargoHelper.js
   Updated: filtered contextstr UI, disabled perf fields when choosing existing perf,
   required ISO-leading date for new perf, URL validation, grouped layout.
*/
( function () {
'use strict';

mw.loader.using([
    'oojs-ui-core',
    'oojs-ui-windows',
    'oojs-ui-widgets',
    'mediawiki.util',
    'mediawiki.api'
]).then(function () {

    // ----------------- Helpers -----------------

    function luaCompatibleSanitize(title) {
        title = String(title || '');
        title = title.replace(/&[#\w]+;/g, '_');
        title = title.replace(/[^\w_.-]/g, '_'); // keep alnum, underscore, hyphen, dot
        return title;
    }

    function logError(tag, err) {
        if (window.console && console.error) console.error(tag, err);
    }

    // fetch performances for this page (to list existing perfs)
	function fetchPerformancesForPage() {
	    // Decode page name like decode() does
	    var page = mw.config.get('wgPageName') || '';
	    page = page.replace(/_/g, ' '); // convert underscores to spaces
	    page = $('<textarea/>').html(page).text(); // decode HTML entities just in case
	
	    var api = new mw.Api();
	    return api.get({
	        action: 'cargoquery',
	        tables: 'Performances',
	        fields: 'perfID,event,date,contextstr',
	        where: 'song="' + page.replace(/"/g, '\\"') + '"',
	        order_by: 'perfID',
	        limit: 5000
	    }).then(function (data) {
	        if (!data.cargoquery) return [];
	        return data.cargoquery.map(r => r.title || {}).map(t => ({
	            perfID: t.perfID || '',
	            event: t.event || '',
	            date: t.date || '',
	            contextstr: t.contextstr || ''
	        }));
	    }).catch(function (err) { logError('fetchPerformancesForPage failed:', err); return []; });
	}

    // fetch ALL distinct contextstr (complete strings) from Performances table
    function fetchAllContexts() {
        var api = new mw.Api();
        // Cargo doesn't have DISTINCT keyword; grouping produces distinct values.
        return api.get({
            action: 'cargoquery',
            tables: 'Performances',
            fields: 'contextstr',
            group_by: 'contextstr',
            order_by: 'contextstr',
            limit: 5000
        }).then(function (data) {
            if (!data.cargoquery) return [];
            var arr = data.cargoquery.map(function (r) { return (r.title && r.title.contextstr) ? r.title.contextstr : ''; })
                .filter(function (s) { return !!s; });
            // preserve query order, already unique via group_by
            return arr;
        }).catch(function (err) { logError('fetchAllContexts failed:', err); return []; });
    }

    // compute next local numeric id
    function computeNextLocalId(perfs, page) {
        var base = luaCompatibleSanitize(page) + '_';
        var max = 0;
        perfs.forEach(function (p) {
            var id = p.perfID || '';
            if (id.indexOf(base) !== 0) return;
            var tail = id.slice(base.length);
            var m = tail.match(/^(\d+)/);
            if (!m) return;
            var n = parseInt(m[1], 10);
            if (!isNaN(n) && n > max) max = n;
        });
        return String(max + 1);
    }

    // Build performance template invocation (literal wikitext)
    function buildPerformanceTemplate(params) {
        var lines = ['{{Performance'];
        if (params.song) lines.push('|song=' + params.song);
        if (params.event) lines.push('|event=' + params.event);
        if (params.context) lines.push('|context=' + params.context);
        if (params.date) lines.push('|date=' + params.date);
        if (params.type) lines.push('|type=' + params.type);
        if (params.pos) lines.push('|pos=' + params.pos);
        if (params.withField) lines.push('|with=' + params.withField);
        if (params.comment) lines.push('|comment=' + params.comment);
        if (params.id) lines.push('|id=' + params.id);
        lines.push('}}');
        return lines.join('\n');
    }

    // Build video template invocation (literal wikitext)
    function buildVideoTemplate(params) {
        var lines = ['{{Video'];
        if (params.pid) lines.push('|pid=' + params.pid);
        if (params.url) lines.push('|url=' + params.url);
        if (params.duration) lines.push('|duration=' + params.duration);
        if (params.quality) lines.push('|quality=' + params.quality);
        if (params.comment) lines.push('|comment=' + params.comment);
        lines.push('}}');
        return lines.join('\n');
    }

    // ----------------- Validation -----------------
    function validIsoLeadingDate(s) {
        // Accepts "YYYY", "YYYY-MM", "YYYY-MM-DD" at the start, optionally followed by space + freeform.
        // Examples accepted: "2020", "2020-05", "2020-05-12", "2020-05-12 extra notes"
        if (!s) return false;
        var m = s.match(/^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?(?:\s.*)?$/);
        if (!m) return false;
        // If month exists, must be 01-12; if day exists, must be 01-31 (basic check).
        if (m[2]) {
            var mm = parseInt(m[2], 10);
            if (mm < 1 || mm > 12) return false;
        }
        if (m[3]) {
            var dd = parseInt(m[3], 10);
            if (dd < 1 || dd > 31) return false;
        }
        return true;
    }
    function validUrl(s) {
        if (!s) return false;
        // Accept absolute http(s) or protocol-relative //.
        return /^(https?:\/\/|\/\/).+/i.test(s.trim());
    }

    // ----------------- OOUI dialog -----------------

function openDialog(existingPerfs, allContexts) {
    // ------------------- Widgets -------------------
    var modeSelect = new OO.ui.DropdownInputWidget({
        options: [
            { data: 'existing', label: 'Use existing performance' },
            { data: 'new', label: 'Create new performance' }
        ],
        value: existingPerfs.length ? 'existing' : 'new'
    });

    var existingPerfDropdown = null;
    if (existingPerfs.length) {
        existingPerfDropdown = new OO.ui.DropdownInputWidget({
            options: existingPerfs.map(function(p) {
                var lastUnd = (p.perfID || '').lastIndexOf('_');
                var localId = (lastUnd >= 0) ? p.perfID.slice(lastUnd + 1) : p.perfID;
                return { data: p.perfID, label: localId + ' — ' + (p.event || '') + ' (' + (p.date || '') + ')' };
            }),
            placeholder: 'Choose existing performance'
        });
    }

    var eventInput = new OO.ui.TextInputWidget();
    var dateInput = new OO.ui.TextInputWidget();
    var contextCombo = new OO.ui.ComboBoxInputWidget({
        options: allContexts.map(c => ({ data: c, label: c })),
        placeholder: 'Type or pick a full context string'
    });
    var originalContextOptions = allContexts.map(c => ({ data: c, label: c }));

    // Dynamic filter for context combo
    contextCombo.on('input', function(value) {
        var valLower = value.toLowerCase();
        contextCombo.clearOptions();
        originalContextOptions.forEach(function(opt) {
            if (opt.data.toLowerCase().includes(valLower)) {
                contextCombo.addOption({ data: opt.data, label: opt.label });
            }
        });
    });

    var typeInput = new OO.ui.DropdownInputWidget({
        options: [
            { data: '', label: '—' },
            { data: 'recording', label: 'recording' },
            { data: 'track', label: 'track' },
            { data: 'music-video', label: 'music-video' },
            { data: 'live', label: 'live' },
            { data: 'rehearsal', label: 'rehearsal' },
            { data: 'soundcheck', label: 'soundcheck' },
            { data: 'sing-along', label: 'sing-along' },
            { data: 'lip-sync', label: 'lip-sync' }
        ]
    });
    var posInput = new OO.ui.TextInputWidget();
    var withInput = new OO.ui.TextInputWidget({ multiline: true });
    var commentPInput = new OO.ui.TextInputWidget({ multiline: true });

    var urlInput = new OO.ui.TextInputWidget();
    var hr = new OO.ui.LabelWidget( { label: $( '<hr>' ).attr('style', 'color: gray' ) });  // horizontal line separator
    var durationInput = new OO.ui.DropdownInputWidget({
        options: [
            { data: '', label: '—' },
            { data: 'full', label: 'full' },
            { data: 'short', label: 'short' },
            { data: 'fragment', label: 'fragment' }
        ]
    });
    var qualityInput = new OO.ui.DropdownInputWidget({
        options: [
            { data: '', label: '—' },
            { data: 'best', label: 'best' },
            { data: 'good', label: 'good' },
            { data: 'acceptable', label: 'acceptable' },
            { data: 'poor', label: 'poor' }
        ]
    });
    var commentVInput = new OO.ui.TextInputWidget({ multiline: true });

    // ------------------- Layout -------------------
    var form = new OO.ui.FieldsetLayout({ label: 'Add Video / Performance' });
    form.addItems([ new OO.ui.FieldLayout(modeSelect, { label: 'Mode' }) ]);
    if (existingPerfDropdown) form.addItems([ new OO.ui.FieldLayout(existingPerfDropdown, { label: 'Existing performance', align: 'top' }) ]);

    form.addItems([
        new OO.ui.FieldLayout(eventInput, { label: 'Event' }),
        new OO.ui.FieldLayout(dateInput, { label: 'Date' }),
        new OO.ui.FieldLayout(contextCombo, { label: 'Context (complete string, # separated)', align: 'top' }),
        new OO.ui.FieldLayout(typeInput, { label: 'Type' }),
        new OO.ui.FieldLayout(posInput, { label: 'Position' }),
        new OO.ui.FieldLayout(withInput, { label: 'With (partners)' }),
        new OO.ui.FieldLayout(commentPInput, { label: 'Performance comment' }),
        hr,
        new OO.ui.FieldLayout(urlInput, { label: 'Video URL' }),
        new OO.ui.FieldLayout(durationInput, { label: 'Duration' }),
        new OO.ui.FieldLayout(qualityInput, { label: 'Quality' }),
        new OO.ui.FieldLayout(commentVInput, { label: 'Video comment' })
    ]);

    var submitButton = new OO.ui.ButtonWidget({ label: 'Insert', flags: ['primary'] });
    var cancelButton = new OO.ui.ButtonWidget({ label: 'Cancel' });
    form.$element.append(submitButton.$element).append(cancelButton.$element);

    var panel = new OO.ui.PanelLayout({ expanded: true, padded: true, content: [ form ] });

    // ------------------- Dialog -------------------
    function AddVideoDialog(config) {
        AddVideoDialog.super.call(this, config);
    }
    OO.inheritClass(AddVideoDialog, OO.ui.ProcessDialog);
    AddVideoDialog.static.name = 'AddVideoDialog';
    AddVideoDialog.static.title = 'Add Video / Performance';
    AddVideoDialog.prototype.initialize = function () {
        AddVideoDialog.super.prototype.initialize.call(this);
        this.$body.append(panel.$element);
        this.$element.css({ width: '900px', 'max-width': '95%', left: '50%', transform: 'translateX(-50%)' });
    };

    var windowManager = new OO.ui.WindowManager();
    $(document.body).append(windowManager.$element);
    var dlg = new AddVideoDialog();
    windowManager.addWindows([ dlg ]);
    windowManager.openWindow(dlg);

    // ------------------- Mode logic -------------------
    // Enable/disable fields based on mode
    function updateFields() {
        var isExisting = modeSelect.getValue() === 'existing';

        if (existingPerfDropdown) {
            existingPerfDropdown.setDisabled(!isExisting);
        }

        if (isExisting) {
            // Existing mode → grey fields, populate values
            eventInput.setDisabled(true);
            dateInput.setDisabled(true);
            contextCombo.setDisabled(true);
            typeInput.setDisabled(true);
            posInput.setDisabled(true);
            withInput.setDisabled(true);
            commentPInput.setDisabled(true);

            var selected = existingPerfDropdown ? existingPerfDropdown.getValue() : null;
            var perf = existingPerfs.find(p => p.perfID === selected) || {};

            eventInput.setValue(perf.event || '');
            dateInput.setValue(perf.date || '');
            contextCombo.setValue(perf.contextstr || '');
            typeInput.setValue(perf.type || '');
            posInput.setValue(perf.pos || '');
            withInput.setValue(perf.with || '');
            commentPInput.setValue(perf.comment || '');

        } else {
            // New mode → clear and enable fields
            eventInput.setDisabled(false);
            dateInput.setDisabled(false);
            contextCombo.setDisabled(false);
            typeInput.setDisabled(false);
            posInput.setDisabled(false);
            withInput.setDisabled(false);
            commentPInput.setDisabled(false);

            // Clear only performance-related fields
            eventInput.setValue('');
            dateInput.setValue('');
            contextCombo.setValue('');
            typeInput.setValue('');
            posInput.setValue('');
            withInput.setValue('');
            commentPInput.setValue('');
        }
    }
    modeSelect.on('change', updateFields);
    if (existingPerfDropdown) existingPerfDropdown.on('change', updateFields);
    updateFields();

    // ------------------- Helper functions -------------------
    function buildPerformanceTemplate(params) {
        var fields = [];
        if (params.song) fields.push("song=" + params.song);
        if (params.event) fields.push("event=" + params.event);
        if (params.context) fields.push("context=" + params.context);
        if (params.date) fields.push("date=" + params.date);
        if (params.type) fields.push("type=" + params.type);
        if (params.pos) fields.push("pos=" + params.pos);
        if (params.withField) fields.push("with=" + params.withField);
        if (params.comment) fields.push("comment=" + params.comment);
        if (params.id) fields.push("id=" + params.id);
        return "{{Performance|" + fields.join('|') + "}}";
    }

    function buildVideoTemplate(params) {
        var fields = [];
        if (params.pid) fields.push("pid=" + params.pid);
        if (params.url) fields.push("url=" + params.url);
        if (params.duration) fields.push("duration=" + params.duration);
        if (params.quality) fields.push("quality=" + params.quality);
        if (params.comment) fields.push("comment=" + params.comment);
        return "{{Video|" + fields.join('|') + "}}";
    }

    function insertAtCursor(textarea, newText) {
        var start = textarea.selectionStart;
        var end = textarea.selectionEnd;
        var original = textarea.value;
        textarea.value = original.slice(0, start) + newText + original.slice(end);
        textarea.selectionStart = textarea.selectionEnd = start + newText.length;
        textarea.scrollTop = textarea.scrollHeight;
    }

	function formatForInsertion(perfBlock, videoBlock, textarea) {
	    var start = textarea.selectionStart;
	    var textBefore = textarea.value.slice(0, start);
	    var atLineStart = textBefore.endsWith('\n') || textBefore === '';
	
	    if (atLineStart) {
	        if (perfBlock) {
	            // only add bullets if perfBlock exists
	            return "* " + perfBlock + "\n*: " + videoBlock;
	        } else {
	            // only video block, no bullet for perf
	            return videoBlock;
	        }
	    } else {
	        // no bullets if not at line start
	        return (perfBlock || '') + videoBlock;
	    }
	}

    // ------------------- Submit (with improved validation) -------------------
    submitButton.on('click', function () {
        try {
            // Clear previous inline error styles
            function clearInvalid(widget) {
                if (widget && widget.$input) {
                    widget.$input.css('outline', '').css('box-shadow', '');
                } else if (widget && widget.$element) {
                    widget.$element.css('outline', '').css('box-shadow', '');
                }
            }
            [ eventInput, dateInput, contextCombo, urlInput, posInput, withInput, commentPInput, commentVInput ].forEach(clearInvalid);

            // helper to mark invalid and attach handler to clear when edited
            function markInvalid(widget) {
                if (!widget) return;
                if (widget.$input) {
                    widget.$input.css('outline', '2px solid #d33').css('box-shadow', '0 0 0 2px rgba(211,51,51,0.08)');
                    widget.$input.one('input', function () { widget.$input.css('outline', '').css('box-shadow', ''); });
                } else if (widget.$element) {
                    widget.$element.css('outline', '2px solid #d33').css('box-shadow', '0 0 0 2px rgba(211,51,51,0.08)');
                    widget.$element.one('input', function () { widget.$element.css('outline', '').css('box-shadow', ''); });
                }
            }

            // Validation helpers (reuse existing validators)
            function isValidIsoLeadingDate(s) {
                if (!s) return false;
                var m = s.match(/^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?(?:\s.*)?$/);
                if (!m) return false;
                if (m[2]) {
                    var mm = parseInt(m[2], 10);
                    if (mm < 1 || mm > 12) return false;
                }
                if (m[3]) {
                    var dd = parseInt(m[3], 10);
                    if (dd < 1 || dd > 31) return false;
                }
                return true;
            }
            function isValidUrl(s) {
                if (!s) return false;
                return /^(https?:\/\/|\/\/).+/i.test(s.trim());
            }

            // gather values
            var page = mw.config.get('wgPageName');
            var mode = modeSelect.getValue();
            var pidLocal;

            // determine pid
            if (mode === 'existing') {
                if (!existingPerfDropdown) { alert('No existing performance to choose.'); return; }
                var fullPerfID = existingPerfDropdown.getValue();
                if (!fullPerfID) { alert('Please select an existing performance.'); return; }
                var lastUnd = fullPerfID.lastIndexOf('_');
                pidLocal = (lastUnd >= 0) ? fullPerfID.slice(lastUnd + 1) : fullPerfID;
            } else {
                pidLocal = computeNextLocalId(existingPerfs || [], page);
            }

            // run field validation and collect errors
            var errors = [];

            // URL is always required
            var urlVal = urlInput.getValue() || '';
            if (!isValidUrl(urlVal)) {
                errors.push('Video URL is required and must start with "http://", "https://" or "//".');
                markInvalid(urlInput);
            }

            // If new performance, date is required and must be valid ISO-leading
            var perfDate = dateInput.getValue() || '';
            if (mode === 'new') {
                if (!perfDate) {
                    errors.push('Date is required when creating a new performance.');
                    markInvalid(dateInput);
                } else if (!isValidIsoLeadingDate(perfDate)) {
                    errors.push('Date must start with an ISO date (YYYY, YYYY-MM or YYYY-MM-DD).');
                    markInvalid(dateInput);
                }
            }

            // optionally check that context is present when creating new perf
            var ctxVal = contextCombo.getValue() || '';
            if (mode === 'new' && !ctxVal) {
                errors.push('Context is required when creating a new performance.');
                markInvalid(contextCombo);
            }

            // If errors, show OOUI dialog and focus first invalid
            if (errors.length) {
                // Message dialog
                function ValidationDialog(config) { ValidationDialog.super.call(this, config); }
                OO.inheritClass(ValidationDialog, OO.ui.MessageDialog);
                ValidationDialog.static.name = 'ValidationDialog';
                ValidationDialog.static.title = 'Validation error';
                ValidationDialog.static.actions = [
                    { action: 'close', label: 'Close', flags: ['primary'] }
                ];

                var wm = new OO.ui.WindowManager();
                $(document.body).append(wm.$element);
                var dlgVal = new ValidationDialog();
                wm.addWindows([dlgVal]);
                wm.openWindow(dlgVal, {
                    message: errors.join('\n')
                });

                // focus first invalid input after the dialog opens
                var toFocus = null;
                if (!isValidUrl(urlVal)) toFocus = urlInput;
                else if (mode === 'new' && (!perfDate || !isValidIsoLeadingDate(perfDate))) toFocus = dateInput;
                else if (mode === 'new' && !ctxVal) toFocus = contextCombo;

                if (toFocus) {
                    // small timeout to wait until dialog is visible
                    setTimeout(function () {
                        if (toFocus.focus) toFocus.focus();
                        else if (toFocus.$input) toFocus.$input.focus();
                    }, 50);
                }
                return;
            }

            // Build single-line templates
            var perfBlock = '';
            if (mode === 'new') {
                // single-line performance template
                var perfFields = [];
                if (page) perfFields.push('song=' + page);
                if (eventInput.getValue()) perfFields.push('event=' + eventInput.getValue());
                if (contextCombo.getValue()) perfFields.push('context=' + contextCombo.getValue());
                if (dateInput.getValue()) perfFields.push('date=' + dateInput.getValue());
                if (typeInput.getValue()) perfFields.push('type=' + typeInput.getValue());
                if (posInput.getValue()) perfFields.push('pos=' + posInput.getValue());
                if (withInput.getValue()) perfFields.push('with=' + withInput.getValue());
                if (commentPInput.getValue()) perfFields.push('comment=' + commentPInput.getValue());
                if (pidLocal) perfFields.push('id=' + pidLocal);
                perfBlock = '{{Performance|' + perfFields.join('|') + '}}';
            }

            var videoFields = [];
            if (pidLocal) videoFields.push('pid=' + pidLocal);
            if (urlInput.getValue()) videoFields.push('url=' + urlInput.getValue());
            if (durationInput.getValue()) videoFields.push('duration=' + durationInput.getValue());
            if (qualityInput.getValue()) videoFields.push('quality=' + qualityInput.getValue());
            if (commentVInput.getValue()) videoFields.push('comment=' + commentVInput.getValue());
            var videoBlock = '{{Video|' + videoFields.join('|') + '}}';

            // Compose combined text according to beginning-of-line rules
            var combinedForPreload = (mode === 'new' ? '* ' + perfBlock + '\n*: ' : '') + videoBlock;

            // Insert at cursor if editing, otherwise redirect with single preload param
            var $textarea = $('#wpTextbox1');
            if ($textarea.length) {
                var insertText;
                // test if cursor at line start
                var ta = $textarea[0];
                var start = ta.selectionStart;
                var textBefore = ta.value.slice(0, start);
                var atLineStart = textBefore.endsWith('\n') || textBefore === '';
                if (atLineStart) {
                    insertText = (mode === 'new' ? '* ' + perfBlock + '\n*: ' : '') + videoBlock;
                } else {
                    insertText = (mode === 'new' ? perfBlock : '') + videoBlock;
                }
                insertAtCursor(ta, insertText);
                windowManager.closeWindow(dlg);
            } else {
                var url = mw.util.getUrl(page, {
                    action: 'edit',
                    section: 'new',
                    preload: 'Template:CargoAddVideoPreload',
                    'preloadparams[]': [ combinedForPreload ]
                });
                window.location.href = url;
            }

        } catch (err) {
            logError('Submit handler failed:', err);
            alert('Error while preparing insertion (see console).');
        }
    });

    // ------------------- Cancel -------------------
    cancelButton.on('click', function() {
        windowManager.closeWindow(dlg);
    });
}

    // ----------------- Wiring / entry -----------------

    // Keep a cached copy of perfs for id computation
    var existingPerfsArray = [];

    function onAddClick() {
        // fetch both page-specific performances (for existing selection) and all contexts (global)
        var p1 = fetchPerformancesForPage();
        var p2 = fetchAllContexts();
        Promise.all([p1, p2]).then(function (res) {
            existingPerfsArray = res[0] || [];
            var allContexts = res[1] || [];
            openDialog(existingPerfsArray, allContexts);
        }).catch(function (err) {
            logError('Failed to fetch data for dialog:', err);
            alert('Failed to load data (see console).');
        });
    }

	// --------------------- Lazy checks on click ---------------------
	
	function showInfoDialog(message) {
	    function InfoDialog(config) { InfoDialog.super.call(this, config); }
	    OO.inheritClass(InfoDialog, OO.ui.MessageDialog);
	
	    InfoDialog.static.name = 'InfoDialog';
	    InfoDialog.static.title = 'Cargo Helper';
	    InfoDialog.static.actions = [
	        { action: 'close', label: 'Close', flags: ['primary'] }
	    ];
	
	    var winMgr = new OO.ui.WindowManager();
	    $(document.body).append(winMgr.$element);
	
	    var dlg = new InfoDialog();
	    winMgr.addWindows([dlg]);
	
	    winMgr.openWindow(dlg, { message: message });
	}
	
	function isSongNamespace() {
	    var ns = mw.config.get('wgNamespaceNumber');
	    var title = mw.config.get('wgPageName') || '';
	
	    // Song pages = main namespace, without colon
	    if (ns !== 0) return false;
	    if (!title || title.indexOf(':') !== -1) return false;
	
	    return true;
	}
	
	function fetchSongCargoEntry() {
	    // Decode page name like decode() does
	    var page = mw.config.get('wgPageName') || '';
	    page = page.replace(/_/g, ' '); // convert underscores to spaces
	    var decodedPage = $('<textarea/>').html(page).text(); // decode HTML entities just in case
	
	    var api = new mw.Api();
	
	    return api.get({
	        action: 'cargoquery',
	        tables: 'Songs',
	        fields: 'page',
	        where: 'page = "' + decodedPage.replace(/"/g, '\\"') + '"',
	        limit: 1
	    }).then(function (data) {
	        if (!data.cargoquery || !data.cargoquery.length) return null;
	        return data.cargoquery[0].title || null;
	    }).catch(function () {
	        return null;
	    });
	}
	
	/**
	 * Runs both checks lazily when the user clicks.
	 * Returns Promise<boolean>
	 */
	function runSongPageCheck() {
	    return new Promise(function (resolve) {
	
	        if (!isSongNamespace()) {
	            showInfoDialog('This helper can only be used on a Song page.');
	            resolve(false);
	            return;
	        }
	
	        fetchSongCargoEntry().then(function (row) {
	            if (!row) {
	                showInfoDialog(
	                    'This page has no entry in the Songs Cargo table.\n' +
	                    'This tool is only working on song articles that already have an {{Infobox song}} or {{Songs}} template defined.'
	                );
	                resolve(false);
	            } else {
	                resolve(true); // proceed
	            }
	        });
	    });
	}

	// --------- Client-side Song-template checks (no server queries) ---------
	
	// Check HTML comments for template usage (works only on view pages)
	function hasSongTemplate_Comment() {
	    var html = document.documentElement.innerHTML;
	    // Cheapest possible substring check:
	    return html.indexOf(" Template:Infobox_song") !== -1;
	}
	
	// Check categories for Song-category membership
	function hasSongCategory() {
	    // Example: Template:Song adds a category like "Songs" or "Song pages"
	    // Adjust the target category name if needed:
	    var CAT_NAME = "Songs"; // the part after the colon in "Category:Songs"
	
	    var links = document.querySelectorAll('#mw-normal-catlinks a, #mw-hidden-catlinks a');
	    for (var i = 0; i < links.length; i++) {
	        var href = links[i].getAttribute('href') || "";
	        if (href.endsWith("/" + CAT_NAME.replace(/ /g, "_"))) {
	            return true;
	        }
	    }
	    console.log('Cargo Helper: no Songs category found');
	    return false;
	}
	
	// Combined “isSongPage” check, using only client-side methods
	function isSongPage_ClientOnly() {
	    // Only valid in view mode; in edit mode we fall back to wikitext check
	    var action = mw.config.get('wgAction');
	
	    if (action === 'view') {
	        if (hasSongTemplate_Comment()) return true;
	        if (hasSongCategory()) return true;
	        return false;
	    }
	
	    if (action === 'edit') {
	        // Fallback for edit mode: detect via raw wikitext
	        var ta = document.getElementById('wpTextbox1');
	        if (!ta) return false;
	        var txt = ta.value;
	        return /\{\{\s*Infobox song\b/i.test(txt);
	    }
	
	    return false;
	}

	function addActionLink() {
	
	    // Simple namespace check (cheap)
	    var ns = mw.config.get('wgNamespaceNumber');
	    var title = mw.config.get('wgPageName') || '';
	    if (ns !== 0 || !title || title.indexOf(':') !== -1) {
	    	console.log('Cargo Helper: not in main namespace');
	        return; // Do not show link outside mainspace
	    }
	
	    // Now apply your chosen client-side checks
	    if (!isSongPage_ClientOnly()) {
	    	console.log('Cargo Helper: no Songs template or cateegory found')
	        return; // Do not show link if no Song template or category detected
	    }
	
	    // Preferred location: "Page tools" portlet
	    // Vector uses p-views; Timeless uses .mw-page-actions; Legacy uses p-cactions
	    var link,
	        portletIdCandidates = [ 'p-views', 'p-cactions' ];
	
	    for (var i = 0; i < portletIdCandidates.length && !link; i++) {
	        link = mw.util.addPortletLink(
	            portletIdCandidates[i],
	            '#',
	            'Add video',
	            'ca-add-video',
	            'Add a video / performance entry'
	        );
	    }
	
	    // If for some reason none of the above exist, fall back to injecting into any visible .mw-page-actions
	    if (!link) {
	        var container = document.querySelector('.mw-page-actions, #p-views ul, #p-cactions ul');
	        if (container) {
	            var li = document.createElement('li');
	            var a = document.createElement('a');
	            a.href = '#';
	            a.id = 'ca-add-video';
	            a.textContent = 'Add video';
	            li.appendChild(a);
	            container.appendChild(li);
	            link = a;
	        }
	    }
	
	    // If still no link, stop
	    if (!link) return;
	
	    // Lazy: Only now perform the expensive Cargo check
	    $(link).on('click', function (e) {
	        e.preventDefault();
	        runSongPageCheck().then(function (allowed) {
	            if (allowed) onAddClick();
	        });
	    });
	}

    // bootstrap
    addActionLink();

}); // end mw.loader.using
}());