Jump to content

MediaWiki:Common.js: Difference between revisions

From SOJI ELECTRONICS
No edit summary
Tag: Reverted
No edit summary
Tag: Reverted
Line 386: Line 386:


})( jQuery );
})( jQuery );
/* ============================================================
/* ============================================================
  *  Version History Row Actions
  *  Version History Table Toolbar
  *  Click row → hover toolbar: Edit / Add Above / Add Below
  *  Prominent "+ Add New Version" button above the table
*  Uses MediaWiki API to insert new {{FW-Row-Detail}} block
*  Plus hover row actions: Edit / Insert Above / Insert Below
  * ============================================================ */
  * ============================================================ */
( function ( $, mw ) {
( function ( $, mw ) {
     'use strict';
     'use strict';


    // Chỉ chạy cho user có quyền edit
     if ( !mw.config.get( 'wgIsArticle' ) ) return;
     if ( !mw.config.get( 'wgIsArticle' ) ) return;
     if ( mw.user.isAnon() ) return;
     if ( mw.user.isAnon() ) return;
Line 400: Line 400:
     var TEMPLATE_NAME = 'FW-Row-Detail';
     var TEMPLATE_NAME = 'FW-Row-Detail';


     // Wikitext mẫu cho row mới (placeholder ở các field)
     function buildNewRowTemplate( version ) {
    var NEW_ROW_TEMPLATE =
         return '{{' + TEMPLATE_NAME + '\n' +
         '{{' + TEMPLATE_NAME + '\n' +
              '| version = ' + ( version || 'X.X.X' ) + '\n' +
        '| version = NEW.VERSION\n' +
              '| date    = ' + new Date().toISOString().split( 'T' )[ 0 ] + '\n' +
        '| date    = ' + new Date().toISOString().split( 'T' )[ 0 ] + '\n' +
              '| type    = Stable\n' +
        '| type    = Stable\n' +
              '| description = \n' +
        '| description = \n' +
              '* \'\'\'Added\'\'\'\n' +
        '* \'\'\'Added\'\'\'\n' +
              '** Describe new feature here\n' +
        '** New feature\n' +
              '* \'\'\'Fixed\'\'\'\n' +
        '* \'\'\'Fixed\'\'\'\n' +
              '** Describe bug fix here\n' +
        '** Bug fix\n' +
              '}}\n';
        '}}\n';
    }


     function init() {
     function init() {
         var $rows = $( '.fw-version-history tr[id^="fw-v"]' );
         var $table = $( '.fw-version-history' );
         if ( !$rows.length ) return;
        if ( !$table.length ) return;
 
        // ===== Add prominent "+ Add New Version" button above table =====
        var $addBtn = $(
            '<div class="fw-add-version-bar">' +
                '<button class="fw-btn-add-new">+ Add New Version</button>' +
                '<span class="fw-add-hint">Hover any row for more options (Edit / Insert Above / Insert Below)</span>' +
            '</div>'
        );
         $table.before( $addBtn );
 
        $addBtn.find( '.fw-btn-add-new' ).on( 'click', function () {
            promptAndAdd( null, 'top' );
        });


        // ===== Add hover toolbar to each row =====
        var $rows = $table.find( 'tr[id^="fw-v"]' );
         $rows.each( function () {
         $rows.each( function () {
             var $row = $( this );
             var $row = $( this );
             var rowId = $row.attr( 'id' ); // e.g., "fw-v1.2.0"
             var rowId = $row.attr( 'id' );
             var version = rowId.replace( /^fw-v/, '' );
             var version = rowId.replace( /^fw-v/, '' );


            // Toolbar HTML
             var $toolbar = $(
             var $toolbar = $(
                 '<div class="fw-row-toolbar">' +
                 '<div class="fw-row-toolbar">' +
                     '<button class="fw-btn fw-btn-edit" title="Edit this version">✏ Edit</button>' +
                     '<button class="fw-btn fw-btn-edit"       title="Edit this version">✏ Edit</button>' +
                     '<button class="fw-btn fw-btn-add-above" title="Add new version above">⬆ Add Above</button>' +
                     '<button class="fw-btn fw-btn-add-above" title="Insert new version above">⬆ Insert Above</button>' +
                     '<button class="fw-btn fw-btn-add-below" title="Add new version below">⬇ Add Below</button>' +
                     '<button class="fw-btn fw-btn-add-below" title="Insert new version below">⬇ Insert Below</button>' +
                 '</div>'
                 '</div>'
             );
             );
Line 433: Line 447:
             $row.find( 'td:first' ).css( 'position', 'relative' ).append( $toolbar );
             $row.find( 'td:first' ).css( 'position', 'relative' ).append( $toolbar );


            // Event handlers
             $toolbar.find( '.fw-btn-edit' ).on( 'click', function ( e ) {
             $toolbar.find( '.fw-btn-edit' ).on( 'click', function ( e ) {
                 e.stopPropagation();
                 e.stopPropagation();
                 editRow( version );
                 window.location.href = mw.util.getUrl( null, { action: 'edit' } ) + '#fw-v' + version;
             });
             });


             $toolbar.find( '.fw-btn-add-above' ).on( 'click', function ( e ) {
             $toolbar.find( '.fw-btn-add-above' ).on( 'click', function ( e ) {
                 e.stopPropagation();
                 e.stopPropagation();
                 addRow( version, 'above' );
                 promptAndAdd( version, 'above' );
             });
             });


             $toolbar.find( '.fw-btn-add-below' ).on( 'click', function ( e ) {
             $toolbar.find( '.fw-btn-add-below' ).on( 'click', function ( e ) {
                 e.stopPropagation();
                 e.stopPropagation();
                 addRow( version, 'below' );
                 promptAndAdd( version, 'below' );
             });
             });
         });
         });
     }
     }


    /**
     function promptAndAdd( targetVersion, position ) {
    * Open page in edit mode, scroll to the template block of target version
         var newVersion = prompt(
    */
            'Enter new firmware version (e.g., 1.3.0):',
     function editRow( version ) {
            ''
         var url = mw.util.getUrl( null, { action: 'edit' } ) + '#fw-edit-v' + version;
        );
         window.location.href = url;
         if ( !newVersion ) return;
    }


    /**
        newVersion = newVersion.trim();
    * Insert new {{FW-Row-Detail}} block above or below target version
         if ( !/^\d+\.\d+\.\d+/.test( newVersion ) ) {
    */
            mw.notify( 'Invalid version format. Use SemVer like 1.3.0', { type: 'warn' } );
    function addRow( targetVersion, position ) {
         if ( !confirm( 'Add new version ' + ( position === 'above' ? 'ABOVE' : 'BELOW' ) + ' v' + targetVersion + '?' ) ) {
             return;
             return;
         }
         }


        insertRow( newVersion, targetVersion, position );
    }
    function insertRow( newVersion, targetVersion, position ) {
         var api = new mw.Api();
         var api = new mw.Api();
        var newBlock = buildNewRowTemplate( newVersion );
        mw.notify( 'Adding v' + newVersion + '...', { type: 'info', autoHide: false, tag: 'fw-add' } );


        // 1. Lấy wikitext hiện tại
         api.get({
         api.get({
             action: 'parse',
             action: 'parse',
Line 477: Line 493:
         }).done( function ( data ) {
         }).done( function ( data ) {
             var wikitext = data.parse.wikitext;
             var wikitext = data.parse.wikitext;
            var newWikitext;


             // 2. Tìm block template của target version
             if ( position === 'top' || !targetVersion ) {
            var escapedVersion = targetVersion.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
                // Add ngay sau header table
            var blockPattern = new RegExp(
                var headerPattern = /(\|-\s*\n\s*!\s*[^\n]*Version[^\n]*\n)/;
                '\\{\\{\\s*' + TEMPLATE_NAME + '[\\s\\S]*?version\\s*=\\s*' + escapedVersion + '[\\s\\S]*?\\}\\}',
                var headerMatch = wikitext.match( headerPattern );
                'i'
                if ( headerMatch ) {
            );
                    newWikitext = wikitext.replace( headerMatch[ 0 ], headerMatch[ 0 ] + newBlock );
 
                 } else {
            var match = wikitext.match( blockPattern );
                    mw.notify( 'Cannot locate table header', { type: 'error', tag: 'fw-add' } );
            if ( !match ) {
                    return;
                 mw.notify( 'Cannot find template block for v' + targetVersion, { type: 'error' } );
                }
                return;
            }
 
            // 3. Insert new block above/below
            var newWikitext;
            if ( position === 'above' ) {
                newWikitext = wikitext.replace( match[ 0 ], NEW_ROW_TEMPLATE + '\n' + match[ 0 ] );
             } else {
             } else {
                 newWikitext = wikitext.replace( match[ 0 ], match[ 0 ] + '\n' + NEW_ROW_TEMPLATE );
                 // Insert above/below target version block
                var escVer = targetVersion.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
                var blockPattern = new RegExp(
                    '\\{\\{\\s*' + TEMPLATE_NAME + '[\\s\\S]*?version\\s*=\\s*' + escVer + '[\\s\\S]*?\\}\\}',
                    'i'
                );
                var match = wikitext.match( blockPattern );
                if ( !match ) {
                    mw.notify( 'Cannot find block for v' + targetVersion, { type: 'error', tag: 'fw-add' } );
                    return;
                }
                if ( position === 'above' ) {
                    newWikitext = wikitext.replace( match[ 0 ], newBlock + match[ 0 ] );
                } else {
                    newWikitext = wikitext.replace( match[ 0 ], match[ 0 ] + '\n' + newBlock );
                }
             }
             }


             // 4. Save via API
             // Save via API
             api.postWithToken( 'csrf', {
             api.postWithToken( 'csrf', {
                 action: 'edit',
                 action: 'edit',
                 title: mw.config.get( 'wgPageName' ),
                 title: mw.config.get( 'wgPageName' ),
                 text: newWikitext,
                 text: newWikitext,
                 summary: 'Added new firmware version row ' + position + ' v' + targetVersion + ' (via row action toolbar)',
                 summary: 'Added firmware version v' + newVersion + ' (via row action)',
                 formatversion: 2
                 formatversion: 2
             }).done( function () {
             }).done( function () {
                 mw.notify( '✓ Row added. Reloading...', { type: 'success' } );
                 mw.notify( '✓ Added v' + newVersion + '. Opening editor...', { type: 'success', tag: 'fw-add' } );
                 setTimeout( function () {
                 setTimeout( function () {
                    // Redirect to edit mode để user fill values
                     window.location.href = mw.util.getUrl( null, { action: 'edit' } ) + '#fw-v' + newVersion;
                     window.location.href = mw.util.getUrl( null, { action: 'edit' } );
                 }, 1000 );
                 }, 800 );
             }).fail( function ( err ) {
             }).fail( function ( err ) {
                 mw.notify( 'Save failed: ' + err, { type: 'error' } );
                 mw.notify( 'Save failed: ' + err, { type: 'error', tag: 'fw-add' } );
             });
             });
         }).fail( function ( err ) {
         }).fail( function ( err ) {
             mw.notify( 'Failed to fetch page content: ' + err, { type: 'error' } );
             mw.notify( 'Fetch failed: ' + err, { type: 'error', tag: 'fw-add' } );
         });
         });
     }
     }

Revision as of 08:30, 15 May 2026

(function () {
    function esc(text) {
        return String(text || "").replace(/[&<>"]/g, function (m) {
            return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[m];
        });
    }

    function renderLink(item, extraClass) {
        var cls = extraClass ? ' class="' + extraClass + '"' : "";
        var href = item.href ? esc(item.href) : "#";
        return '<a' + cls + ' href="' + href + '">' + esc(item.label) + "</a>";
    }

    function buildFooter(data) {
        var columns = (data.columns || []).map(function (col) {
            var items = (col.items || []).map(function (item) {
                return "<li>" + renderLink(item, "soji-footer-link") + "</li>";
            }).join("");

            return [
                '<section class="soji-footer-col">',
                "<h2>" + esc(col.title) + "</h2>",
                "<ul>" + items + "</ul>",
                "</section>"
            ].join("");
        }).join("");

        var policies = (data.policies || []).map(function (item, i) {
            return (i ? '<span class="soji-footer-sep">|</span>' : "") +
                renderLink(item, "soji-footer-link");
        }).join("");

        var social = (data.social || []).map(function (item) {
            var href = item.href ? esc(item.href) : "#";
            return '<a class="soji-footer-social-item" href="' + href + '" aria-label="' + esc(item.label) + '">' +
                esc(item.short || item.label.charAt(0)) + "</a>";
        }).join("");

        var subscribe = data.subscribe
            ? renderLink(data.subscribe, "soji-footer-subscribe")
            : "";

        return [
            '<section id="soji-site-footer" class="soji-site-footer">',
            '<div class="soji-footer-shell">',
            '<div class="soji-footer-grid">' + columns + "</div>",
            '<div class="soji-footer-bottom">',
            '<div class="soji-footer-meta">',
            '<div class="soji-footer-policy-row">' + policies + "</div>",
            '<div class="soji-footer-copy">' + esc(data.copyright || "") + "</div>",
            "</div>",
            '<div class="soji-footer-actions">',
            subscribe,
            '<div class="soji-footer-social">',
            '<span class="soji-footer-social-label">Connect</span>',
            '<div class="soji-footer-social-list">' + social + "</div>",
            "</div>",
            "</div>",
            "</div>",
            "</div>",
            "</section>"
        ].join("");
    }

    function injectFooter(data) {
        var footer = document.getElementById("footer");
        if (!footer || document.getElementById("soji-site-footer")) return;
        footer.insertAdjacentHTML("beforebegin", buildFooter(data));
        document.body.classList.add("has-soji-footer");
    }

    function init() {
        fetch("/index.php?title=MediaWiki:FooterData.json&action=raw")
            .then(function (r) { return r.text(); })
            .then(function (text) { return JSON.parse(text); })
            .then(injectFooter)
            .catch(function (err) { console.error("Footer load failed:", err); });
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", init);
    } else {
        init();
    }
})();

$(function () {
    if (mw.config.get('wgNamespaceNumber') < 0) return;

    var title = mw.config.get('wgTitle');
    var pageName = mw.config.get('wgPageName').replace(/_/g, ' ');

    // Page Main Page: không cần breadcrumb
    if (pageName === 'Main Page') return;

    // Lấy category trực tiếp của page hoặc của category page hiện tại
    var initialCats = mw.config.get('wgCategories') || [];
    if (!initialCats.length) return;

    var api = new mw.Api();
    var chain = [];
    var seen = {};

    // Đi ngược lên cây qua API
    function walkUp(catName) {
        if (!catName || seen[catName] || catName === 'Main Page') {
            return $.Deferred().resolve();
        }
        seen[catName] = true;
        chain.unshift(catName);

        return api.get({
            action: 'query',
            titles: 'Category:' + catName,
            prop: 'categories',
            cllimit: 1,
            format: 'json',
            formatversion: 2
        }).then(function (data) {
            var pages = (data.query && data.query.pages) || [];
            if (!pages.length || !pages[0].categories) return;
            var parent = pages[0].categories[0].title.replace(/^Category:/, '');
            return walkUp(parent);
        });
    }

    walkUp(initialCats[0]).then(function () {
        var parts = [
            '<a href="' + mw.util.getUrl('Main Page') + '">Main Page</a>'
        ];
        chain.forEach(function (c) {
            parts.push('<a href="' + mw.util.getUrl(c) + '">' + c + '</a>');
        });
        parts.push('<strong>' + title + '</strong>');

        var html = '<nav class="custom-breadcrumb">' + parts.join(' &gt; ') + '</nav>';
        $('#mw-content-text').before(html);
    });
});

$(function () {
    // Tự động thêm target="_blank" cho mọi link tới file .pdf
    $('a[href$=".pdf"], a[href*=".pdf?"], a[href*="/file/"][href*=".pdf"]').each(function () {
        $(this).attr('target', '_blank');
        $(this).attr('rel', 'noopener noreferrer');
        // Thêm icon PDF nhỏ
        if (!$(this).find('.pdf-icon').length) {
            $(this).append(' <span class="pdf-icon" title="Open PDF in new tab">📄</span>');
        }
    });
});

$(function () {
    // Force "View" links to open in new tab
    $('.doc-view a').attr({
        target: '_blank',
        rel: 'noopener noreferrer'
    });

    // Force "Download" links to trigger file download
    $('.doc-download a').each(function () {
        var $a = $(this);
        var href = $a.attr('href');
        // Extract filename from URL
        var match = href.match(/\/([^\/]+\.(?:pdf|zip|doc|docx|xls|xlsx))$/i);
        if (match) {
            $a.attr('download', match[1]);
        } else {
            $a.attr('download', '');
        }
    });
});

/* ============================================
 * Auto-collapse TOC sub-headings on page load
 * Only show H2 (main Heading) by default
 * ============================================ */
$(function () {
    var attempts = 0;
    var maxAttempts = 15;

    function collapseAllTOC() {
        // Find all expanded toggle buttons in TOC
        var $toggles = $('.vector-toc-toggle[aria-expanded="true"]');
        
        if ($toggles.length > 0) {
            $toggles.each(function () {
                // Click toggle to collapse (triggers Vue.js handler)
                this.click();
            });
        } else if (attempts < maxAttempts) {
            // TOC chưa render xong, retry
            attempts++;
            setTimeout(collapseAllTOC, 100);
        }
    }

    collapseAllTOC();
});

/* ============================================
 * Sync chevron arrow with collapsible state
 * ============================================ */
$(function () {
    $('.category-block-toggle span[class*="mw-customtoggle-cb-"]').each(function () {
        var $toggle = $(this);
        var match = $toggle.attr('class').match(/mw-customtoggle-(\S+)/);
        if (!match) return;

        var $target = $('#mw-customcollapsible-' + match[1]);
        if (!$target.length) return;

        function syncArrow() {
            if ($target.hasClass('mw-collapsed')) {
                $toggle.removeClass('is-expanded');
            } else {
                $toggle.addClass('is-expanded');
            }
        }

        // Initial state
        syncArrow();

        // Update on click
        $toggle.on('click', function () {
            setTimeout(syncArrow, 50);
        });
    });
});

/* ============================================
 * Pending Changes Badge for Reviewers
 * Show notification of pending changes count
 * ============================================ */
$(function () {
    // Chỉ show cho user có quyền review (reviewer, sysop, admin)
    var userGroups = mw.config.get('wgUserGroups') || [];
    var hasReviewRights = userGroups.some(function (g) {
        return ['reviewer', 'sysop', 'bureaucrat'].indexOf(g) !== -1;
    });

    if (!hasReviewRights) {
        return;
    }

    // Fetch pending count from API
    function fetchPendingCount() {
        new mw.Api().get({
            action: 'query',
            list: 'oldreviewedpages',
            ornamespace: '0|14',
            orfilterredir: 'nonredirects',
            orlimit: 500,
            format: 'json'
        }).done(function (data) {
            var pages = (data.query && data.query.oldreviewedpages) || [];
            updateBadge(pages.length);
        }).fail(function (err) {
            console.log('Failed to fetch pending changes:', err);
        });
    }

    // Update or create badge in sidebar
    function updateBadge(count) {
        var $badge = $('#pending-reviews-badge');

        if (!$badge.length) {
            // Tạo badge mới — tìm sidebar và prepend
            var badgeHtml =
                '<div id="pending-reviews-badge" style="' +
                'padding: 12px 16px;' +
                'margin: 12px 8px;' +
                'background: #fff3e0;' +
                'border: 1px solid #f57c00;' +
                'border-left: 4px solid #e65100;' +
                'border-radius: 4px;' +
                'cursor: pointer;' +
                'transition: background 0.2s;' +
                '">' +
                '<a href="' + mw.util.getUrl('Special:PendingChanges') + '" style="' +
                'color: #e65100;' +
                'text-decoration: none;' +
                'font-weight: 600;' +
                'font-size: 0.95em;' +
                'display: block;' +
                '">' +
                '⚠️ <span id="pending-count" style="' +
                'display: inline-block;' +
                'background: #e65100;' +
                'color: white;' +
                'padding: 2px 10px;' +
                'border-radius: 12px;' +
                'margin: 0 6px;' +
                'font-weight: 700;' +
                '">0</span>' +
                'pending review' +
                '</a>' +
                '</div>';

            // Try multiple sidebar selectors (Vector 2022 + legacy)
            var $sidebar = $('#mw-panel .vector-main-menu-content')
                .add('#mw-panel-toc-list')
                .add('#mw-panel')
                .add('.vector-main-menu')
                .first();

            if ($sidebar.length) {
                $sidebar.prepend(badgeHtml);
                $badge = $('#pending-reviews-badge');
            } else {
                // Fallback: insert at top of body
                $('body').prepend(
                    '<div style="position:fixed;top:60px;right:20px;z-index:9999;">' +
                    badgeHtml + '</div>'
                );
                $badge = $('#pending-reviews-badge');
            }
        }

        $('#pending-count').text(count);

        if (count === 0) {
            $badge.hide();
        } else {
            $badge.show();
        }
    }

    // Initial fetch
    fetchPendingCount();

    // Refresh every 60 seconds
    setInterval(fetchPendingCount, 60000);

    // Also refresh on focus (when user comes back to tab)
    $(window).on('focus', fetchPendingCount);
});

/* ============================================================
 *  Placeholder auto-hide
 *  Logic: section nào có user content → ẩn placeholder trong section đó
 *         section trống chỉ có placeholder → vẫn hiển thị (làm hint)
 * ============================================================ */
( function ( $ ) {
    'use strict';

    function hidePlaceholdersInFilledSections() {
        var $headings = $( '.mw-parser-output h1, .mw-parser-output h2' );
        if ( !$headings.length ) return;

        $headings.each( function ( i ) {
            var $h = $( this );
            var $nextH = $headings.eq( i + 1 );
            var $sectionEls = $nextH.length ? $h.nextUntil( $nextH ) : $h.nextAll();

            // Đọc text trong section, loại trừ text của placeholder
            var contentText = '';
            $sectionEls.each( function () {
                var $clone = $( this ).clone();
                $clone.find( '.soji-placeholder' ).remove();
                contentText += $clone.text();
            });

            // Nếu section có user content (ngoài placeholder) → ẩn placeholder
            if ( contentText.trim().length > 0 ) {
                $sectionEls.find( '.soji-placeholder' ).hide();
            }
        });

        // Xử lý riêng placeholder trong table cell:
        // Nếu cell có content khác → ẩn placeholder của cell đó
        $( '.soji-placeholder:visible' ).each( function () {
            var $ph = $( this );
            var $cell = $ph.closest( 'td, th' );
            if ( $cell.length ) {
                var $clone = $cell.clone();
                $clone.find( '.soji-placeholder' ).remove();
                if ( $clone.text().trim().length > 0 ) {
                    $ph.hide();
                }
            }
        });
    }

    $( hidePlaceholdersInFilledSections );

})( jQuery );

/* ============================================================
 *  Version History Table Toolbar
 *  Prominent "+ Add New Version" button above the table
 *  Plus hover row actions: Edit / Insert Above / Insert Below
 * ============================================================ */
( function ( $, mw ) {
    'use strict';

    if ( !mw.config.get( 'wgIsArticle' ) ) return;
    if ( mw.user.isAnon() ) return;

    var TEMPLATE_NAME = 'FW-Row-Detail';

    function buildNewRowTemplate( version ) {
        return '{{' + TEMPLATE_NAME + '\n' +
               '| version = ' + ( version || 'X.X.X' ) + '\n' +
               '| date    = ' + new Date().toISOString().split( 'T' )[ 0 ] + '\n' +
               '| type    = Stable\n' +
               '| description = \n' +
               '* \'\'\'Added\'\'\'\n' +
               '** Describe new feature here\n' +
               '* \'\'\'Fixed\'\'\'\n' +
               '** Describe bug fix here\n' +
               '}}\n';
    }

    function init() {
        var $table = $( '.fw-version-history' );
        if ( !$table.length ) return;

        // ===== Add prominent "+ Add New Version" button above table =====
        var $addBtn = $(
            '<div class="fw-add-version-bar">' +
                '<button class="fw-btn-add-new">+ Add New Version</button>' +
                '<span class="fw-add-hint">Hover any row for more options (Edit / Insert Above / Insert Below)</span>' +
            '</div>'
        );
        $table.before( $addBtn );

        $addBtn.find( '.fw-btn-add-new' ).on( 'click', function () {
            promptAndAdd( null, 'top' );
        });

        // ===== Add hover toolbar to each row =====
        var $rows = $table.find( 'tr[id^="fw-v"]' );
        $rows.each( function () {
            var $row = $( this );
            var rowId = $row.attr( 'id' );
            var version = rowId.replace( /^fw-v/, '' );

            var $toolbar = $(
                '<div class="fw-row-toolbar">' +
                    '<button class="fw-btn fw-btn-edit"       title="Edit this version">✏ Edit</button>' +
                    '<button class="fw-btn fw-btn-add-above" title="Insert new version above">⬆ Insert Above</button>' +
                    '<button class="fw-btn fw-btn-add-below" title="Insert new version below">⬇ Insert Below</button>' +
                '</div>'
            );

            $row.find( 'td:first' ).css( 'position', 'relative' ).append( $toolbar );

            $toolbar.find( '.fw-btn-edit' ).on( 'click', function ( e ) {
                e.stopPropagation();
                window.location.href = mw.util.getUrl( null, { action: 'edit' } ) + '#fw-v' + version;
            });

            $toolbar.find( '.fw-btn-add-above' ).on( 'click', function ( e ) {
                e.stopPropagation();
                promptAndAdd( version, 'above' );
            });

            $toolbar.find( '.fw-btn-add-below' ).on( 'click', function ( e ) {
                e.stopPropagation();
                promptAndAdd( version, 'below' );
            });
        });
    }

    function promptAndAdd( targetVersion, position ) {
        var newVersion = prompt(
            'Enter new firmware version (e.g., 1.3.0):',
            ''
        );
        if ( !newVersion ) return;

        newVersion = newVersion.trim();
        if ( !/^\d+\.\d+\.\d+/.test( newVersion ) ) {
            mw.notify( 'Invalid version format. Use SemVer like 1.3.0', { type: 'warn' } );
            return;
        }

        insertRow( newVersion, targetVersion, position );
    }

    function insertRow( newVersion, targetVersion, position ) {
        var api = new mw.Api();
        var newBlock = buildNewRowTemplate( newVersion );

        mw.notify( 'Adding v' + newVersion + '...', { type: 'info', autoHide: false, tag: 'fw-add' } );

        api.get({
            action: 'parse',
            page: mw.config.get( 'wgPageName' ),
            prop: 'wikitext',
            formatversion: 2
        }).done( function ( data ) {
            var wikitext = data.parse.wikitext;
            var newWikitext;

            if ( position === 'top' || !targetVersion ) {
                // Add ngay sau header table
                var headerPattern = /(\|-\s*\n\s*!\s*[^\n]*Version[^\n]*\n)/;
                var headerMatch = wikitext.match( headerPattern );
                if ( headerMatch ) {
                    newWikitext = wikitext.replace( headerMatch[ 0 ], headerMatch[ 0 ] + newBlock );
                } else {
                    mw.notify( 'Cannot locate table header', { type: 'error', tag: 'fw-add' } );
                    return;
                }
            } else {
                // Insert above/below target version block
                var escVer = targetVersion.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
                var blockPattern = new RegExp(
                    '\\{\\{\\s*' + TEMPLATE_NAME + '[\\s\\S]*?version\\s*=\\s*' + escVer + '[\\s\\S]*?\\}\\}',
                    'i'
                );
                var match = wikitext.match( blockPattern );
                if ( !match ) {
                    mw.notify( 'Cannot find block for v' + targetVersion, { type: 'error', tag: 'fw-add' } );
                    return;
                }
                if ( position === 'above' ) {
                    newWikitext = wikitext.replace( match[ 0 ], newBlock + match[ 0 ] );
                } else {
                    newWikitext = wikitext.replace( match[ 0 ], match[ 0 ] + '\n' + newBlock );
                }
            }

            // Save via API
            api.postWithToken( 'csrf', {
                action: 'edit',
                title: mw.config.get( 'wgPageName' ),
                text: newWikitext,
                summary: 'Added firmware version v' + newVersion + ' (via row action)',
                formatversion: 2
            }).done( function () {
                mw.notify( '✓ Added v' + newVersion + '. Opening editor...', { type: 'success', tag: 'fw-add' } );
                setTimeout( function () {
                    window.location.href = mw.util.getUrl( null, { action: 'edit' } ) + '#fw-v' + newVersion;
                }, 1000 );
            }).fail( function ( err ) {
                mw.notify( 'Save failed: ' + err, { type: 'error', tag: 'fw-add' } );
            });
        }).fail( function ( err ) {
            mw.notify( 'Fetch failed: ' + err, { type: 'error', tag: 'fw-add' } );
        });
    }

    $( init );

})( jQuery, mediaWiki );
SOJI Electronics