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();
}
})();