MediaWiki:Gadget-ConfirmationStore.js

From Painwiki
Jump to navigation Jump to search

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * DiagnosticTree — Confirmation Store v2
 *
 * Changes from v1:
 * - Anonymous confirmation disabled entirely
 * - Actor is always a username (physician group required)
 * - MAX_SESSIONS_PER_MUSCLE increased to 50
 * - isPhysician() helper exposed for UI gating
 *
 * Storage: Data:Confirmations/<TreeId>
 * Format:  { muscle_id: { n, last, sessions: [{ts, actor}] } }
 */

( function ( mw, $ ) {
'use strict';

const MAX_SESSIONS_PER_MUSCLE = 50;
const DATA_PAGE_PREFIX        = 'Data:Confirmations/';
const LS_KEY_PREFIX           = 'painwiki_confirmations_';
const REQUIRED_GROUP          = 'physician';

// ── Access control ────────────────────────────────────────────────

/**
 * Returns true if the current user is in the 'physician' group.
 * Used to gate the confirm button in the UI.
 */
function isPhysician() {
    const groups = mw.config.get( 'wgUserGroups' ) || [];
    return groups.indexOf( REQUIRED_GROUP ) !== -1;
}

/**
 * Returns true if the user is logged in at all.
 */
function isLoggedIn() {
    return !!mw.config.get( 'wgUserName' );
}

/**
 * Returns the current username, or null if not logged in.
 */
function getActor() {
    return mw.config.get( 'wgUserName' ) || null;
}

// ── Timestamp ─────────────────────────────────────────────────────

function nowISO() {
    return new Date().toISOString();
}

// ── Read ──────────────────────────────────────────────────────────

function loadConfirmations( treeId ) {
    const api   = new mw.Api();
    const lsKey = LS_KEY_PREFIX + treeId;

    return api.get( {
        action:        'query',
        prop:          'revisions',
        titles:        DATA_PAGE_PREFIX + treeId,
        rvprop:        'content',
        rvslots:       'main',
        formatversion: 2
    } ).then( function ( data ) {
        const pages = data.query && data.query.pages;
        if ( !pages || !pages.length ) return localFallback( lsKey );
        const page = pages[0];
        if ( page.missing ) return localFallback( lsKey );
        const content = page.revisions &&
                        page.revisions[0] &&
                        page.revisions[0].slots &&
                        page.revisions[0].slots.main &&
                        page.revisions[0].slots.main.content;
        if ( !content ) return localFallback( lsKey );
        try {
            const parsed = JSON.parse( content );
            localStorage.setItem( lsKey, JSON.stringify( parsed ) );
            return parsed;
        } catch ( e ) {
            return localFallback( lsKey );
        }
    } ).catch( function () {
        return localFallback( lsKey );
    } );
}

function localFallback( lsKey ) {
    try {
        const raw = localStorage.getItem( lsKey );
        if ( raw ) return JSON.parse( raw );
    } catch ( e ) {}
    return {};
}

// ── Write ─────────────────────────────────────────────────────────

/**
 * Records a confirmation.
 * Rejects immediately if the user is not in the physician group.
 *
 * @returns Promise resolving to updated counts, or rejecting with
 *          'not-physician' | 'not-logged-in' error strings.
 */
function confirmMuscle( treeId, muscleId, currentCounts ) {
    if ( !isLoggedIn() ) {
        return Promise.reject( 'not-logged-in' );
    }
    if ( !isPhysician() ) {
        return Promise.reject( 'not-physician' );
    }

    const actor  = getActor();
    const counts = JSON.parse( JSON.stringify( currentCounts ) );

    if ( !counts[muscleId] ) {
        counts[muscleId] = { n: 0, last: null, sessions: [] };
    }

    const entry   = counts[muscleId];
    const ts      = nowISO();

    entry.n    += 1;
    entry.last  = ts;
    entry.sessions.push( { ts: ts, actor: actor } );

    // Enforce rolling window
    if ( entry.sessions.length > MAX_SESSIONS_PER_MUSCLE ) {
        entry.sessions = entry.sessions.slice(
            entry.sessions.length - MAX_SESSIONS_PER_MUSCLE
        );
    }

    return writeConfirmations( treeId, counts ).then( function () {
        // Also update local cache
        localStorage.setItem(
            LS_KEY_PREFIX + treeId,
            JSON.stringify( counts )
        );
        return counts;
    } ).catch( function ( err ) {
        // Write failed (network, permissions) — update local cache only
        localStorage.setItem(
            LS_KEY_PREFIX + treeId,
            JSON.stringify( counts )
        );
        mw.log.warn( 'PainWiki: confirmation write failed —', err );
        return counts;
    } );
}

function writeConfirmations( treeId, counts ) {
    const api = new mw.Api();
    return api.postWithToken( 'csrf', {
        action:       'edit',
        title:        DATA_PAGE_PREFIX + treeId,
        text:         JSON.stringify( counts, null, 2 ),
        summary:      'Confirmation: ' + mw.config.get( 'wgUserName' ),
        minor:        true,
        contentmodel: 'json',
        formatversion: 2
    } );
}

// ── Priors ────────────────────────────────────────────────────────

function computeUpdatedPriors( schemaMuscles, counts, learningRate ) {
    learningRate = learningRate || 0.02;
    const ids = Object.keys( schemaMuscles );
    const p   = {};

    ids.forEach( function ( mid ) {
        const base = schemaMuscles[mid].prior || 0.1;
        const n    = ( counts[mid] && counts[mid].n ) || 0;
        p[mid]     = base + n * learningRate;
    } );

    let total = 0;
    ids.forEach( function ( mid ) { total += p[mid]; } );
    ids.forEach( function ( mid ) { p[mid] /= total; } );
    return p;
}

// ── Public API ────────────────────────────────────────────────────

window.PainWikiConfirmations = {
    load:           loadConfirmations,
    confirm:        confirmMuscle,
    computePriors:  computeUpdatedPriors,
    isPhysician:    isPhysician,
    isLoggedIn:     isLoggedIn,
    MAX_SESSIONS:   MAX_SESSIONS_PER_MUSCLE
};

} )( mediaWiki, jQuery );

/*
 * ── UI integration notes ───────────────────────────────────────────
 *
 * In DiagnosticTree.js, gate the confirm button on render:
 *
 *   const canConfirm = PainWikiConfirmations.isPhysician();
 *
 *   // If physician: render normal confirm button
 *   // If logged in but not physician: render greyed button with tooltip
 *   //   "Physician verification required — contact site admin"
 *   // If not logged in: render login prompt link instead of button
 *
 * Example:
 *
 *   function confirmButtonHTML( muscleId, label ) {
 *       if ( !PainWikiConfirmations.isLoggedIn() ) {
 *           return '<a href="/wiki/Special:UserLogin" class="dt-confirm-btn dt-confirm-login">' +
 *                  'Log in to confirm</a>';
 *       }
 *       if ( !PainWikiConfirmations.isPhysician() ) {
 *           return '<button class="dt-confirm-btn dt-confirm-btn--disabled" disabled ' +
 *                  'title="Physician verification required">' +
 *                  '&#128274; Physician access required</button>';
 *       }
 *       return '<button class="dt-confirm-btn" data-muscle="' + muscleId + '" ' +
 *              'data-label="' + encodeURIComponent(label) + '">&#10003; Confirm</button>';
 *   }
 *
 * On click, call:
 *
 *   PainWikiConfirmations.confirm( treeId, muscleId, currentCounts )
 *       .then( function ( updated ) {
 *           currentCounts = updated;
 *           renderLearnPanel( updated );
 *       } )
 *       .catch( function ( err ) {
 *           if ( err === 'not-physician' ) {
 *               // show "verification required" message
 *           }
 *       } );
 */