MediaWiki:Gadget-CargoHelperCore.js

From Angelina Jordan Wiki
Revision as of 13:42, 16 November 2025 by Most2dot0 (talk | contribs) (bugfix)

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.
/* Gadget-CargoHelperCore.js
   Heavy logic: dialog creation, Cargo queries, validation,
   insertion, form logic.
   Only loaded on demand by Gadget-CargoHelperLoader.js.
*/
( function () {
'use strict';

// At the very top of CargoHelperCore, before any async loading:
window.CargoHelper = window.CargoHelper || {};

// Create a ready promise that resolves once initialization finishes
window.CargoHelper.ready = new Promise(function(resolve, reject) {

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

        // Register dialog function
        window.CargoHelper.openDialog = 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) {
			
			    /************************************************************
			     *  WIDGET DEFINITIONS
			     ************************************************************/
			
			    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 localID = p.perfID.replace(/^.*_/, '');
			                return { data: p.perfID, label: localID + ' — ' + (p.event || '') + ' (' + (p.date || '') + ')' };
			            }),
			            placeholder: 'Choose existing performance'
			        });
			    }
			
			    // PERFORMANCE FIELDS
			    var eventInput    = new OO.ui.TextInputWidget();
			    var dateInput     = new OO.ui.TextInputWidget();
			
			    // CONTEXT: ComboBox with dynamic substring filter
			    var contextCombo = new OO.ui.ComboBoxInputWidget({
			        options: allContexts.map(function (c) { return { data: c, label: c }; }),
			        placeholder: 'Type or pick a full context string'
			    });
			
			    // Keep original as the master list
			    var originalContextOptions = allContexts.map(function (c) {
			        return { data: c, label: c };
			    });
			
				function restoreAllContextOptions() {
				    // If ComboBox supports clearOptions/addOption (some OOUI versions), use them
				    if (typeof contextCombo.clearOptions === 'function' && typeof contextCombo.addOption === 'function') {
				        contextCombo.clearOptions();
				        originalContextOptions.forEach(function (opt) {
				            contextCombo.addOption({ data: opt.data, label: opt.label });
				        });
				    } else {
				        // Fallback: rebuild the internal menu only (works across OOUI versions)
				        var menu = contextCombo.getMenu();
				        if (menu && typeof menu.clearItems === 'function' && typeof menu.addItems === 'function') {
				            menu.clearItems();
				            originalContextOptions.forEach(function (opt) {
				                menu.addItems([
				                    new OO.ui.MenuOptionWidget({
				                        data: opt.data,
				                        label: opt.label
				                    })
				                ]);
				            });
				        }
				    }
				
				    // Ensure the visible input text still matches the underlying value (if any)
				    var cur = contextCombo.getValue();
				    if (cur) {
				        // sometimes ComboBox needs the raw input set as well
				        if (contextCombo.$input && contextCombo.$input.length) {
				            contextCombo.$input.val(cur);
				        }
				    }
				}
			
			    // TYPE
			    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 });
			
			    // VIDEO
			    var urlInput      = new OO.ui.TextInputWidget();
			    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 });
			
			    /************************************************************
			     *  CONTEXT FILTERING (fix)
			     ************************************************************/
				// Use the real input event and rebuild the internal OOUI menu reliably
				contextCombo.$input.on('input', function () {
				    var value = (contextCombo.getValue() || '').toLowerCase();
				    var menu = contextCombo.getMenu();
				
				    // Rebuild menu items from the master list
				    menu.clearItems();
				
				    originalContextOptions.forEach(function (opt) {
				        if (opt.data.toLowerCase().indexOf(value) !== -1) {
				            menu.addItems([
				                new OO.ui.MenuOptionWidget({
				                    data: opt.data,
				                    label: opt.label
				                })
				            ]);
				        }
				    });
				
				    // Ensure the dropdown is visible so the user sees the filtered items
				    if (!menu.isVisible()) {
				        menu.toggle(true);
				    }
				});
			
			    /************************************************************
			     *  FORM 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' })
			        ]);
			    }
			
			    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)' }),
			        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' }),
			
			        // Separator
			        new OO.ui.LabelWidget({ label: $('<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', flags: ['destructive'] });
			
			    form.$element.append(
			        $('<div>').css({ marginTop: '1em', textAlign: 'right' })
			            .append(cancelButton.$element)
			            .append(' ')
			            .append(submitButton.$element)
			    );
			
			    var panel = new OO.ui.PanelLayout({ expanded: true, padded: true, content: [ form ] });
			
			    /************************************************************
			     *  DIALOG CONSTRUCTION
			     ************************************************************/
			
			    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.static.size  = 'medium';
			
			    AddVideoDialog.prototype.initialize = function () {
			        AddVideoDialog.super.prototype.initialize.call(this);
			        this.$body.append(panel.$element);
			        this.$element.css({
			            width: '900px',
			            maxWidth: '95%',
			            left: '50%',
			            transform: 'translateX(-50%)'
			        });
			    };
			
			    var wm = new OO.ui.WindowManager();
			    $(document.body).append(wm.$element);
			    var dlg = new AddVideoDialog();
			    wm.addWindows([dlg]);
			    wm.openWindow(dlg);
			
			    cancelButton.on('click', function () {
			        wm.closeWindow(dlg);
			    });
			
			    /************************************************************
			     *  MODE SWITCHING (fixed with context restore/readOnly)
			     ************************************************************/
			
			    function updateFields() {
			        var isExisting = (modeSelect.getValue() === 'existing');
			
			        // PERFORMANCE FIELDS read-only
			        eventInput.setReadOnly(isExisting);
			        dateInput.setReadOnly(isExisting);
			        posInput.setReadOnly(isExisting);
			        withInput.setReadOnly(isExisting);
			        commentPInput.setReadOnly(isExisting);
			        typeInput.setDisabled(isExisting); // dropdown cannot be readonly
			
			        // CONTEXT special handling
			        if (isExisting) {
			            contextCombo.setReadOnly(true);
			
			            if (existingPerfDropdown) {
			                var id   = existingPerfDropdown.getValue();
			                var perf = existingPerfs.find(p => p.perfID === id) || {};
			
			                eventInput.setValue(perf.event || '');
			                dateInput.setValue(perf.date || '');
			
			                restoreAllContextOptions();
			                contextCombo.setValue(perf.contextstr || '');
			
			                typeInput.setValue(perf.type || '');
			                posInput.setValue(perf.pos || '');
			                withInput.setValue(perf.with || '');
			                commentPInput.setValue(perf.comment || '');
			            }
			        } else {
			            // NEW PERFORMANCE MODE
			            contextCombo.setReadOnly(false);
			            restoreAllContextOptions();
			        }
			
			        // Disable/enable existing perf dropdown
			        if (existingPerfDropdown) {
			            existingPerfDropdown.setDisabled(!isExisting);
			        }
			    }
			
			    modeSelect.on('change', updateFields);
			    if (existingPerfDropdown) {
			        existingPerfDropdown.on('change', updateFields);
			    }
			
			    updateFields();
			
			    /************************************************************
			     *  SUBMIT HANDLER (unchanged)
			     ************************************************************/
			    submitButton.on('click', function () {
			        try {
			            window.CargoHelper.handleSubmit(
			                modeSelect,
			                existingPerfDropdown,
			                existingPerfs,
			                eventInput,
			                dateInput,
			                contextCombo,
			                typeInput,
			                posInput,
			                withInput,
			                commentPInput,
			                urlInput,
			                durationInput,
			                qualityInput,
			                commentVInput,
			                dlg,
			                wm
			            );
			        } catch (err) {
			            console.error('Submit handler failed:', err);
			            alert('Error while preparing insertion (see console).');
			        }
			    });
			
			} // end openDialog
		
	        //---------------------------------------------------------
	        // 1) Run initial Cargo loads + open the dialog
	        //---------------------------------------------------------
	
	        Promise.all([
	            fetchPerformancesForPage(),
	            fetchAllContexts()
	        ]).then(function (res) {
	            var existingPerfsArray = res[0] || [];
	            var allContexts = res[1] || [];
	            openDialog(existingPerfsArray, allContexts);
	        }).catch(function (err) {
	            console.error('CargoHelperCore: failed to load data: ', err);
	            alert('Failed to load data (see console).');
	        });
	
	    }; 
        // Resolve the promise now that initialization is done
        resolve(window.CargoHelper);
	
	    }).catch(function(err) {
	        reject(err);
   });
}); // mw.loader.using
}());