// <nowiki> (function() { 'use strict'; const DEBUG = true; function debug(...args) { if (DEBUG) { console.log(&
// <nowiki>
(function() {
'use strict';
const DEBUG = true;
function debug(...args) {
if (DEBUG) {
console.log('[DiffViewer]', ...args);
}
}
const CURRENT_WIKI = mw.config.get('wgServerName').replace('www.', '');
const API_ENDPOINT = mw.config.get('wgServer') + mw.config.get('wgScriptPath') + '/api.php';
const CURRENT_USER = mw.config.get('wgUserName') || 'Anonymous';
const USER_AGENT = `DiffViewer/1.0 (${mw.config.get('wgServer')}/wiki/User:${encodeURIComponent(CURRENT_USER)})`;
const API_DELAY = 1000;
const MAX_RETRIES = 5;
const RETRY_DELAYS = [30000, 60000, 120000, 300000, 600000];
let apiCallCount = 0;
let abortController = null;
let originalOrder = [];
debug('Initialized with config:', {
CURRENT_WIKI,
API_ENDPOINT,
CURRENT_USER,
USER_AGENT
});
// Check for jQuery
if (typeof $ === 'undefined' || typeof mw === 'undefined') {
console.error('DiffViewer requires jQuery and MediaWiki environment');
return;
}
// Add link to Tools menu on all pages
$(document).ready(function() {
debug('Adding portlet link to tools menu');
mw.util.addPortletLink(
'p-tb',
'/wiki/Special:BlankPage/DiffViewer',
'DiffViewer',
't-diffviewer',
'View and compare user contributions with diffs'
);
});
// Check if we're on the correct page
if (mw.config.get('wgCanonicalSpecialPageName') === 'Blankpage' &&
mw.config.get('wgPageName') === 'Special:BlankPage/DiffViewer') {
debug('On DiffViewer page, initializing');
document.title = 'DiffViewer - ' + CURRENT_WIKI;
$('#firstHeading').text('DiffViewer');
initializePage();
}
function initializePage() {
debug('initializePage called');
const html = `
<div id="diff-viewer-container">
<div id="input-section" style="margin: 20px 0; padding: 20px; border: 1px solid #ddd; border-radius: 5px;">
<h3>Fetch User Contributions</h3>
<div style="margin: 10px 0;">
<label style="display: inline-block; width: 150px;">Usernames (up to 5):</label>
<div style="display: inline-block; vertical-align: top;">
<div style="margin-bottom: 5px;">
<span class="color-indicator" style="display: inline-block; width: 20px; height: 20px; background-color: #3366cc; border: 1px solid #000; vertical-align: middle; margin-right: 5px;"></span>
<input type="text" class="username-input" data-index="0" data-bwignore="true" style="width: 280px; padding: 5px;" placeholder="Username 1">
</div>
<div style="margin-bottom: 5px;">
<span class="color-indicator" style="display: inline-block; width: 20px; height: 20px; background-color: #dc3912; border: 1px solid #000; vertical-align: middle; margin-right: 5px;"></span>
<input type="text" class="username-input" data-index="1" data-bwignore="true" style="width: 280px; padding: 5px;" placeholder="Username 2 (optional)">
</div>
<div style="margin-bottom: 5px;">
<span class="color-indicator" style="display: inline-block; width: 20px; height: 20px; background-color: #ff9900; border: 1px solid #000; vertical-align: middle; margin-right: 5px;"></span>
<input type="text" class="username-input" data-index="2" data-bwignore="true" style="width: 280px; padding: 5px;" placeholder="Username 3 (optional)">
</div>
<div style="margin-bottom: 5px;">
<span class="color-indicator" style="display: inline-block; width: 20px; height: 20px; background-color: #109618; border: 1px solid #000; vertical-align: middle; margin-right: 5px;"></span>
<input type="text" class="username-input" data-index="3" data-bwignore="true" style="width: 280px; padding: 5px;" placeholder="Username 4 (optional)">
</div>
<div style="margin-bottom: 5px;">
<span class="color-indicator" style="display: inline-block; width: 20px; height: 20px; background-color: #990099; border: 1px solid #000; vertical-align: middle; margin-right: 5px;"></span>
<input type="text" class="username-input" data-index="4" data-bwignore="true" style="width: 280px; padding: 5px;" placeholder="Username 5 (optional)">
</div>
</div>
</div>
<div style="margin: 10px 0;">
<label for="limit-input" style="display: inline-block; width: 150px;">Limit per user (0 for all):</label>
<input type="number" id="limit-input" value="50" min="0" style="width: 300px; padding: 5px;">
</div>
<div style="margin: 10px 0;">
<label for="start-timestamp" style="display: inline-block; width: 150px;">Start date:</label>
<input type="text" id="start-timestamp" style="width: 300px; padding: 5px;" placeholder="YYYY-MM-DD (optional)">
</div>
<div style="margin: 10px 0;">
<label for="end-timestamp" style="display: inline-block; width: 150px;">End date:</label>
<input type="text" id="end-timestamp" style="width: 300px; padding: 5px;" placeholder="YYYY-MM-DD (optional)">
</div>
<div style="margin: 10px 0;">
<button id="fetch-btn" style="padding: 10px 20px; font-size: 14px; cursor: pointer;">Fetch Contributions</button>
<button id="abort-btn" style="padding: 10px 20px; font-size: 14px; cursor: pointer; margin-left: 10px; display: none;">Abort</button>
</div>
<div id="progress-section" style="margin-top: 20px; display: none;">
<div id="progress-bar-container" style="width: 100%; height: 30px; background-color: #f0f0f0; border-radius: 5px; overflow: hidden;">
<div id="progress-bar" style="height: 100%; background-color: #4CAF50; width: 0%; transition: width 0.3s;"></div>
</div>
<p id="progress-text" style="margin-top: 10px;"></p>
</div>
<div id="status-section" style="margin-top: 10px; color: #666;"></div>
</div>
<div id="results-section"></div>
</div>
`;
$('#mw-content-text').html(html);
$('#fetch-btn').on('click', fetchContributions);
$('#abort-btn').on('click', abortFetch);
debug('Page initialized, event handlers attached');
}
function abortFetch() {
debug('Abort button clicked');
if (abortController) {
abortController.abort();
updateStatus('Fetch aborted by user');
debug('Fetch aborted');
}
}
function normalizeTimestamp(dateString, isEndDate = false) {
debug('normalizeTimestamp called with:', dateString, 'isEndDate:', isEndDate);
if (!dateString) return null;
dateString = dateString.trim();
// If already a full timestamp, validate and return
if (dateString.includes('T')) {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
throw new Error(`Invalid timestamp: ${dateString}`);
}
debug('Normalized full timestamp:', dateString);
return dateString;
}
// If just a date (YYYY-MM-DD), validate and add time
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
throw new Error(`Invalid date: ${dateString}. Use YYYY-MM-DD format.`);
}
const normalized = isEndDate
? `${dateString}T23:59:59Z`
: `${dateString}T00:00:00Z`;
debug('Normalized date to:', normalized);
return normalized;
}
throw new Error(`Invalid date format: ${dateString}. Use YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ`);
}
const USER_COLORS = ['#3366cc', '#dc3912', '#ff9900', '#109618', '#990099'];
async function fetchContributions() {
debug('fetchContributions called');
//make the usernames uppercase in any language. For example the Cyrillic username иван is turned into Иван
$('.username-input').each(function() {
const value = $(this).val().trim();
if (value && /^\p{Ll}/u.test(value)) {
$(this).val(value.charAt(0).toUpperCase() + value.slice(1));
}
});
const usernames = [];
$('.username-input').each(function() {
const value = $(this).val().trim();
if (value) {
usernames.push(value);
}
});
debug('Usernames collected:', usernames);
const limit = parseInt($('#limit-input').val()) || 0;
debug('Limit:', limit);
let startTimestamp, endTimestamp;
try {
startTimestamp = normalizeTimestamp($('#start-timestamp').val(), false);
endTimestamp = normalizeTimestamp($('#end-timestamp').val(), true);
debug('Timestamps - start:', startTimestamp, 'end:', endTimestamp);
} catch (error) {
debug('Timestamp validation error:', error);
alert(error.message);
return;
}
if (usernames.length === 0) {
debug('No usernames provided');
alert('Please enter at least one username');
return;
}
// Initialize abort controller early
abortController = new AbortController();
debug('AbortController initialized');
// Fetch edit counts first to check if we need confirmation
const editCounts = [];
let totalEditsToFetch = 0;
for (let i = 0; i < usernames.length; i++) {
const username = usernames[i];
debug(`Fetching edit count for user ${i + 1}/${usernames.length}:`, username);
try {
const editCount = await getEditCountFromAPI(username);
debug(`${username} has ${editCount} edits`);
editCounts.push(editCount);
const editsForThisUser = limit === 0 ? editCount : Math.min(limit, editCount);
totalEditsToFetch += editsForThisUser;
debug(`Will fetch ${editsForThisUser} edits for ${username}`);
} catch (error) {
debug(`Failed to fetch edit count for ${username}:`, error);
alert(`Could not fetch edit count for ${username}: ${error.message}`);
abortController = null;
return;
}
}
debug(`Total edits to fetch: ${totalEditsToFetch}`);
// Show confirmation dialog if fetching more than 1000 edits
if (totalEditsToFetch > 1000) {
debug('Showing confirmation dialog for large fetch');
const confirmed = confirm(
`You are about to fetch ${totalEditsToFetch} contributions.\n\n` +
`This will take a significant amount of time and make many API calls.\n\n` +
`Do you want to continue?`
);
if (!confirmed) {
debug('User cancelled large fetch');
abortController = null;
return;
}
debug('User confirmed large fetch');
}
$('#fetch-btn').prop('disabled', true);
$('#abort-btn').show();
$('#results-section').empty();
$('#progress-section').show();
apiCallCount = 0;
try {
const allContributions = [];
for (let i = 0; i < usernames.length; i++) {
const username = usernames[i];
const userColor = USER_COLORS[i];
const editCount = editCounts[i];
debug(`Processing user ${i + 1}/${usernames.length}:`, username);
updateStatus(`Edit count for ${username}: ${editCount}`);
const actualLimit = limit === 0 ? null : limit;
updateStatus(`Fetching contributions for ${username}...`);
const contributions = await getUserContributions(username, actualLimit, startTimestamp, endTimestamp, editCount);
debug(`Fetched ${contributions.length} contributions for ${username}`);
// Add username and color to each contribution
contributions.forEach(contrib => {
contrib.username = username;
contrib.userColor = userColor;
});
allContributions.push(...contributions);
updateStatus(`Fetched ${contributions.length} contributions for ${username}`);
}
debug(`Total contributions fetched: ${allContributions.length}`);
// Sort all contributions by timestamp (newest first)
allContributions.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
debug('Contributions sorted by timestamp');
updateStatus('Fetching diff content...');
await fetchDiffContent(allContributions);
debug('All diffs fetched, displaying results');
displayResults(allContributions, usernames);
updateStatus(`Complete! Total API calls: ${apiCallCount}`);
debug('Fetch complete. Total API calls:', apiCallCount);
} catch (error) {
if (error.name === 'AbortError') {
updateStatus('Fetch aborted by user');
debug('Fetch aborted by user');
} else {
updateStatus(`Error: ${error.message}`);
debug('Error during fetch:', error);
console.error(error);
}
} finally {
$('#abort-btn').hide();
$('#fetch-btn').prop('disabled', false);
debug('Fetch process ended');
}
}
async function getEditCountFromAPI(username) {
debug('getEditCountFromAPI called for:', username);
const params = {
action: 'query',
list: 'users',
ususers: username,
usprop: 'editcount',
format: 'json',
formatversion: 2
};
const queryString = Object.keys(params)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
.join('&');
const url = `${API_ENDPOINT}?${queryString}`;
debug('Fetching edit count from:', url);
for (let retry = 0; retry < MAX_RETRIES; retry++) {
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Api-User-Agent': USER_AGENT
},
signal: abortController.signal
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
debug('Edit count API response:', data);
if (data.error) {
throw new Error(data.error.info);
}
if (!data.query || !data.query.users || !data.query.users[0]) {
throw new Error('User not found');
}
const editCount = data.query.users[0].editcount;
if (typeof editCount !== 'number') {
throw new Error('Could not retrieve edit count');
}
debug(`Edit count retrieved: ${editCount}`);
return editCount;
} catch (error) {
debug(`Edit count API error (attempt ${retry + 1}):`, error);
if (error.name === 'AbortError') {
throw error;
}
if (retry < MAX_RETRIES - 1) {
const delay = RETRY_DELAYS[retry];
updateStatus(`API call failed (attempt ${retry + 1}/${MAX_RETRIES}). Retrying in ${delay / 1000} seconds...`);
await sleep(delay);
} else {
throw new Error(`API failed after ${MAX_RETRIES} retries: ${error.message}`);
}
}
}
}
async function getUserContributions(username, limit, startTimestamp, endTimestamp, editCount) {
debug('getUserContributions called:', { username, limit, startTimestamp, endTimestamp, editCount });
const params = {
action: 'query',
list: 'usercontribs',
ucuser: username,
ucprop: 'ids|title|timestamp|comment',
uclimit: 500,
format: 'json',
formatversion: 2,
maxlag: 5
};
if (startTimestamp) params.ucend = startTimestamp;
if (endTimestamp) params.ucstart = endTimestamp;
debug('API params:', params);
const contributions = [];
let continueParam = null;
while (true) {
if (abortController.signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const currentParams = { ...params };
if (continueParam) {
currentParams.uccontinue = continueParam;
}
debug('Fetching batch with continue:', continueParam);
const response = await fetchWithRetry(API_ENDPOINT, currentParams);
const data = JSON.parse(response);
if (data.error) {
throw new Error(data.error.info);
}
const batch = data.query.usercontribs || [];
debug(`Received batch of ${batch.length} contributions`);
contributions.push(...batch);
// Show progress using limit if set, otherwise use editCount
const progressTotal = (limit !== null && limit > 0) ? limit : editCount;
if (progressTotal > 0) {
updateProgress(contributions.length, progressTotal, `Fetching ${username}`);
}
if (!data.continue || (limit !== null && contributions.length >= limit)) {
debug('No more contributions or limit reached');
break;
}
continueParam = data.continue.uccontinue;
}
const finalContributions = limit !== null && limit > 0 ? contributions.slice(0, limit) : contributions;
debug(`Returning ${finalContributions.length} contributions for ${username}`);
return finalContributions;
}
async function fetchDiffContent(entries) {
debug(`fetchDiffContent called for ${entries.length} entries`);
const total = entries.length;
for (let i = 0; i < entries.length; i++) {
if (abortController.signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const entry = entries[i];
debug(`Fetching diff ${i + 1}/${total} for revid:`, entry.revid);
try {
const params = {
action: 'compare',
fromrev: entry.revid,
torelative: 'prev',
format: 'json',
formatversion: 2,
maxlag: 5
};
const response = await fetchWithRetry(API_ENDPOINT, params);
const data = JSON.parse(response);
if (data.error) {
entry.diffContent = `Error: ${data.error.info}`;
debug('Diff fetch error:', data.error.info);
} else if (data.compare && data.compare.body) {
entry.diffContent = data.compare.body;
debug('Diff content retrieved successfully');
} else {
entry.diffContent = 'No diff available';
debug('No diff available');
}
} catch (error) {
entry.diffContent = `Error: ${error.message}`;
debug('Diff fetch exception:', error);
}
updateProgress(i + 1, total, 'Fetching diffs');
}
debug('All diffs fetched');
}
async function fetchWithRetry(url, params) {
const queryString = Object.keys(params)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
.join('&');
const fullUrl = `${url}?${queryString}`;
debug('fetchWithRetry:', fullUrl);
for (let retry = 0; retry < MAX_RETRIES; retry++) {
try {
await sleep(API_DELAY);
const response = await makeApiCall(fullUrl);
const data = JSON.parse(response);
// Check for maxlag error
if (data.error && data.error.code === 'maxlag') {
const lagSeconds = data.error.lag || 5;
const waitTime = Math.min(lagSeconds * 1000, RETRY_DELAYS[retry]);
debug(`Database lag detected: ${lagSeconds}s, waiting ${waitTime}ms`);
updateStatus(`Database lag detected (${lagSeconds}s). Waiting ${waitTime / 1000}s before retry...`);
await sleep(waitTime);
continue;
}
debug('API call successful');
return response;
} catch (error) {
debug(`API call error (attempt ${retry + 1}):`, error);
if (error.name === 'AbortError') {
throw error;
}
if (retry < MAX_RETRIES - 1) {
const delay = RETRY_DELAYS[retry];
updateStatus(`API call failed (attempt ${retry + 1}/${MAX_RETRIES}). Retrying in ${delay / 1000} seconds...`);
await sleep(delay);
} else {
throw new Error(`API call failed after ${MAX_RETRIES} retries: ${error.message}`);
}
}
}
}
async function makeApiCall(url) {
apiCallCount++;
debug(`Making API call #${apiCallCount}:`, url);
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Api-User-Agent': USER_AGENT
},
signal: abortController.signal
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
}
function sleep(ms) {
debug(`Sleeping for ${ms}ms`);
return new Promise(resolve => setTimeout(resolve, ms));
}
function updateStatus(message) {
debug('Status update:', message);
$('#status-section').text(message);
}
function updateProgress(current, total, label = 'Progress') {
const percentage = Math.round((current / total) * 100);
$('#progress-bar').css('width', `${percentage}%`);
$('#progress-text').text(`${label}: ${current}/${total} (${percentage}%)`);
debug(`Progress: ${label} ${current}/${total} (${percentage}%)`);
}
// Namespace definitions
const NAMESPACES = [
{ value: 'article', label: 'Article (Main)' },
{ value: 'talk', label: 'Talk' },
{ value: 'user', label: 'User' },
{ value: 'user_talk', label: 'User Talk' },
{ value: 'wikipedia', label: 'Wikipedia' },
{ value: 'wikipedia_talk', label: 'Wikipedia Talk' },
{ value: 'file', label: 'File' },
{ value: 'file_talk', label: 'File Talk' },
{ value: 'mediawiki', label: 'MediaWiki' },
{ value: 'mediawiki_talk', label: 'MediaWiki Talk' },
{ value: 'template', label: 'Template' },
{ value: 'template_talk', label: 'Template Talk' },
{ value: 'help', label: 'Help' },
{ value: 'help_talk', label: 'Help Talk' },
{ value: 'category', label: 'Category' },
{ value: 'category_talk', label: 'Category Talk' },
{ value: 'portal', label: 'Portal' },
{ value: 'portal_talk', label: 'Portal Talk' },
{ value: 'draft', label: 'Draft' },
{ value: 'draft_talk', label: 'Draft Talk' },
{ value: 'timedtext', label: 'TimedText' },
{ value: 'timedtext_talk', label: 'TimedText Talk' },
{ value: 'module', label: 'Module' },
{ value: 'module_talk', label: 'Module Talk' }
];
function getNamespace(articleName) {
if (articleName.startsWith('Talk:')) return 'talk';
if (articleName.startsWith('User talk:')) return 'user_talk';
if (articleName.startsWith('User:')) return 'user';
if (articleName.startsWith('Wikipedia talk:')) return 'wikipedia_talk';
if (articleName.startsWith('Wikipedia:')) return 'wikipedia';
if (articleName.startsWith('File talk:')) return 'file_talk';
if (articleName.startsWith('File:')) return 'file';
if (articleName.startsWith('MediaWiki talk:')) return 'mediawiki_talk';
if (articleName.startsWith('MediaWiki:')) return 'mediawiki';
if (articleName.startsWith('Template talk:')) return 'template_talk';
if (articleName.startsWith('Template:')) return 'template';
if (articleName.startsWith('Help talk:')) return 'help_talk';
if (articleName.startsWith('Help:')) return 'help';
if (articleName.startsWith('Category talk:')) return 'category_talk';
if (articleName.startsWith('Category:')) return 'category';
if (articleName.startsWith('Portal talk:')) return 'portal_talk';
if (articleName.startsWith('Portal:')) return 'portal';
if (articleName.startsWith('Draft talk:')) return 'draft_talk';
if (articleName.startsWith('Draft:')) return 'draft';
if (articleName.startsWith('TimedText talk:')) return 'timedtext_talk';
if (articleName.startsWith('TimedText:')) return 'timedtext';
if (articleName.startsWith('Module talk:')) return 'module_talk';
if (articleName.startsWith('Module:')) return 'module';
return 'article';
}
// Filter state management
const FilterState = {
selectedNamespaces: new Set(),
selectedArticles: new Set(),
init() {
// Load from localStorage
NAMESPACES.forEach(ns => {
const stored = localStorage.getItem(`namespace_${ns.value}`);
if (stored === null || stored === 'true') {
this.selectedNamespaces.add(ns.value);
}
});
},
toggleNamespace(namespace, checked) {
if (checked) {
this.selectedNamespaces.add(namespace);
} else {
this.selectedNamespaces.delete(namespace);
}
localStorage.setItem(`namespace_${namespace}`, checked.toString());
},
toggleArticle(article, checked) {
if (checked) {
this.selectedArticles.add(article);
} else {
this.selectedArticles.delete(article);
}
localStorage.setItem(`article_${article}`, checked.toString());
},
isNamespaceSelected(namespace) {
return this.selectedNamespaces.has(namespace);
},
isArticleSelected(article) {
return this.selectedArticles.has(article);
},
setAllNamespaces(checked) {
NAMESPACES.forEach(ns => {
if (checked) {
this.selectedNamespaces.add(ns.value);
} else {
this.selectedNamespaces.delete(ns.value);
}
localStorage.setItem(`namespace_${ns.value}`, checked.toString());
});
},
setAllArticles(articles, checked) {
articles.forEach(article => {
if (checked) {
this.selectedArticles.add(article);
} else {
this.selectedArticles.delete(article);
}
localStorage.setItem(`article_${article}`, checked.toString());
});
}
};
function displayResults(contributions, usernames) {
debug('displayResults called with', contributions.length, 'contributions for', usernames.length, 'users');
const wikiServer = mw.config.get('wgServer');
const wikiScriptPath = mw.config.get('wgScriptPath');
// Initialize filter state
FilterState.init();
// Store original order
originalOrder = contributions.map((entry, index) => ({
articleName: entry.title,
timestamp: entry.timestamp,
index: index,
element: null // Will be set after row creation
}));
// Pre-calculate namespace counts
const namespaceCounts = {};
NAMESPACES.forEach(ns => namespaceCounts[ns.value] = 0);
contributions.forEach(entry => {
const namespace = getNamespace(entry.title);
namespaceCounts[namespace]++;
});
// Get unique articles
const articlesSet = new Set();
contributions.forEach(entry => articlesSet.add(entry.title));
const articles = Array.from(articlesSet).sort();
// Initialize article filter state from localStorage
articles.forEach(article => {
const stored = localStorage.getItem(`article_${article}`);
if (stored === null || stored === 'true') {
FilterState.selectedArticles.add(article);
}
});
// Create results container
const resultsSection = document.getElementById('results-section');
resultsSection.innerHTML = '';
// Add styles
const styleEl = document.createElement('style');
styleEl.textContent = `
.diff-viewer-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
.diff-viewer-table th,
.diff-viewer-table td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.diff-viewer-table th {
background-color: #f2f2f2;
font-weight: bold;
}
.diff-viewer-table tbody tr:nth-child(even):not(.pinned-row) {
background-color: #f9f9f9;
}
.metadata-cell {
width: 250px;
vertical-align: top;
word-break: break-word;
overflow-wrap: break-word;
}
.sticky-wrapper {
position: sticky;
top: 0;
}
.diff-cell {
max-width: 800px;
overflow-x: auto;
}
.diff-cell table {
table-layout: fixed;
width: 100%;
}
.diff-cell td,
.diff-cell div {
word-break: break-word;
overflow-wrap: break-word;
}
.edit-summary {
background-color: #f8f9fa;
padding: 8px;
margin-bottom: 10px;
border-left: 3px solid #0645ad;
font-style: italic;
color: #333;
}
.edit-summary-empty {
color: #999;
}
.diff-context {
background-color: #f8f9fa;
}
.diff-addedline {
background-color: #d8f6d8;
}
.diff-deletedline {
background-color: #fee;
}
.toggle-context-btn {
margin: 10px 0;
padding: 5px 10px;
cursor: pointer;
}
.hidden {
display: none !important;
}
.user-legend {
display: flex;
gap: 20px;
margin: 15px 0;
padding: 10px;
background-color: #f8f9fa;
border-radius: 5px;
}
.user-legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.user-legend-color {
width: 20px;
height: 20px;
border: 2px solid #000;
display: inline-block;
}
.row-controls {
display: flex;
flex-direction: column;
gap: 5px;
margin-top: 5px;
}
.pin-button, .collapse-button {
padding: 2px 8px;
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
background: #f8f8f8;
width: 100%;
}
.pin-button:hover, .collapse-button:hover {
background: #eee;
}
.pinned-row {
background-color: #fff8dc !important;
}
.pin-button.active {
background-color: #ffd700;
border-color: #daa520;
}
.collapsed-row td:nth-child(2) {
display: none;
}
.collapsed-row td:first-child {
border-right: none;
}
.collapsed-row .pin-button {
display: none;
}
.filter-controls {
margin: 20px 0;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: #f9f9f9;
}
.namespace-item {
margin-bottom: 5px;
white-space: nowrap;
}
.namespace-count {
color: #666;
font-size: 0.9em;
}
.namespace-checkbox:disabled + span {
color: #999;
cursor: not-allowed;
}
.filter-hidden {
display: none !important;
}
`;
document.head.appendChild(styleEl);
// Create title
const userCount = usernames.length;
const title = userCount === 1
? `Contributions for ${usernames[0]}`
: `Contributions for ${userCount} users`;
const titleEl = document.createElement('h3');
titleEl.textContent = `${title} (${contributions.length} entries total)`;
resultsSection.appendChild(titleEl);
// Add user legend if multiple users
if (userCount > 1) {
const legendDiv = document.createElement('div');
legendDiv.className = 'user-legend';
usernames.forEach((username, index) => {
const item = document.createElement('div');
item.className = 'user-legend-item';
const colorBox = document.createElement('span');
colorBox.className = 'user-legend-color';
colorBox.style.backgroundColor = USER_COLORS[index];
const nameSpan = document.createElement('span');
nameSpan.textContent = username;
item.appendChild(colorBox);
item.appendChild(nameSpan);
legendDiv.appendChild(item);
});
resultsSection.appendChild(legendDiv);
}
// Create filter controls
const filterDiv = document.createElement('div');
filterDiv.className = 'filter-controls';
const filterCounter = document.createElement('div');
filterCounter.id = 'filterCounter';
filterCounter.style.marginBottom = '15px';
filterCounter.style.fontWeight = 'bold';
filterDiv.appendChild(filterCounter);
// Namespace filter
const nsDiv = document.createElement('div');
nsDiv.style.marginBottom = '15px';
const nsControlsDiv = document.createElement('div');
nsControlsDiv.style.marginBottom = '10px';
nsControlsDiv.innerHTML = '<strong>Namespace Filter:</strong>';
const checkAllNsBtn = document.createElement('button');
checkAllNsBtn.textContent = 'Check All';
checkAllNsBtn.style.marginLeft = '10px';
checkAllNsBtn.onclick = () => {
FilterState.setAllNamespaces(true);
document.querySelectorAll('.namespace-checkbox:not(:disabled)').forEach(cb => cb.checked = true);
applyFilters();
};
const checkNoneNsBtn = document.createElement('button');
checkNoneNsBtn.textContent = 'Check None';
checkNoneNsBtn.style.marginLeft = '5px';
checkNoneNsBtn.onclick = () => {
FilterState.setAllNamespaces(false);
document.querySelectorAll('.namespace-checkbox:not(:disabled)').forEach(cb => cb.checked = false);
applyFilters();
};
nsControlsDiv.appendChild(checkAllNsBtn);
nsControlsDiv.appendChild(checkNoneNsBtn);
nsDiv.appendChild(nsControlsDiv);
// Create namespace grid
const nsGrid = document.createElement('div');
nsGrid.style.display = 'grid';
nsGrid.style.gridTemplateColumns = 'repeat(4, 1fr)';
nsGrid.style.gap = '10px';
const nsItemsPerColumn = Math.ceil(NAMESPACES.length / 4);
for (let col = 0; col < 4; col++) {
const columnDiv = document.createElement('div');
columnDiv.style.display = 'flex';
columnDiv.style.flexDirection = 'column';
columnDiv.style.gap = '5px';
for (let i = 0; i < nsItemsPerColumn; i++) {
const nsIndex = col * nsItemsPerColumn + i;
if (nsIndex >= NAMESPACES.length) break;
const ns = NAMESPACES[nsIndex];
const count = namespaceCounts[ns.value] || 0;
const isChecked = FilterState.isNamespaceSelected(ns.value);
const isDisabled = count === 0;
const itemDiv = document.createElement('div');
itemDiv.className = 'namespace-item';
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'namespace-checkbox';
checkbox.value = ns.value;
checkbox.checked = isChecked;
checkbox.disabled = isDisabled;
checkbox.onchange = (e) => {
FilterState.toggleNamespace(ns.value, e.target.checked);
applyFilters();
};
const span = document.createElement('span');
span.innerHTML = `${ns.label} <span class="namespace-count">(${count})</span>`;
label.appendChild(checkbox);
label.appendChild(document.createTextNode(' '));
label.appendChild(span);
itemDiv.appendChild(label);
columnDiv.appendChild(itemDiv);
}
nsGrid.appendChild(columnDiv);
}
nsDiv.appendChild(nsGrid);
filterDiv.appendChild(nsDiv);
// Article filter
const artDiv = document.createElement('div');
const artControlsDiv = document.createElement('div');
artControlsDiv.style.marginBottom = '10px';
artControlsDiv.innerHTML = '<strong>Article Filter:</strong>';
const checkAllArtBtn = document.createElement('button');
checkAllArtBtn.textContent = 'Check All';
checkAllArtBtn.style.marginLeft = '10px';
checkAllArtBtn.onclick = () => {
FilterState.setAllArticles(articles, true);
document.querySelectorAll('.article-checkbox').forEach(cb => cb.checked = true);
applyFilters();
};
const checkNoneArtBtn = document.createElement('button');
checkNoneArtBtn.textContent = 'Check None';
checkNoneArtBtn.style.marginLeft = '5px';
checkNoneArtBtn.onclick = () => {
FilterState.setAllArticles(articles, false);
document.querySelectorAll('.article-checkbox').forEach(cb => cb.checked = false);
applyFilters();
};
artControlsDiv.appendChild(checkAllArtBtn);
artControlsDiv.appendChild(checkNoneArtBtn);
artDiv.appendChild(artControlsDiv);
const artList = document.createElement('div');
artList.id = 'articleList';
artList.style.minHeight = '100px';
artList.style.height = '300px';
artList.style.maxHeight = '80vh';
artList.style.overflowY = 'auto';
artList.style.border = '1px solid #eee';
artList.style.padding = '10px';
artList.style.resize = 'vertical';
const artGrid = document.createElement('div');
artGrid.style.display = 'grid';
artGrid.style.gridTemplateColumns = 'repeat(4, 1fr)';
artGrid.style.gap = '10px';
const artItemsPerColumn = Math.ceil(articles.length / 4);
for (let col = 0; col < 4; col++) {
const columnDiv = document.createElement('div');
columnDiv.style.display = 'flex';
columnDiv.style.flexDirection = 'column';
columnDiv.style.gap = '5px';
for (let i = 0; i < artItemsPerColumn; i++) {
const artIndex = col * artItemsPerColumn + i;
if (artIndex >= articles.length) break;
const article = articles[artIndex];
const namespace = getNamespace(article);
const isChecked = FilterState.isArticleSelected(article);
const itemDiv = document.createElement('div');
itemDiv.className = 'article-checkbox-container';
itemDiv.setAttribute('data-namespace', namespace);
itemDiv.style.whiteSpace = 'nowrap';
itemDiv.style.overflow = 'hidden';
itemDiv.style.textOverflow = 'ellipsis';
const label = document.createElement('label');
label.title = article;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'article-checkbox';
checkbox.value = article;
checkbox.checked = isChecked;
checkbox.onchange = (e) => {
FilterState.toggleArticle(article, e.target.checked);
applyFilters();
};
label.appendChild(checkbox);
label.appendChild(document.createTextNode(` ${article}`));
itemDiv.appendChild(label);
columnDiv.appendChild(itemDiv);
}
artGrid.appendChild(columnDiv);
}
artList.appendChild(artGrid);
artDiv.appendChild(artList);
filterDiv.appendChild(artDiv);
resultsSection.appendChild(filterDiv);
// Context toggle button
const toggleBtn = document.createElement('button');
toggleBtn.className = 'toggle-context-btn';
toggleBtn.innerHTML = '<span id="icon">👁️</span> <span id="text">Hide Context</span>';
toggleBtn.onclick = () => {
const contextElements = document.querySelectorAll('.diff-context');
const iconSpan = toggleBtn.querySelector('#icon');
const textSpan = toggleBtn.querySelector('#text');
const isVisible = contextElements.length > 0 && !contextElements[0].classList.contains('hidden');
contextElements.forEach(element => element.classList.toggle('hidden'));
if (isVisible) {
iconSpan.textContent = '👁️🗨️';
textSpan.textContent = 'Show Context';
} else {
iconSpan.textContent = '👁️';
textSpan.textContent = 'Hide Context';
}
};
resultsSection.appendChild(toggleBtn);
// Create table
const table = document.createElement('table');
table.className = 'diff-viewer-table wikitable';
const thead = document.createElement('thead');
thead.innerHTML = '<tr><th>Metadata</th><th>Diff</th></tr>';
table.appendChild(thead);
const tbody = document.createElement('tbody');
// Create rows
contributions.forEach((entry, index) => {
const cleanedDiff = cleanupDiffContent(entry.diffContent || '');
const wrappedDiff = `<table style="width:100%; table-layout: fixed;"><tr><td style="width:50%;"></td><td style="width:50%;"></td></tr>${cleanedDiff}</table>`;
const row = document.createElement('tr');
row.className = 'user-row';
const rowId = `row_${index}`;
row.setAttribute('data-row-id', rowId);
row.setAttribute('data-article', entry.title);
row.setAttribute('data-namespace', getNamespace(entry.title));
if (userCount > 1) {
row.style.borderLeft = `5px solid ${entry.userColor}`;
}
// Metadata cell
const metaCell = document.createElement('td');
metaCell.className = 'metadata-cell';
const stickyWrapper = document.createElement('div');
stickyWrapper.className = 'sticky-wrapper';
if (userCount > 1) {
const userP = document.createElement('p');
const userStrong = document.createElement('strong');
userStrong.textContent = entry.username;
userP.appendChild(userStrong);
stickyWrapper.appendChild(userP);
}
const titleP = document.createElement('p');
titleP.textContent = entry.title;
stickyWrapper.appendChild(titleP);
const linkP = document.createElement('p');
const link = document.createElement('a');
link.href = `${wikiServer}${wikiScriptPath}/index.php?diff=${entry.revid}`;
link.target = '_blank';
link.textContent = 'diff';
linkP.appendChild(link);
stickyWrapper.appendChild(linkP);
const timestampP = document.createElement('p');
timestampP.textContent = entry.timestamp;
stickyWrapper.appendChild(timestampP);
// Row controls
const controlsDiv = document.createElement('div');
controlsDiv.className = 'row-controls';
const pinBtn = document.createElement('button');
pinBtn.className = 'pin-button';
pinBtn.textContent = '[PIN]';
pinBtn.setAttribute('data-row-id', rowId);
const collapseBtn = document.createElement('button');
collapseBtn.className = 'collapse-button';
collapseBtn.textContent = '[COLLAPSE]';
collapseBtn.setAttribute('data-row-id', rowId);
controlsDiv.appendChild(pinBtn);
controlsDiv.appendChild(collapseBtn);
stickyWrapper.appendChild(controlsDiv);
metaCell.appendChild(stickyWrapper);
row.appendChild(metaCell);
// Diff cell with edit summary
const diffCell = document.createElement('td');
diffCell.className = 'diff-cell';
// Add edit summary at the top
const summaryDiv = document.createElement('div');
summaryDiv.className = 'edit-summary';
if (entry.comment && entry.comment.trim()) {
summaryDiv.textContent = entry.comment;
} else {
summaryDiv.className = 'edit-summary edit-summary-empty';
summaryDiv.textContent = '(no edit summary)';
}
diffCell.appendChild(summaryDiv);
// Add diff content below
const diffContentDiv = document.createElement('div');
diffContentDiv.innerHTML = wrappedDiff;
diffCell.appendChild(diffContentDiv);
row.appendChild(diffCell);
tbody.appendChild(row);
// Store reference in originalOrder
originalOrder[index].element = row;
// Restore pinned/collapsed state
if (localStorage.getItem(`pinned_${rowId}`) === 'true') {
row.classList.add('pinned-row');
pinBtn.classList.add('active');
pinBtn.textContent = '[PINNED]';
}
if (localStorage.getItem(`collapsed_${rowId}`) === 'true') {
row.classList.add('collapsed-row');
collapseBtn.textContent = '[EXPAND]';
}
});
table.appendChild(tbody);
resultsSection.appendChild(table);
// Event delegation for buttons
tbody.addEventListener('click', (e) => {
const target = e.target;
if (target.classList.contains('pin-button')) {
const rowId = target.getAttribute('data-row-id');
const row = tbody.querySelector(`[data-row-id="${rowId}"]`);
togglePin(row, target, rowId);
} else if (target.classList.contains('collapse-button')) {
const rowId = target.getAttribute('data-row-id');
const row = tbody.querySelector(`[data-row-id="${rowId}"]`);
toggleCollapse(row, target, rowId);
}
});
// Sort pinned rows
sortPinnedRows();
// Apply initial filters
applyFilters();
}
function applyFilters() {
let visibleCount = 0;
const rows = document.querySelectorAll('.user-row');
const totalCount = rows.length;
rows.forEach(row => {
const articleName = row.getAttribute('data-article');
const namespace = row.getAttribute('data-namespace');
const namespaceMatch = FilterState.isNamespaceSelected(namespace);
const articleMatch = FilterState.isArticleSelected(articleName);
const isVisible = namespaceMatch && articleMatch;
row.classList.toggle('filter-hidden', !isVisible);
if (isVisible) visibleCount++;
});
// Filter article checkboxes
document.querySelectorAll('.article-checkbox-container').forEach(container => {
const namespace = container.getAttribute('data-namespace');
container.classList.toggle('filter-hidden', !FilterState.isNamespaceSelected(namespace));
});
document.getElementById('filterCounter').textContent =
`Showing ${visibleCount} out of ${totalCount} entries`;
}
function togglePin(row, button, rowId) {
const isPinned = row.classList.toggle('pinned-row');
button.classList.toggle('active');
button.textContent = isPinned ? '[PINNED]' : '[PIN]';
localStorage.setItem(`pinned_${rowId}`, isPinned.toString());
sortPinnedRows();
}
function toggleCollapse(row, button, rowId) {
const isCollapsed = row.classList.toggle('collapsed-row');
button.textContent = isCollapsed ? '[EXPAND]' : '[COLLAPSE]';
localStorage.setItem(`collapsed_${rowId}`, isCollapsed.toString());
}
function sortPinnedRows() {
const tbody = document.querySelector('.diff-viewer-table tbody');
if (!tbody) return;
const pinnedRows = [];
const unpinnedRows = [];
originalOrder.forEach(item => {
const row = item.element;
if (row && row.classList.contains('pinned-row')) {
pinnedRows.push(row);
} else if (row) {
unpinnedRows.push(row);
}
});
const fragment = document.createDocumentFragment();
pinnedRows.forEach(row => fragment.appendChild(row));
unpinnedRows.forEach(row => fragment.appendChild(row));
tbody.innerHTML = '';
tbody.appendChild(fragment);
}
function cleanupDiffContent(content) {
if (!content) return '';
let cleaned = content;
// Remove moved paragraph markers
cleaned = cleaned.replace(/<a class="mw-diff-movedpara[^>]*>.*?<\/a>|<a name="movedpara_[^"]*"><\/a>/g, '');
// Remove colspan attributes
cleaned = cleaned.replace(/colspan="\d+"/g, '');
// Normalize br tags
cleaned = cleaned.replace(/<br \/>/g, '<br>');
// Remove empty diff-marker cells
cleaned = cleaned.replace(/<td\s+class\s*=\s*"diff-marker"\s*(?:data-marker\s*=\s*"[+\-−]")?\s*>\s*<\/td>/g, '');
// Add inline styles for ins tags
cleaned = cleaned.replace(
/<ins class="diffchange diffchange-inline">/g,
'<ins class="diffchange diffchange-inline" style="text-decoration: none; font-weight: bold; background-color:#a3d3ff; color:#202122; border-radius: 0.33em; padding: 0.25em 0;">'
);
// Add inline styles for del tags
cleaned = cleaned.replace(
/<del class="diffchange diffchange-inline">/g,
'<del class="diffchange diffchange-inline" style="text-decoration: none; font-weight: bold; background-color:#f8ddc3; color:#202122; border-radius: 0.33em; padding: 0.25em 0;">'
);
return cleaned;
}
})();
// </nowiki>
Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.