Guest User

Untitled

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