Jump to content

User:Saroj/CatSync.js: Difference between revisions

From Meta, a Wikimedia project coordination wiki
Content deleted Content added
// Edit via Wikiplus
Add mobile (Minerva) support via p-tb
 
Line 360: Line 360:
};
};


// ---------------- Dialog 2: preview & insert ----------------
// ---------------- Dialog 2: preview & add ----------------


function PreviewDialog(config) { PreviewDialog.super.call(this, config); }
function PreviewDialog(config) { PreviewDialog.super.call(this, config); }
Line 367: Line 367:
PreviewDialog.static.title = 'Categories to add';
PreviewDialog.static.title = 'Categories to add';
PreviewDialog.static.actions = [
PreviewDialog.static.actions = [
{ action: 'insert', label: 'Insert', flags: ['primary', 'progressive'] },
{ action: 'add', label: 'Add', flags: ['primary', 'progressive'] },
{ label: 'Cancel', flags: 'safe' }
{ label: 'Cancel', flags: 'safe' }
];
];
Line 406: Line 406:
PreviewDialog.prototype.getActionProcess = function (action) {
PreviewDialog.prototype.getActionProcess = function (action) {
var dialog = this;
var dialog = this;
if (action === 'insert') {
if (action === 'add') {
return new OO.ui.Process(function () {
return new OO.ui.Process(function () {
var selected = dialog.checkboxes.getValue();
var selected = dialog.checkboxes.getValue();
Line 416: Line 416:
dialog.popPending();
dialog.popPending();
dialog.close();
dialog.close();
mw.notify(selected.length + (selected.length === 1 ? ' category' : ' categories') + ' inserted. Reloading…', { type: 'success' });
mw.notify(selected.length + (selected.length === 1 ? ' category' : ' categories') + ' added. Reloading…', { type: 'success' });
setTimeout(function () { window.location.reload(); }, 1200);
setTimeout(function () { window.location.reload(); }, 1200);
}, function () {
}, function () {
Line 454: Line 454:
}
}


// use Tools portlet on Minerva (mobile)
mw.util.addPortletLink('p-cactions', '#', 'Sync categories', 'ca-synccategories', 'Copy categories from another project via Wikidata');
var skin = mw.config.get('skin');
var portlet = skin === 'minerva' ? 'p-tb' : 'p-cactions';
mw.util.addPortletLink(portlet, '#', 'Sync categories', 'ca-synccategories', 'Copy categories from another project via Wikidata');
$('#ca-synccategories a').on('click', function (e) {
$('#ca-synccategories a').on('click', function (e) {
e.preventDefault();
e.preventDefault();

Latest revision as of 13:47, 3 August 2026

// <nowiki>
(function() {
    'use strict';

    mw.loader.using([
        'mediawiki.util',
        'mediawiki.api',
        'oojs-ui-core',
        'oojs-ui-widgets',
        'oojs-ui-windows'
    ]).then(function () {

        if (mw.config.get('wgNamespaceNumber') !== 0 || mw.config.get('wgAction') !== 'view') {
            return;
        }

        var currentTitle = mw.config.get('wgPageName').replace(/_/g, ' ');
        var localDbName = mw.config.get('wgDBname');
        var localCategoryNs = mw.config.get('wgFormattedNamespaces')['14'];

        // Note: Wikipedia's dbSuffix is 'wiki', not 'wikipedia'.
        var PROJECTS = [
            { id: 'wikipedia', label: 'Wikipedia', domain: 'wikipedia.org', dbSuffix: 'wiki' },
            { id: 'wikiquote', label: 'Wikiquote', domain: 'wikiquote.org', dbSuffix: 'wikiquote' }
        ];

        function getProject(id) {
            var found = null;
            $.each(PROJECTS, function (i, p) { if (p.id === id) { found = p; return false; } });
            return found || PROJECTS[0];
        }

        // Runs only on Wikipedia or Wikiquote; anywhere else, exit before any setup.
        function detectLocalProject() {
            var byLongestSuffix = PROJECTS.slice().sort(function (a, b) { return b.dbSuffix.length - a.dbSuffix.length; });
            var match = null;
            $.each(byLongestSuffix, function (i, p) {
                if (localDbName.indexOf(p.dbSuffix, localDbName.length - p.dbSuffix.length) !== -1) {
                    match = p;
                    return false;
                }
            });
            return match;
        }

        var localProject = detectLocalProject();
        if (!localProject) {
            return;
        }

        // ---------------- data layer ----------------

        function crossApi(domain, params) {
            params.format = 'json';
            params.origin = '*';
            return $.getJSON('https://' + domain + '/w/api.php', params);
        }

        // Fallback until the language list resolves.
        var FALLBACK_LANGS = [
            { data: 'en', label: 'English (en)' },
            { data: 'hi', label: 'Hindi (hi)' },
            { data: 'ne', label: 'Nepali (ne)' }
        ];

        // Same-origin call to this wiki's own interface languages list.
        function fetchWikipediaLanguages() {
            var d = $.Deferred();
            new mw.Api().get({
                action: 'query',
                meta: 'siteinfo',
                siprop: 'languages',
                formatversion: 2
            }).done(function (data) {
                var raw = (data.query && data.query.languages) || [];
                if (raw.length === 0) {
                    d.resolve(FALLBACK_LANGS);
                    return;
                }
                var langs = raw.map(function (l) {
                    return { data: l.code, label: l.name + ' (' + l.code + ')' };
                });
                var enIndex = langs.findIndex(function (l) { return l.data === 'en'; });
                if (enIndex > -1) {
                    langs.unshift(langs.splice(enIndex, 1)[0]);
                }
                d.resolve(langs);
            }).fail(function () { d.resolve(FALLBACK_LANGS); });
            return d.promise();
        }

        var wikipediaLangs = FALLBACK_LANGS;
        var languagesPromise = fetchWikipediaLanguages().done(function (langs) { wikipediaLangs = langs; });

        function chunk(arr, size) {
            var out = [];
            for (var i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
            return out;
        }

        function getLocalWikidataItem() {
            var d = $.Deferred();
            var existing = mw.config.get('wgWikibaseItemId');
            if (existing) {
                return d.resolve(existing).promise();
            }
            new mw.Api().get({
                action: 'query',
                prop: 'pageprops',
                ppprop: 'wikibase_item',
                titles: currentTitle,
                formatversion: 2
            }).done(function (data) {
                var page = data.query && data.query.pages && data.query.pages[0];
                var qid = page && page.pageprops && page.pageprops.wikibase_item;
                if (qid) d.resolve(qid); else d.reject('no-item');
            }).fail(function () { d.reject('no-item'); });
            return d.promise();
        }

        function getSourceArticleTitle(qid, sourceLang, project) {
            var d = $.Deferred();
            var siteId = sourceLang + project.dbSuffix;
            crossApi('www.wikidata.org', {
                action: 'wbgetentities',
                ids: qid,
                props: 'sitelinks',
                sitefilter: siteId
            }).done(function (data) {
                var entity = data.entities && data.entities[qid];
                var link = entity && entity.sitelinks && entity.sitelinks[siteId];
                if (link && link.title) d.resolve(link.title); else d.reject('no-source-article');
            }).fail(function () { d.reject('no-source-article'); });
            return d.promise();
        }

        function getSourceCategories(sourceLang, project, sourceTitle) {
            var d = $.Deferred();
            crossApi(sourceLang + '.' + project.domain, {
                action: 'query',
                titles: sourceTitle,
                prop: 'categories',
                cllimit: 'max',
                clshow: '!hidden',
                formatversion: 2
            }).done(function (data) {
                var page = data.query && data.query.pages && data.query.pages[0];
                var cats = ((page && page.categories) || []).map(function (c) {
                    return c.title.replace(/^[^:]+:/, '').trim();
                });
                d.resolve(cats);
            }).fail(function () { d.resolve([]); });
            return d.promise();
        }

        // sourceLang category name -> QID
        function getQidsForCategories(sourceLang, project, categories) {
            var d = $.Deferred();
            var siteId = sourceLang + project.dbSuffix;
            var titles = categories.map(function (c) { return 'Category:' + c; });
            var map = {};
            var calls = chunk(titles, 50).map(function (batch) {
                return crossApi('www.wikidata.org', {
                    action: 'wbgetentities',
                    sites: siteId,
                    titles: batch.join('|'),
                    props: 'sitelinks',
                    sitefilter: siteId
                }).done(function (data) {
                    $.each(data.entities || {}, function (qid, entity) {
                        if (qid === '-1') return;
                        var link = entity.sitelinks && entity.sitelinks[siteId];
                        var title = link && link.title && link.title.replace(/^[^:]+:/, '').trim();
                        if (title) map[title] = qid;
                    });
                });
            });
            $.when.apply($, calls).always(function () { d.resolve(map); });
            return d.promise();
        }

        // QID -> local wiki title (namespace stripped)
        function getLocalTitlesForQids(qidMap) {
            var d = $.Deferred();
            var qids = Object.keys(qidMap)
                .map(function (k) { return qidMap[k]; })
                .filter(function (v, i, a) { return a.indexOf(v) === i; });
            var qidToLocal = {};
            var calls = chunk(qids, 50).map(function (batch) {
                return crossApi('www.wikidata.org', {
                    action: 'wbgetentities',
                    ids: batch.join('|'),
                    props: 'sitelinks',
                    sitefilter: localDbName
                }).done(function (data) {
                    $.each(data.entities || {}, function (qid, entity) {
                        var link = entity.sitelinks && entity.sitelinks[localDbName];
                        if (link && link.title) {
                            qidToLocal[qid] = link.title.replace(/^[^:]+:/, '').trim();
                        }
                    });
                });
            });
            $.when.apply($, calls).always(function () { d.resolve(qidToLocal); });
            return d.promise();
        }

        function getExistingLocalCategories() {
            var d = $.Deferred();
            new mw.Api().get({
                action: 'query',
                titles: currentTitle,
                prop: 'categories',
                cllimit: 'max',
                formatversion: 2
            }).done(function (data) {
                var page = data.query && data.query.pages && data.query.pages[0];
                var cats = ((page && page.categories) || []).map(function (c) {
                    return c.title.replace(/^[^:]+:/, '').trim().toLowerCase();
                });
                d.resolve(cats);
            }).fail(function () { d.resolve([]); });
            return d.promise();
        }

        // Full pipeline -> { toAdd, alreadyCount, missingCount, total }
        function runSync(sourceLang, project) {
            var d = $.Deferred();
            getLocalWikidataItem().then(function (qid) {
                return getSourceArticleTitle(qid, sourceLang, project);
            }).then(function (sourceTitle) {
                return getSourceCategories(sourceLang, project, sourceTitle);
            }).then(function (sourceCats) {
                if (sourceCats.length === 0) {
                    d.resolve({ toAdd: [], alreadyCount: 0, missingCount: 0, total: 0 });
                    return;
                }
                $.when(
                    getQidsForCategories(sourceLang, project, sourceCats),
                    getExistingLocalCategories()
                ).done(function (qidMap, existingCats) {
                    getLocalTitlesForQids(qidMap).done(function (qidToLocal) {
                        var localNames = Object.keys(qidMap)
                            .map(function (src) { return qidToLocal[qidMap[src]]; })
                            .filter(Boolean);
                        localNames = localNames.filter(function (v, i, a) { return a.indexOf(v) === i; });

                        var missingCount = sourceCats.length - localNames.length;
                        var toAdd = [];
                        var alreadyCount = 0;
                        localNames.forEach(function (name) {
                            if (existingCats.indexOf(name.toLowerCase()) !== -1) {
                                alreadyCount++;
                            } else {
                                toAdd.push(name);
                            }
                        });
                        d.resolve({ toAdd: toAdd, alreadyCount: alreadyCount, missingCount: missingCount, total: sourceCats.length });
                    });
                }).fail(function () { d.reject('fetch-failed'); });
            }, function (reason) { d.reject(reason); });
            return d.promise();
        }

        function insertCategories(sourceLang, project, names) {
            var lines = names.map(function (n) { return '[[' + localCategoryNs + ':' + n + ']]'; }).join('\n');
            return new mw.Api().postWithToken('csrf', {
                action: 'edit',
                title: currentTitle,
                appendtext: '\n' + lines,
                summary: 'Synced ' + names.length + (names.length === 1 ? ' category' : ' categories') + ' from ' + sourceLang + project.dbSuffix + ' ([[:m:User:Saroj/CatSync|CatSync]])',
                minor: false
            });
        }

        // ---------------- Dialog 1: choose source wiki ----------------

        function SourceDialog(config) { SourceDialog.super.call(this, config); }
        OO.inheritClass(SourceDialog, OO.ui.ProcessDialog);
        SourceDialog.static.name = 'categorySyncSource';
        SourceDialog.static.title = 'Sync categories';
        SourceDialog.static.actions = [
            { action: 'sync', label: 'Sync', flags: ['primary', 'progressive'] },
            { label: 'Cancel', flags: 'safe' }
        ];

        SourceDialog.prototype.initialize = function () {
            SourceDialog.super.prototype.initialize.call(this);
            this.panel = new OO.ui.PanelLayout({ padded: true, expanded: false });

            this.projectDropdown = new OO.ui.DropdownInputWidget({
                options: PROJECTS.map(function (p) { return { data: p.id, label: p.label }; }),
                value: getProject('wikipedia').id
            });

            // Selecting an option fills in its code; typing an unlisted code works too.
            this.langInput = new OO.ui.ComboBoxInputWidget({
                options: wikipediaLangs,
                value: 'en',
                placeholder: 'e.g. bn, hi, ja'
            });

            this.fieldset = new OO.ui.FieldsetLayout({
                label: 'Copy categories from the same article on another project, matched via Wikidata.'
            });
            this.fieldset.addItems([
                new OO.ui.FieldLayout(this.projectDropdown, { label: 'Source project', align: 'top' }),
                new OO.ui.FieldLayout(this.langInput, {
                    label: 'Source language',
                    align: 'top',
                    help: 'Search or type a language code.',
                    helpInline: true
                })
            ]);

            this.panel.$element.append(this.fieldset.$element);
            this.$body.append(this.panel.$element);
        };

        SourceDialog.prototype.getSetupProcess = function (data) {
            return SourceDialog.super.prototype.getSetupProcess.call(this, data).next(function () {
                if (wikipediaLangs.length > FALLBACK_LANGS.length) {
                    var current = this.langInput.getValue();
                    this.langInput.setOptions(wikipediaLangs);
                    this.langInput.setValue(current || 'en');
                }
            }, this);
        };

        SourceDialog.prototype.getBodyHeight = function () { return 240; };

        SourceDialog.prototype.getActionProcess = function (action) {
            var dialog = this;
            if (action === 'sync') {
                return new OO.ui.Process(function () {
                    var sourceLang = dialog.langInput.getValue().trim().toLowerCase();
                    var project = getProject(dialog.projectDropdown.getValue());

                    if (!sourceLang) {
                        return $.Deferred().reject(new OO.ui.Error('Enter a language code.')).promise();
                    }

                    dialog.pushPending();
                    return runSync(sourceLang, project).then(function (result) {
                        dialog.popPending();
                        dialog.close();
                        openPreview(sourceLang, project, result);
                    }, function (reason) {
                        dialog.popPending();
                        var messages = {
                            'no-item': 'This page is not connected to a Wikidata item.',
                            'no-source-article': 'No article exists on ' + sourceLang + '.' + project.domain + ' for this item.',
                            'fetch-failed': 'Something went wrong while fetching categories. Try again.'
                        };
                        return $.Deferred().reject(new OO.ui.Error(messages[reason] || 'Sync failed.')).promise();
                    });
                });
            }
            return SourceDialog.super.prototype.getActionProcess.call(this, action);
        };

        // ---------------- Dialog 2: preview & add ----------------

        function PreviewDialog(config) { PreviewDialog.super.call(this, config); }
        OO.inheritClass(PreviewDialog, OO.ui.ProcessDialog);
        PreviewDialog.static.name = 'categorySyncPreview';
        PreviewDialog.static.title = 'Categories to add';
        PreviewDialog.static.actions = [
            { action: 'add', label: 'Add', flags: ['primary', 'progressive'] },
            { label: 'Cancel', flags: 'safe' }
        ];

        PreviewDialog.prototype.initialize = function () {
            PreviewDialog.super.prototype.initialize.call(this);
            this.panel = new OO.ui.PanelLayout({ padded: true, expanded: false });
            this.summary = new OO.ui.LabelWidget({ label: '' });
            this.checkboxes = new OO.ui.CheckboxMultiselectInputWidget({ options: [] });

            this.panel.$element.append(
                this.summary.$element,
                $('<div>').css('margin-top', '12px').append(this.checkboxes.$element)
            );
            this.$body.append(this.panel.$element);
        };

        PreviewDialog.prototype.getSetupProcess = function (data) {
            return PreviewDialog.super.prototype.getSetupProcess.call(this, data).next(function () {
                this.sourceLang = data.sourceLang;
                this.project = data.project;
                var sourceLabel = data.sourceLang + '.' + data.project.domain.replace(/\.org$/, '');
                var parts = [data.toAdd.length + ' new categories found from ' + sourceLabel];
                if (data.alreadyCount) parts.push(data.alreadyCount + ' already present');
                if (data.missingCount) parts.push(data.missingCount + ' with no equivalent here');
                this.summary.setLabel(parts.join(' · '));

                this.checkboxes.setOptions(data.toAdd.map(function (name) {
                    return { data: name, label: name };
                }));
                // Per-option 'selected' is ignored; set via setValue() instead.
                this.checkboxes.setValue(data.toAdd);
            }, this);
        };

        PreviewDialog.prototype.getBodyHeight = function () { return 320; };

        PreviewDialog.prototype.getActionProcess = function (action) {
            var dialog = this;
            if (action === 'add') {
                return new OO.ui.Process(function () {
                    var selected = dialog.checkboxes.getValue();
                    if (selected.length === 0) {
                        return $.Deferred().reject(new OO.ui.Error('Select at least one category.')).promise();
                    }
                    dialog.pushPending();
                    return insertCategories(dialog.sourceLang, dialog.project, selected).then(function () {
                        dialog.popPending();
                        dialog.close();
                        mw.notify(selected.length + (selected.length === 1 ? ' category' : ' categories') + ' added. Reloading…', { type: 'success' });
                        setTimeout(function () { window.location.reload(); }, 1200);
                    }, function () {
                        dialog.popPending();
                        return $.Deferred().reject(new OO.ui.Error('Could not save the page. Check your edit permissions.')).promise();
                    });
                });
            }
            return PreviewDialog.super.prototype.getActionProcess.call(this, action);
        };

        // ---------------- wiring ----------------

        var windowManager = new OO.ui.WindowManager();
        $(document.body).append(windowManager.$element);
        var sourceDialog = new SourceDialog();
        var previewDialog = new PreviewDialog();
        windowManager.addWindows([sourceDialog, previewDialog]);

        function openPreview(sourceLang, project, result) {
            if (result.toAdd.length === 0) {
                mw.notify(
                    result.total === 0
                        ? 'No categories found on the source article.'
                        : 'Nothing to add. All categories are already present or have no equivalent here.',
                    { type: 'warn' }
                );
                return;
            }
            windowManager.openWindow(previewDialog, {
                sourceLang: sourceLang,
                project: project,
                toAdd: result.toAdd,
                alreadyCount: result.alreadyCount,
                missingCount: result.missingCount
            });
        }

        // use Tools portlet on Minerva (mobile)
        var skin = mw.config.get('skin');
        var portlet = skin === 'minerva' ? 'p-tb' : 'p-cactions';
        mw.util.addPortletLink(portlet, '#', 'Sync categories', 'ca-synccategories', 'Copy categories from another project via Wikidata');
        $('#ca-synccategories a').on('click', function (e) {
            e.preventDefault();
            languagesPromise.always(function () {
                windowManager.openWindow(sourceDialog);
            });
        });
    }).catch(function (err) {
        console.error('[CatSync] failed to initialize:', err);
    });
})();
// </nowiki>