Actions

MediaWiki

ChangelogEditor.js: Difference between revisions

From Project Rebearth

Created page with "(function () { 'use strict'; var DATA_PAGE = 'Module:Changelog/data.json'; var PER_PAGE = 15; var VALID_TAGS = ['New', 'Balance', 'Fix', 'QoL', 'Performance']; var entries = []; var originalJSON = ''; var currentPage = 0; var editToken = null; var editingIndex = null; var allPageTitles = []; var dirty = false; var root = document.getElementById('rb-changelog-editor'); if (!root) return; function el(tag, attrs, c..."
 
No edit summary
 
Line 25: Line 25:
                 else if (k === 'innerHTML') node.innerHTML = attrs[k];
                 else if (k === 'innerHTML') node.innerHTML = attrs[k];
                 else if (k.indexOf('on') === 0) node.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
                 else if (k.indexOf('on') === 0) node.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
                 else node.setAttribute(k, attrs[k]);
                 else if (attrs[k] != null) node.setAttribute(k, attrs[k]);
             });
             });
         }
         }

Latest revision as of 14:36, 4 March 2026

(function () {
    'use strict';

    var DATA_PAGE = 'Module:Changelog/data.json';
    var PER_PAGE = 15;
    var VALID_TAGS = ['New', 'Balance', 'Fix', 'QoL', 'Performance'];

    var entries = [];
    var originalJSON = '';
    var currentPage = 0;
    var editToken = null;
    var editingIndex = null;
    var allPageTitles = [];
    var dirty = false;

    var root = document.getElementById('rb-changelog-editor');
    if (!root) return;

    function el(tag, attrs, children) {
        var node = document.createElement(tag);
        if (attrs) {
            Object.keys(attrs).forEach(function (k) {
                if (k === 'className') node.className = attrs[k];
                else if (k === 'textContent') node.textContent = attrs[k];
                else if (k === 'innerHTML') node.innerHTML = attrs[k];
                else if (k.indexOf('on') === 0) node.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
                else if (attrs[k] != null) node.setAttribute(k, attrs[k]);
            });
        }
        if (children) {
            (Array.isArray(children) ? children : [children]).forEach(function (c) {
                if (c == null) return;
                node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
            });
        }
        return node;
    }

    function todayISO() {
        var d = new Date();
        return d.getFullYear() + '-' +
            String(d.getMonth() + 1).padStart(2, '0') + '-' +
            String(d.getDate()).padStart(2, '0');
    }

    function generateId(date) {
        var parts = date.split('-');
        var dd = parts[2], mm = parts[1], yy = parts[0].slice(2);
        var prefix = dd + mm + yy;
        var maxNum = 0;
        entries.forEach(function (e) {
            if (e.id && e.id.indexOf(prefix) === 0) {
                var num = parseInt(e.id.slice(prefix.length), 10);
                if (num > maxNum) maxNum = num;
            }
        });
        return prefix + (maxNum + 1);
    }

    function markDirty() {
        dirty = true;
        render();
    }

    function getUnsavedCount() {
        if (!dirty) return 0;
        var currentJSON = JSON.stringify(entries, null, 2);
        return currentJSON !== originalJSON ? 1 : 0;
    }

    function computeChangeSummary() {
        var orig;
        try { orig = JSON.parse(originalJSON); } catch (e) { orig = []; }
        var origMap = {};
        orig.forEach(function (e) { origMap[e.id] = JSON.stringify(e); });
        var curMap = {};
        entries.forEach(function (e) { curMap[e.id] = JSON.stringify(e); });

        var added = [], modified = [], deleted = [];
        entries.forEach(function (e) {
            if (!origMap[e.id]) added.push(e);
            else if (origMap[e.id] !== curMap[e.id]) modified.push(e);
        });
        orig.forEach(function (e) {
            if (!curMap[e.id]) deleted.push(e);
        });

        var origIds = orig.map(function (e) { return e.id; }).join(',');
        var curIds = entries.map(function (e) { return e.id; }).join(',');
        var reordered = origIds !== curIds && added.length === 0 && deleted.length === 0;

        return { added: added, modified: modified, deleted: deleted, reordered: reordered };
    }

    function apiGet(params) {
        var url = mw.util.wikiScript('api') + '?' + Object.keys(params).map(function (k) {
            return encodeURIComponent(k) + '=' + encodeURIComponent(params[k]);
        }).join('&');
        return fetch(url).then(function (r) { return r.json(); });
    }

    function fetchData() {
        var url = mw.util.wikiScript('index') + '?title=' + encodeURIComponent(DATA_PAGE) + '&action=raw';
        return fetch(url).then(function (r) {
            if (!r.ok) throw new Error('Failed to fetch data: ' + r.status);
            return r.text();
        }).then(function (text) {
            entries = JSON.parse(text);
            originalJSON = JSON.stringify(entries, null, 2);
            dirty = false;
        });
    }

    function fetchEditToken() {
        return apiGet({ action: 'query', meta: 'tokens', type: 'csrf', format: 'json' })
            .then(function (data) {
                editToken = data.query.tokens.csrftoken;
            });
    }

    function saveData(summary) {
        var content = JSON.stringify(entries, null, 2);
        var params = new URLSearchParams();
        params.append('action', 'edit');
        params.append('title', DATA_PAGE);
        params.append('text', content);
        params.append('summary', summary);
        params.append('contentformat', 'application/json');
        params.append('contentmodel', 'json');
        params.append('token', editToken);
        params.append('format', 'json');

        return fetch(mw.util.wikiScript('api'), {
            method: 'POST',
            body: params,
            credentials: 'same-origin'
        }).then(function (r) { return r.json(); }).then(function (data) {
            if (data.error) {
                if (data.error.code === 'badtoken') {
                    return fetchEditToken().then(function () {
                        params.set('token', editToken);
                        return fetch(mw.util.wikiScript('api'), {
                            method: 'POST',
                            body: params,
                            credentials: 'same-origin'
                        }).then(function (r) { return r.json(); });
                    }).then(function (data2) {
                        if (data2.error) throw new Error(data2.error.info);
                        return data2;
                    });
                }
                throw new Error(data.error.info);
            }
            originalJSON = JSON.stringify(entries, null, 2);
            dirty = false;
            return data;
        });
    }

    function searchPageTitles(query) {
        if (!query || query.length < 2) return [];
        var q = query.toLowerCase();
        return allPageTitles.filter(function (t) {
            return t.toLowerCase().indexOf(q) !== -1;
        }).slice(0, 10);
    }

    function fetchAllPageTitles() {
        var titles = [];
        function fetchBatch(apcontinue) {
            var params = {
                action: 'query',
                list: 'allpages',
                aplimit: '500',
                apnamespace: '0',
                format: 'json'
            };
            if (apcontinue) params.apcontinue = apcontinue;
            return apiGet(params).then(function (data) {
                data.query.allpages.forEach(function (p) { titles.push(p.title); });
                if (data['continue'] && data['continue'].apcontinue) {
                    return fetchBatch(data['continue'].apcontinue);
                }
                allPageTitles = titles;
            });
        }
        return fetchBatch(null);
    }

    window.addEventListener('beforeunload', function (e) {
        if (dirty) {
            e.preventDefault();
            e.returnValue = '';
        }
    });

    function render() {
        root.innerHTML = '';
        root.appendChild(renderApp());
    }

    function renderApp() {
        var wrap = el('div', { className: 'rb-ce-wrap' });
        wrap.appendChild(renderHeader());
        if (editingIndex !== null) {
            wrap.appendChild(renderForm());
        }
        wrap.appendChild(renderList());
        wrap.appendChild(renderPagination());
        return wrap;
    }

    function renderHeader() {
        var title = el('h2', null, 'Changelog Editor');
        var unsaved = dirty ? el('span', { className: 'rb-ce-badge', textContent: 'unsaved' }) : null;

        var addBtn = el('button', {
            className: 'rb-ce-btn rb-ce-btn-add',
            textContent: '+ Add Entry',
            onClick: function () {
                editingIndex = -1;
                currentPage = 0;
                render();
            }
        });

        var saveBtn = el('button', {
            className: 'rb-ce-btn rb-ce-btn-primary',
            textContent: 'Save All',
            disabled: !dirty ? 'disabled' : null,
            onClick: function () { showConfirmDialog(); }
        });

        var actions = el('div', { className: 'rb-ce-header-actions' }, [addBtn, saveBtn, unsaved]);
        return el('div', { className: 'rb-ce-header' }, [title, actions]);
    }

    function renderList() {
        var list = el('div', { className: 'rb-ce-list' });
        if (entries.length === 0) {
            list.appendChild(el('div', { className: 'rb-ce-empty', textContent: 'No changelog entries yet.' }));
            return list;
        }

        var start = currentPage * PER_PAGE;
        var end = Math.min(start + PER_PAGE, entries.length);
        for (var i = start; i < end; i++) {
            list.appendChild(renderEntry(i));
        }
        return list;
    }

    function renderEntry(idx) {
        var entry = entries[idx];
        var tag = el('span', { className: 'rb-ce-tag rb-ce-tag-' + entry.tag, textContent: entry.tag });
        var date = el('span', { className: 'rb-ce-entry-date', textContent: entry.date });
        var meta = el('div', { className: 'rb-ce-entry-meta' }, [tag, date]);

        var text = el('p', { className: 'rb-ce-entry-text', textContent: entry.text });
        var idSpan = el('span', { className: 'rb-ce-entry-id', textContent: 'ID: ' + entry.id });
        var body = el('div', { className: 'rb-ce-entry-body' }, [text, idSpan]);

        if ((entry.pages && entry.pages.length) || (entry.exclude && entry.exclude.length)) {
            var chips = el('div', { className: 'rb-ce-chips' });
            if (entry.pages) {
                entry.pages.forEach(function (p) {
                    chips.appendChild(el('span', { className: 'rb-ce-chip' }, [
                        el('span', { className: 'rb-ce-chip-label', textContent: '+' }),
                        p
                    ]));
                });
            }
            if (entry.exclude) {
                entry.exclude.forEach(function (p) {
                    chips.appendChild(el('span', { className: 'rb-ce-chip rb-ce-chip-exclude' }, [
                        el('span', { className: 'rb-ce-chip-label', textContent: '\u2212' }),
                        p
                    ]));
                });
            }
            body.appendChild(chips);
        }

        var editBtn = el('button', {
            className: 'rb-ce-btn rb-ce-btn-ghost rb-ce-btn-sm',
            textContent: 'Edit',
            onClick: function () { editingIndex = idx; render(); }
        });
        var delBtn = el('button', {
            className: 'rb-ce-btn rb-ce-btn-danger rb-ce-btn-sm',
            textContent: 'Del',
            onClick: function () {
                if (confirm('Delete this entry?')) {
                    entries.splice(idx, 1);
                    if (editingIndex === idx) editingIndex = null;
                    markDirty();
                }
            }
        });
        var upBtn = el('button', {
            className: 'rb-ce-btn rb-ce-btn-ghost rb-ce-btn-sm',
            textContent: '\u25B2',
            disabled: idx === 0 ? 'disabled' : null,
            onClick: function () {
                var tmp = entries[idx - 1];
                entries[idx - 1] = entries[idx];
                entries[idx] = tmp;
                markDirty();
            }
        });
        var downBtn = el('button', {
            className: 'rb-ce-btn rb-ce-btn-ghost rb-ce-btn-sm',
            textContent: '\u25BC',
            disabled: idx === entries.length - 1 ? 'disabled' : null,
            onClick: function () {
                var tmp = entries[idx + 1];
                entries[idx + 1] = entries[idx];
                entries[idx] = tmp;
                markDirty();
            }
        });
        var actions = el('div', { className: 'rb-ce-entry-actions' }, [upBtn, downBtn, editBtn, delBtn]);

        return el('div', { className: 'rb-ce-entry' }, [meta, body, actions]);
    }

    function renderForm() {
        var isNew = editingIndex === -1;
        var entry = isNew ? { id: '', date: todayISO(), tag: 'New', text: '', pages: [], exclude: [] } : JSON.parse(JSON.stringify(entries[editingIndex]));
        if (!entry.pages) entry.pages = [];
        if (!entry.exclude) entry.exclude = [];
        var autoId = isNew ? generateId(entry.date) : entry.id;

        var form = el('div', { className: 'rb-ce-form' });

        var dateInput = el('input', { type: 'date', value: entry.date });
        var tagSelect = el('select');
        VALID_TAGS.forEach(function (t) {
            var opt = el('option', { value: t, textContent: t });
            if (t === entry.tag) opt.selected = true;
            tagSelect.appendChild(opt);
        });
        var idInput = el('input', { type: 'text', value: isNew ? autoId : entry.id, placeholder: 'Auto-generated' });

        dateInput.addEventListener('change', function () {
            if (isNew) {
                entry.date = dateInput.value;
                idInput.value = generateId(dateInput.value);
            }
        });

        var row1 = el('div', { className: 'rb-ce-form-row' }, [
            el('div', { className: 'rb-ce-form-field' }, [el('label', null, 'Date'), dateInput]),
            el('div', { className: 'rb-ce-form-field' }, [el('label', null, 'Tag'), tagSelect]),
            el('div', { className: 'rb-ce-form-field' }, [el('label', null, 'ID'), idInput])
        ]);

        var textArea = el('textarea', { placeholder: 'Changelog text (wikitext supported)' });
        textArea.value = entry.text;
        var row2 = el('div', { className: 'rb-ce-form-row' }, [
            el('div', { className: 'rb-ce-form-field', style: 'flex:1' }, [el('label', null, 'Text'), textArea])
        ]);

        var pagesChips = createChipInput(entry.pages);
        var excludeChips = createChipInput(entry.exclude);
        var row3 = el('div', { className: 'rb-ce-form-row' }, [
            el('div', { className: 'rb-ce-form-field', style: 'flex:1' }, [el('label', null, 'Pages (manual additions)'), pagesChips.element]),
            el('div', { className: 'rb-ce-form-field', style: 'flex:1' }, [el('label', null, 'Exclude pages'), excludeChips.element])
        ]);

        var saveFormBtn = el('button', {
            className: 'rb-ce-btn rb-ce-btn-primary',
            textContent: isNew ? 'Add Entry' : 'Update Entry',
            onClick: function () {
                var newEntry = {
                    id: idInput.value.trim() || autoId,
                    date: dateInput.value,
                    tag: tagSelect.value,
                    text: textArea.value
                };
                var pages = pagesChips.getValues();
                var exclude = excludeChips.getValues();
                if (pages.length > 0) newEntry.pages = pages;
                if (exclude.length > 0) newEntry.exclude = exclude;

                if (!newEntry.text.trim()) {
                    alert('Text is required.');
                    return;
                }

                if (isNew) {
                    entries.unshift(newEntry);
                } else {
                    entries[editingIndex] = newEntry;
                }
                editingIndex = null;
                markDirty();
            }
        });
        var cancelBtn = el('button', {
            className: 'rb-ce-btn rb-ce-btn-ghost',
            textContent: 'Cancel',
            onClick: function () { editingIndex = null; render(); }
        });
        var actions = el('div', { className: 'rb-ce-form-actions' }, [saveFormBtn, cancelBtn]);

        form.appendChild(row1);
        form.appendChild(row2);
        form.appendChild(row3);
        form.appendChild(actions);
        return form;
    }

    function createChipInput(initialValues) {
        var values = (initialValues || []).slice();
        var wrap = el('div', { className: 'rb-ce-chip-input-wrap' });
        var input = el('input', { type: 'text', placeholder: 'Type page name...' });
        var dropdown = null;
        var debounceTimer = null;
        var activeIdx = -1;

        function renderChips() {
            while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
            values.forEach(function (v, i) {
                var removeBtn = el('span', {
                    className: 'rb-ce-chip-remove',
                    textContent: '\u00D7',
                    onClick: function (e) {
                        e.stopPropagation();
                        values.splice(i, 1);
                        renderChips();
                    }
                });
                wrap.appendChild(el('span', { className: 'rb-ce-chip' }, [v, removeBtn]));
            });
            wrap.appendChild(input);
        }

        function addChip(val) {
            var trimmed = val.trim();
            if (trimmed && values.indexOf(trimmed) === -1) {
                values.push(trimmed);
            }
            input.value = '';
            hideDropdown();
            renderChips();
            input.focus();
        }

        function showDropdown(items) {
            hideDropdown();
            if (items.length === 0) return;
            dropdown = el('div', { className: 'rb-ce-autocomplete' });
            activeIdx = -1;
            items.forEach(function (item, i) {
                var div = el('div', {
                    className: 'rb-ce-autocomplete-item',
                    textContent: item,
                    onClick: function () { addChip(item); }
                });
                dropdown.appendChild(div);
            });
            wrap.appendChild(dropdown);
        }

        function hideDropdown() {
            if (dropdown && dropdown.parentNode) {
                dropdown.parentNode.removeChild(dropdown);
            }
            dropdown = null;
            activeIdx = -1;
        }

        function highlightItem(idx) {
            if (!dropdown) return;
            var items = dropdown.querySelectorAll('.rb-ce-autocomplete-item');
            items.forEach(function (it, i) {
                it.classList.toggle('rb-ce-ac-active', i === idx);
            });
            activeIdx = idx;
        }

        input.addEventListener('input', function () {
            clearTimeout(debounceTimer);
            var q = input.value;
            if (q.length < 2) { hideDropdown(); return; }
            debounceTimer = setTimeout(function () {
                var results = searchPageTitles(q);
                showDropdown(results);
            }, 300);
        });

        input.addEventListener('keydown', function (e) {
            if (e.key === ',' || e.key === 'Enter') {
                e.preventDefault();
                if (activeIdx >= 0 && dropdown) {
                    var items = dropdown.querySelectorAll('.rb-ce-autocomplete-item');
                    if (items[activeIdx]) addChip(items[activeIdx].textContent);
                } else if (input.value.trim()) {
                    addChip(input.value);
                }
            } else if (e.key === 'ArrowDown' && dropdown) {
                e.preventDefault();
                var items = dropdown.querySelectorAll('.rb-ce-autocomplete-item');
                highlightItem(Math.min(activeIdx + 1, items.length - 1));
            } else if (e.key === 'ArrowUp' && dropdown) {
                e.preventDefault();
                highlightItem(Math.max(activeIdx - 1, 0));
            } else if (e.key === 'Escape') {
                hideDropdown();
            } else if (e.key === 'Backspace' && input.value === '' && values.length > 0) {
                values.pop();
                renderChips();
            }
        });

        input.addEventListener('blur', function () {
            setTimeout(hideDropdown, 200);
        });

        wrap.addEventListener('click', function () { input.focus(); });

        renderChips();
        return { element: wrap, getValues: function () { return values.slice(); } };
    }

    function renderPagination() {
        var totalPages = Math.max(1, Math.ceil(entries.length / PER_PAGE));
        var info = el('span', null, 'Page ' + (currentPage + 1) + ' of ' + totalPages + ' (' + entries.length + ' entries)');
        var prevBtn = el('button', {
            className: 'rb-ce-btn rb-ce-btn-ghost rb-ce-btn-sm',
            textContent: '\u25C0 Prev',
            disabled: currentPage === 0 ? 'disabled' : null,
            onClick: function () { currentPage--; editingIndex = null; render(); }
        });
        var nextBtn = el('button', {
            className: 'rb-ce-btn rb-ce-btn-ghost rb-ce-btn-sm',
            textContent: 'Next \u25B6',
            disabled: currentPage >= totalPages - 1 ? 'disabled' : null,
            onClick: function () { currentPage++; editingIndex = null; render(); }
        });
        return el('div', { className: 'rb-ce-pagination' }, [prevBtn, info, nextBtn]);
    }

    function showConfirmDialog() {
        var summary = computeChangeSummary();
        var lines = [];
        if (summary.added.length) lines.push(summary.added.length + ' added');
        if (summary.modified.length) lines.push(summary.modified.length + ' modified');
        if (summary.deleted.length) lines.push(summary.deleted.length + ' deleted');
        if (summary.reordered) lines.push('entries reordered');
        var changeText = lines.join(', ') || 'No detectable changes';

        var overlay = el('div', { className: 'rb-ce-overlay' });
        var dialog = el('div', { className: 'rb-ce-dialog' });

        dialog.appendChild(el('h3', null, 'Confirm Save'));

        var summaryDiv = el('div', { className: 'rb-ce-dialog-summary' });
        summaryDiv.appendChild(el('p', null, 'Changes: ' + changeText));
        if (summary.added.length) {
            var addedList = el('ul');
            summary.added.forEach(function (e) {
                addedList.appendChild(el('li', null, '+ [' + e.tag + '] ' + e.text.substring(0, 80) + (e.text.length > 80 ? '...' : '')));
            });
            summaryDiv.appendChild(addedList);
        }
        if (summary.deleted.length) {
            var delList = el('ul');
            summary.deleted.forEach(function (e) {
                delList.appendChild(el('li', null, '\u2212 [' + e.tag + '] ' + e.text.substring(0, 80) + (e.text.length > 80 ? '...' : '')));
            });
            summaryDiv.appendChild(delList);
        }
        dialog.appendChild(summaryDiv);

        var defaultSummary = 'Changelog: ' + changeText;
        var summaryField = el('div', { className: 'rb-ce-dialog-field' });
        summaryField.appendChild(el('label', null, 'Edit summary'));
        var summaryInput = el('input', { type: 'text', value: defaultSummary });
        summaryField.appendChild(summaryInput);
        dialog.appendChild(summaryField);

        var saving = false;
        var saveBtn = el('button', {
            className: 'rb-ce-btn rb-ce-btn-primary',
            textContent: 'Save',
            onClick: function () {
                if (saving) return;
                saving = true;
                saveBtn.disabled = true;
                saveBtn.textContent = 'Saving...';
                saveData(summaryInput.value || defaultSummary).then(function () {
                    overlay.parentNode.removeChild(overlay);
                    render();
                }).catch(function (err) {
                    saving = false;
                    saveBtn.disabled = false;
                    saveBtn.textContent = 'Save';
                    try {
                        sessionStorage.setItem('rb-ce-backup', JSON.stringify(entries));
                    } catch (e) {}
                    alert('Save failed: ' + err.message + '\n\nYour changes have been backed up to session storage.');
                });
            }
        });
        var cancelBtn = el('button', {
            className: 'rb-ce-btn rb-ce-btn-ghost',
            textContent: 'Cancel',
            onClick: function () { overlay.parentNode.removeChild(overlay); }
        });
        dialog.appendChild(el('div', { className: 'rb-ce-dialog-actions' }, [cancelBtn, saveBtn]));

        overlay.appendChild(dialog);
        overlay.addEventListener('click', function (e) {
            if (e.target === overlay) overlay.parentNode.removeChild(overlay);
        });
        document.body.appendChild(overlay);
        summaryInput.focus();
        summaryInput.select();
    }

    function init() {
        root.innerHTML = '<div class="rb-ce-status">Loading changelog data...</div>';

        try {
            var backup = sessionStorage.getItem('rb-ce-backup');
            if (backup) {
                if (confirm('A backup from a previous unsaved session was found. Restore it?')) {
                    entries = JSON.parse(backup);
                    dirty = true;
                    sessionStorage.removeItem('rb-ce-backup');
                    Promise.all([fetchEditToken(), fetchAllPageTitles()]).then(function () {
                        render();
                    });
                    return;
                }
                sessionStorage.removeItem('rb-ce-backup');
            }
        } catch (e) {}

        Promise.all([fetchData(), fetchEditToken(), fetchAllPageTitles()])
            .then(function () {
                render();
            })
            .catch(function (err) {
                root.innerHTML = '<div class="rb-ce-error">Failed to load: ' + err.message + '</div>';
            });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();