User:Anne drew/Linklog.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump.
This code will be executed when previewing this page.
This code will be executed when previewing this page.
Documentation for this user script can be added at User:Anne drew/Linklog.
// Linklog — GPL-3.0-only
// [[Category:Wikipedia scripts]]
// <nowiki>
(function () {
'use strict';
class ApiError extends Error {
constructor(code, message) {
super(message);
this.code = code;
this.name = 'ApiError';
}
}
function responsePromise(request) {
return new Promise((resolve, reject) => {
request.then(response => {
if (response.error) {
reject(new ApiError(response.error.code, response.error.info || response.error.code));
}
else if (response.warnings) {
// A truncated or otherwise incomplete query must never become a saved baseline.
reject(new ApiError('api-warning', 'Wikipedia returned an API warning; the scan was not saved.'));
}
else {
resolve(response);
}
}, (code, details) => {
var _a;
reject(new ApiError(code, ((_a = details === null || details === void 0 ? void 0 : details.error) === null || _a === void 0 ? void 0 : _a.info) || (details === null || details === void 0 ? void 0 : details.textStatus) || code));
});
});
}
class WikiApi {
constructor(api, username) {
this.api = api;
this.username = username;
}
params(params) {
return Object.assign(Object.assign({}, params), { format: 'json', formatversion: 2, assert: 'user', assertuser: this.username, maxlag: 5 });
}
get(params) {
return responsePromise(this.api.get(this.params(params)));
}
async pageInfo(title) {
var _a, _b, _c, _d;
const result = await this.get({ action: 'query', titles: title, prop: 'info' });
const page = (_b = (_a = result.query) === null || _a === void 0 ? void 0 : _a.pages) === null || _b === void 0 ? void 0 : _b[0];
if (((_d = (_c = result.query) === null || _c === void 0 ? void 0 : _c.pages) === null || _d === void 0 ? void 0 : _d.length) !== 1 || !page || page.invalid || typeof page.title !== 'string' ||
!Number.isSafeInteger(page.ns) || page.ns === undefined || page.ns < 0) {
throw new Error((page === null || page === void 0 ? void 0 : page.invalidreason) || 'Choose a valid Wikipedia page title.');
}
// Do not follow redirects: backlinks to a redirect title are a distinct list.
return { title: page.title, ns: page.ns, missing: page.missing, contentmodel: page.contentmodel };
}
async readPage(title) {
var _a, _b, _c, _d, _e, _f, _g;
const result = await this.get({
action: 'query', titles: title, prop: 'info|revisions',
rvprop: 'ids|content', rvslots: 'main', curtimestamp: true
});
const page = (_b = (_a = result.query) === null || _a === void 0 ? void 0 : _a.pages) === null || _b === void 0 ? void 0 : _b[0];
if (((_d = (_c = result.query) === null || _c === void 0 ? void 0 : _c.pages) === null || _d === void 0 ? void 0 : _d.length) !== 1 || !page || page.invalid || typeof page.title !== 'string' ||
!Number.isSafeInteger(page.ns) || page.ns === undefined || page.ns < 0 || !result.curtimestamp ||
Number.isNaN(Date.parse(result.curtimestamp))) {
throw new Error((page === null || page === void 0 ? void 0 : page.invalidreason) || 'Could not read the tracking page and server time.');
}
if (page.redirect)
throw new Error('The tracking page is a redirect. Choose a normal wikitext page.');
if (page.contentmodel && page.contentmodel !== 'wikitext') {
throw new Error('The tracking page must use the wikitext content model.');
}
const revision = (_e = page.revisions) === null || _e === void 0 ? void 0 : _e[0];
const text = (_g = (_f = revision === null || revision === void 0 ? void 0 : revision.slots) === null || _f === void 0 ? void 0 : _f.main) === null || _g === void 0 ? void 0 : _g.content;
if (!page.missing && (typeof text !== 'string' || !(revision === null || revision === void 0 ? void 0 : revision.revid) || !page.pageid)) {
throw new Error('The tracking page content is unavailable; no changes were made.');
}
return {
title: page.title, pageid: page.pageid, missing: page.missing === true,
text: text !== null && text !== void 0 ? text : '', revid: revision === null || revision === void 0 ? void 0 : revision.revid, startedAt: result.curtimestamp
};
}
async readTrackingPage(target, trackingTitle) {
return this.readPage(trackingTitle || `User:${this.username}/Linklog/${target}`);
}
async fetchBacklinks(title, onProgress) {
var _a;
const links = new Map();
const continuations = new Set();
let next = {};
while (next) {
const result = await this.get(Object.assign({ action: 'query', list: 'backlinks', bltitle: title, bllimit: 'max', blfilterredir: 'all' }, next));
if (!Array.isArray((_a = result.query) === null || _a === void 0 ? void 0 : _a.backlinks))
throw new Error('Wikipedia returned an incomplete backlink list.');
for (const link of result.query.backlinks) {
if (!Number.isSafeInteger(link.pageid) || link.pageid <= 0 || !Number.isInteger(link.ns) ||
typeof link.title !== 'string' || !link.title.trim()) {
throw new Error('Wikipedia returned an invalid backlink record.');
}
links.set(link.pageid, link);
}
onProgress === null || onProgress === void 0 ? void 0 : onProgress(links.size);
next = result.continue;
if (!next)
break;
const key = JSON.stringify(next);
if (!next.blcontinue || continuations.has(key)) {
throw new Error('Wikipedia returned an invalid continuation token; the scan was not saved.');
}
continuations.add(key);
}
return Array.from(links.values());
}
async fetchSourceText(pageids, onProgress) {
var _a, _b, _c, _d, _e, _f;
const sources = new Map();
const ids = Array.from(new Set(pageids));
if (!ids.every(id => Number.isSafeInteger(id) && id > 0))
throw new Error('Cannot check source text for an invalid page ID.');
for (let offset = 0; offset < ids.length; offset += 50) {
const batch = ids.slice(offset, offset + 50);
const continuations = new Set();
let next = {};
while (next) {
const result = await this.get(Object.assign({ action: 'query', prop: 'revisions', pageids: batch.join('|'), rvprop: 'ids|content', rvslots: 'main' }, next));
if (!Array.isArray((_a = result.query) === null || _a === void 0 ? void 0 : _a.pages))
throw new Error('Wikipedia returned an incomplete source-text response.');
for (const page of result.query.pages) {
const id = page.pageid;
if (id === undefined || !batch.includes(id) || page.invalid)
throw new Error('Wikipedia returned an unexpected source page.');
if (page.missing) {
sources.set(id, null);
}
else if ((_b = page.revisions) === null || _b === void 0 ? void 0 : _b.length) {
const source = (_e = (_d = (_c = page.revisions[0]) === null || _c === void 0 ? void 0 : _c.slots) === null || _d === void 0 ? void 0 : _d.main) === null || _e === void 0 ? void 0 : _e.content;
if (page.revisions.length !== 1 || !Number.isSafeInteger((_f = page.revisions[0]) === null || _f === void 0 ? void 0 : _f.revid) ||
typeof source !== 'string' || typeof page.title !== 'string' || !page.title.trim() ||
page.ns === undefined || !Number.isSafeInteger(page.ns))
throw new Error(`Source text for page ID ${id} is unavailable; no changes were made.`);
sources.set(id, { pageid: id, title: page.title, ns: page.ns, text: source });
}
}
next = result.continue;
if (next) {
const key = JSON.stringify(next);
if (!next.rvcontinue || continuations.has(key))
throw new Error('Wikipedia returned an invalid source-text continuation.');
continuations.add(key);
}
}
if (batch.some(id => !sources.has(id)))
throw new Error('Wikipedia did not return every requested source text; no changes were made.');
onProgress === null || onProgress === void 0 ? void 0 : onProgress(sources.size);
}
return sources;
}
async savePage(page, text, summary) {
var _a, _b, _c, _d;
const params = {
action: 'edit', title: page.title, text, summary, starttimestamp: page.startedAt,
watchlist: 'nochange', notminor: true, contentmodel: 'wikitext', contentformat: 'text/x-wiki'
};
if (page.missing) {
params.createonly = true;
}
else {
if (!page.revid)
throw new Error('The tracking page revision is missing.');
params.baserevid = page.revid;
params.nocreate = true;
}
// baserevid catches concurrent changes, including edits by this same account in another tab.
const result = await responsePromise(this.api.postWithToken('csrf', this.params(params)));
if (((_a = result.edit) === null || _a === void 0 ? void 0 : _a.result) !== 'Success') {
if ((_b = result.edit) === null || _b === void 0 ? void 0 : _b.captcha)
throw new ApiError('captcha', 'Wikipedia requires a CAPTCHA. Open the tracking page to resolve it, then run again.');
throw new ApiError(((_c = result.edit) === null || _c === void 0 ? void 0 : _c.code) || 'edit-failed', ((_d = result.edit) === null || _d === void 0 ? void 0 : _d.info) || 'Wikipedia did not confirm that the tracking page was saved.');
}
return !result.edit.nochange;
}
}
function normalizeNamespaceFilter(value) {
if (value === 'all')
return value;
if (Array.isArray(value) && value.every(ns => Number.isSafeInteger(ns) && ns >= 0)) {
return Array.from(new Set(value)).sort((a, b) => a - b);
}
throw new Error('namespaces must be "all" or an array of nonnegative namespace IDs, such as [0, 2, 4].');
}
function includesNamespace(filter, namespace) {
if (!Number.isSafeInteger(namespace) || namespace < 0)
return false;
return filter === 'all' || filter.includes(namespace);
}
function describeNamespaces(filter) {
if (filter === 'all')
return 'All namespaces are included.';
if (filter.length === 0)
return 'No namespaces are selected.';
return `Included namespace IDs: ${filter.join(', ')}.`;
}
function normalizeSourceText(text) {
return text.replace(/\s+/g, ' ').trim().toLowerCase();
}
function normalizeRules(value, label) {
if (!Array.isArray(value) || value.length > 100 ||
!value.every(rule => typeof rule === 'string' && rule.trim() && rule.length <= 1000 && !/[\r\n]/.test(rule))) {
throw new Error(`${label} must be a list of at most 100 nonempty, single-line rules, each at most 1,000 characters.`);
}
const unique = new Map();
for (const rule of value)
unique.set(normalizeSourceText(rule), rule.trim());
return Array.from(unique.values());
}
function normalizeExclusions(value) {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new Error('Exclusions must contain title and source-text rule lists.');
const rules = value;
return { titles: normalizeRules(rules.titles, 'Title exclusions'), text: normalizeRules(rules.text, 'Source-text exclusions') };
}
function normalizeTextMatches(value) {
return normalizeRules(value, 'Saved source-text matches').map(normalizeSourceText);
}
function matchesTitlePattern(title, pattern) {
const normalizedTitle = normalizeSourceText(title.replace(/_/g, ' '));
const parts = normalizeSourceText(pattern.replace(/_/g, ' ')).split('*');
if (parts.length === 1)
return normalizedTitle === parts[0];
if (!normalizedTitle.startsWith(parts[0]))
return false;
let position = parts[0].length;
// Literal searches avoid regex backtracking even with many wildcard segments.
for (const part of parts.slice(1, -1)) {
const found = normalizedTitle.indexOf(part, position);
if (found < 0)
return false;
position = found + part.length;
}
const last = parts[parts.length - 1];
return normalizedTitle.endsWith(last) && normalizedTitle.length - last.length >= position;
}
function matchSourceText(text, exclusions) {
const source = normalizeSourceText(text);
return exclusions.text.map(normalizeSourceText).filter(rule => source.includes(rule));
}
function includesPageTitle(namespaces, exclusions, page) {
return includesNamespace(namespaces, page.ns) && !exclusions.titles.some(pattern => matchesTitlePattern(page.title, pattern));
}
function includesBacklink(namespaces, exclusions, page) {
return includesPageTitle(namespaces, exclusions, page) &&
!exclusions.text.some(rule => { var _a; return (_a = page.textMatches) === null || _a === void 0 ? void 0 : _a.includes(normalizeSourceText(rule)); });
}
const START_MARKER = '<!-- linklog:start -->';
const END_MARKER = '<!-- linklog:end -->';
const STATE_PREFIX = '<!-- linklog:state ';
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function validDate(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value))
return false;
const date = new Date(value);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
}
function validEntry(value) {
return isRecord(value) && Number.isSafeInteger(value.pageid) && Number(value.pageid) > 0 &&
typeof value.ns === 'number' && Number.isSafeInteger(value.ns) && value.ns >= 0 &&
typeof value.title === 'string' && value.title.trim().length > 0 && validDate(value.firstSeen);
}
function readManagedBlock(text) {
if (!text.includes('<!-- linklog:'))
return null;
const start = text.indexOf(START_MARKER);
const endStart = text.indexOf(END_MARKER);
if (start < 0 || endStart <= start || text.indexOf(START_MARKER, start + 1) !== -1 ||
text.indexOf(END_MARKER, endStart + 1) !== -1) {
throw new Error('The tracking markers are damaged or duplicated. Restore them from page history before running again.');
}
const end = endStart + END_MARKER.length;
const block = text.slice(start, end);
const match = block.match(/<!-- linklog:state ([^\r\n]+?) -->/);
if (!(match === null || match === void 0 ? void 0 : match[1]) || text.split(STATE_PREFIX).length !== 2) {
throw new Error('The saved backlink history is missing or duplicated. Restore it from page history.');
}
let value;
try {
value = JSON.parse(decodeURIComponent(match[1]));
}
catch (_a) {
throw new Error('The saved backlink history cannot be read. Restore it from page history.');
}
if (!isRecord(value) || value.version !== 1 || typeof value.target !== 'string' ||
!value.target.trim() || !Array.isArray(value.entries) || !value.entries.every(validEntry)) {
throw new Error('The saved backlink history has an unsupported version or invalid records.');
}
const entries = value.entries.map(entry => (Object.assign({ pageid: entry.pageid, ns: entry.ns, title: entry.title, firstSeen: entry.firstSeen }, (entry.textMatches === undefined ? {} : { textMatches: normalizeTextMatches(entry.textMatches) }))));
if (new Set(entries.map(entry => entry.pageid)).size !== entries.length) {
throw new Error('The saved backlink history contains duplicate page IDs.');
}
if (!Array.isArray(value.targets) || !value.targets.length ||
!value.targets.every(target => typeof target === 'string' && target.trim().length > 0) ||
!value.targets.includes(value.target) || new Set(value.targets).size !== value.targets.length) {
throw new Error('The saved backlink history has invalid target pages.');
}
return {
state: {
version: 1, target: value.target, targets: value.targets, namespaces: normalizeNamespaceFilter(value.namespaces),
exclusions: normalizeExclusions(value.exclusions), entries
},
start, end
};
}
function escapeWikitext(text) {
return text.replace(/[&<>[\]{}|'\r\n]/g, character => `&#${character.charCodeAt(0)};`);
}
function wikiLink(title) {
const escaped = escapeWikitext(title);
// Leading colon makes File and Category titles ordinary links.
return `[[:${escaped}|${escaped}]]`;
}
function renderManagedBlock(state) {
const entries = state.entries.map(entry => (Object.assign({ pageid: entry.pageid, ns: entry.ns, title: entry.title, firstSeen: entry.firstSeen }, (entry.textMatches === undefined ? {} : { textMatches: normalizeTextMatches(entry.textMatches) })))).sort((left, right) => {
const a = `${left.firstSeen}\u0000${left.title}`;
const b = `${right.firstSeen}\u0000${right.title}`;
if (left.firstSeen !== right.firstSeen)
return left.firstSeen > right.firstSeen ? -1 : 1;
return a < b ? -1 : a > b ? 1 : left.pageid - right.pageid;
});
const exclusions = normalizeExclusions(state.exclusions);
const canonical = { version: state.version, target: state.target, targets: state.targets, namespaces: state.namespaces, exclusions, entries };
const visibleEntries = entries.filter(entry => includesBacklink(state.namespaces, exclusions, entry));
const lines = [
START_MARKER,
`${STATE_PREFIX}${encodeURIComponent(JSON.stringify(canonical))} -->`,
`Backlinks to ${state.targets.map(wikiLink).join(' and ')}, dated when first noticed (UTC).`,
'Only links written in a source page itself qualify; links inherited from templates are omitted.',
`This is a historical log: dates are observation dates, and entries remain if links disappear. ${describeNamespaces(state.namespaces)}`,
...['titles', 'text'].filter(key => exclusions[key].length).map(key => `Excluded ${key === 'titles' ? 'title patterns' : 'source-text snippets'}: ${exclusions[key].map(rule => `<code>${escapeWikitext(rule)}</code>`).join(', ')}.`),
'{| class="wikitable sortable"',
'! First noticed (UTC) !! Backlink'
];
for (const entry of visibleEntries) {
lines.push('|-', `| ${entry.firstSeen} || ${wikiLink(entry.title)}`);
}
if (visibleEntries.length === 0)
lines.push('|-', '| colspan="2" | No backlinks noticed with the selected filters yet.');
lines.push('|}', END_MARKER);
return lines.join('\n');
}
function replaceManagedBlock(text, state) {
const block = readManagedBlock(text);
const rendered = renderManagedBlock(state);
if (!text.trim())
return `${rendered}\n`;
if (!block)
return `${text}${text && !text.endsWith('\n\n') ? '\n\n' : ''}${rendered}\n`;
return text.slice(0, block.start) + rendered + text.slice(block.end);
}
function isScript(page) {
return page.ns === 2 && !page.missing && page.contentmodel === 'javascript';
}
async function resolveTargetGroup(api, title) {
const page = await api.pageInfo(title);
const single = { target: page.title, targets: [page.title] };
if (page.ns !== 2 || !page.title.includes('/'))
return single;
// Veracity-style pairs: User:Author/Script and User:Author/Script.js.
// Also recognize User:Author/Script.js/doc when that documentation page exists.
const base = page.title.replace(/\.js(?:\/doc)?$/, '');
const scriptTitle = base === page.title ? `${page.title}.js` : `${base}.js`;
if (base === page.title && page.contentmodel && page.contentmodel !== 'wikitext')
return single;
const script = scriptTitle === page.title ? page : await api.pageInfo(scriptTitle);
if (!isScript(script))
return single;
const documentation = page.title === base ? page : await api.pageInfo(base);
if (!documentation.missing && documentation.contentmodel !== 'wikitext')
return single;
const subpageTitle = `${script.title}/doc`;
const subpage = page.title === subpageTitle ? page : await api.pageInfo(subpageTitle);
const targets = [documentation.title, script.title];
if (!subpage.missing && subpage.contentmodel === 'wikitext')
targets.push(subpage.title);
return { target: documentation.title, targets: Array.from(new Set(targets)) };
}
// The browser supplies MediaWiki's own title parser. This canonical-title fallback keeps
// the core usable without browser globals; callers on other wikis should supply their parser.
const canonicalLinkContext = {
parseTitle: text => {
const title = text.replace(/_/g, ' ').replace(/\s+/g, ' ').trim().replace(/^:/, '').trim();
if (!title || /[<>[\]{}|#]/.test(title))
return null;
return { title, namespace: /^(?:File|Image):/.test(title) ? 6 : /^Category:/.test(title) ? 14 : 0 };
},
decodeEntity: entity => {
var _a;
const named = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ' ': ' ' };
if (named[entity])
return named[entity];
const numeric = (_a = entity.match(/^&#(x[0-9a-f]+|[0-9]+);$/i)) === null || _a === void 0 ? void 0 : _a[1];
if (!numeric)
return entity;
const code = /^x/i.test(numeric) ? parseInt(numeric.slice(1), 16) : Number(numeric);
return code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : entity;
},
supportsSubpages: namespace => namespace > 0
};
function visibleSource(source) {
return source
.replace(/<!--[\s\S]*?(?:-->|$)/g, '')
// These extension tags do not parse their contents as ordinary wikitext links.
.replace(/<(nowiki|pre|syntaxhighlight|source|math|chem|score|templatedata)\b[^>]*\/>/gi, ' ')
.replace(/<(nowiki|pre|syntaxhighlight|source|math|chem|score|templatedata)\b[^>]*>[\s\S]*?(?:<\/\1\s*>|$)/gi, ' ')
// includeonly content is not part of a template's own page view; noinclude content is.
.replace(/<includeonly\b[^>]*>[\s\S]*?(?:<\/includeonly\s*>|$)/gi, ' ')
// Link syntax inside HTML attributes is not a wikilink.
.replace(/<\/?[a-z][^>]*>/gi, ' ');
}
function absoluteTarget(text, source, context) {
let target = text.trim();
if (!context.supportsSubpages(source.ns))
return target;
if (target.startsWith('/'))
return source.title + target.replace(/\/$/, '');
if (target.startsWith('../')) {
let parent = source.title;
while (target.startsWith('../')) {
const separator = parent.lastIndexOf('/');
if (separator < 0)
return null;
parent = parent.slice(0, separator);
target = target.slice(3);
}
return parent + (target ? `/${target.replace(/\/$/, '')}` : '');
}
return target;
}
function hasDirectLink(source, page, targets, context = canonicalLinkContext) {
const wanted = new Set(targets.map(title => { var _a; return (_a = context.parseTitle(title)) === null || _a === void 0 ? void 0 : _a.title; }).filter(Boolean));
const text = visibleSource(source);
// Only literal wikilinks qualify. Bare mentions, URLs and template-generated destinations do not.
const links = /\[\[([^\][]+?)\]\]/g;
let match;
while ((match = links.exec(text))) {
const raw = match[1].split('|', 1)[0];
const decoded = raw.replace(/&(?:#[0-9]+|#x[0-9a-f]+|[a-z][a-z0-9]+);/gi, context.decodeEntity);
const destination = decoded.split('#', 1)[0].trim();
// Fragment-only links and transcluded template expressions are not explicit page destinations.
if (!destination || /[{}<>[\]|]/.test(destination))
continue;
const absolute = absoluteTarget(destination, page, context);
const title = absolute === null ? null : context.parseTitle(absolute);
if (!title || (!destination.startsWith(':') && [6, 14].includes(title.namespace)))
continue;
if (wanted.has(title.title))
return true;
}
return false;
}
function mergeBacklinks(state, backlinks, firstSeen, trackingPage, trackingPageId, textMatches = new Map(), direct = new Set()) {
var _a, _b;
const entries = new Map(state.entries
.filter(entry => entry.title !== trackingPage && entry.pageid !== trackingPageId)
.map(entry => [entry.pageid, Object.assign(Object.assign({}, entry), (textMatches.has(entry.pageid) ? { textMatches: textMatches.get(entry.pageid) } : {}))]));
let added = 0;
for (const link of backlinks) {
if (link.ns < 0 || link.title === trackingPage || link.pageid === trackingPageId) {
entries.delete(link.pageid);
continue;
}
const previous = entries.get(link.pageid);
const matches = (_a = textMatches.get(link.pageid)) !== null && _a !== void 0 ? _a : previous === null || previous === void 0 ? void 0 : previous.textMatches;
const candidate = Object.assign(Object.assign({}, link), (matches === undefined ? {} : { textMatches: matches }));
// Hide excluded history without discarding dates. Record new pages only when included.
if (!previous && (!direct.has(link.pageid) || !includesBacklink(state.namespaces, state.exclusions, candidate)))
continue;
if (!previous)
added++;
entries.set(link.pageid, Object.assign(Object.assign({}, candidate), { firstSeen: (_b = previous === null || previous === void 0 ? void 0 : previous.firstSeen) !== null && _b !== void 0 ? _b : firstSeen }));
}
// Entries absent from the latest scan remain in this historical log.
return { state: Object.assign(Object.assign({}, state), { entries: Array.from(entries.values()) }), added };
}
async function updateTrackingPage(api, targetTitle, trackingTitle, onProgress, options = {}, linkContext) {
var _a, _b, _c;
const configuredNamespaces = options.namespaces === undefined ? undefined : normalizeNamespaceFilter(options.namespaces);
const configuredExclusions = options.exclusions === undefined ? undefined : normalizeExclusions(options.exclusions);
const group = await resolveTargetGroup(api, targetTitle);
const page = await api.readTrackingPage(group.target, trackingTitle);
const previous = readManagedBlock(page.text);
if (previous) {
if (!previous.state.targets.some(target => group.targets.includes(target))) {
throw new Error(`This tracking page already tracks ${previous.state.target}. Choose a different tracking page.`);
}
// A companion page can be created or deleted between scans; keep the established group.
if (!group.targets.includes(previous.state.target))
group.target = previous.state.target;
group.targets = Array.from(new Set([...group.targets, ...previous.state.targets]));
}
if (group.targets.includes(page.title))
throw new Error('The tracking page must be different from every page whose backlinks you are tracking.');
const initial = Object.assign(Object.assign({ version: 1 }, group), { entries: (_a = previous === null || previous === void 0 ? void 0 : previous.state.entries) !== null && _a !== void 0 ? _a : [], namespaces: (_b = configuredNamespaces !== null && configuredNamespaces !== void 0 ? configuredNamespaces : previous === null || previous === void 0 ? void 0 : previous.state.namespaces) !== null && _b !== void 0 ? _b : 'all', exclusions: (_c = configuredExclusions !== null && configuredExclusions !== void 0 ? configuredExclusions : previous === null || previous === void 0 ? void 0 : previous.state.exclusions) !== null && _c !== void 0 ? _c : { titles: [], text: [] } });
const backlinks = new Map();
let checked = 0;
for (const target of group.targets) {
const batch = await api.fetchBacklinks(target, count => onProgress === null || onProgress === void 0 ? void 0 : onProgress(checked + count, 'backlinks'));
for (const link of batch)
backlinks.set(link.pageid, link);
checked += batch.length;
}
const textMatches = new Map();
const direct = new Set();
{
// Recheck history too: an absent backlink must still respond to changes in exclusion rules.
const candidates = new Map(initial.entries.map(entry => [entry.pageid, entry]));
for (const link of backlinks.values())
candidates.set(link.pageid, link);
const recorded = new Set(initial.entries.map(entry => entry.pageid));
const ids = Array.from(candidates.values()).filter(link => link.title !== page.title && link.pageid !== page.pageid &&
includesPageTitle(initial.namespaces, initial.exclusions, link) &&
(initial.exclusions.text.length || !recorded.has(link.pageid))).map(link => link.pageid);
onProgress === null || onProgress === void 0 ? void 0 : onProgress(0, 'source');
const sources = await api.fetchSourceText(ids, count => onProgress === null || onProgress === void 0 ? void 0 : onProgress(count, 'source'));
for (const [id, source] of sources) {
if (source === null) {
// Deleted pages retain their saved matches; never invent a new observation from an unreadable source.
backlinks.delete(id);
}
else {
if (!recorded.has(id) && hasDirectLink(source.text, source, group.targets, linkContext))
direct.add(id);
if (initial.exclusions.text.length)
textMatches.set(id, matchSourceText(source.text, initial.exclusions));
}
}
}
const { state, added } = mergeBacklinks(initial, Array.from(backlinks.values()), page.startedAt.slice(0, 10), page.title, page.pageid, textMatches, direct);
const text = replaceManagedBlock(page.text, state);
const total = state.entries.filter(entry => includesBacklink(state.namespaces, state.exclusions, entry)).length;
const result = { trackingPage: page.title, added, total, saved: false };
if (text === page.text)
return result;
result.saved = await api.savePage(page, text, `Linklog: ${previous ? 'add' : 'initialize with'} ${added} newly noticed backlink${added === 1 ? '' : 's'}; ${total} shown`);
return result;
}
const markup = `
<style>
#linklog-dialog { position:fixed; inset:0; z-index:10000; background:rgba(0,0,0,.4); display:flex; align-items:center; justify-content:center; padding:20px; box-sizing:border-box; }
#linklog-dialog * { box-sizing:border-box; }
#linklog-dialog [hidden] { display:none !important; }
#linklog-dialog .ll-panel { width:100%; max-width:640px; max-height:calc(100vh - 40px); overflow:hidden; display:flex; flex-direction:column; border:1px solid var(--border-color-base,#a2a9b1); border-radius:4px; background:var(--background-color-base,#fff); color:var(--color-base,#202122); box-shadow:0 8px 32px rgba(0,0,0,.22); padding:24px; font:14px/1.5 sans-serif; }
#linklog-dialog form { min-height:0; display:flex; flex-direction:column; margin:0; }
#linklog-dialog .ll-body { overflow:auto; min-height:0; padding:2px; }
#linklog-dialog h2 { font:600 22px/1.3 sans-serif; border:0; padding:0; margin:0 0 6px; }
#linklog-dialog p { margin:0 0 18px; }
#linklog-dialog .ll-muted { color:var(--color-subtle,#54595d); font-size:13px; }
#linklog-dialog .ll-field { margin:18px 0; }
#linklog-dialog .ll-label { display:block; font-weight:600; margin-bottom:6px; }
#linklog-dialog input[type=text], #linklog-dialog select, #linklog-dialog textarea { font:inherit; width:100%; min-height:36px; padding:6px 10px; border:1px solid var(--border-color-base,#a2a9b1); border-radius:2px; background:var(--background-color-base,#fff); color:inherit; }
#linklog-dialog textarea { resize:vertical; }
#linklog-dialog input:focus, #linklog-dialog select:focus, #linklog-dialog textarea:focus, #linklog-dialog button:focus-visible { outline:2px solid var(--color-progressive,#36c); outline-offset:1px; }
#linklog-dialog .ll-targets { overflow-wrap:anywhere; margin:6px 0 0; }
#linklog-dialog .ll-namespace-actions { display:flex; align-items:center; gap:8px; margin:8px 0; flex-wrap:wrap; }
#linklog-dialog select[multiple] { height:180px; padding:4px; }
#linklog-dialog select[multiple] option { padding:4px 6px; }
#linklog-dialog .ll-status { flex-shrink:0; min-height:21px; margin-top:12px; }
#linklog-dialog .ll-error { flex-shrink:0; max-height:120px; overflow:auto; color:var(--color-error,#b32424); margin-top:12px; }
#linklog-dialog .ll-actions { display:flex; flex-shrink:0; justify-content:flex-end; gap:10px; border-top:1px solid var(--border-color-subtle,#c8ccd1); padding-top:18px; margin-top:18px; }
#linklog-dialog button { font:600 14px/1.4 sans-serif; padding:8px 14px; border:1px solid var(--border-color-base,#a2a9b1); border-radius:2px; color:inherit; background:var(--background-color-neutral-subtle,#f8f9fa); cursor:pointer; }
#linklog-dialog button.ll-primary { background:var(--background-color-progressive,#36c); border-color:var(--background-color-progressive,#36c); color:#fff; }
#linklog-dialog button:disabled { opacity:.55; cursor:default; }
@media(max-width:480px) { #linklog-dialog { padding:10px; } #linklog-dialog .ll-panel { padding:18px; max-height:calc(100vh - 20px); } }
</style>
<section class="ll-panel" role="dialog" aria-modal="true" aria-labelledby="ll-heading" aria-describedby="ll-intro" tabindex="-1">
<h2 id="ll-heading">Linklog</h2>
<p id="ll-intro" class="ll-muted">Choose a page and where to keep its backlink history. Only links written in the source page itself count; links inherited from templates are omitted.</p>
<form>
<div class="ll-body">
<div class="ll-field">
<label class="ll-label" for="ll-input">Input page</label>
<input id="ll-input" name="inputPage" type="text" required autocomplete="off" spellcheck="false" aria-describedby="ll-targets">
<div id="ll-targets" class="ll-targets ll-muted"></div>
</div>
<div class="ll-field">
<label class="ll-label" for="ll-output">Output page</label>
<input id="ll-output" name="trackingPage" type="text" required autocomplete="off" spellcheck="false" disabled aria-describedby="ll-output-help">
<div id="ll-output-help" class="ll-muted">An empty or non-existent output page starts a fresh history.</div>
</div>
<div class="ll-field">
<label class="ll-label" for="ll-namespaces">Backlink namespaces</label>
<select id="ll-namespaces" name="namespaces" multiple size="8" disabled aria-describedby="ll-namespace-help ll-namespace-count"></select>
<div class="ll-namespace-actions">
<button id="ll-select-all" type="button" disabled>Select all</button>
<button id="ll-select-none" type="button" disabled>Clear</button>
<span id="ll-namespace-count" class="ll-muted" role="status" aria-live="polite"></span>
</div>
<div id="ll-namespace-help" class="ll-muted">Each namespace is independent. Hold Ctrl (⌘ on Mac) to toggle individual selections.</div>
</div>
<div class="ll-field">
<label class="ll-label" for="ll-exclude-titles">Exclude page titles</label>
<textarea id="ll-exclude-titles" rows="3" autocomplete="off" spellcheck="false" disabled aria-describedby="ll-title-help" placeholder="User:*/*.js Wikipedia:Peer review/*/archive*"></textarea>
<div id="ll-title-help" class="ll-muted">One full title or pattern per line. * matches any text, including slashes. Other characters are literal. Underscores match spaces; matching ignores case.</div>
</div>
<div class="ll-field">
<label class="ll-label" for="ll-exclude-text">Exclude pages containing source text</label>
<textarea id="ll-exclude-text" rows="2" autocomplete="off" spellcheck="false" disabled aria-describedby="ll-text-help" placeholder="Paste a distinctive phrase from the page’s source"></textarea>
<div id="ll-text-help" class="ll-muted">One literal snippet per line; case and spacing are ignored. Any matching rule hides the page. This reads page source during the update and may take longer. Existing dates are kept.</div>
</div>
</div>
<div id="ll-status" class="ll-status ll-muted" role="status" aria-live="polite"></div>
<div id="ll-error" class="ll-error" role="alert" hidden></div>
<button id="ll-retry" type="button" hidden>Retry loading settings</button>
<div class="ll-actions">
<button id="ll-cancel" type="button">Cancel</button>
<button id="ll-update" class="ll-primary" type="submit" disabled>Update tracking page</button>
</div>
</form>
</section>`;
function normalized(title) {
return title.replace(/_/g, ' ').trim();
}
function showSettingsDialog(options) {
const { document } = options;
const previousFocus = document.activeElement;
const overlay = document.createElement('div');
overlay.id = 'linklog-dialog';
// Static markup only; page titles and namespace labels are inserted as text or input values.
overlay.innerHTML = markup;
document.body.appendChild(overlay);
const get = (selector) => overlay.querySelector(selector);
const form = get('form');
const input = get('#ll-input');
const output = get('#ll-output');
const namespaces = get('#ll-namespaces');
const selectAll = get('#ll-select-all');
const selectNone = get('#ll-select-none');
const excludeTitles = get('#ll-exclude-titles');
const excludeText = get('#ll-exclude-text');
const status = get('#ll-status');
const error = get('#ll-error');
const retry = get('#ll-retry');
const cancel = get('#ll-cancel');
const update = get('#ll-update');
input.value = options.initialInput;
let loaded;
let loadedInput = '';
let loadedOutput = '';
let request = 0;
let closed = false;
let running = false;
let finish;
const result = new Promise(resolve => { finish = resolve; });
function close(outcome) {
closed = true;
request++;
document.removeEventListener('focusin', keepFocus);
overlay.remove();
previousFocus === null || previousFocus === void 0 ? void 0 : previousFocus.focus();
finish(outcome);
}
function keepFocus(event) {
if (!overlay.contains(event.target))
get('.ll-panel').focus();
}
document.addEventListener('focusin', keepFocus);
function showError(message) {
error.textContent = message;
error.hidden = !message;
}
function enabled(ready) {
input.disabled = running;
output.disabled = !loaded || running;
namespaces.disabled = !ready || running;
selectAll.disabled = !ready || running;
selectNone.disabled = !ready || running;
excludeTitles.disabled = !ready || running;
excludeText.disabled = !ready || running;
update.disabled = !ready || running;
cancel.disabled = running;
}
function updateNamespaceCount() {
get('#ll-namespace-count').textContent = `${namespaces.selectedOptions.length} of ${namespaces.options.length} selected`;
}
function setNamespaces(selection) {
const names = new Map(Object.entries(options.namespaceNames)
.filter(([id]) => Number.isSafeInteger(Number(id)) && Number(id) >= 0)
.map(([id, name]) => [Number(id), name || 'Article (main namespace)']));
if (Array.isArray(selection)) {
for (const id of selection)
if (!names.has(id))
names.set(id, `Namespace ${id}`);
}
namespaces.replaceChildren();
for (const [id, name] of Array.from(names.entries()).sort(([a], [b]) => a - b)) {
const option = document.createElement('option');
option.value = String(id);
option.textContent = name;
option.selected = includesNamespace(selection, id);
namespaces.appendChild(option);
}
updateNamespaceCount();
}
function selectedNamespaces() {
return namespaces.options.length && namespaces.selectedOptions.length === namespaces.options.length ? 'all'
: normalizeNamespaceFilter(Array.from(namespaces.selectedOptions, option => Number(option.value)));
}
function selectedExclusions() {
const lines = (value) => value.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
return normalizeExclusions({ titles: lines(excludeTitles.value), text: lines(excludeText.value) });
}
function applySettings(settings) {
loaded = settings;
loadedOutput = normalized(settings.trackingPage);
output.value = settings.trackingPage;
setNamespaces(settings.namespaces);
excludeTitles.value = settings.exclusions.titles.join('\n');
excludeText.value = settings.exclusions.text.join('\n');
get('#ll-targets').textContent = settings.targets.length > 1
? `Combined backlinks: ${settings.targets.join(' + ')}` : `Backlinks to ${settings.target}`;
status.textContent = settings.warning || (settings.source === 'page'
? 'Using settings saved on the output page. Updating saves your choices with the backlink history.'
: 'Updating saves these settings and the backlink history together on the output page.');
enabled(true);
}
async function load() {
const title = normalized(input.value);
const current = ++request;
loaded = undefined;
loadedInput = '';
loadedOutput = '';
enabled(false);
showError('');
retry.hidden = true;
get('#ll-targets').textContent = '';
if (!title) {
status.textContent = 'Enter an input page to load its settings.';
return;
}
status.textContent = 'Loading settings for this input…';
try {
const settings = await options.load(title);
if (closed || current !== request)
return;
loadedInput = title;
applySettings(settings);
}
catch (failure) {
if (closed || current !== request)
return;
status.textContent = '';
showError(options.formatError(failure));
retry.hidden = false;
}
}
async function loadOutput() {
if (!loaded || normalized(input.value) !== loadedInput)
return;
const title = normalized(output.value);
const current = ++request;
loadedOutput = '';
enabled(false);
showError('');
retry.hidden = true;
if (!title) {
status.textContent = 'Enter an output page.';
return;
}
status.textContent = 'Loading settings from the output page…';
try {
const settings = await options.loadOutput(loaded, title, selectedNamespaces(), selectedExclusions());
if (closed || current !== request)
return;
applySettings(settings);
}
catch (failure) {
if (closed || current !== request)
return;
status.textContent = '';
showError(options.formatError(failure));
retry.hidden = false;
enabled(true);
}
}
input.addEventListener('input', () => {
request++;
loaded = undefined;
enabled(false);
showError('');
retry.hidden = true;
status.textContent = 'Leave the input field or press Enter to load its settings.';
});
input.addEventListener('change', () => { if (!loaded || normalized(input.value) !== loadedInput)
void load(); });
input.addEventListener('keydown', event => {
if (event.key === 'Enter') {
event.preventDefault();
if (loaded && normalized(input.value) === loadedInput)
output.focus();
else
void load().then(() => { if (!closed && loaded)
output.focus(); });
}
});
output.addEventListener('input', () => {
request++;
loadedOutput = '';
enabled(false);
showError('');
retry.hidden = true;
status.textContent = 'Leave the output field or press Enter to load its saved settings.';
});
output.addEventListener('change', () => {
if (normalized(output.value) !== loadedOutput)
void loadOutput();
});
output.addEventListener('keydown', event => {
if (event.key === 'Enter') {
event.preventDefault();
if (loadedOutput && normalized(output.value) === loadedOutput)
namespaces.focus();
else
void loadOutput().then(() => { if (!closed && loadedOutput)
namespaces.focus(); });
}
});
namespaces.addEventListener('change', updateNamespaceCount);
for (const [button, selected] of [[selectAll, true], [selectNone, false]]) {
button.addEventListener('click', () => {
for (const option of Array.from(namespaces.options))
option.selected = selected;
updateNamespaceCount();
});
}
retry.addEventListener('click', () => { if (loaded)
void loadOutput();
else
void load(); });
cancel.addEventListener('click', () => { if (!running)
close(null); });
overlay.addEventListener('click', event => { if (event.target === overlay && !running)
close(null); });
overlay.addEventListener('keydown', event => {
if (event.key === 'Escape') {
event.preventDefault();
if (!running)
close(null);
}
if (event.key === 'Tab') {
const focusable = Array.from(overlay.querySelectorAll('input, select, textarea, button'))
.filter(element => !element.matches(':disabled') && !element.closest('[hidden]'));
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last === null || last === void 0 ? void 0 : last.focus();
}
else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first === null || first === void 0 ? void 0 : first.focus();
}
}
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (running || !loaded || normalized(input.value) !== loadedInput)
return;
const trackingPage = normalized(output.value);
if (!trackingPage) {
showError('Enter an output page.');
output.focus();
return;
}
if (loaded.targets.includes(trackingPage)) {
showError('Choose an output page different from the input pages.');
output.focus();
return;
}
// Load changed destinations before allowing an update, so their saved filter can be reviewed.
if (trackingPage !== loadedOutput) {
await loadOutput();
return;
}
let selection;
try {
selection = Object.assign(Object.assign({}, loaded), { inputPage: loadedInput, trackingPage, namespaces: selectedNamespaces(), exclusions: selectedExclusions() });
}
catch (failure) {
showError(options.formatError(failure));
return;
}
running = true;
enabled(true);
showError('');
update.textContent = 'Updating…';
status.textContent = 'Scanning backlinks…';
try {
const outcome = await options.run(selection, (count, phase) => {
status.textContent = `${phase === 'source' ? 'Checking direct links and exclusions' : 'Scanning backlinks'}… ${count.toLocaleString()} pages checked.`;
});
close(outcome);
}
catch (failure) {
running = false;
enabled(true);
update.textContent = 'Update tracking page';
status.textContent = '';
showError(options.formatError(failure));
}
});
input.focus();
void load();
return result;
}
function record(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseLocations(value) {
if (!value)
return [];
try {
const data = JSON.parse(value);
if (!record(data) || data.version !== 1 || !Array.isArray(data.outputs))
throw new Error();
const outputs = data.outputs.map((output) => {
if (!record(output) || typeof output.target !== 'string' || !output.target.trim() ||
typeof output.trackingPage !== 'string' || !output.trackingPage.trim() || !Array.isArray(output.targets) ||
!output.targets.every(title => typeof title === 'string' && title.trim()) || !output.targets.includes(output.target)) {
throw new Error();
}
return { target: output.target, targets: Array.from(new Set(output.targets)), trackingPage: output.trackingPage };
});
if (new Set(outputs.map(output => output.target)).size !== outputs.length)
throw new Error();
return outputs;
}
catch (_a) {
throw new Error('The browser’s saved output locations could not be read. They have not been overwritten.');
}
}
// localStorage is scoped to the wiki's origin; the key also separates registered accounts.
// Inject its accessor because getting localStorage itself can fail when storage is blocked.
class OutputLocations {
constructor(storage, username) {
this.storage = storage;
this.key = `linklog:outputs:${encodeURIComponent(username)}`;
}
find(group) {
const outputs = parseLocations(this.storage().getItem(this.key));
return outputs.find(output => output.target === group.target) ||
outputs.find(output => output.targets.some(target => group.targets.includes(target)));
}
remember(location) {
var _a;
const storage = this.storage();
// Merge with the latest value to retain outputs selected in other tabs.
const raw = storage.getItem(this.key);
const outputs = parseLocations(raw);
const existing = outputs.find(output => output.target === location.target);
const next = {
target: location.target, targets: Array.from(new Set([...location.targets, ...((_a = existing === null || existing === void 0 ? void 0 : existing.targets) !== null && _a !== void 0 ? _a : [])])),
trackingPage: location.trackingPage
};
const merged = outputs.filter(output => !output.targets.some(target => next.targets.includes(target)));
merged.push(next);
const value = JSON.stringify({ version: 1, outputs: merged });
if (raw !== value)
storage.setItem(this.key, value);
}
}
async function loadOutputSettings(api, group, trackingPage, namespaces = 'all', exclusions = { titles: [], text: [] }) {
const settings = Object.assign(Object.assign({}, group), { trackingPage: trackingPage || `User:${api.username}/Linklog/${group.target}`, namespaces: normalizeNamespaceFilter(namespaces), exclusions: normalizeExclusions(exclusions), source: 'defaults' });
try {
const page = await api.readTrackingPage(group.target, trackingPage);
settings.trackingPage = page.title;
const block = readManagedBlock(page.text);
if (block) {
if (!block.state.targets.some(target => group.targets.includes(target))) {
return Object.assign(Object.assign({}, settings), { warning: `This output page already tracks ${block.state.target}. Choose a different output page.` });
}
return Object.assign(Object.assign({}, settings), { target: group.targets.includes(block.state.target) ? group.target : block.state.target, targets: Array.from(new Set([...group.targets, ...block.state.targets])), trackingPage: page.title, namespaces: block.state.namespaces, exclusions: block.state.exclusions, source: 'page' });
}
return Object.assign(Object.assign({}, settings), { trackingPage: page.title });
}
catch (_a) {
// Keep the fields editable so the user can choose another output; saving revalidates the page.
return Object.assign(Object.assign({}, settings), { warning: 'Could not read previous settings from the output page. Review the settings below; the page will be checked again before saving.' });
}
}
async function loadInputSettings(api, title, defaults = {}, locations) {
var _a, _b;
const group = await resolveTargetGroup(api, title);
let trackingPage = defaults.trackingPage;
let warning;
try {
trackingPage = ((_a = locations === null || locations === void 0 ? void 0 : locations.find(group)) === null || _a === void 0 ? void 0 : _a.trackingPage) || trackingPage;
}
catch (_c) {
warning = 'This browser could not recall your last output page. Choose a custom output again if needed.';
}
const settings = await loadOutputSettings(api, group, trackingPage, (_b = defaults.namespaces) !== null && _b !== void 0 ? _b : 'all', defaults.exclusions);
return warning ? Object.assign(Object.assign({}, settings), { warning: [warning, settings.warning].filter(Boolean).join(' ') }) : settings;
}
function normalizeTitle(title) {
return title.replace(/_/g, ' ').trim();
}
function resolveContext(context, options = {}) {
var _a;
const page = normalizeTitle(context.pageName);
const root = `User:${context.username}/Linklog/`;
let trackingPage = options.trackingPage ? normalizeTitle(options.trackingPage) : undefined;
let target = normalizeTitle((_a = options.targetPage) !== null && _a !== void 0 ? _a : '');
if (!target && context.specialPage === 'Whatlinkshere') {
target = normalizeTitle(context.backlinksTarget || context.title.split('/').slice(1).join('/'));
}
else if (!target && context.namespace >= 0) {
const fromOutput = page.startsWith(root);
target = fromOutput ? page.slice(root.length) : page;
if (fromOutput && !trackingPage)
trackingPage = page;
}
if (!target)
return null;
return Object.assign({ target }, (trackingPage ? { trackingPage } : {}));
}
function errorMessage(error) {
if (error instanceof ApiError) {
if (['editconflict', 'articleexists', 'pagedeleted'].includes(error.code)) {
return 'The tracking page changed during the scan. Run Linklog again to merge with its latest history.';
}
if (['assertuserfailed', 'assertnameduserfailed', 'assertuserincorrect'].includes(error.code)) {
return 'Your Wikipedia login changed or expired. Log in and reload the page before running again.';
}
return `Wikipedia could not complete the update (${error.code}): ${error.message}`;
}
return error instanceof Error ? error.message : 'The scan failed. Check your connection and run again.';
}
function installMenu(wiki, document, window) {
var _a, _b;
if (document.getElementById('ca-linklog'))
return;
const username = String(wiki.config.get('wgUserName') || '');
const context = {
pageName: String(wiki.config.get('wgPageName') || ''),
title: String(wiki.config.get('wgTitle') || ''),
namespace: Number(wiki.config.get('wgNamespaceNumber')),
specialPage: String(wiki.config.get('wgCanonicalSpecialPageName') || ''),
username,
backlinksTarget: new URL(window.location.href).searchParams.get('target') ||
((_a = document.querySelector('input[name="target"]')) === null || _a === void 0 ? void 0 : _a.value)
};
const options = window.linklogConfig;
const settings = (_b = resolveContext(context, options)) !== null && _b !== void 0 ? _b : { target: '' };
const item = wiki.util.addPortletLink('p-tb', '#', 'Linklog', 'ca-linklog', 'Choose an input page, output page and namespace filter, then update backlink history');
if (!item)
return;
const link = item.querySelector('a') || item;
let running = false;
const notify = (message, type = 'info') => {
wiki.notify(message, { type, tag: 'linklog', autoHide: false });
};
item.addEventListener('click', async (event) => {
var _a;
event.preventDefault();
if (running) {
(_a = document.querySelector('#linklog-dialog .ll-panel')) === null || _a === void 0 ? void 0 : _a.focus();
return;
}
if (!username || wiki.config.get('wgUserIsTemp') === true) {
notify('Log in with a registered Wikipedia account to save backlink history.', 'error');
return;
}
running = true;
link.setAttribute('aria-expanded', 'true');
try {
const api = new WikiApi(new wiki.Api(), username);
const subpages = wiki.config.get('wgNamespacesWithSubpages');
const decoder = document.createElement('textarea');
const linkContext = {
parseTitle: (text) => {
const title = wiki.Title.newFromText(text);
return title ? { title: title.getPrefixedText(), namespace: title.getNamespaceId() } : null;
},
decodeEntity: (entity) => {
// The matcher passes only a single entity token, never arbitrary page HTML.
decoder.innerHTML = entity;
return decoder.value;
},
supportsSubpages: (namespace) => Boolean(subpages && typeof subpages === 'object' &&
subpages[namespace])
};
const locations = new OutputLocations(() => window.localStorage, username);
const formatted = wiki.config.get('wgFormattedNamespaces');
if (!formatted || typeof formatted !== 'object' || Array.isArray(formatted)) {
throw new Error('The wiki’s namespace list is unavailable. Reload the page to try again.');
}
const namespaceNames = Object.entries(formatted).reduce((names, [id, name]) => {
if (Number.isSafeInteger(Number(id)) && Number(id) >= 0 && typeof name === 'string')
names[id] = name;
return names;
}, {});
if (!Object.keys(namespaceNames).length)
throw new Error('The wiki’s namespace list is empty. Reload the page to try again.');
const outcome = await showSettingsDialog({
document, initialInput: settings.target, namespaceNames, formatError: errorMessage,
load: title => {
const fromOutput = settings.trackingPage && !(options === null || options === void 0 ? void 0 : options.targetPage) && !(options === null || options === void 0 ? void 0 : options.trackingPage) && title === settings.target;
return loadInputSettings(api, title, fromOutput ? Object.assign(Object.assign({}, options), { trackingPage: settings.trackingPage }) : options, fromOutput ? undefined : locations);
},
loadOutput: (group, output, namespaces, exclusions) => loadOutputSettings(api, group, output, namespaces, exclusions),
run: async (selection, progress) => {
const result = await updateTrackingPage(api, selection.inputPage, selection.trackingPage, progress, {
namespaces: selection.namespaces, exclusions: selection.exclusions
}, linkContext);
try {
locations.remember(Object.assign(Object.assign({}, selection), { trackingPage: result.trackingPage }));
return { result };
}
catch (_a) {
return { result, warning: 'Settings and history are saved on the output page, but this browser could not remember its location. Choose the output again next time if needed.' };
}
}
});
if (!outcome)
return;
const { result } = outcome;
const message = document.createElement('span');
message.appendChild(document.createTextNode(result.saved
? `Saved ${result.added} newly noticed backlink${result.added === 1 ? '' : 's'} (${result.total} tracked). `
: `No changes needed (${result.total} backlinks tracked). `));
const destination = document.createElement('a');
destination.href = wiki.util.getUrl(result.trackingPage);
destination.textContent = 'View tracking page';
message.appendChild(destination);
if (outcome.warning)
message.appendChild(document.createTextNode(` ${outcome.warning}`));
notify(message, 'success');
}
catch (error) {
notify(errorMessage(error), 'error');
}
finally {
running = false;
link.setAttribute('aria-expanded', 'false');
}
});
}
function initialize(wiki, document, window) {
wiki.loader.using(['mediawiki.api', 'mediawiki.util', 'mediawiki.notification', 'mediawiki.Title']).then(() => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => installMenu(wiki, document, window), { once: true });
}
else {
installMenu(wiki, document, window);
}
}, () => {
wiki.notify('Linklog could not load its Wikipedia modules. Reload the page to try again.', { type: 'error' });
});
}
initialize(mw, document, window);
})();
// </nowiki>