Jump to content

MediaWiki:Gadget-GrAnnotations.js: Difference between revisions

From Grantha
No edit summary
No edit summary
 
(7 intermediate revisions by the same user not shown)
Line 11: Line 11:
  *    - Opens an inline composer card anchored to the selection position.
  *    - Opens an inline composer card anchored to the selection position.
  *    - On submit: wraps the selected text in a yellow highlight span,
  *    - On submit: wraps the selected text in a yellow highlight span,
  *      saves the comment to <PageTitle>/Comments via MW edit API,
  *      saves the comment to Talk:<PageTitle>/GrComments via MW edit API,
  *      posts a notification to the admin user talk page,
  *      sends email notification to admin,
  *      and adds a card to the Comments tab of the right panel.
  *      and adds a card to the Comments tab of the right panel.
*    - Each comment card has a delete (×) button (logged-in users only).
*    - On delete: removes from wiki page, localStorage anchor, and DOM;
*      sends a deletion notification email to admin.
  * 4. Clicking Bookmark:
  * 4. Clicking Bookmark:
  *    - Opens a small composer to name the bookmark.
  *    - Opens a small composer to name the bookmark.
Line 19: Line 22:
  *      saves bookmark in localStorage (per-user, per-page),
  *      saves bookmark in localStorage (per-user, per-page),
  *      and adds a card to the Bookmarks tab.
  *      and adds a card to the Bookmarks tab.
*    - Bookmark stores quote + quoteIndex (nth occurrence) so restore
*      after page refresh lands on the correct match even when the same
*      text appears multiple times on the page (e.g. Sanskrit verses).
  *    - Clicking a bookmark card scrolls to and flashes that highlight.
  *    - Clicking a bookmark card scrolls to and flashes that highlight.
  * 5. Right panel:
  * 5. Right panel:
  *    - Slides in from the right as an overlay (no layout shift).
  *    - Slides in from the right as an overlay (no layout shift).
  *    - Tab 1: Comments  (loads from /Comments wiki page)
  *    - Tab 1: Comments  (loads from Talk:/GrComments wiki page)
  *    - Tab 2: Bookmarks (loads from localStorage)
  *    - Tab 2: Bookmarks (loads from localStorage)
  *    - Panel can be closed via ✕ or clicking the backdrop.
  *    - Panel can be closed via ✕ or clicking the backdrop.
Line 31: Line 37:
  * ───────
  * ───────
  * Comments  → MediaWiki page: Talk:<PageTitle>/GrComments
  * Comments  → MediaWiki page: Talk:<PageTitle>/GrComments
*            Talk namespace is excluded from XML/PDF exports and never
*            shown in the content editor — comments cannot affect exports.
  *            Format: {{GrComment|id=…|author=…|timestamp=…|quote=…|text=…}}
  *            Format: {{GrComment|id=…|author=…|timestamp=…|quote=…|text=…}}
  * Bookmarks → localStorage (per-user, per-page — not shared across users)
  * Bookmarks → localStorage Key: grantha_bm_<pageName>
  *             Key: grantha_bm_<pageName>
*            Each entry includes quoteIndex (0-based nth occurrence).
  * Highlight anchors → localStorage  Key: grantha_cmt_<pageName>
*            Stores {id, quote, quoteIndex} for comment highlight restore.
  *
  *
  * ADMIN NOTIFICATION
  * ADMIN NOTIFICATION
  * ──────────────────
  * ──────────────────
  * On new comment: sends email via MW EmailUser API (action=emailuser).
  * On new comment AND on comment delete: sends email via MW EmailUser API.
* Email contains the comment text and a direct anchor link to the passage.
  * Fallback: posts to User_talk:ADMIN_USER for Echo notification.
  * Fallback: if admin has no email set, posts to User_talk:ADMIN_USER instead.
  * Requires $wgEnableEmail = true and $wgEnableUserEmail = true (MW defaults).
  * Requires $wgEnableEmail = true and $wgEnableUserEmail = true (MW defaults).
  *
  *
Line 49: Line 54:
  * • mw.Api  (MediaWiki core)
  * • mw.Api  (MediaWiki core)
  * • gr_annotations.css  (companion stylesheet)
  * • gr_annotations.css  (companion stylesheet)
* • /images/commentary.svg, /images/bookmark.svg  (Hostinger)
*
* DEPLOY
* ──────
* Add both files to MediaWiki:Common.css / Common.js, or register as
* a gadget in MediaWiki:Gadgets-definition:
*  GrAnnotations[ResourceLoader|default]|gr_annotations.js|gr_annotations.css
  * ══════════════════════════════════════════════════════════════════════
  * ══════════════════════════════════════════════════════════════════════
  */
  */
Line 64: Line 62:


   // ── Configuration ────────────────────────────────────────────────────
   // ── Configuration ────────────────────────────────────────────────────
   var ADMIN_USER  = 'GranthaGate';   // MW username for talk-page fallback
   var ADMIN_USER  = 'GranthaGate';
  var ADMIN_EMAIL = 'admin@grantha.io'; // ← set your email here directly
   var CONTENT_SEL = '#mw-content-text';
   var CONTENT_SEL = '#mw-content-text';
   var BM_LS_KEY  = 'grantha_bm_'  + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
   var BM_LS_KEY  = 'grantha_bm_'  + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
   var CMT_LS_KEY  = 'grantha_cmt_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
   var CMT_LS_KEY  = 'grantha_cmt_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
   var pageTitle  = ( window.mw && mw.config.get( 'wgPageName' ) ) || '';
   var pageTitle  = ( window.mw && mw.config.get( 'wgPageName' ) ) || '';
  /* Comments stored on Talk:<PageTitle>/GrComments — talk namespace is
  * excluded from XML/PDF exports and never shown in the content editor,
  * so comments cannot tamper with document content or affect exports. */
   var commentsPage = 'Talk:' + pageTitle + '/GrComments';
   var commentsPage = 'Talk:' + pageTitle + '/GrComments';
   var currentUser  = ( window.mw && mw.config.get( 'wgUserName' ) ) || '';
   var currentUser  = ( window.mw && mw.config.get( 'wgUserName' ) ) || '';
   var userInitial  = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?';
   var userInitial  = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?';


   // Only run on content namespaces, not on the /Comments page itself
   // Only run on content namespaces
   if ( window.mw ) {
   if ( window.mw ) {
     var ns = mw.config.get( 'wgNamespaceNumber' );
     var ns = mw.config.get( 'wgNamespaceNumber' );
Line 85: Line 79:


   // ── State ────────────────────────────────────────────────────────────
   // ── State ────────────────────────────────────────────────────────────
  var _sel        = null;  // current Selection snapshot
   var _selRange  = null;
   var _selRange  = null;   // saved Range for wrapping
   var _selText    = '';
   var _selText    = '';     // selected text string
   var _selRect    = null;
   var _selRect    = null;   // bounding rect of selection
   var _comments  = [];
   var _comments  = [];     // [{id, anchor, author, ts, quote, text}]
   var _bookmarks  = [];
   var _bookmarks  = [];     // [{id, name, quote, anchorHtml, ts}]
   var _cmtLoaded  = false;
   var _cmtLoaded  = false;
   var _activeTab  = 'comments'; // 'comments' | 'bookmarks'
   var _activeTab  = 'comments';


   // ── Helpers ──────────────────────────────────────────────────────────
   // ── Helpers ──────────────────────────────────────────────────────────
Line 115: Line 108:
   function clamp( val, min, max ) { return Math.max(min, Math.min(max, val)); }
   function clamp( val, min, max ) { return Math.max(min, Math.min(max, val)); }


   // ── DOM references (populated in boot) ───────────────────────────────
   // ── DOM references (populated in buildDom) ───────────────────────────
   var $fab, $panel, $backdrop, $panelBody;
   var $fab, $panel, $backdrop, $panelBody;
   var $cmpComposer, $cmpInput, $cmpSubmit;
   var $cmpComposer, $cmpInput, $cmpSubmit;
Line 149: Line 142:
       '    <div class="gra-avatar" id="gra-cmp-avatar">' + esc(userInitial) + '</div>',
       '    <div class="gra-avatar" id="gra-cmp-avatar">' + esc(userInitial) + '</div>',
       '    <div class="gra-composer-name" id="gra-cmp-name">' + esc(currentUser || 'Anonymous') + '</div>',
       '    <div class="gra-composer-name" id="gra-cmp-name">' + esc(currentUser || 'Anonymous') + '</div>',
      '  </div>',
      '  <div class="gra-quick-chips" id="gra-quick-chips">',
      '    <button class="gra-chip" data-val="Spelling mistake" type="button">Spelling mistake</button>',
      '    <button class="gra-chip" data-val="Reference error" type="button">Reference error</button>',
      '    <button class="gra-chip" data-val="Others" type="button">Others ▾</button>',
       '  </div>',
       '  </div>',
       '  <textarea class="gra-composer-input" id="gra-cmp-input"',
       '  <textarea class="gra-composer-input" id="gra-cmp-input"',
       '    placeholder="Describe the issue…" rows="3"',
       '    placeholder="Comment or add others with @…" rows="3"></textarea>',
      '    style="display:none"></textarea>',
       '  <div class="gra-composer-actions">',
       '  <div class="gra-composer-actions">',
       '    <button class="gra-btn-cancel" id="gra-cmp-cancel">Cancel</button>',
       '    <button class="gra-btn-cancel" id="gra-cmp-cancel">Cancel</button>',
Line 164: Line 151:
       '</div>',
       '</div>',
     ].join('') );
     ].join('') );
 
    $( 'body' ).append( $cmpComposer );


     // ── Bookmark composer card ────────────────────────────────────────
     // ── Bookmark composer card ────────────────────────────────────────
Line 206: Line 193:
     $( 'body' ).append( $panel );
     $( 'body' ).append( $panel );


    // Backdrop
     $backdrop = $('<div id="gra-backdrop"></div>');
     $backdrop = $('<div id="gra-backdrop"></div>');
     $( 'body' ).append( $backdrop );
     $( 'body' ).append( $backdrop );


     // Persistent toggle button — always visible, opens the panel
     var badgeStyle = 'display:none;position:absolute;top:2px;right:2px;'
                  + 'min-width:16px;height:16px;padding:0 4px;'
                  + 'background:#e53935;color:#fff;'
                  + 'font-size:10px;font-weight:700;line-height:16px;'
                  + 'border-radius:8px;text-align:center;'
                  + 'pointer-events:none;box-sizing:border-box;';
     var $toggle = $( [
     var $toggle = $( [
       '<button id="gra-toggle" title="Comments &amp; Bookmarks">',
       '<button id="gra-toggle" title="Comments &amp; Bookmarks">',
       '  <span class="gra-icon gra-icon-comment" id="gra-toggle-icon"></span>',
       '  <span class="gra-icon gra-icon-comment" id="gra-toggle-icon"></span>',
       '  <span id="gra-toggle-badge"></span>',
       '  <span id="gra-toggle-badge" style="' + badgeStyle + '"></span>',
       '</button>',
       '</button>',
     ].join('') );
     ].join('') );
     $( 'body' ).append( $toggle );
     $( 'body' ).append( $toggle );
     $toggle.on( 'click', function() {
     $toggle.on( 'click', function() {
       if ( $panel.hasClass('gra-panel-open') ) {
       if ( $panel.hasClass('gra-panel-open') ) { closePanel(); }
        closePanel();
       else { openPanel( _activeTab ); }
       } else {
        openPanel( _activeTab );
      }
     } );
     } );


    // Cache references
     $panelBody   = $( '#gra-panel-body' );
     $panelBody     = $( '#gra-panel-body' );
    // Set panel title to page name
     $( '#gra-panel-head div' ).first()
     $( '#gra-panel-head div' ).first()
       .text( pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30) )
       .text( pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30) )
       .css({ 'font-size':'13px', 'font-weight':'600', 'color':'#5a3a00' });
       .css({ 'font-size':'13px', 'font-weight':'600', 'color':'#5a3a00' });
     $tabComments   = $( '#gra-tab-comments' );
     $tabComments = $( '#gra-tab-comments' );
     $tabBookmarks = $( '#gra-tab-bookmarks' );
     $tabBookmarks = $( '#gra-tab-bookmarks' );
     $paneComments = $( '#gra-pane-comments' );
     $paneComments = $( '#gra-pane-comments' );
     $paneBookmarks = $( '#gra-pane-bookmarks' );
     $paneBookmarks= $( '#gra-pane-bookmarks' );
     $cmpInput     = $( '#gra-cmp-input' );
     $cmpInput     = $( '#gra-cmp-input' );
     $cmpSubmit     = $( '#gra-cmp-submit' );
     $cmpSubmit   = $( '#gra-cmp-submit' );
     $bmInput       = $( '#gra-bm-input' );
     $bmInput     = $( '#gra-bm-input' );
     $bmSubmit     = $( '#gra-bm-submit' );
     $bmSubmit     = $( '#gra-bm-submit' );


    // Disable comment submit when not logged in
     if ( !currentUser ) {
     if ( !currentUser ) {
       $cmpSubmit.prop( 'disabled', true );
       $cmpSubmit.prop( 'disabled', true );
Line 251: Line 236:


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
   // FLOATING ACTION STRIP — position & show/hide
   // FAB — position & show/hide
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════


   function showFab( rect ) {
   function showFab( rect ) {
     if ( !rect ) return;
     if ( !rect ) return;
    // FAB uses position:fixed — coords are viewport-relative (no scroll offset needed).
     var fabW = 46, fabH = 84;
    // Place strip to the right of the selection; if it would go off-screen, place to the left.
     var fabW = 46; var fabH = 84;
     var top  = rect.top + ( rect.height / 2 ) - ( fabH / 2 );
     var top  = rect.top + ( rect.height / 2 ) - ( fabH / 2 );
     var left = rect.right + 10;
     var left = rect.right + 10;
 
     if ( left + fabW > window.innerWidth - 8 ) { left = rect.left - fabW - 10; }
    // If too close to right edge, flip to left of selection
     top = clamp( top, 8, window.innerHeight - fabH - 8 );
     if ( left + fabW > window.innerWidth - 8 ) {
     left = clamp( left, 8, window.innerWidth - fabW - 8 );
      left = rect.left - fabW - 10;
    }
    // Clamp vertically within viewport
     top = clamp( top, 8, window.innerHeight - fabH - 8 );
    // Clamp horizontally
     left = clamp( left, 8, window.innerWidth - fabW - 8 );
 
     $fab.css({ top: top + 'px', left: left + 'px' }).addClass('gra-fab-visible');
     $fab.css({ top: top + 'px', left: left + 'px' }).addClass('gra-fab-visible');
   }
   }


   function hideFab() {
   function hideFab() { $fab.removeClass('gra-fab-visible'); }
    $fab.removeClass('gra-fab-visible');
  }


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
Line 285: Line 259:
     var sel = window.getSelection();
     var sel = window.getSelection();
     if ( !sel || sel.isCollapsed || !sel.rangeCount ) return false;
     if ( !sel || sel.isCollapsed || !sel.rangeCount ) return false;
 
     var range   = sel.getRangeAt(0);
     var range   = sel.getRangeAt(0);
     var text   = sel.toString().trim();
     var text     = sel.toString().trim();
     if ( !text || text.length < 2 ) return false;
     if ( !text || text.length < 2 ) return false;
    // Must be inside mw-content-text
     var contentEl = document.querySelector( CONTENT_SEL );
     var contentEl = document.querySelector( CONTENT_SEL );
     if ( !contentEl ) return false;
     if ( !contentEl ) return false;
Line 296: Line 267:
     if ( startEl.nodeType === 3 ) startEl = startEl.parentNode;
     if ( startEl.nodeType === 3 ) startEl = startEl.parentNode;
     if ( !contentEl.contains( startEl ) ) return false;
     if ( !contentEl.contains( startEl ) ) return false;
     _selText  = text;
     _selText  = text;
     _selRange = range.cloneRange();
     _selRange = range.cloneRange();
Line 309: Line 279:
   function positionComposer( $el ) {
   function positionComposer( $el ) {
     if ( !_selRect ) return;
     if ( !_selRect ) return;
    // position:fixed — viewport coords only
     var top  = _selRect.bottom + 8;
     var top  = _selRect.bottom + 8;
     var left = _selRect.left;
     var left = _selRect.left;
    // Keep composer within viewport
     var composerW = 308;
     var composerW = 308;
     if ( left + composerW > window.innerWidth - 8 ) {
     if ( left + composerW > window.innerWidth - 8 ) { left = window.innerWidth - composerW - 8; }
      left = window.innerWidth - composerW - 8;
    }
     left = Math.max( left, 8 );
     left = Math.max( left, 8 );
    // If composer would appear below viewport, show above selection instead
     if ( top + 160 > window.innerHeight ) { top = _selRect.top - 170; }
     if ( top + 160 > window.innerHeight ) {
      top = _selRect.top - 170;
    }
     top = Math.max( top, 8 );
     top = Math.max( top, 8 );
     $el.css({ top: top + 'px', left: left + 'px' });
     $el.css({ top: top + 'px', left: left + 'px' });
Line 332: Line 295:
   function wrapSelection( id, cssClass ) {
   function wrapSelection( id, cssClass ) {
     if ( !_selRange ) return null;
     if ( !_selRange ) return null;
    // Capture into local var and null out _selRange immediately so
    // surroundContents() mutation cannot corrupt future selections.
    var range = _selRange;
    _selRange = null;
     try {
     try {
       var span = document.createElement('span');
       var span = document.createElement('span');
       span.className = cssClass;
       span.className = cssClass;
       span.setAttribute('data-gra-id', id);
       span.setAttribute('data-gra-id', id);
       _selRange.surroundContents( span );
       range.surroundContents( span );
       return span;
       return span;
     } catch ( e ) {
     } catch ( e ) {
      // surroundContents fails for multi-element selections;
      // extract and wrap the fragment
       try {
       try {
         var frag = _selRange.extractContents();
         var frag = range.extractContents();
         var span2 = document.createElement('span');
         var span2 = document.createElement('span');
         span2.className = cssClass;
         span2.className = cssClass;
         span2.setAttribute('data-gra-id', id);
         span2.setAttribute('data-gra-id', id);
         span2.appendChild( frag );
         span2.appendChild( frag );
         _selRange.insertNode( span2 );
         range.insertNode( span2 );
         return span2;
         return span2;
       } catch(e2) { return null; }
       } catch(e2) { return null; }
     }
     }
  }
  // ════════════════════════════════════════════════════════════════════
  // OCCURRENCE INDEX
  // Returns the 0-based count of how many times needle appears in the
  // page text *before* the start of savedRange. This tells us which
  // occurrence was selected so we can restore the highlight accurately.
  // Must be called BEFORE wrapSelection() nulls out _selRange.
  // ════════════════════════════════════════════════════════════════════
  function computeQuoteIndex( needle, savedRange ) {
    if ( !savedRange || !needle ) return 0;
    var contentEl = document.querySelector( CONTENT_SEL );
    if ( !contentEl ) return 0;
    try {
      var preRange = document.createRange();
      preRange.setStart( contentEl, 0 );
      preRange.setEnd( savedRange.startContainer, savedRange.startOffset );
      var textBefore = preRange.toString();
      var count = 0, idx = 0;
      while ( ( idx = textBefore.indexOf( needle, idx ) ) !== -1 ) {
        count++;
        idx += needle.length;
      }
      return count; // 0 = first occurrence
    } catch(e) { return 0; }
   }
   }


Line 359: Line 350:
   function openCommentComposer() {
   function openCommentComposer() {
     hideFab();
     hideFab();
    // Keep selection alive — blur the textarea briefly then refocus
     positionComposer( $cmpComposer );
     positionComposer( $cmpComposer );
     $cmpComposer.addClass('gra-composer-visible');
     $cmpComposer.addClass('gra-composer-visible');
Line 367: Line 357:
   function closeCommentComposer() {
   function closeCommentComposer() {
     $cmpComposer.removeClass('gra-composer-visible');
     $cmpComposer.removeClass('gra-composer-visible');
    $( '#gra-quick-chips .gra-chip' ).removeClass('gra-chip-active');
     $cmpInput.val('');
     $cmpInput.hide().val('');
     $cmpSubmit.prop('disabled', true);
     $cmpSubmit.prop('disabled', true);
     _selRange = null;
     _selRange = null;
Line 382: Line 371:
     var quote = _selText.slice(0, 120) + ( _selText.length > 120 ? '…' : '' );
     var quote = _selText.slice(0, 120) + ( _selText.length > 120 ? '…' : '' );


     // Wrap text in highlight span
     // Compute occurrence index BEFORE wrapSelection nulls _selRange
    var needle    = _selText.replace(/…$/, '').trim().slice(0, 80);
    var quoteIndex = computeQuoteIndex( needle, _selRange );
 
     var span = wrapSelection( id, 'gra-comment-highlight' );
     var span = wrapSelection( id, 'gra-comment-highlight' );
     if ( span ) span.setAttribute('data-gra-quote', quote);
     if ( span ) span.setAttribute('data-gra-quote', quote);


    // Save locally
     var entry = { id:id, author:currentUser, ts:ts, quote:quote, text:text };
     var entry = { id:id, author:currentUser, ts:ts, quote:quote, text:text };
     _comments.push( entry );
     _comments.push( entry );
    // Persist highlight anchor so it survives page refresh
     persistCommentHighlight( id, quote, quoteIndex );
     persistCommentHighlight( id, quote );


    // Persist to wiki
     saveCommentToWiki( id, quote, text, ts );
     saveCommentToWiki( id, quote, text, ts );
     notifyAdmin( _activeId || '', quote, text, ts );
     notifyAdmin( 'add', id, quote, text, ts );


    // Update panel
     renderCommentCards();
     renderCommentCards();
     closeCommentComposer();
     closeCommentComposer();
     openPanel('comments');
     openPanel('comments');
  }
  // ── Delete comment ───────────────────────────────────────────────────
  function deleteComment( id ) {
    var entry = null;
    _comments = _comments.filter(function(c){
      if ( c.id === id ) { entry = c; return false; }
      return true;
    });
    // Remove highlight span from DOM
    var span = document.querySelector('[data-gra-id="' + id + '"].gra-comment-highlight');
    if ( span ) {
      var parent = span.parentNode;
      while ( span.firstChild ) parent.insertBefore( span.firstChild, span );
      parent.removeChild( span );
    }
    removeCommentHighlight( id );
    deleteCommentFromWiki( id );
    if ( entry ) {
      notifyAdmin( 'delete', id, entry.quote, entry.text, nowIso() );
    }
    renderCommentCards();
  }
  function deleteCommentFromWiki( id ) {
    if ( !window.mw ) return;
    var api = new mw.Api();
    api.get({
      action:'query', prop:'revisions', titles:commentsPage,
      rvprop:'content', rvslots:'main', formatversion:2,
    }).then(function(data){
      var page = (data.query.pages||[])[0]||{};
      var existing = '';
      if ( page.revisions && page.revisions[0] ) {
        var rev = page.revisions[0];
        existing = rev.slots ? rev.slots.main.content : rev['*'] || '';
      }
      if ( !existing ) return;
      // Remove the {{GrComment…}} block whose id field matches
      var re = new RegExp(
        '\\{\\{GrComment\\s*\\n(?:[^}]|\\}(?!\\}))*?\\|\\s*id\\s*=\\s*' +
        id.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') +
        '[\\s\\S]*?\\}\\}\\n?', 'g'
      );
      var updated = existing.replace( re, '' ).replace(/\n{3,}/g, '\n\n').trim();
      api.postWithEditToken({
        action:'edit', title:commentsPage, text:updated,
        summary:'Deleted comment ' + id + ' by ' + currentUser + ' on [[' + pageTitle + ']]',
        bot:0,
      }).catch(function(){});
    }).catch(function(){});
   }
   }


Line 411: Line 454:
       var page = (data.query.pages||[])[0]||{};
       var page = (data.query.pages||[])[0]||{};
       var existing = '';
       var existing = '';
       if (page.revisions && page.revisions[0]) {
       if ( page.revisions && page.revisions[0] ) {
         var rev = page.revisions[0];
         var rev = page.revisions[0];
         existing = rev.slots ? rev.slots.main.content : rev['*'] || '';
         existing = rev.slots ? rev.slots.main.content : rev['*'] || '';
Line 431: Line 474:
   }
   }


   function notifyAdmin( anchorId, quote, commentText, ts ) {
  // ── Admin notification (add AND delete) ──────────────────────────────
    /* Send notification email directly using MW's EmailUser API, targeting
   function notifyAdmin( action, anchorId, quote, commentText, ts ) {
    * ADMIN_EMAIL via the ADMIN_USER account — no server-side SMTP config needed.
     if ( !window.mw || !ADMIN_USER ) return;
    * ADMIN_EMAIL is set at the top of this file; change it to any address. */
     if ( !window.mw ) return;


     var articlePath = ( mw.config.get('wgArticlePath') || '/wiki/$1' )
     var articlePath = ( mw.config.get('wgArticlePath') || '/wiki/$1' ).replace( '$1', pageTitle );
                        .replace( '$1', pageTitle );
     var anchorLink  = window.location.origin + articlePath + ( anchorId ? '#' + anchorId : '' );
     var anchorLink  = window.location.origin + articlePath
                    + ( anchorId ? '#' + anchorId : '' );
     var pageDisplay = pageTitle.replace( /_/g, ' ' );
     var pageDisplay = pageTitle.replace( /_/g, ' ' );
     var subject     = '[Grantha] New comment on "' + pageDisplay + '"';
    var isDelete    = ( action === 'delete' );
     var body       = 'Page    : ' + pageDisplay + '\n'
 
                    + 'By      : ' + ( currentUser || 'Anonymous' ) + '\n'
     var subject = isDelete
                    + 'Time   : ' + ts + '\n'
      ? '[Grantha] Comment deleted on "' + pageDisplay + '"'
                    + 'Passage : "' + quote + '"\n\n'
      : '[Grantha] New comment on "'     + pageDisplay + '"';
                    + 'Comment :\n' + commentText + '\n\n'
 
                    + 'Link    : ' + anchorLink + '\n';
     var body = ( isDelete ? 'A comment was DELETED on ' : 'A new comment was posted on ' )
            + pageDisplay + '.\n\n'
            + ( isDelete ? 'Deleted by : ' : 'Posted by  : ' )
            + ( currentUser || 'Anonymous' ) + '\n'
            + 'Time       : ' + ts + '\n'
            + 'Passage   : "' + quote + '"\n\n'
            + 'Comment   :\n' + commentText + '\n\n'
            + '\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n'
            + 'Jump to passage:\n' + anchorLink + '\n\n'
            + 'View all comments:\n'
            + window.location.origin
            + ( mw.config.get('wgArticlePath') || '/wiki/$1' )
                .replace( '$1', 'Talk:' + pageTitle + '/GrComments' )
            + '\n';


    /* Primary: send via MW EmailUser API to ADMIN_USER account.
    * The admin account must have ADMIN_EMAIL set in Special:Preferences,
    * OR you can hardcode the target as any confirmed MW user. */
     new mw.Api().post({
     new mw.Api().post({
       action:  'emailuser',
       action:  'emailuser',
Line 460: Line 509:
       token:  mw.user.tokens.get( 'csrfToken' ),
       token:  mw.user.tokens.get( 'csrfToken' ),
     }).catch(function(){
     }).catch(function(){
       /* Fallback: post a section to admin talk page for Echo notification */
       /* Fallback: post to admin talk page for Echo notification */
       if ( !currentUser ) return;
       if ( !currentUser ) return;
       var wikimsg = '== New comment on [[' + pageDisplay + ']] ==\n'
      var verb    = isDelete ? 'deleted' : 'posted';
       var wikimsg = '== Comment ' + verb + ' on [[' + pageDisplay + ']] ==\n'
                   + '; By: ' + ( currentUser || 'Anonymous' ) + '\n'
                   + '; By: ' + ( currentUser || 'Anonymous' ) + '\n'
                   + '; Passage: //' + quote + '//\n'
                   + '; Passage: //' + quote + '//\n'
                   + '; Link: ' + anchorLink + '\n\n'
                   + '; Link: ' + anchorLink + '\n\n'
                   + commentText.slice(0,500)
                   + commentText.slice(0,500)
                   + ( commentText.length > 500 ? '\n...' : '' )
                   + ( commentText.length > 500 ? '\n…' : '' )
                   + '\n\n[[User:Chandrashekars|Chandrashekars]] ([[User talk:Chandrashekars|talk]]) 18:21, 25 April 2026 (UTC)';
                   + '\n\n--[[User:Chandrashekars|Chandrashekars]] ([[User talk:Chandrashekars|talk]]) 18:46, 26 April 2026 (UTC)';
       new mw.Api().postWithEditToken({
       new mw.Api().postWithEditToken({
         action:'edit', title:'User_talk:' + ADMIN_USER, section:'new',
         action:'edit', title:'User_talk:' + ADMIN_USER, section:'new',
         sectiontitle:'Comment on ' + pageDisplay,
         sectiontitle: ( isDelete ? 'Comment deleted on ' : 'Comment on ' ) + pageDisplay,
         text:wikimsg,
         text:wikimsg,
         summary:'Comment notification from [[' + pageDisplay + ']]',
         summary:'Comment notification from [[' + pageDisplay + ']]',
Line 479: Line 529:
   }
   }


   // ── Load comments from /Comments wiki page ───────────────────────
   // ── Load comments from wiki ───────────────────────────────────────────
   function loadComments( cb ) {
   function loadComments( cb ) {
     if ( _cmtLoaded ) { if (cb) cb(); return; }
     if ( _cmtLoaded ) { if (cb) cb(); return; }
Line 489: Line 539:
       var page = (data.query.pages||[])[0]||{};
       var page = (data.query.pages||[])[0]||{};
       var wt = '';
       var wt = '';
       if (page.revisions && page.revisions[0]) {
       if ( page.revisions && page.revisions[0] ) {
         var rev = page.revisions[0];
         var rev = page.revisions[0];
         wt = rev.slots ? rev.slots.main.content : rev['*'] || '';
         wt = rev.slots ? rev.slots.main.content : rev['*'] || '';
       }
       }
       _comments = parseCommentsWt(wt);
       _comments = parseCommentsWt(wt);
       _cmtLoaded = true;
       _cmtLoaded = true;
       if (cb) cb();
       if (cb) cb();
Line 501: Line 551:
   function parseCommentsWt( wt ) {
   function parseCommentsWt( wt ) {
     var out = [];
     var out = [];
     var re = /\{\{GrComment\s*\n([\s\S]*?)\}\}/g;
     var re = /\{\{GrComment\s*\n([\s\S]*?)\}\}/g;
     var m;
     var m;
     while ((m = re.exec(wt)) !== null) {
     while ( (m = re.exec(wt)) !== null ) {
       var block = m[1];
       var block = m[1];
       var f = {};
       var f     = {};
       var lr = /\|\s*([\w_]+)\s*=\s*([\s\S]*?)(?=\n\||\n\}\}|$)/g;
       var lr   = /\|\s*([\w_]+)\s*=\s*([\s\S]*?)(?=\n\||\n\}\}|$)/g;
       var lm;
       var lm;
       while ((lm = lr.exec(block)) !== null) {
       while ( (lm = lr.exec(block)) !== null ) {
         f[lm[1].trim()] = lm[2].trim();
         f[lm[1].trim()] = lm[2].trim();
       }
       }
Line 544: Line 594:
     var quote = _selText.slice(0,120) + (_selText.length>120?'…':'');
     var quote = _selText.slice(0,120) + (_selText.length>120?'…':'');


     // Wrap text in bookmark highlight span
     // Compute occurrence index BEFORE wrapSelection nulls _selRange
    var needle    = _selText.replace(/…$/, '').trim().slice(0, 80);
    var quoteIndex = computeQuoteIndex( needle, _selRange );
 
     var span = wrapSelection( id, 'gra-bookmark-highlight' );
     var span = wrapSelection( id, 'gra-bookmark-highlight' );
     if ( span ) {
     if ( span ) {
       span.setAttribute('data-gra-id', id);
       span.setAttribute('data-gra-id',   id);
       span.setAttribute('data-gra-name', name);
       span.setAttribute('data-gra-name', name);
     }
     }


     var entry = { id:id, name:name, quote:quote, ts:ts };
    // Store quoteIndex for accurate restore on page reload
     var entry = { id:id, name:name, quote:quote, quoteIndex:quoteIndex, ts:ts };
     _bookmarks.push( entry );
     _bookmarks.push( entry );
     persistBookmarks();
     persistBookmarks();
Line 561: Line 615:
   function deleteBookmark( id ) {
   function deleteBookmark( id ) {
     _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
     _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
    // Remove highlight span, replace with its text content
     var span = document.querySelector('[data-gra-id="' + id + '"].gra-bookmark-highlight');
     var span = document.querySelector('[data-gra-id="' + id + '"].gra-bookmark-highlight');
     if (span) {
     if (span) {
Line 601: Line 654:
   function switchTab( tab ) {
   function switchTab( tab ) {
     _activeTab = tab;
     _activeTab = tab;
     $tabComments.toggleClass('gra-tab-active', tab==='comments');
     $tabComments.toggleClass('gra-tab-active', tab==='comments');
     $tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks');
     $tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks');
     $paneComments.toggleClass('gra-pane-active', tab==='comments');
     $paneComments.toggleClass('gra-pane-active', tab==='comments');
     $paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks');
     $paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks');
     if (tab==='comments') {
     if (tab==='comments') {
Line 615: Line 668:
   // RENDER CARDS
   // RENDER CARDS
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
  // ── Badge: keep the toggle-button count bubble in sync ───────────────
  // Called from renderCommentCards (which is called by submitComment,
  // deleteComment, and switchTab) so the badge is always current.
  function updateBadge() {
    var $badge    = $( '#gra-toggle-badge' );
    var $tabBadge = $tabComments.find('.gra-tab-count-badge');
    var n = _comments.length;
    if ( n > 0 ) {
      $badge.text(n).css('display','block');
      if ( !$tabBadge.length ) {
        $tabComments.find('span.gra-icon').after(
          '<span class="gra-tab-count-badge" style="background:#e53935;color:#fff;' +
          'border-radius:9px;font-size:10px;padding:0 5px;margin-left:4px;">' + n + '</span>'
        );
      } else {
        $tabBadge.text(n);
      }
    } else {
      $badge.text('').css('display','none');
      $tabBadge.remove();
    }
  }


   function renderCommentCards() {
   function renderCommentCards() {
    updateBadge();
     if ( _comments.length === 0 ) {
     if ( _comments.length === 0 ) {
       $paneComments.html('<div class="gra-empty-state">No comments yet.<br>Select text and click 💬 to add one.</div>');
       $paneComments.html('<div class="gra-empty-state">No comments yet.<br>Select text and click 💬 to add one.</div>');
       return;
       return;
     }
     }
    var canDelete = !!currentUser;
     var html = '';
     var html = '';
     _comments.slice().reverse().forEach(function(c){
     _comments.slice().reverse().forEach(function(c){
Line 630: Line 708:
             + (c.ts ? '<div class="gra-card-ts">' + esc(fmtTs(c.ts)) + '</div>' : '')
             + (c.ts ? '<div class="gra-card-ts">' + esc(fmtTs(c.ts)) + '</div>' : '')
             + '</div>'
             + '</div>'
            + ( canDelete
                ? '<button class="gra-comment-del" data-del-id="' + esc(c.id) + '" title="Delete comment">×</button>'
                : '' )
             + '</div>'
             + '</div>'
             + (c.quote ? '<div class="gra-card-quote">' + esc(c.quote) + '</div>' : '')
             + (c.quote ? '<div class="gra-card-quote">' + esc(c.quote) + '</div>' : '')
Line 676: Line 757:


     // ── Selection → show FAB ──────────────────────────────────────
     // ── Selection → show FAB ──────────────────────────────────────
     $( document ).on('mouseup keyup', function(e){
     $( document ).on('mouseup keyup', function(){
      // Skip if a composer is open
      if ( $cmpComposer.hasClass('gra-composer-visible') ) return;
      if ( $bmComposer.hasClass('gra-composer-visible') ) return;
      // Skip mouseup that originated inside the FAB itself
      if ( $(e.target).closest('#gra-fab').length ) return;
      // Delay slightly so selection is fully committed by browser
       setTimeout(function(){
       setTimeout(function(){
         if ( captureSelection() ) {
         if ( $cmpComposer.hasClass('gra-composer-visible') ) return;
          showFab( _selRect );
        if ( $bmComposer.hasClass('gra-composer-visible') ) return;
        } else {
        if ( captureSelection() ) { showFab( _selRect ); }
          // Only hide if a composer isn't open
         else { hideFab(); }
          if ( !$cmpComposer.hasClass('gra-composer-visible') &&
       }, 20);
              !$bmComposer.hasClass('gra-composer-visible') ) {
            hideFab();
          }
         }
       }, 30);
    });
 
    // ── FAB mousedown — prevent browser from collapsing selection ────
    // Browsers collapse the text selection on mousedown. Calling
    // preventDefault() on the FAB buttons stops that, keeping _selRange
    // valid so the subsequent click handler can use it.
    $fab.on('mousedown', function(e){
      e.preventDefault();
     });
     });


Line 715: Line 777:


     // ── FAB: Comment ──────────────────────────────────────────────
     // ── FAB: Comment ──────────────────────────────────────────────
    // Use saved _selRange — do NOT call captureSelection() here.
    // By the time a click fires, mousedown has already collapsed
    // the browser selection, so getSelection() returns empty.
     $( '#gra-fab-comment' ).on('click', function(e){
     $( '#gra-fab-comment' ).on('click', function(e){
       e.preventDefault();
       e.preventDefault(); e.stopPropagation();
      e.stopPropagation();
       if ( !captureSelection() ) return;
       if ( !_selRange ) return;
       openCommentComposer();
       openCommentComposer();
     });
     });
Line 727: Line 785:
     // ── FAB: Bookmark ─────────────────────────────────────────────
     // ── FAB: Bookmark ─────────────────────────────────────────────
     $( '#gra-fab-bookmark' ).on('click', function(e){
     $( '#gra-fab-bookmark' ).on('click', function(e){
       e.preventDefault();
       e.preventDefault(); e.stopPropagation();
      e.stopPropagation();
       if ( !captureSelection() ) return;
       if ( !_selRange ) return;
       openBookmarkComposer();
       openBookmarkComposer();
     });
     });


     // ── Comment composer ──────────────────────────────────────────
     // ── Comment composer ──────────────────────────────────────────
    /* Quick-select chips: clicking a chip selects it and sets comment text.
    * 'Others' chip shows the textarea for free-form input.
    * Any other chip sets the text directly and enables submit. */
    $( '#gra-cmp-composer' ).on( 'click', '.gra-chip', function() {
      var $chip = $( this );
      var val  = $chip.attr('data-val');
      // Toggle active state — allow deselecting
      var wasActive = $chip.hasClass('gra-chip-active');
      $( '#gra-quick-chips .gra-chip' ).removeClass('gra-chip-active');
      if ( !wasActive ) $chip.addClass('gra-chip-active');
      if ( val === 'Others' && !wasActive ) {
        // Show textarea for free-form input
        $cmpInput.show().focus().val('');
        $cmpSubmit.prop('disabled', true);
      } else if ( !wasActive ) {
        // Pre-fill and hide textarea — chip text is the comment
        $cmpInput.hide().val(val);
        $cmpSubmit.prop('disabled', !currentUser);
      } else {
        // Deselected — clear
        $cmpInput.hide().val('');
        $cmpSubmit.prop('disabled', true);
      }
    } );
     $cmpInput.on('input', function(){
     $cmpInput.on('input', function(){
       $cmpSubmit.prop('disabled', !$(this).val().trim() || !currentUser);
       $cmpSubmit.prop('disabled', !$(this).val().trim() || !currentUser);
     });
     });
     $( '#gra-cmp-cancel' ).on('click', function(){
     $( '#gra-cmp-cancel' ).on('click', function(){ closeCommentComposer(); hideFab(); });
      closeCommentComposer();
      hideFab();
    });
     $cmpSubmit.on('click', submitComment);
     $cmpSubmit.on('click', submitComment);
     $cmpInput.on('keydown', function(e){
     $cmpInput.on('keydown', function(e){
      // Ctrl+Enter or Cmd+Enter submits
       if ((e.ctrlKey||e.metaKey) && e.key==='Enter') { submitComment(); }
       if ((e.ctrlKey||e.metaKey) && e.key==='Enter') { submitComment(); }
       if (e.key==='Escape') { closeCommentComposer(); hideFab(); }
       if (e.key==='Escape') { closeCommentComposer(); hideFab(); }
Line 773: Line 802:


     // ── Bookmark composer ─────────────────────────────────────────
     // ── Bookmark composer ─────────────────────────────────────────
     $( '#gra-bm-cancel' ).on('click', function(){
     $( '#gra-bm-cancel' ).on('click', function(){ closeBookmarkComposer(); hideFab(); });
      closeBookmarkComposer();
      hideFab();
    });
     $bmSubmit.on('click', submitBookmark);
     $bmSubmit.on('click', submitBookmark);
     $bmInput.on('keydown', function(e){
     $bmInput.on('keydown', function(e){
       if (e.key==='Enter') submitBookmark();
       if (e.key==='Enter') submitBookmark();
       if (e.key==='Escape'){ closeBookmarkComposer(); hideFab(); }
       if (e.key==='Escape') { closeBookmarkComposer(); hideFab(); }
     });
     });


Line 788: Line 814:


     // ── Tab switching ─────────────────────────────────────────────
     // ── Tab switching ─────────────────────────────────────────────
     $tabComments.on('click', function(){ switchTab('comments'); });
     $tabComments.on('click', function(){ switchTab('comments'); });
     $tabBookmarks.on('click', function(){ switchTab('bookmarks'); });
     $tabBookmarks.on('click', function(){ switchTab('bookmarks'); });


     // ── Panel: click comment card → scroll to highlight ───────────
     // ── Panel: click comment card body → scroll to highlight ──────
     $paneComments.on('click', '.gra-comment-card', function(){
     $paneComments.on('click', '.gra-comment-card', function(e){
      if ( $(e.target).hasClass('gra-comment-del') ) return;
       var id = $(this).attr('data-gra-id');
       var id = $(this).attr('data-gra-id');
       if (id) scrollToHighlight(id);
       if (id) scrollToHighlight(id);
    });
    // ── Panel: delete comment ─────────────────────────────────────
    $paneComments.on('click', '.gra-comment-del', function(e){
      e.stopPropagation();
      var id = $(this).attr('data-del-id');
      if ( !id ) return;
      if ( !window.confirm('Delete this comment? This cannot be undone.') ) return;
      deleteComment(id);
     });
     });


     // ── Panel: click bookmark card → scroll to highlight ──────────
     // ── Panel: click bookmark card → scroll to highlight ──────────
     $paneBookmarks.on('click', '.gra-bookmark-card', function(e){
     $paneBookmarks.on('click', '.gra-bookmark-card', function(e){
       if ($(e.target).hasClass('gra-bookmark-del')) return;
       if ( $(e.target).hasClass('gra-bookmark-del') ) return;
       var id = $(this).attr('data-gra-id');
       var id = $(this).attr('data-gra-id');
       if (id) scrollToHighlight(id);
       if (id) scrollToHighlight(id);
Line 811: Line 847:
     });
     });


     // ── Click on highlight in text → open panel ───────────────────
     // ── Click highlight in text → open panel ─────────────────────
     $( CONTENT_SEL ).on('click', '.gra-comment-highlight', function(){
     $( CONTENT_SEL ).on('click', '.gra-comment-highlight', function(){
       var id = $(this).attr('data-gra-id');
       var id = $(this).attr('data-gra-id');
       openPanel('comments');
       openPanel('comments');
      // After render, scroll to matching card
       setTimeout(function(){
       setTimeout(function(){
         var $card = $paneComments.find('[data-gra-id="'+id+'"]');
         var $card = $paneComments.find('[data-gra-id="'+id+'"]');
Line 835: Line 870:
     });
     });


     // ── Escape key closes panels / composers ─────────────────────
     // ── Escape key ────────────────────────────────────────────────
     $( document ).on('keydown', function(e){
     $( document ).on('keydown', function(e){
       if (e.key==='Escape'){
       if (e.key==='Escape'){
Line 846: Line 881:


   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
   // RESTORE BOOKMARK HIGHLIGHTS from localStorage on page load
   // PERSIST / RESTORE HIGHLIGHT ANCHORS
   // ════════════════════════════════════════════════════════════════════
   // ════════════════════════════════════════════════════════════════════
  // Bookmarks store the quote text. We do a best-effort text search
  // in the content to re-wrap the same span after page reload.
  // (Comments are server-stored and we only re-render cards, not re-wrap.)


   // ── Persist comment highlight anchors to localStorage ────────────────
   function persistCommentHighlight( id, quote, quoteIndex ) {
  // We only store {id, quote} — the full comment data lives on the wiki.
    try {
  // On reload, restoreCommentHighlights re-wraps the quote text in the page
      var stored = JSON.parse( localStorage.getItem( CMT_LS_KEY ) || '[]' );
   // so the yellow highlight appears again.
      stored = stored.filter( function(h){ return h.id !== id; } );
      stored.push( { id:id, quote:quote, quoteIndex: quoteIndex || 0 } );
      localStorage.setItem( CMT_LS_KEY, JSON.stringify( stored ) );
    } catch(e){}
   }


   function persistCommentHighlight( id, quote ) {
   function removeCommentHighlight( id ) {
     try {
     try {
       var stored = JSON.parse( localStorage.getItem( CMT_LS_KEY ) || '[]' );
       var stored = JSON.parse( localStorage.getItem( CMT_LS_KEY ) || '[]' );
      // Deduplicate
       stored = stored.filter( function(h){ return h.id !== id; } );
       stored = stored.filter( function(h){ return h.id !== id; } );
      stored.push( { id: id, quote: quote } );
       localStorage.setItem( CMT_LS_KEY, JSON.stringify( stored ) );
       localStorage.setItem( CMT_LS_KEY, JSON.stringify( stored ) );
     } catch(e){}
     } catch(e){}
Line 875: Line 909:
       var needle = h.quote.replace(/…$/, '').trim().slice(0, 80);
       var needle = h.quote.replace(/…$/, '').trim().slice(0, 80);
       if ( !needle ) return;
       if ( !needle ) return;
       var range = findTextInContent( document.querySelector(CONTENT_SEL), needle );
       var range = findNthOccurrence( document.querySelector(CONTENT_SEL), needle, h.quoteIndex || 0 );
       if ( range ) {
       if ( range ) {
         var span = document.createElement('span');
         var span = document.createElement('span');
Line 888: Line 922:
     _bookmarks.forEach(function(b){
     _bookmarks.forEach(function(b){
       if ( !b.quote ) return;
       if ( !b.quote ) return;
      // Already highlighted from this session
       if ( document.querySelector('[data-gra-id="'+b.id+'"].gra-bookmark-highlight') ) return;
       if ( document.querySelector('[data-gra-id="'+b.id+'"].gra-bookmark-highlight') ) return;
      // Text search — find first occurrence of quote in content
       var contentEl = document.querySelector( CONTENT_SEL );
       var contentEl = document.querySelector( CONTENT_SEL );
       if (!contentEl) return;
       if (!contentEl) return;
       var needle = b.quote.replace(/…$/,'').trim().slice(0,60);
       var needle = b.quote.replace(/…$/,'').trim().slice(0,80);
       if (!needle) return;
       if (!needle) return;
       // Walk text nodes to find a match
       // Use stored quoteIndex; default to 0 for older entries without it
       var found = findTextInContent( contentEl, needle );
       var found = findNthOccurrence( contentEl, needle, b.quoteIndex || 0 );
       if (found) {
       if (found) {
         var span = document.createElement('span');
         var span = document.createElement('span');
         span.className = 'gra-bookmark-highlight';
         span.className = 'gra-bookmark-highlight';
         span.setAttribute('data-gra-id', b.id);
         span.setAttribute('data-gra-id',   b.id);
         span.setAttribute('data-gra-name', b.name);
         span.setAttribute('data-gra-name', b.name);
         try {
         try { found.surroundContents(span); } catch(e){}
          found.surroundContents(span);
        } catch(e){}
       }
       }
     });
     });
   }
   }


   function findTextInContent( root, needle ) {
  // ════════════════════════════════════════════════════════════════════
     // Returns a Range covering the first occurrence of needle in root's text
  // FIND Nth OCCURRENCE of needle in root's text
  // Replaces the old findTextInContent() which always returned the first
  // occurrence, causing wrong highlight placement for repeated Sanskrit text.
  // ════════════════════════════════════════════════════════════════════
 
   function findNthOccurrence( root, needle, occurrenceIndex ) {
     if ( !root || !needle ) return null;
     var text = root.textContent || '';
     var text = root.textContent || '';
     var idx  = text.indexOf(needle);
 
     if (idx < 0) return null;
    // Find char offset of the Nth occurrence
     // Walk to find the exact text nodes
     var charIdx = -1;
    var count  = 0;
    var searchFrom = 0;
    while ( (charIdx = text.indexOf(needle, searchFrom)) !== -1 ) {
      if ( count === occurrenceIndex ) break;
      count++;
      searchFrom = charIdx + needle.length;
    }
     if ( charIdx === -1 ) {
      // Requested occurrence not found — fall back to first
      charIdx = text.indexOf(needle);
      if ( charIdx === -1 ) return null;
    }
 
     // Walk text nodes to map charIdx → DOM node + offset
     var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
     var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
     var pos  = 0;
     var pos  = 0;
     var node, startNode, startOffset, endNode, endOffset;
     var node, startNode, startOffset, endNode, endOffset;
     while ((node = iter.nextNode())) {
     while ( (node = iter.nextNode()) ) {
       var len = node.nodeValue.length;
       var len   = node.nodeValue.length;
       if (!startNode && pos+len > idx) {
      var endIdx = charIdx + needle.length;
       if ( !startNode && pos + len > charIdx ) {
         startNode  = node;
         startNode  = node;
         startOffset = idx - pos;
         startOffset = charIdx - pos;
       }
       }
      var endIdx = idx + needle.length;
       if ( startNode && pos + len >= endIdx ) {
       if (startNode && pos+len >= endIdx) {
         endNode  = node;
         endNode  = node;
         endOffset = endIdx - pos;
         endOffset = endIdx - pos;
Line 932: Line 982:
       pos += len;
       pos += len;
     }
     }
     if (!startNode || !endNode) return null;
     if ( !startNode || !endNode ) return null;
     var range = document.createRange();
     var range = document.createRange();
     range.setStart(startNode, startOffset);
     range.setStart(startNode, startOffset);
Line 949: Line 999:
     restoreBookmarkHighlights();
     restoreBookmarkHighlights();
     restoreCommentHighlights();
     restoreCommentHighlights();
     // Pre-load comment count in background
     // Load comments then sync badge/tab count via renderCommentCards → updateBadge
     loadComments(function(){
     loadComments(function(){ renderCommentCards(); });
      // Update tab label with count
      if (_comments.length > 0) {
        $tabComments.find('span.gra-icon').after(
          ' <span style="background:#e53935;color:#fff;border-radius:9px;' +
          'font-size:10px;padding:0 5px;margin-left:2px;">' + _comments.length + '</span>'
        );
        // Update toggle badge
        var $badge = $( '#gra-toggle-badge' );
        if ( $badge.length ) $badge.text( _comments.length ).css('display','flex');
      }
    });
   } );
   } );


}() );
}() );

Latest revision as of 18:54, 26 April 2026

/**
 * gr_annotations.js  —  grantha.io inline Comments + Bookmarks
 * ══════════════════════════════════════════════════════════════════════
 *
 * BEHAVIOUR (mirrors Google Docs)
 * ────────────────────────────────
 * 1. User selects text anywhere in mw-content-text.
 * 2. A floating action strip (FAB) appears to the right of the selection
 *    with two icon buttons: Comment and Bookmark.
 * 3. Clicking Comment:
 *    - Opens an inline composer card anchored to the selection position.
 *    - On submit: wraps the selected text in a yellow highlight span,
 *      saves the comment to Talk:<PageTitle>/GrComments via MW edit API,
 *      sends email notification to admin,
 *      and adds a card to the Comments tab of the right panel.
 *    - Each comment card has a delete (×) button (logged-in users only).
 *    - On delete: removes from wiki page, localStorage anchor, and DOM;
 *      sends a deletion notification email to admin.
 * 4. Clicking Bookmark:
 *    - Opens a small composer to name the bookmark.
 *    - On submit: wraps selected text in a blue highlight span,
 *      saves bookmark in localStorage (per-user, per-page),
 *      and adds a card to the Bookmarks tab.
 *    - Bookmark stores quote + quoteIndex (nth occurrence) so restore
 *      after page refresh lands on the correct match even when the same
 *      text appears multiple times on the page (e.g. Sanskrit verses).
 *    - Clicking a bookmark card scrolls to and flashes that highlight.
 * 5. Right panel:
 *    - Slides in from the right as an overlay (no layout shift).
 *    - Tab 1: Comments  (loads from Talk:/GrComments wiki page)
 *    - Tab 2: Bookmarks (loads from localStorage)
 *    - Panel can be closed via ✕ or clicking the backdrop.
 * 6. Clicking any comment/bookmark highlight in the text:
 *    - Opens the panel to the correct tab and scrolls to the card.
 *
 * STORAGE
 * ───────
 * Comments  → MediaWiki page: Talk:<PageTitle>/GrComments
 *             Format: {{GrComment|id=…|author=…|timestamp=…|quote=…|text=…}}
 * Bookmarks → localStorage  Key: grantha_bm_<pageName>
 *             Each entry includes quoteIndex (0-based nth occurrence).
 * Highlight anchors → localStorage  Key: grantha_cmt_<pageName>
 *             Stores {id, quote, quoteIndex} for comment highlight restore.
 *
 * ADMIN NOTIFICATION
 * ──────────────────
 * On new comment AND on comment delete: sends email via MW EmailUser API.
 * Fallback: posts to User_talk:ADMIN_USER for Echo notification.
 * Requires $wgEnableEmail = true and $wgEnableUserEmail = true (MW defaults).
 *
 * DEPENDENCIES
 * ────────────
 * • jQuery (loaded by MediaWiki)
 * • mw.Api  (MediaWiki core)
 * • gr_annotations.css  (companion stylesheet)
 * ══════════════════════════════════════════════════════════════════════
 */

/* global mw, $ */
( function () {
  'use strict';

  // ── Configuration ────────────────────────────────────────────────────
  var ADMIN_USER  = 'GranthaGate';
  var CONTENT_SEL = '#mw-content-text';
  var BM_LS_KEY   = 'grantha_bm_'  + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
  var CMT_LS_KEY  = 'grantha_cmt_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
  var pageTitle   = ( window.mw && mw.config.get( 'wgPageName' ) ) || '';
  var commentsPage = 'Talk:' + pageTitle + '/GrComments';
  var currentUser  = ( window.mw && mw.config.get( 'wgUserName' ) ) || '';
  var userInitial  = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?';

  // Only run on content namespaces
  if ( window.mw ) {
    var ns = mw.config.get( 'wgNamespaceNumber' );
    if ( ns < 0 ) return;
    if ( /\/Comments$/.test( pageTitle ) ) return;
  }

  // ── State ────────────────────────────────────────────────────────────
  var _selRange   = null;
  var _selText    = '';
  var _selRect    = null;
  var _comments   = [];
  var _bookmarks  = [];
  var _cmtLoaded  = false;
  var _activeTab  = 'comments';

  // ── Helpers ──────────────────────────────────────────────────────────
  function uid() {
    return 'gra_' + Date.now() + '_' + Math.random().toString(36).slice(2,7);
  }
  function esc( s ) {
    return String( s || '' )
      .replace(/&/g,'&amp;').replace(/</g,'&lt;')
      .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  }
  function nowIso() {
    return new Date().toISOString().replace(/\.\d{3}Z$/,'Z');
  }
  function fmtTs( ts ) {
    try {
      var d = new Date( ts );
      return d.toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'})
           + ' ' + d.toLocaleTimeString('en-IN',{hour:'2-digit',minute:'2-digit',hour12:false});
    } catch(e){ return ts; }
  }
  function clamp( val, min, max ) { return Math.max(min, Math.min(max, val)); }

  // ── DOM references (populated in buildDom) ───────────────────────────
  var $fab, $panel, $backdrop, $panelBody;
  var $cmpComposer, $cmpInput, $cmpSubmit;
  var $bmComposer, $bmInput, $bmSubmit;
  var $tabComments, $tabBookmarks;
  var $paneComments, $paneBookmarks;

  // ════════════════════════════════════════════════════════════════════
  // DOM BUILDER
  // ════════════════════════════════════════════════════════════════════

  function buildDom() {

    // ── Floating action strip ────────────────────────────────────────
    $fab = $( [
      '<div id="gra-fab">',
      '  <button class="gra-fab-btn" id="gra-fab-comment" type="button">',
      '    <span class="gra-icon gra-icon-comment"></span>',
      '    <span class="gra-fab-tooltip">Add comment</span>',
      '  </button>',
      '  <button class="gra-fab-btn" id="gra-fab-bookmark" type="button">',
      '    <span class="gra-icon gra-icon-bookmark"></span>',
      '    <span class="gra-fab-tooltip">Bookmark</span>',
      '  </button>',
      '</div>',
    ].join('') );
    $( 'body' ).append( $fab );

    // ── Comment composer card ────────────────────────────────────────
    $cmpComposer = $( [
      '<div class="gra-composer" id="gra-cmp-composer">',
      '  <div class="gra-composer-user">',
      '    <div class="gra-avatar" id="gra-cmp-avatar">' + esc(userInitial) + '</div>',
      '    <div class="gra-composer-name" id="gra-cmp-name">' + esc(currentUser || 'Anonymous') + '</div>',
      '  </div>',
      '  <textarea class="gra-composer-input" id="gra-cmp-input"',
      '    placeholder="Comment or add others with @…" rows="3"></textarea>',
      '  <div class="gra-composer-actions">',
      '    <button class="gra-btn-cancel" id="gra-cmp-cancel">Cancel</button>',
      '    <button class="gra-btn-submit" id="gra-cmp-submit" disabled>Comment</button>',
      '  </div>',
      '</div>',
    ].join('') );
    $( 'body' ).append( $cmpComposer );

    // ── Bookmark composer card ────────────────────────────────────────
    $bmComposer = $( [
      '<div class="gra-bm-composer" id="gra-bm-composer">',
      '  <div class="gra-bm-composer-label">',
      '    <span class="gra-icon gra-icon-bookmark"></span>',
      '    Save bookmark',
      '  </div>',
      '  <input class="gra-composer-input" id="gra-bm-input"',
      '    type="text" placeholder="Bookmark name…" autocomplete="off">',
      '  <div class="gra-composer-actions">',
      '    <button class="gra-btn-cancel" id="gra-bm-cancel">Cancel</button>',
      '    <button class="gra-btn-submit" id="gra-bm-submit">Save</button>',
      '  </div>',
      '</div>',
    ].join('') );
    $( 'body' ).append( $bmComposer );

    // ── Right panel ───────────────────────────────────────────────────
    $panel = $( [
      '<div id="gra-panel">',
      '  <div id="gra-panel-head">',
      '    <div></div>',
      '    <button id="gra-panel-close" title="Close panel">✕</button>',
      '  </div>',
      '  <div id="gra-tabs">',
      '    <button class="gra-tab gra-tab-active" id="gra-tab-comments">',
      '      <span class="gra-icon gra-icon-comment"></span> Comments',
      '    </button>',
      '    <button class="gra-tab" id="gra-tab-bookmarks">',
      '      <span class="gra-icon gra-icon-bookmark"></span> Bookmarks',
      '    </button>',
      '  </div>',
      '  <div id="gra-panel-body">',
      '    <div class="gra-pane gra-pane-active" id="gra-pane-comments"></div>',
      '    <div class="gra-pane"              id="gra-pane-bookmarks"></div>',
      '  </div>',
      '</div>',
    ].join('') );
    $( 'body' ).append( $panel );

    $backdrop = $('<div id="gra-backdrop"></div>');
    $( 'body' ).append( $backdrop );

    var badgeStyle = 'display:none;position:absolute;top:2px;right:2px;'
                   + 'min-width:16px;height:16px;padding:0 4px;'
                   + 'background:#e53935;color:#fff;'
                   + 'font-size:10px;font-weight:700;line-height:16px;'
                   + 'border-radius:8px;text-align:center;'
                   + 'pointer-events:none;box-sizing:border-box;';
    var $toggle = $( [
      '<button id="gra-toggle" title="Comments &amp; Bookmarks">',
      '  <span class="gra-icon gra-icon-comment" id="gra-toggle-icon"></span>',
      '  <span id="gra-toggle-badge" style="' + badgeStyle + '"></span>',
      '</button>',
    ].join('') );
    $( 'body' ).append( $toggle );
    $toggle.on( 'click', function() {
      if ( $panel.hasClass('gra-panel-open') ) { closePanel(); }
      else { openPanel( _activeTab ); }
    } );

    $panelBody    = $( '#gra-panel-body' );
    $( '#gra-panel-head div' ).first()
      .text( pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30) )
      .css({ 'font-size':'13px', 'font-weight':'600', 'color':'#5a3a00' });
    $tabComments  = $( '#gra-tab-comments' );
    $tabBookmarks = $( '#gra-tab-bookmarks' );
    $paneComments = $( '#gra-pane-comments' );
    $paneBookmarks= $( '#gra-pane-bookmarks' );
    $cmpInput     = $( '#gra-cmp-input' );
    $cmpSubmit    = $( '#gra-cmp-submit' );
    $bmInput      = $( '#gra-bm-input' );
    $bmSubmit     = $( '#gra-bm-submit' );

    if ( !currentUser ) {
      $cmpSubmit.prop( 'disabled', true );
      $cmpComposer.find( '.gra-composer-actions' )
        .prepend( '<a href="' + esc( mw ? mw.util.getUrl('Special:UserLogin') : '/wiki/Special:UserLogin' ) +
                  '" style="font-size:11px;color:#999;margin-right:auto">Log in to comment</a>' );
    }
  }

  // ════════════════════════════════════════════════════════════════════
  // FAB — position & show/hide
  // ════════════════════════════════════════════════════════════════════

  function showFab( rect ) {
    if ( !rect ) return;
    var fabW = 46, fabH = 84;
    var top  = rect.top + ( rect.height / 2 ) - ( fabH / 2 );
    var left = rect.right + 10;
    if ( left + fabW > window.innerWidth - 8 ) { left = rect.left - fabW - 10; }
    top  = clamp( top,  8, window.innerHeight - fabH - 8 );
    left = clamp( left, 8, window.innerWidth  - fabW - 8 );
    $fab.css({ top: top + 'px', left: left + 'px' }).addClass('gra-fab-visible');
  }

  function hideFab() { $fab.removeClass('gra-fab-visible'); }

  // ════════════════════════════════════════════════════════════════════
  // SELECTION HANDLING
  // ════════════════════════════════════════════════════════════════════

  function captureSelection() {
    var sel = window.getSelection();
    if ( !sel || sel.isCollapsed || !sel.rangeCount ) return false;
    var range   = sel.getRangeAt(0);
    var text    = sel.toString().trim();
    if ( !text || text.length < 2 ) return false;
    var contentEl = document.querySelector( CONTENT_SEL );
    if ( !contentEl ) return false;
    var startEl = range.commonAncestorContainer;
    if ( startEl.nodeType === 3 ) startEl = startEl.parentNode;
    if ( !contentEl.contains( startEl ) ) return false;
    _selText  = text;
    _selRange = range.cloneRange();
    _selRect  = range.getBoundingClientRect();
    return true;
  }

  // ════════════════════════════════════════════════════════════════════
  // COMPOSER POSITIONING
  // ════════════════════════════════════════════════════════════════════

  function positionComposer( $el ) {
    if ( !_selRect ) return;
    var top  = _selRect.bottom + 8;
    var left = _selRect.left;
    var composerW = 308;
    if ( left + composerW > window.innerWidth - 8 ) { left = window.innerWidth - composerW - 8; }
    left = Math.max( left, 8 );
    if ( top + 160 > window.innerHeight ) { top = _selRect.top - 170; }
    top = Math.max( top, 8 );
    $el.css({ top: top + 'px', left: left + 'px' });
  }

  // ════════════════════════════════════════════════════════════════════
  // WRAP SELECTION IN HIGHLIGHT SPAN
  // ════════════════════════════════════════════════════════════════════

  function wrapSelection( id, cssClass ) {
    if ( !_selRange ) return null;
    // Capture into local var and null out _selRange immediately so
    // surroundContents() mutation cannot corrupt future selections.
    var range = _selRange;
    _selRange = null;
    try {
      var span = document.createElement('span');
      span.className = cssClass;
      span.setAttribute('data-gra-id', id);
      range.surroundContents( span );
      return span;
    } catch ( e ) {
      try {
        var frag  = range.extractContents();
        var span2 = document.createElement('span');
        span2.className = cssClass;
        span2.setAttribute('data-gra-id', id);
        span2.appendChild( frag );
        range.insertNode( span2 );
        return span2;
      } catch(e2) { return null; }
    }
  }

  // ════════════════════════════════════════════════════════════════════
  // OCCURRENCE INDEX
  // Returns the 0-based count of how many times needle appears in the
  // page text *before* the start of savedRange. This tells us which
  // occurrence was selected so we can restore the highlight accurately.
  // Must be called BEFORE wrapSelection() nulls out _selRange.
  // ════════════════════════════════════════════════════════════════════

  function computeQuoteIndex( needle, savedRange ) {
    if ( !savedRange || !needle ) return 0;
    var contentEl = document.querySelector( CONTENT_SEL );
    if ( !contentEl ) return 0;
    try {
      var preRange = document.createRange();
      preRange.setStart( contentEl, 0 );
      preRange.setEnd( savedRange.startContainer, savedRange.startOffset );
      var textBefore = preRange.toString();
      var count = 0, idx = 0;
      while ( ( idx = textBefore.indexOf( needle, idx ) ) !== -1 ) {
        count++;
        idx += needle.length;
      }
      return count; // 0 = first occurrence
    } catch(e) { return 0; }
  }

  // ════════════════════════════════════════════════════════════════════
  // COMMENT FLOW
  // ════════════════════════════════════════════════════════════════════

  function openCommentComposer() {
    hideFab();
    positionComposer( $cmpComposer );
    $cmpComposer.addClass('gra-composer-visible');
    $cmpInput.val('').focus();
  }

  function closeCommentComposer() {
    $cmpComposer.removeClass('gra-composer-visible');
    $cmpInput.val('');
    $cmpSubmit.prop('disabled', true);
    _selRange = null;
    _selText  = '';
    _selRect  = null;
  }

  function submitComment() {
    var text  = $cmpInput.val().trim();
    if ( !text || !currentUser ) return;
    var id    = uid();
    var ts    = nowIso();
    var quote = _selText.slice(0, 120) + ( _selText.length > 120 ? '…' : '' );

    // Compute occurrence index BEFORE wrapSelection nulls _selRange
    var needle     = _selText.replace(/…$/, '').trim().slice(0, 80);
    var quoteIndex = computeQuoteIndex( needle, _selRange );

    var span = wrapSelection( id, 'gra-comment-highlight' );
    if ( span ) span.setAttribute('data-gra-quote', quote);

    var entry = { id:id, author:currentUser, ts:ts, quote:quote, text:text };
    _comments.push( entry );
    persistCommentHighlight( id, quote, quoteIndex );

    saveCommentToWiki( id, quote, text, ts );
    notifyAdmin( 'add', id, quote, text, ts );

    renderCommentCards();
    closeCommentComposer();
    openPanel('comments');
  }

  // ── Delete comment ───────────────────────────────────────────────────
  function deleteComment( id ) {
    var entry = null;
    _comments = _comments.filter(function(c){
      if ( c.id === id ) { entry = c; return false; }
      return true;
    });

    // Remove highlight span from DOM
    var span = document.querySelector('[data-gra-id="' + id + '"].gra-comment-highlight');
    if ( span ) {
      var parent = span.parentNode;
      while ( span.firstChild ) parent.insertBefore( span.firstChild, span );
      parent.removeChild( span );
    }

    removeCommentHighlight( id );
    deleteCommentFromWiki( id );

    if ( entry ) {
      notifyAdmin( 'delete', id, entry.quote, entry.text, nowIso() );
    }

    renderCommentCards();
  }

  function deleteCommentFromWiki( id ) {
    if ( !window.mw ) return;
    var api = new mw.Api();
    api.get({
      action:'query', prop:'revisions', titles:commentsPage,
      rvprop:'content', rvslots:'main', formatversion:2,
    }).then(function(data){
      var page = (data.query.pages||[])[0]||{};
      var existing = '';
      if ( page.revisions && page.revisions[0] ) {
        var rev = page.revisions[0];
        existing = rev.slots ? rev.slots.main.content : rev['*'] || '';
      }
      if ( !existing ) return;
      // Remove the {{GrComment…}} block whose id field matches
      var re = new RegExp(
        '\\{\\{GrComment\\s*\\n(?:[^}]|\\}(?!\\}))*?\\|\\s*id\\s*=\\s*' +
        id.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') +
        '[\\s\\S]*?\\}\\}\\n?', 'g'
      );
      var updated = existing.replace( re, '' ).replace(/\n{3,}/g, '\n\n').trim();
      api.postWithEditToken({
        action:'edit', title:commentsPage, text:updated,
        summary:'Deleted comment ' + id + ' by ' + currentUser + ' on [[' + pageTitle + ']]',
        bot:0,
      }).catch(function(){});
    }).catch(function(){});
  }

  function saveCommentToWiki( id, quote, text, ts ) {
    if ( !window.mw ) return;
    var api = new mw.Api();
    api.get({
      action:'query', prop:'revisions', titles:commentsPage,
      rvprop:'content', rvslots:'main', formatversion:2,
    }).then(function(data){
      var page = (data.query.pages||[])[0]||{};
      var existing = '';
      if ( page.revisions && page.revisions[0] ) {
        var rev = page.revisions[0];
        existing = rev.slots ? rev.slots.main.content : rev['*'] || '';
      }
      var entry = '{{GrComment\n'
                + '| id        = ' + id          + '\n'
                + '| author    = ' + currentUser + '\n'
                + '| timestamp = ' + ts          + '\n'
                + '| quote     = ' + quote.replace(/\n/g,' ') + '\n'
                + '| text      = ' + text.replace(/\n/g,' ')  + '\n'
                + '}}\n';
      var updated = (existing.trim() ? existing.trim() + '\n\n' : '') + entry;
      api.postWithEditToken({
        action:'edit', title:commentsPage, text:updated,
        summary:'New comment by ' + currentUser + ' on [[' + pageTitle + ']] (stored off-page)',
        bot:0,
      }).catch(function(){});
    }).catch(function(){});
  }

  // ── Admin notification (add AND delete) ──────────────────────────────
  function notifyAdmin( action, anchorId, quote, commentText, ts ) {
    if ( !window.mw || !ADMIN_USER ) return;

    var articlePath = ( mw.config.get('wgArticlePath') || '/wiki/$1' ).replace( '$1', pageTitle );
    var anchorLink  = window.location.origin + articlePath + ( anchorId ? '#' + anchorId : '' );
    var pageDisplay = pageTitle.replace( /_/g, ' ' );
    var isDelete    = ( action === 'delete' );

    var subject = isDelete
      ? '[Grantha] Comment deleted on "' + pageDisplay + '"'
      : '[Grantha] New comment on "'     + pageDisplay + '"';

    var body = ( isDelete ? 'A comment was DELETED on ' : 'A new comment was posted on ' )
             + pageDisplay + '.\n\n'
             + ( isDelete ? 'Deleted by : ' : 'Posted by  : ' )
             + ( currentUser || 'Anonymous' ) + '\n'
             + 'Time       : ' + ts + '\n'
             + 'Passage    : "' + quote + '"\n\n'
             + 'Comment    :\n' + commentText + '\n\n'
             + '\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n'
             + 'Jump to passage:\n' + anchorLink + '\n\n'
             + 'View all comments:\n'
             + window.location.origin
             + ( mw.config.get('wgArticlePath') || '/wiki/$1' )
                 .replace( '$1', 'Talk:' + pageTitle + '/GrComments' )
             + '\n';

    new mw.Api().post({
      action:  'emailuser',
      target:  ADMIN_USER,
      subject: subject,
      text:    body,
      token:   mw.user.tokens.get( 'csrfToken' ),
    }).catch(function(){
      /* Fallback: post to admin talk page for Echo notification */
      if ( !currentUser ) return;
      var verb    = isDelete ? 'deleted' : 'posted';
      var wikimsg = '== Comment ' + verb + ' on [[' + pageDisplay + ']] ==\n'
                  + '; By: ' + ( currentUser || 'Anonymous' ) + '\n'
                  + '; Passage: //' + quote + '//\n'
                  + '; Link: ' + anchorLink + '\n\n'
                  + commentText.slice(0,500)
                  + ( commentText.length > 500 ? '\n…' : '' )
                  + '\n\n--[[User:Chandrashekars|Chandrashekars]] ([[User talk:Chandrashekars|talk]]) 18:46, 26 April 2026 (UTC)';
      new mw.Api().postWithEditToken({
        action:'edit', title:'User_talk:' + ADMIN_USER, section:'new',
        sectiontitle: ( isDelete ? 'Comment deleted on ' : 'Comment on ' ) + pageDisplay,
        text:wikimsg,
        summary:'Comment notification from [[' + pageDisplay + ']]',
        bot:0,
      }).catch(function(){});
    });
  }

  // ── Load comments from wiki ───────────────────────────────────────────
  function loadComments( cb ) {
    if ( _cmtLoaded ) { if (cb) cb(); return; }
    if ( !window.mw ) { _cmtLoaded=true; if(cb)cb(); return; }
    new mw.Api().get({
      action:'query', prop:'revisions', titles:commentsPage,
      rvprop:'content', rvslots:'main', formatversion:2,
    }).then(function(data){
      var page = (data.query.pages||[])[0]||{};
      var wt = '';
      if ( page.revisions && page.revisions[0] ) {
        var rev = page.revisions[0];
        wt = rev.slots ? rev.slots.main.content : rev['*'] || '';
      }
      _comments  = parseCommentsWt(wt);
      _cmtLoaded = true;
      if (cb) cb();
    }).catch(function(){ _cmtLoaded=true; if(cb)cb(); });
  }

  function parseCommentsWt( wt ) {
    var out = [];
    var re  = /\{\{GrComment\s*\n([\s\S]*?)\}\}/g;
    var m;
    while ( (m = re.exec(wt)) !== null ) {
      var block = m[1];
      var f     = {};
      var lr    = /\|\s*([\w_]+)\s*=\s*([\s\S]*?)(?=\n\||\n\}\}|$)/g;
      var lm;
      while ( (lm = lr.exec(block)) !== null ) {
        f[lm[1].trim()] = lm[2].trim();
      }
      if (f.id) out.push({
        id:f.id, author:f.author||'Anonymous',
        ts:f.timestamp||'', quote:f.quote||'', text:f.text||''
      });
    }
    return out;
  }

  // ════════════════════════════════════════════════════════════════════
  // BOOKMARK FLOW
  // ════════════════════════════════════════════════════════════════════

  function openBookmarkComposer() {
    hideFab();
    positionComposer( $bmComposer );
    $bmComposer.addClass('gra-composer-visible');
    $bmInput.val('').focus();
  }

  function closeBookmarkComposer() {
    $bmComposer.removeClass('gra-composer-visible');
    $bmInput.val('');
    _selRange = null;
    _selText  = '';
    _selRect  = null;
  }

  function submitBookmark() {
    var name  = $bmInput.val().trim() || ( 'Bookmark ' + (_bookmarks.length+1) );
    var id    = uid();
    var ts    = nowIso();
    var quote = _selText.slice(0,120) + (_selText.length>120?'…':'');

    // Compute occurrence index BEFORE wrapSelection nulls _selRange
    var needle     = _selText.replace(/…$/, '').trim().slice(0, 80);
    var quoteIndex = computeQuoteIndex( needle, _selRange );

    var span = wrapSelection( id, 'gra-bookmark-highlight' );
    if ( span ) {
      span.setAttribute('data-gra-id',   id);
      span.setAttribute('data-gra-name', name);
    }

    // Store quoteIndex for accurate restore on page reload
    var entry = { id:id, name:name, quote:quote, quoteIndex:quoteIndex, ts:ts };
    _bookmarks.push( entry );
    persistBookmarks();
    renderBookmarkCards();
    closeBookmarkComposer();
    openPanel('bookmarks');
  }

  function deleteBookmark( id ) {
    _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
    var span = document.querySelector('[data-gra-id="' + id + '"].gra-bookmark-highlight');
    if (span) {
      var parent = span.parentNode;
      while (span.firstChild) parent.insertBefore(span.firstChild, span);
      parent.removeChild(span);
    }
    persistBookmarks();
    renderBookmarkCards();
  }

  function persistBookmarks() {
    try { localStorage.setItem(BM_LS_KEY, JSON.stringify(_bookmarks)); } catch(e){}
  }

  function loadBookmarks() {
    try {
      var raw = localStorage.getItem(BM_LS_KEY);
      if (raw) _bookmarks = JSON.parse(raw) || [];
    } catch(e){ _bookmarks = []; }
  }

  // ════════════════════════════════════════════════════════════════════
  // PANEL — open / close / tabs
  // ════════════════════════════════════════════════════════════════════

  function openPanel( tab ) {
    _activeTab = tab || _activeTab;
    switchTab( _activeTab );
    $panel.addClass('gra-panel-open');
    $backdrop.addClass('gra-backdrop-visible');
  }

  function closePanel() {
    $panel.removeClass('gra-panel-open');
    $backdrop.removeClass('gra-backdrop-visible');
  }

  function switchTab( tab ) {
    _activeTab = tab;
    $tabComments.toggleClass('gra-tab-active',  tab==='comments');
    $tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks');
    $paneComments.toggleClass('gra-pane-active',  tab==='comments');
    $paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks');
    if (tab==='comments') {
      loadComments(function(){ renderCommentCards(); });
    } else {
      renderBookmarkCards();
    }
  }

  // ════════════════════════════════════════════════════════════════════
  // RENDER CARDS
  // ════════════════════════════════════════════════════════════════════

  // ── Badge: keep the toggle-button count bubble in sync ───────────────
  // Called from renderCommentCards (which is called by submitComment,
  // deleteComment, and switchTab) so the badge is always current.
  function updateBadge() {
    var $badge    = $( '#gra-toggle-badge' );
    var $tabBadge = $tabComments.find('.gra-tab-count-badge');
    var n = _comments.length;
    if ( n > 0 ) {
      $badge.text(n).css('display','block');
      if ( !$tabBadge.length ) {
        $tabComments.find('span.gra-icon').after(
          '<span class="gra-tab-count-badge" style="background:#e53935;color:#fff;' +
          'border-radius:9px;font-size:10px;padding:0 5px;margin-left:4px;">' + n + '</span>'
        );
      } else {
        $tabBadge.text(n);
      }
    } else {
      $badge.text('').css('display','none');
      $tabBadge.remove();
    }
  }

  function renderCommentCards() {
    updateBadge();
    if ( _comments.length === 0 ) {
      $paneComments.html('<div class="gra-empty-state">No comments yet.<br>Select text and click 💬 to add one.</div>');
      return;
    }
    var canDelete = !!currentUser;
    var html = '';
    _comments.slice().reverse().forEach(function(c){
      html += '<div class="gra-comment-card" data-gra-id="' + esc(c.id) + '">'
            + '<div class="gra-card-header">'
            + '<div class="gra-avatar">' + esc((c.author||'?').charAt(0).toUpperCase()) + '</div>'
            + '<div class="gra-card-meta">'
            + '<div class="gra-card-author">' + esc(c.author) + '</div>'
            + (c.ts ? '<div class="gra-card-ts">' + esc(fmtTs(c.ts)) + '</div>' : '')
            + '</div>'
            + ( canDelete
                ? '<button class="gra-comment-del" data-del-id="' + esc(c.id) + '" title="Delete comment">×</button>'
                : '' )
            + '</div>'
            + (c.quote ? '<div class="gra-card-quote">' + esc(c.quote) + '</div>' : '')
            + '<div class="gra-card-text">' + esc(c.text) + '</div>'
            + '</div>';
    });
    $paneComments.html(html);
  }

  function renderBookmarkCards() {
    if ( _bookmarks.length === 0 ) {
      $paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and click 🔖 to save a bookmark.</div>');
      return;
    }
    var html = '';
    _bookmarks.slice().reverse().forEach(function(b){
      html += '<div class="gra-bookmark-card" data-gra-id="' + esc(b.id) + '">'
            + '<span class="gra-icon gra-icon-bookmark"></span>'
            + '<div class="gra-bookmark-info">'
            + '<div class="gra-bookmark-name">' + esc(b.name) + '</div>'
            + (b.quote ? '<div class="gra-bookmark-quote">' + esc(b.quote) + '</div>' : '')
            + '</div>'
            + '<button class="gra-bookmark-del" data-del-id="' + esc(b.id) + '" title="Remove bookmark">×</button>'
            + '</div>';
    });
    $paneBookmarks.html(html);
  }

  // ════════════════════════════════════════════════════════════════════
  // SCROLL TO HIGHLIGHT
  // ════════════════════════════════════════════════════════════════════

  function scrollToHighlight( id ) {
    var el = document.querySelector('[data-gra-id="' + id + '"]');
    if (!el) return;
    el.scrollIntoView({ behavior:'smooth', block:'center' });
    el.classList.add('gra-hl-active');
    setTimeout(function(){ el.classList.remove('gra-hl-active'); }, 2000);
  }

  // ════════════════════════════════════════════════════════════════════
  // EVENT WIRING
  // ════════════════════════════════════════════════════════════════════

  function wireEvents() {

    // ── Selection → show FAB ──────────────────────────────────────
    $( document ).on('mouseup keyup', function(){
      setTimeout(function(){
        if ( $cmpComposer.hasClass('gra-composer-visible') ) return;
        if ( $bmComposer.hasClass('gra-composer-visible') ) return;
        if ( captureSelection() ) { showFab( _selRect ); }
        else { hideFab(); }
      }, 20);
    });

    // ── Click outside → hide FAB ──────────────────────────────────
    $( document ).on('mousedown', function(e){
      var $t = $(e.target);
      if ( !$t.closest('#gra-fab').length &&
           !$t.closest('.gra-composer').length &&
           !$t.closest('.gra-bm-composer').length ) {
        hideFab();
      }
    });

    // ── FAB: Comment ──────────────────────────────────────────────
    $( '#gra-fab-comment' ).on('click', function(e){
      e.preventDefault(); e.stopPropagation();
      if ( !captureSelection() ) return;
      openCommentComposer();
    });

    // ── FAB: Bookmark ─────────────────────────────────────────────
    $( '#gra-fab-bookmark' ).on('click', function(e){
      e.preventDefault(); e.stopPropagation();
      if ( !captureSelection() ) return;
      openBookmarkComposer();
    });

    // ── Comment composer ──────────────────────────────────────────
    $cmpInput.on('input', function(){
      $cmpSubmit.prop('disabled', !$(this).val().trim() || !currentUser);
    });
    $( '#gra-cmp-cancel' ).on('click', function(){ closeCommentComposer(); hideFab(); });
    $cmpSubmit.on('click', submitComment);
    $cmpInput.on('keydown', function(e){
      if ((e.ctrlKey||e.metaKey) && e.key==='Enter') { submitComment(); }
      if (e.key==='Escape') { closeCommentComposer(); hideFab(); }
    });

    // ── Bookmark composer ─────────────────────────────────────────
    $( '#gra-bm-cancel' ).on('click', function(){ closeBookmarkComposer(); hideFab(); });
    $bmSubmit.on('click', submitBookmark);
    $bmInput.on('keydown', function(e){
      if (e.key==='Enter')  submitBookmark();
      if (e.key==='Escape') { closeBookmarkComposer(); hideFab(); }
    });

    // ── Panel close ───────────────────────────────────────────────
    $( '#gra-panel-close' ).on('click', closePanel);
    $backdrop.on('click', closePanel);

    // ── Tab switching ─────────────────────────────────────────────
    $tabComments.on('click',  function(){ switchTab('comments');  });
    $tabBookmarks.on('click', function(){ switchTab('bookmarks'); });

    // ── Panel: click comment card body → scroll to highlight ──────
    $paneComments.on('click', '.gra-comment-card', function(e){
      if ( $(e.target).hasClass('gra-comment-del') ) return;
      var id = $(this).attr('data-gra-id');
      if (id) scrollToHighlight(id);
    });

    // ── Panel: delete comment ─────────────────────────────────────
    $paneComments.on('click', '.gra-comment-del', function(e){
      e.stopPropagation();
      var id = $(this).attr('data-del-id');
      if ( !id ) return;
      if ( !window.confirm('Delete this comment? This cannot be undone.') ) return;
      deleteComment(id);
    });

    // ── Panel: click bookmark card → scroll to highlight ──────────
    $paneBookmarks.on('click', '.gra-bookmark-card', function(e){
      if ( $(e.target).hasClass('gra-bookmark-del') ) return;
      var id = $(this).attr('data-gra-id');
      if (id) scrollToHighlight(id);
    });

    // ── Panel: delete bookmark ────────────────────────────────────
    $paneBookmarks.on('click', '.gra-bookmark-del', function(e){
      e.stopPropagation();
      var id = $(this).attr('data-del-id');
      if (id) deleteBookmark(id);
    });

    // ── Click highlight in text → open panel ─────────────────────
    $( CONTENT_SEL ).on('click', '.gra-comment-highlight', function(){
      var id = $(this).attr('data-gra-id');
      openPanel('comments');
      setTimeout(function(){
        var $card = $paneComments.find('[data-gra-id="'+id+'"]');
        if ($card.length) {
          $card.addClass('gra-card-active');
          $card[0].scrollIntoView({behavior:'smooth',block:'nearest'});
          setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000);
        }
      }, 100);
    });

    $( CONTENT_SEL ).on('click', '.gra-bookmark-highlight', function(){
      var id = $(this).attr('data-gra-id');
      openPanel('bookmarks');
      setTimeout(function(){
        var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]');
        if ($card.length) $card[0].scrollIntoView({behavior:'smooth',block:'nearest'});
      }, 100);
    });

    // ── Escape key ────────────────────────────────────────────────
    $( document ).on('keydown', function(e){
      if (e.key==='Escape'){
        if ($cmpComposer.hasClass('gra-composer-visible')) { closeCommentComposer(); hideFab(); }
        else if ($bmComposer.hasClass('gra-composer-visible')) { closeBookmarkComposer(); hideFab(); }
        else closePanel();
      }
    });
  }

  // ════════════════════════════════════════════════════════════════════
  // PERSIST / RESTORE HIGHLIGHT ANCHORS
  // ════════════════════════════════════════════════════════════════════

  function persistCommentHighlight( id, quote, quoteIndex ) {
    try {
      var stored = JSON.parse( localStorage.getItem( CMT_LS_KEY ) || '[]' );
      stored = stored.filter( function(h){ return h.id !== id; } );
      stored.push( { id:id, quote:quote, quoteIndex: quoteIndex || 0 } );
      localStorage.setItem( CMT_LS_KEY, JSON.stringify( stored ) );
    } catch(e){}
  }

  function removeCommentHighlight( id ) {
    try {
      var stored = JSON.parse( localStorage.getItem( CMT_LS_KEY ) || '[]' );
      stored = stored.filter( function(h){ return h.id !== id; } );
      localStorage.setItem( CMT_LS_KEY, JSON.stringify( stored ) );
    } catch(e){}
  }

  function restoreCommentHighlights() {
    var stored = [];
    try { stored = JSON.parse( localStorage.getItem( CMT_LS_KEY ) || '[]' ); } catch(e){}
    stored.forEach( function(h) {
      if ( !h.quote || !h.id ) return;
      if ( document.querySelector( '[data-gra-id="' + h.id + '"].gra-comment-highlight' ) ) return;
      var needle = h.quote.replace(/…$/, '').trim().slice(0, 80);
      if ( !needle ) return;
      var range = findNthOccurrence( document.querySelector(CONTENT_SEL), needle, h.quoteIndex || 0 );
      if ( range ) {
        var span = document.createElement('span');
        span.className = 'gra-comment-highlight';
        span.setAttribute('data-gra-id', h.id);
        try { range.surroundContents(span); } catch(e){}
      }
    });
  }

  function restoreBookmarkHighlights() {
    _bookmarks.forEach(function(b){
      if ( !b.quote ) return;
      if ( document.querySelector('[data-gra-id="'+b.id+'"].gra-bookmark-highlight') ) return;
      var contentEl = document.querySelector( CONTENT_SEL );
      if (!contentEl) return;
      var needle = b.quote.replace(/…$/,'').trim().slice(0,80);
      if (!needle) return;
      // Use stored quoteIndex; default to 0 for older entries without it
      var found = findNthOccurrence( contentEl, needle, b.quoteIndex || 0 );
      if (found) {
        var span = document.createElement('span');
        span.className = 'gra-bookmark-highlight';
        span.setAttribute('data-gra-id',   b.id);
        span.setAttribute('data-gra-name', b.name);
        try { found.surroundContents(span); } catch(e){}
      }
    });
  }

  // ════════════════════════════════════════════════════════════════════
  // FIND Nth OCCURRENCE of needle in root's text
  // Replaces the old findTextInContent() which always returned the first
  // occurrence, causing wrong highlight placement for repeated Sanskrit text.
  // ════════════════════════════════════════════════════════════════════

  function findNthOccurrence( root, needle, occurrenceIndex ) {
    if ( !root || !needle ) return null;
    var text = root.textContent || '';

    // Find char offset of the Nth occurrence
    var charIdx = -1;
    var count   = 0;
    var searchFrom = 0;
    while ( (charIdx = text.indexOf(needle, searchFrom)) !== -1 ) {
      if ( count === occurrenceIndex ) break;
      count++;
      searchFrom = charIdx + needle.length;
    }
    if ( charIdx === -1 ) {
      // Requested occurrence not found — fall back to first
      charIdx = text.indexOf(needle);
      if ( charIdx === -1 ) return null;
    }

    // Walk text nodes to map charIdx → DOM node + offset
    var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
    var pos  = 0;
    var node, startNode, startOffset, endNode, endOffset;
    while ( (node = iter.nextNode()) ) {
      var len    = node.nodeValue.length;
      var endIdx = charIdx + needle.length;
      if ( !startNode && pos + len > charIdx ) {
        startNode   = node;
        startOffset = charIdx - pos;
      }
      if ( startNode && pos + len >= endIdx ) {
        endNode   = node;
        endOffset = endIdx - pos;
        break;
      }
      pos += len;
    }
    if ( !startNode || !endNode ) return null;
    var range = document.createRange();
    range.setStart(startNode, startOffset);
    range.setEnd(endNode, endOffset);
    return range;
  }

  // ════════════════════════════════════════════════════════════════════
  // BOOT
  // ════════════════════════════════════════════════════════════════════

  $( function () {
    buildDom();
    wireEvents();
    loadBookmarks();
    restoreBookmarkHighlights();
    restoreCommentHighlights();
    // Load comments then sync badge/tab count via renderCommentCards → updateBadge
    loadComments(function(){ renderCommentCards(); });
  } );

}() );