User:Asked42/LeadManager.js
Appearance
Note: After saving, you may have to bypass your browser's cache to see the changes. Mozilla / Firefox / Safari: hold down Shift while clicking Reload, or press Ctrl-Shift-R (Cmd-Shift-R on Apple Mac); IE: hold Ctrl while clicking Refresh, or press Ctrl-F5; Konqueror: simply click the Reload button, or press F5; Opera users may need to completely clear their cache in Tools→Preferences. — More skins
// <nowiki>
if (typeof leadManager === 'undefined') {
var leadManager = {};
}
leadManager.api = new mw.Api();
mw.loader.load('//en.wikinews.org/w/index.php?title=User:Bawolff/sanbox/leadGenerator.js&action=raw&ctype=text/javascript');
leadManager.config = {
buttonMinWidth: '140px',
categoryName: 'Published',
defaultImageSize: '150x150px (100x100px)',
updateDelay: 100,
messageDisplayTime: 3000,
leadPages: [
"Template:Lead_article_1",
"Template:Lead_article_2",
"Template:Lead_article_3",
"Template:Lead_article_4",
"Template:Lead_article_5"
],
};
leadManager.i18n = {
// Form labels
title: 'Title',
sentences: 'Number of Sentences',
image: 'Image',
summary: 'Summary',
imageSize: 'Image Size',
newsType: 'Type of News',
shortTitle: 'Short Title',
leadTemplate: 'Lead Article Template',
// Buttons
autoFetch: 'Fetch Automatically',
startUpdate: 'Start Update!',
reset: 'Start Fresh',
confirm: 'Yes, Confirm.',
cancel: 'Cancel',
ok: 'Okay',
// Messages
enterTitle: 'Please provide a title or use keywords like "ML1", "ML2"!',
enterSummary: 'Please provide a summary.',
searching: 'Searching for data...',
pageNotFound: 'Page not found.',
fetchSuccess: 'Data fetched successfully.',
fetchDescriptionSuccess: 'Description fetched successfully.',
fetchError: 'Error occurred while fetching data: ',
updateSuccess: 'Lead articles updated successfully.',
updateError: 'An error occurred while updating the lead articles.',
// Placeholders
titlePlaceholder: 'Enter title',
imagePlaceholder: 'Without "File:" namespace',
summaryPlaceholder: 'Write a short description...',
shortTitlePlaceholder: 'Short title (optional)',
// Dialog titles
updateConfirmTitle: 'Update Confirmation',
updateSuccessTitle: 'Successful Update',
// Other texts
additionalOptions: 'Additional Options',
optionsDescription: 'Options for image size, template, and updates',
singleUpdateLabel: 'Single Update',
singleUpdateDesc: 'Selecting this option will update only the selected template.',
confirmUpdateMessage: 'Are you sure you want to update the lead articles?',
explicitUpdateLabel: 'Explicit Update',
explicitUpdateDesc: 'Specify custom template shuffle pattern',
templateShufflePreview: 'Template Shuffle Preview:',
explicitUpdateFormat: 'Use "template->template" separated by commas',
explicitUpdateExamples: 'Example moves:',
explicitUpdateExample1: '• Single move: "1->2" ',
explicitUpdateExample2: '• Multiple moves: "1->2, 2->3" ',
explicitUpdateExample3: '• Complex pattern: "1->3, 3->2, 2->4" ',
imageSizeFormat: '"Size for selected template: 150x150px (others: 100x100px)',
};
leadManager.imageMap = {
"United States": "Flag of the United States.svg",
"United Kingdom": "Flag of the United Kingdom (3-5).svg",
"Canada": "Flag of Canada.svg",
"Australia": "Flag of Australia.svg",
"India": "Flag of India.svg",
"Bangladesh": "Flag of Bangladesh.svg",
"Pakistan": "Flag of Pakistan.svg",
"Sri Lanka": "Flag of Sri Lanka.svg",
"Nepal": "Flag of Nepal.svg",
"Bhutan": "Flag of Bhutan.svg",
"Maldives": "Flag of Maldives.svg",
"China": "Flag of the People's Republic of China.svg",
"Japan": "Flag of Japan.svg",
"Russia": "Flag of Russia.svg",
"Brazil": "Flag of Brazil.svg",
"Germany": "Flag of Germany.svg",
"France": "Flag of France.svg",
"Italy": "Flag of Italy.svg",
"Spain": "Flag of Spain.svg",
"Saudi Arabia": "Flag of Saudi Arabia.svg",
"Iran": "Flag of Iran.svg",
"Iraq": "Flag of Iraq.svg",
"Israel": "Flag of Israel.svg",
"Indonesia": "Flag of Indonesia.svg",
"Malaysia": "Flag of Malaysia.svg",
"Thailand": "Flag of Thailand.svg",
"South Korea": "Flag of South Korea.svg",
"North Korea": "Flag of North Korea.svg",
"South Africa": "Flag of South Africa.svg",
"Egypt": "Flag of Egypt.svg",
"Turkey": "Flag of Turkey.svg",
"Mexico": "Flag of Mexico.svg",
"Argentina": "Flag of Argentina.svg",
"Nigeria": "Flag of Nigeria.svg",
"Kenya": "Flag of Kenya.svg",
"Vietnam": "Flag of Vietnam.svg",
"Singapore": "Flag of Singapore.svg",
"Philippines": "Flag of Philippines.svg",
"Greece": "Flag of Greece.svg",
"Netherlands": "Flag of Netherlands.svg",
"Switzerland": "Flag of Switzerland.svg",
"Poland": "Flag of Poland.svg",
"Ukraine": "Flag of Ukraine.svg",
"Sweden": "Flag of Sweden.svg",
"Norway": "Flag of Norway.svg",
"Finland": "Flag of Finland.svg",
"Portugal": "Flag of Portugal.svg",
"Czech Republic": "Flag of Czech Republic.svg",
"Myanmar": "Flag of Myanmar.svg",
"Qatar": "Flag of Qatar.svg",
"United Arab Emirates": "Flag of the United Arab Emirates.svg",
"Kuwait": "Flag of Kuwait.svg",
"Denmark": "Flag of Denmark.svg",
"Belgium": "Flag of Belgium.svg",
"Ireland": "Flag of Ireland.svg",
"Austria": "Flag of Austria.svg",
"Bulgaria": "Flag of Bulgaria.svg",
"Romania": "Flag of Romania.svg",
"New Zealand": "Flag of New Zealand.svg",
"Jamaica": "Flag of Jamaica.svg",
"Belize": "Flag of Belize.svg",
"Seychelles": "Flag of Seychelles.svg",
"Fiji": "Flag of Fiji.svg",
"Papua New Guinea": "Flag of Papua New Guinea.svg",
"Samoa": "Flag of Samoa.svg",
"Vanuatu": "Flag of Vanuatu.svg",
"Crime and Law": "Scale of justice 2.svg",
"Culture and entertainment": "Munken kino (kinolerret).jpg",
"Disasters and accidents": "Blue lightbar (fire and rescue).jpg",
"Economy and business": "Exchange Money Conversion to Foreign Currency.jpg",
"Education": "Curiosity at peak.jpg",
"Environment": "Hopetoun falls.jpg",
"Health": "Star of life2.svg",
"Obituaries": "Wikinews-logo-obituaries.png",
"Politics and conflicts": "RIAN archive 828797 Mikhail Gorbachev addressing UN General Assembly session.jpg",
"Politics": "Election MG 3455.JPG",
"Elections": "Election MG 3455.JPG",
"Disasters": "Amazing-natural-disasters.jpg",
"Natural disasters": "Amazing-natural-disasters.jpg",
"Accidents": "Post-and-Grant-Avenue-Look.jpg",
"Earthquakes": "Valdivia after earthquake, 1960.jpg",
"Science": "Stylised atom with three Bohr model orbits and stylised nucleus (encircled).svg",
"Technology": "Technologie.jpg",
"Science and technology": "Wikinoticias Ciencia y Tecnología.svg",
"Sports": "Youth-soccer-indiana.jpg",
"Cricket": "Muralitharan bowling to Adam Gilchrist.jpg"
};
leadManager.helpers = {
extractFirstImage: function(content) {
const imageRegex = /\[\[(File|Image):([^\]\|]+)(?:\|[^\]]*)?\]\]/i;
const match = content.match(imageRegex);
return match ? match[2].trim() : null;
},
extractInfoboxes: function(content) {
if (!content) return [];
const infoboxes = [];
const templateRegex = /\{\{([^{}|\n]+)(?:\|[\s\S]*?)?\}\}/g;
let match;
while ((match = templateRegex.exec(content)) !== null) {
let templateName = match[1].trim();
templateName = templateName.replace(/^Template:/, '');
infoboxes.push(templateName);
}
return infoboxes;
},
extractCategories: function(content) {
if (!content) return [];
const categories = [];
const categoryRegex = /\[\[Category:([^\]\|]+)(?:\|[^\]]*)?\]\]/g;
let match;
while ((match = categoryRegex.exec(content)) !== null) {
categories.push(match[1]);
}
return categories;
},
findBackupImage: function(content, pageTitle) {
// First check infoboxes
const infoboxes = this.extractInfoboxes(content);
for (const infobox of infoboxes) {
const lowerInbox = infobox.toLowerCase();
if (leadManager.imageMap[lowerInbox] ||
Object.keys(leadManager.imageMap).find(key => key.toLowerCase() === lowerInbox)) {
return leadManager.imageMap[Object.keys(leadManager.imageMap)
.find(key => key.toLowerCase() === lowerInbox) || infobox];
}
if (Bawolff?.leadGen?.imgMap?.[lowerInbox] ||
Object.keys(Bawolff?.leadGen?.imgMap || {}).find(key => key.toLowerCase() === lowerInbox)) {
return Bawolff.leadGen.imgMap[Object.keys(Bawolff.leadGen.imgMap)
.find(key => key.toLowerCase() === lowerInbox) || infobox];
}
}
// Then check categories
const categories = this.extractCategories(content);
for (const category of categories) {
const lowerCategory = category.toLowerCase();
if (leadManager.imageMap[lowerCategory] ||
Object.keys(leadManager.imageMap).find(key => key.toLowerCase() === lowerCategory)) {
return leadManager.imageMap[Object.keys(leadManager.imageMap)
.find(key => key.toLowerCase() === lowerCategory) || category];
}
if (Bawolff?.leadGen?.imgMap?.[lowerCategory] ||
Object.keys(Bawolff?.leadGen?.imgMap || {}).find(key => key.toLowerCase() === lowerCategory)) {
return Bawolff.leadGen.imgMap[Object.keys(Bawolff.leadGen.imgMap)
.find(key => key.toLowerCase() === lowerCategory) || category];
}
}
return '';
},
detectType: function(content) {
if (!content) return 'none';
if (content.match(/\{\{[bB]reaking(?: [nN]ews)?\}\}/)) {
return 'breaking-news';
} else if (content.match(/\{\{[iI]nterview(?:\|[^}]*)?\}\}/i)) {
return 'exclusive-interview';
} else if (content.match(/\{\{[oO]riginal(?: reporting)?(?:\|[^}]*)?\}\}/i)) {
return 'original-report';
} else if (content.match(/\{\{[sS]pecial(?: [rR]eport)?(?:\|[^}]*)?\}\}/i)) {
return 'special-report';
}
return 'none';
},
cleanupContent: function(content, sentenceCount = 2) {
if (!content) return '';
let cleanText = content;
const removeComments = (text) => text.replace(/<!--[\s\S]*?-->/g, '');
const removeNoticeTemplates = (text) => text.replace(
/\{\{(?:Ombox|[Aa]mbox|[Mm]box|[Nn]otice|[Mm]essage)[^}]*?\|\s*text\s*=\s*([^}\|]+)(?:[^}]*?)\}\}/g,
'$1'
);
const fixWikilinks = (text) => text.replace(
/\{\{[Ww]\|((?:[^|{}]|\{\{[^{}]*\}\})*?)(?:\|((?:[^|{}]|\{\{[^{}]*\}\})*?))?\}\}/g,
(match, p1, p2) => (p2 ? `[[${p2}]]` : `[[${p1}]]`)
);
const removeMediaLinks = (text) =>
text.replace(/\[\[(?:File|Image|Media):[^\[\]]*?\]\]/gi, '')
.replace(/\[\[(?:File|Image|Media):(?:[^\[\]]|\[\[(?:[^\[\]]|\[\[[^\[\]]*\]\])*\]\])*\]\]/gi, '');
const removeTemplates = (text) => {
const stripTemplates = /\{\{[^\}\{]*(?:\{\{[^\}\{]*(?:\{\{[^\}\{]*(?:\{\{[^\}\{]*\}\})?\}\})?\}\})?\}\}/g;
while (text.match(/\{\{[^\}]*\}\}/)) {
text = text.replace(stripTemplates, '');
}
return text;
};
const removeCategories = (text) => text.replace(/\[\[(?:Category):[\s\S]*?\]\]/gi, '');
const cleanFormatting = (text) =>
text.replace(/'''''(.+?)'''''/g, '$1')
.replace(/'''(.+?)'''/g, '$1')
.replace(/''(.+?)''/g, '$1')
.replace(/[«»‹›『』「」]/g, '');
const handleLinks = (text) => {
const pipedLink = /\[\[(?:[^|\]]*\|)?([^\]]+)\]\]/g;
while (text.match(/\[\[/)) {
text = text.replace(pipedLink, '$1');
}
return text.replace(/\[(?:https?|ftp|gopher|irc):\/\/[^\]\s]*(?:\s+([^\]]+))?\]/g, '$1 ');
};
const removeHTMLandTables = (text) =>
text.replace(/<ref[^>]*?>[\s\S]*?<\/ref>/g, '')
.replace(/<ref[^>\/]*?\/>/g, '')
.replace(/<[^>]+>/g, '')
.replace(/\{\|[\s\S]*?\|\}/g, '');
const cleanWhitespace = (text) =>
text.replace(/(\r\n|\n|\r)/gm, ' ')
.replace(/ /g, ' ')
.replace(/&[a-z]+;/g, '')
.replace(/\s+/g, ' ')
.trim();
const removeHeaders = (text) => text.replace(/==+\s*[^=]+\s*==+/g, '');
cleanText = removeComments(cleanText);
cleanText = removeNoticeTemplates(cleanText);
cleanText = fixWikilinks(cleanText);
cleanText = removeMediaLinks(cleanText);
cleanText = removeTemplates(cleanText);
cleanText = removeCategories(cleanText);
cleanText = cleanFormatting(cleanText);
cleanText = handleLinks(cleanText);
cleanText = removeHTMLandTables(cleanText);
cleanText = cleanWhitespace(cleanText);
cleanText = removeHeaders(cleanText);
const splitIntoSentences = (text) => {
const exceptions = [
'Mr', 'Mrs', 'Ms', 'Dr', 'Prof', 'Sr', 'Jr', 'vs', 'etc',
'Inc', 'Ltd', 'Co', 'Corp', 'Ref', 'Ed', 'U.S', 'i.e', 'e.g', 'St'
];
const abbrPattern = exceptions.map(abbr =>
abbr.split('.').join('\\.?')
).join('|');
let processedText = text;
processedText = processedText.replace(
new RegExp(`"([^"]*?(${abbrPattern})\\.\\s+[^"]*?)"`, 'g'),
(match, p1, p2) => match.replace('. ', '.@@@')
);
processedText = processedText.replace(
new RegExp(`\\b(${abbrPattern})\\.\\s+(?=[A-Z][a-z]+|[0-9])`, 'g'),
'$1.@@@'
);
processedText = processedText.replace(
new RegExp(`\\b(${abbrPattern})\\.\\s+(?=[A-Z]\\.)`, 'g'),
'$1.@@@'
);
const sentences = processedText
.split(/(?<=[.!?])(?<!\.@@@)\s+(?=[A-Z])/g)
.map(sentence => sentence.replace(/@@@/g, ' '))
.filter(s => s.length > 0);
return sentences;
};
const sentences = splitIntoSentences(cleanText)
.filter(s => s.length > 0 && s.length < 500)
.slice(0, sentenceCount);
return sentences.join(' ');
}
};
mw.loader.using('@wikimedia/codex').then(function(require) {
const Vue = require('vue');
const Codex = require('@wikimedia/codex');
const mountPoint = document.body.appendChild(document.createElement('div'));
Vue.createMwApp({
data() {
return {
i18n: leadManager.i18n,
config: leadManager.config,
leadImage: '',
leadWidth: leadManager.config.defaultImageSize,
leadType: 'none',
leadTitle: '',
leadShortTitle: '',
leadSummary: '',
startPosition: 1,
sentenceCount: 2,
singleUpdate: false,
showMessage: false,
messageType: '',
messageContent: '',
showProgress: false,
showConfirmDialog: false,
showSuccessDialog: false,
isLoading: false,
typeOptions: [
{ label: 'None', value: 'none' },
{ label: 'Breaking News', value: 'breaking-news' },
{ label: 'Special Report', value: 'special-report' },
{ label: 'Original Report', value: 'original-report' },
{ label: 'Exclusive Interview', value: 'exclusive-interview' }
],
sentenceOptions: [
{ label: '1 Sentence', value: 1 },
{ label: '2 Sentences', value: 2 },
{ label: '4 Sentences', value: 4 },
{ label: '6 Sentences', value: 6 }
],
positionOptions: [
{ label: 'Lead Article 1 (Row 1, top)', value: 1 },
{ label: 'Lead Article 2 (Row 2, left)', value: 2 },
{ label: 'Lead Article 3 (Row 2, right)', value: 3 },
{ label: 'Lead Article 4 (Row 3, left)', value: 4 },
{ label: 'Lead Article 5 (Row 3, right)', value: 5 }
],
explicitUpdate: false,
moveCommandError: '',
};
},
computed: {
isFormValid: function() {
return this.leadTitle && this.leadSummary;
},
i18n() {
return leadManager.i18n;
},
config() {
return leadManager.config;
},
moveCommandPlaceholder: function() {
const pos = this.startPosition;
switch(pos) {
case 1:
return "1->2, 2->3, 3->4, 4->5";
case 2:
return "2->3, 3->4, 4->5";
case 3:
return "3->4, 4->5";
case 4:
return "4->5";
case 5:
return "5->1";
default:
return "Example: 1->2, 2->3";
}
}
},
methods: {
validateForm: async function() {
this.showMessage = false;
await new Promise(resolve => setTimeout(resolve, this.config.updateDelay));
if (!this.leadTitle) {
this.showMessage = true;
this.messageType = 'warning';
this.messageContent = this.i18n.enterTitle;
return false;
}
const activeElement = document.activeElement;
if (activeElement && activeElement.textContent === this.i18n.autoFetch) {
return true;
}
if (!this.leadSummary) {
this.showMessage = true;
this.messageType = 'warning';
this.messageContent = this.i18n.enterSummary;
return false;
}
return true;
},
handleUpdateClick: async function() {
const isValid = await this.validateForm();
if (!isValid) {
return;
}
if (this.explicitUpdate && !this.validateMoveCommand()) {
this.showMessage = true;
this.messageType = 'warning';
this.messageContent = this.moveCommandError;
return;
}
this.showConfirmDialog = true;
},
autoFetch: async function() {
this.resetMessageState();
if (!await this.validateForm()) {
return;
}
this.isLoading = true;
this.showMessage = true;
this.messageType = 'notice';
this.messageContent = this.i18n.searching;
try {
let pageTitle;
let content;
const leadMatch = this.leadTitle.match(/^Lead([1-5])$/i);
const mlMatch = this.leadTitle.match(/^ML([1-5])$/i);
if (leadMatch && (parseInt(leadMatch[1]) < 1 || parseInt(leadMatch[1]) > 5)) {
throw new Error('Lead number must be between 1 and 5');
}
if (mlMatch && (parseInt(mlMatch[1]) < 1 || parseInt(mlMatch[1]) > 5)) {
throw new Error('ML number must be between 1 and 5');
}
if (leadMatch) {
const leadNumber = parseInt(leadMatch[1]);
if (!leadManager.config.leadPages[leadNumber - 1]) {
throw new Error(`Invalid lead page configuration for Lead${leadNumber}`);
}
try {
const response = await leadManager.api.get({
action: 'query',
format: 'json',
prop: 'revisions',
titles: leadManager.config.leadPages[leadNumber - 1],
formatversion: '2',
rvprop: 'content',
rvslots: '*'
});
if (!response?.query?.pages?.[0]) {
throw new Error('Invalid API response structure');
}
const page = response.query.pages[0];
if (page.missing) {
throw new Error(this.i18n.pageNotFound);
}
content = page.revisions?.[0]?.slots?.main?.content;
if (!content) {
throw new Error('No content found in page revision');
}
const templateRegex = /\{\{Lead 2\.0\s*\n([\s\S]*?)\}\}/;
const templateMatch = content.match(templateRegex);
if (templateMatch) {
const templateContent = templateMatch[1];
const getParam = (param) => {
const regex = new RegExp(`\\|\\s*${param}\\s*=\\s*([^\\n|]+)`);
const match = templateContent.match(regex);
return match ? match[1].trim() : '';
};
this.leadImage = this.sanitizeInput(getParam('image'));
this.leadWidth = this.sanitizeInput(getParam('width')) || leadManager.config.defaultImageSize;
this.leadType = this.validateType(getParam('type'));
this.leadTitle = this.sanitizeInput(getParam('title'));
this.leadShortTitle = this.sanitizeInput(getParam('short_title'));
this.leadSummary = this.sanitizeInput(getParam('summary'));
this.showSuccess();
return;
}
} catch (error) {
console.error('Error fetching lead template:', error);
throw new Error(`Error fetching lead template: ${error.message}`);
}
}
// Handle ML fetch
if (mlMatch) {
const mlNumber = parseInt(mlMatch[1]);
try {
const categoryResponse = await leadManager.api.get({
action: 'query',
format: 'json',
list: 'categorymembers',
cmtitle: `Category:${this.config.categoryName}`,
cmsort: 'timestamp',
cmdir: 'desc',
cmlimit: mlNumber,
formatversion: '2'
});
if (!categoryResponse?.query?.categorymembers) {
throw new Error('Invalid category API response');
}
if (categoryResponse.query.categorymembers.length < mlNumber) {
throw new Error(`Not enough articles found in the category. Requested: ${mlNumber}, Found: ${categoryResponse.query.categorymembers.length}`);
}
pageTitle = categoryResponse.query.categorymembers[mlNumber - 1].title;
this.leadTitle = pageTitle;
} catch (error) {
console.error('Error fetching category members:', error);
throw new Error(`Error fetching category members: ${error.message}`);
}
}
// Fetch article content
if (mlMatch || (!leadMatch && !mlMatch)) {
pageTitle = this.leadTitle;
try {
const response = await leadManager.api.get({
action: 'query',
format: 'json',
prop: 'revisions|info',
titles: pageTitle,
formatversion: '2',
rvprop: 'content',
rvslots: '*',
redirects: 1
});
if (!response?.query?.pages?.[0]) {
throw new Error('Invalid API response structure');
}
const page = response.query.pages[0];
if (page.missing) {
throw new Error(this.i18n.pageNotFound);
}
// Update the title if it was redirected
if (response.query.redirects?.[0]) {
this.leadTitle = response.query.redirects[0].to;
}
content = page.revisions?.[0]?.slots?.main?.content;
if (!content) {
throw new Error('No content found in page revision');
}
let image = leadManager.helpers.extractFirstImage(content);
if (!image) {
image = leadManager.helpers.findBackupImage(content, this.leadTitle);
}
this.leadImage = this.sanitizeInput(image || '');
this.leadType = this.validateType(leadManager.helpers.detectType(content));
this.leadSummary = this.sanitizeInput(
leadManager.helpers.cleanupContent(content, this.sentenceCount)
);
} catch (error) {
console.error('Error fetching article content:', error);
throw new Error(`Error fetching article content: ${error.message}`);
}
}
this.showSuccess();
} catch (error) {
console.error('Error in autoFetch:', error);
this.showError(error.message);
}
},
handleConfirmDialogConfirm: async function() {
this.showConfirmDialog = false;
this.showProgress = true;
try {
const newLeadContent = this.buildTemplate({
id: this.startPosition,
image: this.leadImage,
width: this.leadWidth,
type: this.leadType,
title: this.leadTitle,
short_title: this.leadShortTitle,
summary: this.leadSummary
});
await this.updateLeadArticles(newLeadContent, this.startPosition, this.singleUpdate);
this.showProgress = false;
this.showSuccessDialog = true;
this.showMessage = true;
this.messageType = 'success';
this.messageContent = leadManager.i18n.updateSuccess;
} catch (error) {
console.error('Error updating leads:', error);
this.showProgress = false;
this.showMessage = true;
this.messageType = 'error';
this.messageContent = leadManager.i18n.updateError;
}
},
parseImageSize: function(sizeString) {
const match = sizeString.match(/^([^(]+)(?:\s*\(([^)]+)\))?/);
if (!match) return { mainSize: sizeString, otherSize: sizeString };
const mainSize = match[1].trim();
const otherSize = match[2] ? match[2].trim() : mainSize;
return { mainSize, otherSize };
},
updateLeadArticles: async function(newLeadContent, startPosition, singleUpdate) {
try {
if (!newLeadContent || typeof startPosition !== 'number' || startPosition < 1 || startPosition > 5) {
throw new Error('Invalid input parameters');
}
const delay = async (seconds) => {
return new Promise(resolve => setTimeout(resolve, seconds * 1000));
};
const fetchLeadContent = async (lead, retryCount = 3) => {
for (let i = 0; i < retryCount; i++) {
try {
const response = await Promise.race([
leadManager.api.get({
action: 'query',
format: 'json',
prop: 'revisions',
titles: lead,
formatversion: '2',
rvprop: 'content',
rvslots: '*'
}),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timeout')), 10000)
)
]);
if (!response?.query?.pages?.[0]) {
throw new Error('Invalid API response structure');
}
const page = response.query.pages[0];
if (!page?.revisions?.[0]?.slots?.main?.content) {
throw new Error('No content found in page revision');
}
return page.revisions[0].slots.main.content;
} catch (error) {
if (i === retryCount - 1) throw error;
await delay(i + 1);
}
}
};
const leadContents = await Promise.all(
leadManager.config.leadPages.map(lead => fetchLeadContent(lead))
);
const { mainSize, otherSize } = this.parseImageSize(this.leadWidth);
if (!mainSize || !otherSize) {
throw new Error('Invalid image size format');
}
const leads = leadManager.config.leadPages;
let updatedContents;
let titleMap = new Map();
const extractTitle = (content) => {
const titleMatch = content.match(/\|title\s*=\s*([^\n|]+)/);
if (!titleMatch) {
throw new Error('Failed to extract title from template');
}
return titleMatch[1].trim();
};
if (this.explicitUpdate) {
if (!this.validateMoveCommand()) {
throw new Error(this.moveCommandError || 'Invalid move command');
}
updatedContents = [...leadContents];
const moves = this.moveCommand.split(',').map(m => m.trim());
moves.forEach(move => {
const [from, to] = move.split('->').map(n => parseInt(n.trim()) - 1);
if (isNaN(from) || isNaN(to)) {
throw new Error('Invalid move command format');
}
titleMap.set(to, extractTitle(leadContents[from]));
});
titleMap.set(startPosition - 1, this.leadTitle);
moves.forEach(move => {
const [from, to] = move.split('->').map(n => parseInt(n.trim()) - 1);
let content = leadContents[from];
content = this.updateTemplateWidth(content, otherSize);
updatedContents[to] = content;
});
updatedContents[startPosition - 1] = newLeadContent;
} else if (!singleUpdate) {
updatedContents = [...leadContents];
for (let i = leads.length - 1; i > startPosition - 1; i--) {
titleMap.set(i, extractTitle(leadContents[i - 1]));
}
titleMap.set(startPosition - 1, this.leadTitle);
for (let i = leads.length - 1; i > startPosition - 1; i--) {
let content = leadContents[i - 1];
content = this.updateTemplateWidth(content, otherSize);
updatedContents[i] = content;
}
updatedContents[startPosition - 1] = newLeadContent;
} else {
updatedContents = [...leadContents];
updatedContents[startPosition - 1] = newLeadContent;
titleMap.set(startPosition - 1, this.leadTitle);
}
// Update template IDs
updatedContents = updatedContents.map((content, index) => {
return this.updateTemplateId(content, index + 1);
});
// Create edit summaries
const createEditSummary = (index) => {
const leadNumber = `Lead${index + 1}`;
const title = titleMap.get(index) || this.leadTitle;
return `Updating ${leadNumber} with [[${title}]] ([[User:Asked42/LeadManager|Lead Manager]])`;
};
for (let i = 0; i < leads.length; i++) {
let retryCount = 3;
while (retryCount > 0) {
try {
await leadManager.api.postWithToken('csrf', {
action: 'edit',
format: 'json',
title: leads[i],
formatversion: '2',
nocreate: 1,
text: updatedContents[i],
summary: createEditSummary(i)
});
break;
} catch (error) {
retryCount--;
if (retryCount === 0) {
throw new Error(`Failed to update ${leads[i]}: ${error.message}`);
}
await delay(1);
}
}
}
} catch (error) {
console.error('Error in updateLeadArticles:', error);
throw error;
}
},
sanitizeInput: function(input) {
if (!input) return '';
return input.trim()
.replace(/[<>]/g, '')
.replace(/^\s+|\s+$/g, '');
},
validateType: function(type) {
const validTypes = ['none', 'breaking-news', 'special-report', 'original-report', 'exclusive-interview'];
return validTypes.includes(type) ? type : 'none';
},
updateTemplateWidth: function(content, width) {
return content.replace(/\|width\s*=\s*[^|\n]+/, `|width = ${width}`);
},
updateTemplateId: function(content, id) {
return content.replace(/(\|id\s*=\s*)\d+/g, `$1${id}`);
},
showSuccess: async function() {
this.showMessage = false;
await new Promise(resolve => setTimeout(resolve, this.config.updateDelay));
this.isLoading = false;
this.showMessage = true;
this.messageType = 'success';
this.messageContent = this.leadImage ? this.i18n.fetchSuccess : this.i18n.fetchDescriptionSuccess;
},
showError: async function(message) {
this.showMessage = false;
await new Promise(resolve => setTimeout(resolve, this.config.updateDelay));
this.isLoading = false;
this.showMessage = true;
this.messageType = 'error';
this.messageContent = this.i18n.fetchError + message;
},
resetForm: function() {
this.leadImage = '';
this.sentenceCount = 2;
this.leadWidth = leadManager.config.defaultImageSize,
this.leadType = 'none';
this.leadTitle = '';
this.leadShortTitle = '';
this.leadSummary = '';
this.startPosition = 1;
this.singleUpdate = false;
this.explicitUpdate = false;
this.moveCommand = '',
this.moveCommandError = '';
this.showMessage = false;
this.messageType = '';
this.messageContent = '';
this.showConfirmDialog = false;
this.showSuccessDialog = false;
this.showProgress = false;
this.isLoading = false;
},
resetMessageState: function() {
this.showMessage = false;
this.messageType = '';
this.messageContent = '';
},
handleMessageDismiss: function() {
this.showMessage = false;
},
handleSuccessDialogClose: function() {
this.showSuccessDialog = false;
window.location.reload();
},
handleConfirmDialogCancel: function() {
this.showConfirmDialog = false;
},
buildTemplate: function(params) {
const { mainSize } = this.parseImageSize(params.width);
return `{{Lead 2.0
|id = ${params.id} <!-- Do not change. Each lead must have its own unique ID -->
|image = ${params.image}
|width = ${mainSize}
|type = ${params.type}
|title = ${params.title}
|short_title = ${params.short_title}
|summary = ${params.summary}
}}<noinclude>{{Lead article doc}}</noinclude>`;
},
validateMoveCommand: function() {
this.moveCommandError = '';
if (!this.explicitUpdate || !this.moveCommand.trim()) {
return true;
}
const moves = this.moveCommand.split(',').map(m => m.trim());
const usedSources = new Set();
const usedDestinations = new Set();
const templateCount = 5;
const firstMove = moves[0].split('->').map(n => parseInt(n.trim()));
if (firstMove[0] !== this.startPosition) {
this.moveCommandError = `First move must start from selected lead template number, which is ${this.startPosition}.`;
return false;
}
for (const move of moves) {
if (!/^\d+\s*->\s*\d+$/.test(move)) {
const invalidPart = move.includes('->') ?
move.split('->').find(part => !/^\s*\d+\s*$/.test(part)) :
move;
this.moveCommandError = `Invalid syntax in "${move}". Correct format: "1->2, 2->3". Issue with: "${invalidPart}"`;
return false;
}
const [from, to] = move.split('->').map(n => parseInt(n.trim()));
if (from < 1 || from > templateCount || to < 1 || to > templateCount) {
const invalidNum = from < 1 || from > templateCount ? from : to;
this.moveCommandError = `Invalid lead number "${invalidNum}". Must be between 1 and ${templateCount}`;
return false;
}
if (usedSources.has(from)) {
this.moveCommandError = `Lead ${from} is moved multiple times. Each lead can only be moved once`;
return false;
}
if (usedDestinations.has(to)) {
this.moveCommandError = `Multiple templates moved to lead ${to}. Each lead can only receive one template`;
return false;
}
usedSources.add(from);
usedDestinations.add(to);
}
for (const source of usedSources) {
if (!usedDestinations.has(source) && source !== this.startPosition) {
this.moveCommandError = `Lead ${source} would be empty after moves. Ensure all leads are filled`;
return false;
}
}
return true;
},
generateMovePreview: function() {
const totalTemplates = leadManager.config.leadPages.length;
const newContentTitle = this.leadTitle || 'New content';
let preview = {
moves: [],
method: ''
};
try {
if (this.explicitUpdate && this.moveCommand.trim()) {
preview.method = 'Dynamic: Explicit Movement';
const moves = this.moveCommand.split(',').map(m => {
const [from, to] = m.trim().split('->').map(n => parseInt(n.trim()));
return {
from: from,
to: to,
isNew: from === this.startPosition
};
});
moves.forEach(move => {
const moveText = ` Lead ${move.from} → Lead ${move.to}: Content will be moved`;
preview.moves.push(moveText);
});
preview.moves.push(`NEW CONTENT: Lead ${this.startPosition} with "${newContentTitle}"`);
} else if (this.singleUpdate) {
preview.method = 'Static: Single Template Update';
preview.moves.push(`NEW CONTENT: Lead ${this.startPosition} with "${newContentTitle}"`);
} else {
preview.method = 'Dynamic: Sequential Movement';
for (let i = totalTemplates; i > this.startPosition; i--) {
preview.moves.push(`ℹ️ Lead ${i-1} → Lead ${i}: Content will be moved`);
}
preview.moves.push(`NEW CONTENT: Lead ${this.startPosition} with "${newContentTitle}"`);
}
} catch (error) {
console.error('Error in generateMovePreview:', error);
preview.moves = ['Unable to generate movement preview'];
preview.method = 'Error';
}
return {
summary: [
`Update Method: ${preview.method}`,
'Template Shuffle Preview:',
preview.moves.join('\n')
].join('\n'),
method: preview.method,
moves: preview.moves.join('\n')
};
},
},
template: `
<div style="max-width: 500px; margin: 20px auto; padding: 20px; border: 1px solid #c8ccd1; border-radius: 0; box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);">
<cdx-label :input-id="'ml_title'">{{ i18n.title }}</cdx-label>
<div style="margin-bottom: 15px;">
<cdx-text-input id="ml_title" v-model="leadTitle" :placeholder="i18n.titlePlaceholder" required style="flex-grow: 1;" />
</div>
<cdx-label :input-id="'ml_sentences'">{{ i18n.sentences }}</cdx-label>
<div style="display: flex; gap: 10px; margin-bottom: 15px; flex-wrap: wrap;">
<cdx-select
id="ml_sentences"
v-model:selected="sentenceCount"
:menu-items="sentenceOptions"
style="flex-grow: 1; min-width: 200px;"
/>
<cdx-button @click="autoFetch" action="normal" :disabled="isLoading" :style="{ minWidth: config.buttonMinWidth }">
{{ i18n.autoFetch }}
</cdx-button>
</div>
<cdx-label :input-id="'ml_image'">{{ i18n.image }}</cdx-label>
<cdx-text-input id="ml_image" v-model="leadImage" :placeholder="i18n.imagePlaceholder" style="margin-bottom: 15px;" />
<cdx-label :input-id="'ml_summary'">{{ i18n.summary }}</cdx-label>
<cdx-textarea id="ml_summary" v-model="leadSummary" :aria-label="i18n.summary" :placeholder="i18n.summaryPlaceholder" rows="8" required style="margin-bottom: 15px;" />
<cdx-accordion>
<template #title>{{ i18n.additionalOptions }}</template>
<template #description>{{ i18n.optionsDescription }}</template>
<div style="font-size: 14px; margin-top: 20px;">
<cdx-label :input-id="'ml_width'">{{ i18n.imageSize }}</cdx-label>
<cdx-text-input
id="ml_width"
v-model="leadWidth"
:placeholder="config.defaultImageSize"
style="margin-bottom: 5px;"
/>
<div style="font-size: 12px; color: #54595d; margin-bottom: 15px;">
Format: {{ i18n.imageSizeFormat }}
</div>
<cdx-label :input-id="'ml_type'">{{ i18n.newsType }}</cdx-label>
<cdx-select id="ml_type" v-model:selected="leadType" :menu-items="typeOptions" style="margin-bottom: 15px;" />
<cdx-label :input-id="'ml_short_title'">{{ i18n.shortTitle }}</cdx-label>
<cdx-text-input id="ml_short_title" v-model="leadShortTitle" :placeholder="i18n.shortTitlePlaceholder" style="margin-bottom: 15px;" />
<cdx-label :input-id="'ml_position'">{{ i18n.leadTemplate }}</cdx-label>
<cdx-select id="ml_position" v-model:selected="startPosition" :menu-items="positionOptions" style="margin-bottom: 15px;" />
<cdx-checkbox
v-model="singleUpdate"
:disabled="explicitUpdate"
style="margin-bottom: 15px;"
>
{{ i18n.singleUpdateLabel }}
<template #description>{{ i18n.singleUpdateDesc }}</template>
</cdx-checkbox>
<cdx-checkbox
v-model="explicitUpdate"
:disabled="singleUpdate"
style="margin-bottom: 15px;"
>
{{ i18n.explicitUpdateLabel }}
<template #description>{{ i18n.explicitUpdateDesc }}</template>
</cdx-checkbox>
<template v-if="explicitUpdate">
<cdx-label :input-id="'ml_move_command'">{{ i18n.moveCommandLabel }}</cdx-label>
<div class="move-command-container" style="position: relative;">
<cdx-text-input
id="ml_move_command"
v-model="moveCommand"
:placeholder="moveCommandPlaceholder"
style="margin-bottom: 5px; font-family: monospace;"
/>
<div class="move-command-help" style="font-size: 12px; color: #54595d; margin-bottom: 15px; font-family: monospace;">
<strong>Format:</strong> {{ i18n.explicitUpdateFormat }} <br/>
<strong>{{ i18n.explicitUpdateExamples }} </strong><br/>
{{ i18n.explicitUpdateExample1 }}<br/>
{{ i18n.explicitUpdateExample2 }}<br/>
{{ i18n.explicitUpdateExample3 }}<br/>
<div v-if="moveCommandError" class="error-message" style="color: #d33; margin-top: 5px;">
{{ moveCommandError }}
</div>
</div>
</div>
</template>
</div>
</cdx-accordion>
<div style="display: flex; justify-content: space-between; margin-top: 15px;">
<cdx-button
@click="handleUpdateClick"
action="progressive"
weight="primary"
style="margin-bottom: 15px; margin-right: 5px;"
>
{{ i18n.startUpdate }}
</cdx-button>
<cdx-button @click="resetForm" action="normal" style="margin-bottom: 15px;">
{{ i18n.reset }}
</cdx-button>
</div>
<div style="margin: 15px 0;">
<cdx-progress-bar v-if="showProgress" inline />
</div>
</div>
<cdx-dialog v-if="showConfirmDialog" :open="showConfirmDialog" @close="handleConfirmDialogCancel" :title="i18n.updateConfirmTitle">
<template #default>
<p>{{ i18n.confirmUpdateMessage }}</p>
<div style="margin-top: 15px;">
<p style="font-weight: bold; margin-bottom: 5px;">Update Method: {{ generateMovePreview().method }}</p>
<p style="font-weight: bold; margin-bottom: 5px;">{{ i18n.templateShufflePreview }}</p>
<pre style="white-space: pre-line; margin: 10px 0; padding: 10px; background-color: #f8f9fa; border: 1px solid #eaecf0; border-radius: 2px;">{{ generateMovePreview().moves }}</pre>
</div>
</template>
<template #footer>
<cdx-button action="progressive" weight="primary" @click="handleConfirmDialogConfirm" style="margin-right: 10px;">
{{ i18n.confirm }}
</cdx-button>
<cdx-button action="normal" @click="handleConfirmDialogCancel">
{{ i18n.cancel }}
</cdx-button>
</template>
</cdx-dialog>
<cdx-dialog v-if="showSuccessDialog" :open="showSuccessDialog" @close="handleSuccessDialogClose" :title="i18n.updateSuccessTitle">
<template #default>
<p>{{ i18n.updateSuccess }}</p>
</template>
<template #footer>
<cdx-button action="progressive" weight="primary" @click="handleSuccessDialogClose">
{{ i18n.ok }}
</cdx-button>
</template>
</cdx-dialog>
<div v-if="showMessage" style="position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); width: 60%; z-index: 999;">
<cdx-message
:type="messageType"
:fade-in="true"
:auto-dismiss="messageType === 'success' || messageType === 'notice'"
:display-time="config.messageDisplayTime"
dismiss-button-label="Close"
@dismissed="handleMessageDismiss"
>
{{ messageContent }}
</cdx-message>
</div>
`
})
.component('cdx-text-input', Codex.CdxTextInput)
.component('cdx-textarea', Codex.CdxTextArea)
.component('cdx-select', Codex.CdxSelect)
.component('cdx-checkbox', Codex.CdxCheckbox)
.component('cdx-button', Codex.CdxButton)
.component('cdx-progress-bar', Codex.CdxProgressBar)
.component('cdx-message', Codex.CdxMessage)
.component('cdx-dialog', Codex.CdxDialog)
.component('cdx-label', Codex.CdxLabel)
.component('cdx-accordion', Codex.CdxAccordion)
.mount('#lead-manager-container');
});
// </nowiki>