Guest User

annkri

a guest
Aug 15th, 2026
49
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
  1. // ==UserScript==
  2. // @name         Neuroglancer Copy Completion Row
  3. // @namespace    eyewire-elite
  4. // @version      2.2.0.annkri
  5. // @description  Copies completion date, selected segment ID, and a clickable Neuroglancer link for Google Sheets.
  6. // @match        https://spelunker.cave-explorer.org/*
  7. // @run-at       document-idle
  8. // @grant        GM_setClipboard
  9. // ==/UserScript==
  10.  
  11. (function () {
  12.   'use strict';
  13.  
  14.   const USERNAME = 'annkri'
  15.  
  16.   const CONFIG = Object.freeze({
  17.     buttonId: 'nseraf-copy-completion-widget',
  18.  
  19.     positionStorageKey:
  20.       'nseraf-copy-completion-widget-position-v1',
  21.  
  22.     defaultPosition: {
  23.       top: 75,
  24.       right: 20
  25.     },
  26.  
  27.     dragThresholdPixels: 4
  28.   });
  29.  
  30.   let dragState = null;
  31.  
  32.   installWidget();
  33.  
  34.  
  35.   // ==========================================================================
  36.   // DRAGGABLE COPY BUTTON
  37.   // ==========================================================================
  38.  
  39.   function installWidget() {
  40.     if (document.getElementById(CONFIG.buttonId)) {
  41.       return;
  42.     }
  43.  
  44.     const widget = document.createElement('div');
  45.  
  46.     widget.id = CONFIG.buttonId;
  47.  
  48.     widget.title =
  49.       'Drag to reposition. Click to copy completion data.';
  50.  
  51.     Object.assign(widget.style, {
  52.       position: 'fixed',
  53.       zIndex: '2147483647',
  54.       display: 'flex',
  55.       flexDirection: 'column',
  56.       alignItems: 'stretch',
  57.       minWidth: '170px',
  58.       maxWidth: '310px',
  59.       fontFamily: 'Arial, Helvetica, sans-serif',
  60.       userSelect: 'none',
  61.       touchAction: 'none'
  62.     });
  63.  
  64.     const button = document.createElement('button');
  65.  
  66.     button.type = 'button';
  67.     button.textContent = 'Copy completion';
  68.  
  69.     Object.assign(button.style, {
  70.       border: '1px solid rgba(255, 255, 255, 0.65)',
  71.       borderRadius: '7px',
  72.       padding: '9px 13px',
  73.       background: '#1769aa',
  74.       color: '#ffffff',
  75.       fontSize: '13px',
  76.       fontWeight: 'bold',
  77.       cursor: 'grab',
  78.       boxShadow: '0 2px 8px rgba(0, 0, 0, 0.45)'
  79.     });
  80.  
  81.     const message = document.createElement('div');
  82.  
  83.     Object.assign(message.style, {
  84.       display: 'none',
  85.       marginTop: '5px',
  86.       padding: '7px 9px',
  87.       borderRadius: '6px',
  88.       background: 'rgba(0, 0, 0, 0.88)',
  89.       color: '#ffffff',
  90.       fontSize: '12px',
  91.       lineHeight: '1.35',
  92.       overflowWrap: 'anywhere',
  93.       whiteSpace: 'pre-wrap'
  94.     });
  95.  
  96.     widget.appendChild(button);
  97.     widget.appendChild(message);
  98.  
  99.     document.documentElement.appendChild(widget);
  100.  
  101.     restorePosition(widget);
  102.     keepWidgetOnScreen(widget);
  103.  
  104.     button.addEventListener(
  105.       'pointerdown',
  106.       function (event) {
  107.         beginDrag(event, widget, button);
  108.       }
  109.     );
  110.  
  111.     button.addEventListener(
  112.       'pointermove',
  113.       function (event) {
  114.         continueDrag(event, widget);
  115.       }
  116.     );
  117.  
  118.     button.addEventListener(
  119.       'pointerup',
  120.       function (event) {
  121.         finishDrag(
  122.           event,
  123.           widget,
  124.           button,
  125.           message
  126.         );
  127.       }
  128.     );
  129.  
  130.     button.addEventListener(
  131.       'pointercancel',
  132.       function () {
  133.         cancelDrag(button);
  134.       }
  135.     );
  136.  
  137.     window.addEventListener(
  138.       'resize',
  139.       function () {
  140.         keepWidgetOnScreen(widget);
  141.         savePosition(widget);
  142.       }
  143.     );
  144.   }
  145.  
  146.  
  147.   function beginDrag(event, widget, button) {
  148.     if (
  149.       event.pointerType === 'mouse' &&
  150.       event.button !== 0
  151.     ) {
  152.       return;
  153.     }
  154.  
  155.     const rectangle =
  156.       widget.getBoundingClientRect();
  157.  
  158.     dragState = {
  159.       pointerId: event.pointerId,
  160.       startPointerX: event.clientX,
  161.       startPointerY: event.clientY,
  162.       startWidgetLeft: rectangle.left,
  163.       startWidgetTop: rectangle.top,
  164.       moved: false
  165.     };
  166.  
  167.     button.setPointerCapture(
  168.       event.pointerId
  169.     );
  170.  
  171.     button.style.cursor = 'grabbing';
  172.  
  173.     event.preventDefault();
  174.   }
  175.  
  176.  
  177.   function continueDrag(event, widget) {
  178.     if (
  179.       !dragState ||
  180.       event.pointerId !== dragState.pointerId
  181.     ) {
  182.       return;
  183.     }
  184.  
  185.     const deltaX =
  186.       event.clientX -
  187.       dragState.startPointerX;
  188.  
  189.     const deltaY =
  190.       event.clientY -
  191.       dragState.startPointerY;
  192.  
  193.     if (
  194.       Math.abs(deltaX) >=
  195.         CONFIG.dragThresholdPixels ||
  196.       Math.abs(deltaY) >=
  197.         CONFIG.dragThresholdPixels
  198.     ) {
  199.       dragState.moved = true;
  200.     }
  201.  
  202.     if (!dragState.moved) {
  203.       return;
  204.     }
  205.  
  206.     const rectangle =
  207.       widget.getBoundingClientRect();
  208.  
  209.     const maximumLeft = Math.max(
  210.       0,
  211.       window.innerWidth - rectangle.width
  212.     );
  213.  
  214.     const maximumTop = Math.max(
  215.       0,
  216.       window.innerHeight - rectangle.height
  217.     );
  218.  
  219.     const nextLeft = clamp(
  220.       dragState.startWidgetLeft + deltaX,
  221.       0,
  222.       maximumLeft
  223.     );
  224.  
  225.     const nextTop = clamp(
  226.       dragState.startWidgetTop + deltaY,
  227.       0,
  228.       maximumTop
  229.     );
  230.  
  231.     widget.style.left =
  232.       nextLeft + 'px';
  233.  
  234.     widget.style.top =
  235.       nextTop + 'px';
  236.  
  237.     widget.style.right = 'auto';
  238.     widget.style.bottom = 'auto';
  239.  
  240.     event.preventDefault();
  241.   }
  242.  
  243.  
  244.   function finishDrag(
  245.     event,
  246.     widget,
  247.     button,
  248.     message
  249.   ) {
  250.     if (
  251.       !dragState ||
  252.       event.pointerId !== dragState.pointerId
  253.     ) {
  254.       return;
  255.     }
  256.  
  257.     const wasDragged =
  258.       dragState.moved;
  259.  
  260.     try {
  261.       button.releasePointerCapture(
  262.         event.pointerId
  263.       );
  264.     } catch (error) {
  265.       // Pointer capture may already be released.
  266.     }
  267.  
  268.     dragState = null;
  269.     button.style.cursor = 'grab';
  270.  
  271.     if (wasDragged) {
  272.       keepWidgetOnScreen(widget);
  273.       savePosition(widget);
  274.  
  275.       showMessage(
  276.         message,
  277.         'Button position saved.',
  278.         'success'
  279.       );
  280.     } else {
  281.       copyCompletionData(
  282.         button,
  283.         message
  284.       );
  285.     }
  286.  
  287.     event.preventDefault();
  288.   }
  289.  
  290.  
  291.   function cancelDrag(button) {
  292.     dragState = null;
  293.     button.style.cursor = 'grab';
  294.   }
  295.  
  296.  
  297.   // ==========================================================================
  298.   // COPY COMPLETION DATA
  299.   // ==========================================================================
  300.  
  301. async function copyCompletionData(
  302.   button,
  303.   message
  304. ) {
  305.   const originalText = button.textContent;
  306.  
  307.   try {
  308.     button.disabled = true;
  309.     button.textContent = 'Reading viewer…';
  310.  
  311.     const v = unsafeWindow.viewer;
  312.     if (!v) {
  313.       throw new Error(
  314.         'The global "viewer" object was not found. Make sure you are in the correct frame/window.'
  315.       );
  316.     }
  317.  
  318.     // Extract segment IDs from the current long URL before shortening
  319.     const currentUrl = window.location.href;
  320.     const segmentIds = extractSelectedSegmentIds(currentUrl);
  321.  
  322.     if (segmentIds.length === 0) {
  323.       throw new Error(
  324.         'No selected segment was found in the current Neuroglancer URL.'
  325.       );
  326.     }
  327.  
  328.     if (segmentIds.length > 1) {
  329.       throw new Error(
  330.         'Multiple selected segments were found: ' +
  331.         segmentIds.join(', ') +
  332.         '. Leave only the final segment selected and try again.'
  333.       );
  334.     }
  335.  
  336.     const status = 'Complete (cut off)';
  337.     const completionDate = formatLocalDate(new Date());
  338.     const finalSegmentId = segmentIds[0];
  339.  
  340.     button.textContent = 'Saving state…';
  341.     const state = v.state.toJSON();
  342.  
  343.     // Determine state server endpoint
  344.     let stateServerUrl =
  345.       state.jsonStateServer ||
  346.       v.jsonStateServer ||
  347.       'https://global.daf-apis.com/nglstate/api/v1/post';
  348.  
  349.     // Helper to identify a 32-character CAVE/DAF token
  350.     const findTokenInValue = (val) => {
  351.       if (!val) return null;
  352.       val = val.trim();
  353.       try {
  354.         const parsed = JSON.parse(val);
  355.         if (typeof parsed === 'string') {
  356.           val = parsed;
  357.         } else if (typeof parsed === 'object' && parsed !== null) {
  358.           for (const k in parsed) {
  359.             const subVal = parsed[k];
  360.             if (
  361.               typeof subVal === 'string' &&
  362.               /^[0-9a-f]{32}$/i.test(subVal.trim())
  363.             ) {
  364.               return subVal.trim();
  365.             }
  366.           }
  367.         }
  368.       } catch {}
  369.       val = val.replace(/^["']|["']$/g, '').trim();
  370.       return /^[0-9a-f]{32}$/i.test(val) ? val : null;
  371.     };
  372.  
  373.     // Scan storage to locate an active token
  374.     const scanForToken = () => {
  375.       const keys = [
  376.         'token',
  377.         'middleauth_token',
  378.         'cave-token',
  379.         'cave_token',
  380.         'api_token',
  381.         'secret',
  382.         'cave_secret',
  383.       ];
  384.       for (const key of keys) {
  385.         const tok =
  386.           findTokenInValue(localStorage.getItem(key)) ||
  387.           findTokenInValue(sessionStorage.getItem(key));
  388.         if (tok) return tok;
  389.       }
  390.       for (let i = 0; i < localStorage.length; i++) {
  391.         const tok = findTokenInValue(
  392.           localStorage.getItem(localStorage.key(i))
  393.         );
  394.         if (tok) return tok;
  395.       }
  396.       for (let i = 0; i < sessionStorage.length; i++) {
  397.         const tok = findTokenInValue(
  398.           sessionStorage.getItem(sessionStorage.key(i))
  399.         );
  400.         if (tok) return tok;
  401.       }
  402.       return null;
  403.     };
  404.  
  405.     let token = scanForToken();
  406.  
  407.     const uploadState = async (authToken) => {
  408.       const headers = { 'Content-Type': 'application/json' };
  409.       if (authToken) {
  410.         headers.Authorization = `Bearer ${authToken}`;
  411.       }
  412.       const response = await fetch(stateServerUrl, {
  413.         method: 'POST',
  414.         headers: headers,
  415.         body: JSON.stringify(state),
  416.       });
  417.       return response;
  418.     };
  419.  
  420.     let response = await uploadState(token);
  421.  
  422.     // Prompt user if the state server rejects authorization
  423.     if (response.status === 401) {
  424.       const isFlywire = stateServerUrl.includes('flywire');
  425.       const tokenPortalUrl = isFlywire
  426.         ? 'https://globalv1.flywire-daf.com/auth/api/v1/user/token'
  427.         : 'https://global.daf-apis.com/auth/api/v1/user/token';
  428.  
  429.       const userToken = prompt(
  430.         `An API token is required to save states to this server.\n\n1. Please visit:\n${tokenPortalUrl}\n2. Copy the token\n3. Paste it here:`,
  431.         token || ''
  432.       );
  433.  
  434.       if (!userToken) {
  435.         throw new Error('API token is required to generate the short link.');
  436.       }
  437.  
  438.       token = userToken.trim();
  439.  
  440.       // Persist token so user doesn't need to re-enter it next time
  441.       if (/^[0-9a-f]{32}$/i.test(token)) {
  442.         localStorage.setItem('cave_token', token);
  443.       }
  444.  
  445.       response = await uploadState(token);
  446.     }
  447.  
  448.     if (!response.ok) {
  449.       throw new Error(
  450.         `State server responded with status: ${response.status}`
  451.       );
  452.     }
  453.  
  454.     // Parse the state ID
  455.     const contentType = response.headers.get('content-type');
  456.     let stateId;
  457.  
  458.     if (contentType && contentType.includes('application/json')) {
  459.       stateId = await response.json();
  460.     }
  461.  
  462.     // Format the final shortened URL
  463.     let fetchEndpoint = stateServerUrl.replace(/\/post\/?$/, '');
  464.     let finalUrl;
  465.  
  466.     if (
  467.       window.location.origin.includes('flywire') ||
  468.       stateServerUrl.includes('flywire')
  469.     ) {
  470.       finalUrl = `https://ngl.flywire.ai/?json_url=${fetchEndpoint}/${stateId}`;
  471.     } else {
  472.       finalUrl = `https://spelunker.cave-explorer.org/#!middleauth+${stateId}`;
  473.     }
  474.  
  475.     /*
  476.      * Escape quotation marks so the URL can safely be placed inside a
  477.      * Google Sheets HYPERLINK formula.
  478.      */
  479.     const safeFinalUrl = finalUrl.replace(/"/g, '""');
  480.  
  481.     /*
  482.      * Tab-separated values paste into adjacent Google Sheets columns:
  483.      *
  484.      * K = Date Complete
  485.      * L = Final SegID
  486.      * M = Final Link
  487.      */
  488.     const clipboardText = [
  489.       USERNAME,
  490.       status,
  491.       completionDate,
  492.       finalSegmentId,
  493.       safeFinalUrl,
  494.     ].join('\t');
  495.  
  496.     button.textContent = 'Copying…';
  497.  
  498.     await copyTextToClipboard(clipboardText);
  499.  
  500.     button.textContent = 'Copied ✓';
  501.  
  502.     showMessage(
  503.       message,
  504.       'Copied:\n' +
  505.         completionDate +
  506.         ' | ' +
  507.         finalSegmentId +
  508.         '\nPaste into the row’s column K cell.',
  509.       'success'
  510.     );
  511.   } catch (error) {
  512.     console.error('[Completion script] Copy failed:', error);
  513.  
  514.     button.textContent = 'Copy failed';
  515.  
  516.     showMessage(
  517.       message,
  518.       error && error.message ? error.message : String(error),
  519.       'error'
  520.     );
  521.   } finally {
  522.     window.setTimeout(function () {
  523.       button.disabled = false;
  524.       button.textContent = originalText;
  525.     }, 1800);
  526.   }
  527. }
  528.  
  529.   /**
  530.    * Extracts all selected segmentation IDs from the current URL state.
  531.    */
  532.   function extractSelectedSegmentIds(
  533.     url
  534.   ) {
  535.     const marker = '#!';
  536.  
  537.     const markerIndex =
  538.       url.indexOf(marker);
  539.  
  540.     if (markerIndex === -1) {
  541.       throw new Error(
  542.         'The URL does not contain a Neuroglancer #! viewer state.'
  543.       );
  544.     }
  545.  
  546.     const encodedState =
  547.       url.substring(
  548.         markerIndex +
  549.         marker.length
  550.       );
  551.  
  552.     let decodedState;
  553.  
  554.     try {
  555.       decodedState =
  556.         decodeURIComponent(
  557.           encodedState
  558.         );
  559.     } catch (error) {
  560.       throw new Error(
  561.         'The Neuroglancer URL could not be decoded.'
  562.       );
  563.     }
  564.  
  565.     let viewerState;
  566.  
  567.     try {
  568.       viewerState =
  569.         JSON.parse(
  570.           decodedState
  571.         );
  572.     } catch (error) {
  573.       throw new Error(
  574.         'The decoded Neuroglancer state is not valid JSON.'
  575.       );
  576.     }
  577.  
  578.     if (
  579.       !Array.isArray(
  580.         viewerState.layers
  581.       )
  582.     ) {
  583.       throw new Error(
  584.         'The Neuroglancer state does not contain a layer list.'
  585.       );
  586.     }
  587.  
  588.     const segmentIds = [];
  589.  
  590.     viewerState.layers.forEach(
  591.       function (layer) {
  592.         if (
  593.           !layer ||
  594.           layer.type !==
  595.             'segmentation' ||
  596.           !Array.isArray(
  597.             layer.segments
  598.           )
  599.         ) {
  600.           return;
  601.         }
  602.  
  603.         layer.segments.forEach(
  604.           function (segmentId) {
  605.             const value =
  606.               String(
  607.                 segmentId
  608.               ).trim();
  609.  
  610.             if (/^\d+$/.test(value)) {
  611.               segmentIds.push(value);
  612.             }
  613.           }
  614.         );
  615.       }
  616.     );
  617.  
  618.     return Array.from(
  619.       new Set(segmentIds)
  620.     );
  621.   }
  622.  
  623.  
  624.   /**
  625.    * Produces dates such as 8/5/2026 using the computer's local date.
  626.    */
  627.   function formatLocalDate(date) {
  628.     return [
  629.       date.getMonth() + 1,
  630.       date.getDate(),
  631.       date.getFullYear()
  632.     ].join('/');
  633.   }
  634.  
  635.  
  636.   async function copyTextToClipboard(
  637.     text
  638.   ) {
  639.     if (
  640.       typeof GM_setClipboard ===
  641.       'function'
  642.     ) {
  643.       GM_setClipboard(
  644.         text,
  645.         'text'
  646.       );
  647.  
  648.       return;
  649.     }
  650.  
  651.     if (
  652.       navigator.clipboard &&
  653.       typeof navigator.clipboard
  654.         .writeText === 'function'
  655.     ) {
  656.       await navigator.clipboard
  657.         .writeText(text);
  658.  
  659.       return;
  660.     }
  661.  
  662.     const temporaryTextArea =
  663.       document.createElement(
  664.         'textarea'
  665.       );
  666.  
  667.     temporaryTextArea.value =
  668.       text;
  669.  
  670.     temporaryTextArea.setAttribute(
  671.       'readonly',
  672.       ''
  673.     );
  674.  
  675.     Object.assign(
  676.       temporaryTextArea.style,
  677.       {
  678.         position: 'fixed',
  679.         left: '-9999px',
  680.         top: '0'
  681.       }
  682.     );
  683.  
  684.     document.body.appendChild(
  685.       temporaryTextArea
  686.     );
  687.  
  688.     temporaryTextArea.select();
  689.  
  690.     const copied =
  691.       document.execCommand(
  692.         'copy'
  693.       );
  694.  
  695.     temporaryTextArea.remove();
  696.  
  697.     if (!copied) {
  698.       throw new Error(
  699.         'The browser refused clipboard access.'
  700.       );
  701.     }
  702.   }
  703.  
  704.  
  705.   // ==========================================================================
  706.   // SAVED BUTTON POSITION
  707.   // ==========================================================================
  708.  
  709.   function savePosition(widget) {
  710.     const rectangle =
  711.       widget.getBoundingClientRect();
  712.  
  713.     const position = {
  714.       left: Math.round(
  715.         rectangle.left
  716.       ),
  717.       top: Math.round(
  718.         rectangle.top
  719.       )
  720.     };
  721.  
  722.     try {
  723.       localStorage.setItem(
  724.         CONFIG.positionStorageKey,
  725.         JSON.stringify(position)
  726.       );
  727.     } catch (error) {
  728.       console.warn(
  729.         '[Completion script] Could not save button position:',
  730.         error
  731.       );
  732.     }
  733.   }
  734.  
  735.  
  736.   function restorePosition(widget) {
  737.     let restoredPosition = null;
  738.  
  739.     try {
  740.       const storedValue =
  741.         localStorage.getItem(
  742.           CONFIG.positionStorageKey
  743.         );
  744.  
  745.       if (storedValue) {
  746.         restoredPosition =
  747.           JSON.parse(storedValue);
  748.       }
  749.     } catch (error) {
  750.       console.warn(
  751.         '[Completion script] Could not restore button position:',
  752.         error
  753.       );
  754.     }
  755.  
  756.     if (
  757.       restoredPosition &&
  758.       Number.isFinite(
  759.         restoredPosition.left
  760.       ) &&
  761.       Number.isFinite(
  762.         restoredPosition.top
  763.       )
  764.     ) {
  765.       widget.style.left =
  766.         restoredPosition.left +
  767.         'px';
  768.  
  769.       widget.style.top =
  770.         restoredPosition.top +
  771.         'px';
  772.  
  773.       widget.style.right =
  774.         'auto';
  775.  
  776.       return;
  777.     }
  778.  
  779.     widget.style.top =
  780.       CONFIG.defaultPosition.top +
  781.       'px';
  782.  
  783.     widget.style.right =
  784.       CONFIG.defaultPosition.right +
  785.       'px';
  786.   }
  787.  
  788.  
  789.   function keepWidgetOnScreen(widget) {
  790.     const rectangle =
  791.       widget.getBoundingClientRect();
  792.  
  793.     const maximumLeft = Math.max(
  794.       0,
  795.       window.innerWidth -
  796.       rectangle.width
  797.     );
  798.  
  799.     const maximumTop = Math.max(
  800.       0,
  801.       window.innerHeight -
  802.       rectangle.height
  803.     );
  804.  
  805.     const nextLeft = clamp(
  806.       rectangle.left,
  807.       0,
  808.       maximumLeft
  809.     );
  810.  
  811.     const nextTop = clamp(
  812.       rectangle.top,
  813.       0,
  814.       maximumTop
  815.     );
  816.  
  817.     widget.style.left =
  818.       nextLeft + 'px';
  819.  
  820.     widget.style.top =
  821.       nextTop + 'px';
  822.  
  823.     widget.style.right =
  824.       'auto';
  825.  
  826.     widget.style.bottom =
  827.       'auto';
  828.   }
  829.  
  830.  
  831.   function showMessage(
  832.     messageElement,
  833.     text,
  834.     type
  835.   ) {
  836.     messageElement.textContent =
  837.       text;
  838.  
  839.     messageElement.style.background =
  840.       type === 'error'
  841.         ? 'rgba(165, 20, 20, 0.95)'
  842.         : 'rgba(0, 105, 45, 0.95)';
  843.  
  844.     messageElement.style.display =
  845.       'block';
  846.  
  847.     window.clearTimeout(
  848.       showMessage.timeoutId
  849.     );
  850.  
  851.     showMessage.timeoutId =
  852.       window.setTimeout(
  853.         function () {
  854.           messageElement.style.display =
  855.             'none';
  856.         },
  857.         6000
  858.       );
  859.   }
  860.  
  861.  
  862.   function clamp(
  863.     value,
  864.     minimum,
  865.     maximum
  866.   ) {
  867.     return Math.min(
  868.       Math.max(
  869.         value,
  870.         minimum
  871.       ),
  872.       maximum
  873.     );
  874.   }
  875. })();
Advertisement
Add Comment
Please, Sign In to add comment