Guest User

Untitled

a guest
Aug 5th, 2026
36
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 18.78 KB | None | 0 0
  1. // ==UserScript==
  2. // @name Neuroglancer Copy Completion Row
  3. // @namespace eyewire-elite
  4. // @version 2.1.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 completionDate = formatLocalDate(new Date());
  335. const finalSegmentId = segmentIds[0];
  336.  
  337. button.textContent = 'Saving state…';
  338. const state = v.state.toJSON();
  339.  
  340. // Determine state server endpoint
  341. let stateServerUrl =
  342. state.jsonStateServer ||
  343. v.jsonStateServer ||
  344. 'https://global.daf-apis.com/nglstate/api/v1/post';
  345.  
  346. // Helper to identify a 32-character CAVE/DAF token
  347. const findTokenInValue = (val) => {
  348. if (!val) return null;
  349. val = val.trim();
  350. try {
  351. const parsed = JSON.parse(val);
  352. if (typeof parsed === 'string') {
  353. val = parsed;
  354. } else if (typeof parsed === 'object' && parsed !== null) {
  355. for (const k in parsed) {
  356. const subVal = parsed[k];
  357. if (
  358. typeof subVal === 'string' &&
  359. /^[0-9a-f]{32}$/i.test(subVal.trim())
  360. ) {
  361. return subVal.trim();
  362. }
  363. }
  364. }
  365. } catch {}
  366. val = val.replace(/^["']|["']$/g, '').trim();
  367. return /^[0-9a-f]{32}$/i.test(val) ? val : null;
  368. };
  369.  
  370. // Scan storage to locate an active token
  371. const scanForToken = () => {
  372. const keys = [
  373. 'token',
  374. 'middleauth_token',
  375. 'cave-token',
  376. 'cave_token',
  377. 'api_token',
  378. 'secret',
  379. 'cave_secret',
  380. ];
  381. for (const key of keys) {
  382. const tok =
  383. findTokenInValue(localStorage.getItem(key)) ||
  384. findTokenInValue(sessionStorage.getItem(key));
  385. if (tok) return tok;
  386. }
  387. for (let i = 0; i < localStorage.length; i++) {
  388. const tok = findTokenInValue(
  389. localStorage.getItem(localStorage.key(i))
  390. );
  391. if (tok) return tok;
  392. }
  393. for (let i = 0; i < sessionStorage.length; i++) {
  394. const tok = findTokenInValue(
  395. sessionStorage.getItem(sessionStorage.key(i))
  396. );
  397. if (tok) return tok;
  398. }
  399. return null;
  400. };
  401.  
  402. let token = scanForToken();
  403.  
  404. const uploadState = async (authToken) => {
  405. const headers = { 'Content-Type': 'application/json' };
  406. if (authToken) {
  407. headers['Authorization'] = `Bearer ${authToken}`;
  408. }
  409. const response = await fetch(stateServerUrl, {
  410. method: 'POST',
  411. headers: headers,
  412. body: JSON.stringify(state),
  413. });
  414. return response;
  415. };
  416.  
  417. let response = await uploadState(token);
  418.  
  419. // Prompt user if the state server rejects authorization
  420. if (response.status === 401) {
  421. const isFlywire = stateServerUrl.includes('flywire');
  422. const tokenPortalUrl = isFlywire
  423. ? 'https://globalv1.flywire-daf.com/auth/api/v1/user/token'
  424. : 'https://global.daf-apis.com/auth/api/v1/user/token';
  425.  
  426. const userToken = prompt(
  427. `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:`,
  428. token || ''
  429. );
  430.  
  431. if (!userToken) {
  432. throw new Error('API token is required to generate the short link.');
  433. }
  434.  
  435. token = userToken.trim();
  436.  
  437. // Persist token so user doesn't need to re-enter it next time
  438. if (/^[0-9a-f]{32}$/i.test(token)) {
  439. localStorage.setItem('cave_token', token);
  440. }
  441.  
  442. response = await uploadState(token);
  443. }
  444.  
  445. if (!response.ok) {
  446. throw new Error(
  447. `State server responded with status: ${response.status}`
  448. );
  449. }
  450.  
  451. // Parse the state ID
  452. const contentType = response.headers.get('content-type');
  453. let stateId;
  454.  
  455. if (contentType && contentType.includes('application/json')) {
  456. const data = await response.json();
  457. }
  458.  
  459. // Format the final shortened URL
  460. let fetchEndpoint = stateServerUrl.replace(/\/post\/?$/, '');
  461. let finalUrl;
  462.  
  463. if (
  464. window.location.origin.includes('flywire') ||
  465. stateServerUrl.includes('flywire')
  466. ) {
  467. finalUrl = `https://ngl.flywire.ai/?json_url=${fetchEndpoint}/${stateId}`;
  468. } else {
  469. finalUrl = `https://spelunker.cave-explorer.org/#!middleauth+${fetchEndpoint}/${stateId}`;
  470. }
  471.  
  472. /*
  473. * Escape quotation marks so the URL can safely be placed inside a
  474. * Google Sheets HYPERLINK formula.
  475. */
  476. const safeFinalUrl = finalUrl.replace(/"/g, '""');
  477.  
  478. /*
  479. * Column M becomes a clickable hyperlink displayed as:
  480. *
  481. * https://spelunker
  482. */
  483. const hyperlinkFormula =
  484. '=HYPERLINK("' + safeFinalUrl + '","https://spelunker")';
  485.  
  486. /*
  487. * Tab-separated values paste into adjacent Google Sheets columns:
  488. *
  489. * K = Date Complete
  490. * L = Final SegID
  491. * M = Final Link
  492. */
  493. const clipboardText = [
  494. completionDate,
  495. finalSegmentId,
  496. hyperlinkFormula,
  497. ].join('\t');
  498.  
  499. button.textContent = 'Copying…';
  500.  
  501. await copyTextToClipboard(clipboardText);
  502.  
  503. button.textContent = 'Copied ✓';
  504.  
  505. showMessage(
  506. message,
  507. 'Copied:\n' +
  508. completionDate +
  509. ' | ' +
  510. finalSegmentId +
  511. '\nPaste into the row’s column K cell.',
  512. 'success'
  513. );
  514. } catch (error) {
  515. console.error('[Completion script] Copy failed:', error);
  516.  
  517. button.textContent = 'Copy failed';
  518.  
  519. showMessage(
  520. message,
  521. error && error.message ? error.message : String(error),
  522. 'error'
  523. );
  524. } finally {
  525. window.setTimeout(function () {
  526. button.disabled = false;
  527. button.textContent = originalText;
  528. }, 1800);
  529. }
  530. }
  531.  
  532. /**
  533. * Extracts all selected segmentation IDs from the current URL state.
  534. */
  535. function extractSelectedSegmentIds(
  536. url
  537. ) {
  538. const marker = '#!';
  539.  
  540. const markerIndex =
  541. url.indexOf(marker);
  542.  
  543. if (markerIndex === -1) {
  544. throw new Error(
  545. 'The URL does not contain a Neuroglancer #! viewer state.'
  546. );
  547. }
  548.  
  549. const encodedState =
  550. url.substring(
  551. markerIndex +
  552. marker.length
  553. );
  554.  
  555. let decodedState;
  556.  
  557. try {
  558. decodedState =
  559. decodeURIComponent(
  560. encodedState
  561. );
  562. } catch (error) {
  563. throw new Error(
  564. 'The Neuroglancer URL could not be decoded.'
  565. );
  566. }
  567.  
  568. let viewerState;
  569.  
  570. try {
  571. viewerState =
  572. JSON.parse(
  573. decodedState
  574. );
  575. } catch (error) {
  576. throw new Error(
  577. 'The decoded Neuroglancer state is not valid JSON.'
  578. );
  579. }
  580.  
  581. if (
  582. !Array.isArray(
  583. viewerState.layers
  584. )
  585. ) {
  586. throw new Error(
  587. 'The Neuroglancer state does not contain a layer list.'
  588. );
  589. }
  590.  
  591. const segmentIds = [];
  592.  
  593. viewerState.layers.forEach(
  594. function (layer) {
  595. if (
  596. !layer ||
  597. layer.type !==
  598. 'segmentation' ||
  599. !Array.isArray(
  600. layer.segments
  601. )
  602. ) {
  603. return;
  604. }
  605.  
  606. layer.segments.forEach(
  607. function (segmentId) {
  608. const value =
  609. String(
  610. segmentId
  611. ).trim();
  612.  
  613. if (/^\d+$/.test(value)) {
  614. segmentIds.push(value);
  615. }
  616. }
  617. );
  618. }
  619. );
  620.  
  621. return Array.from(
  622. new Set(segmentIds)
  623. );
  624. }
  625.  
  626.  
  627. /**
  628. * Produces dates such as 8/5/2026 using the computer's local date.
  629. */
  630. function formatLocalDate(date) {
  631. return [
  632. date.getMonth() + 1,
  633. date.getDate(),
  634. date.getFullYear()
  635. ].join('/');
  636. }
  637.  
  638.  
  639. async function copyTextToClipboard(
  640. text
  641. ) {
  642. if (
  643. typeof GM_setClipboard ===
  644. 'function'
  645. ) {
  646. GM_setClipboard(
  647. text,
  648. 'text'
  649. );
  650.  
  651. return;
  652. }
  653.  
  654. if (
  655. navigator.clipboard &&
  656. typeof navigator.clipboard
  657. .writeText === 'function'
  658. ) {
  659. await navigator.clipboard
  660. .writeText(text);
  661.  
  662. return;
  663. }
  664.  
  665. const temporaryTextArea =
  666. document.createElement(
  667. 'textarea'
  668. );
  669.  
  670. temporaryTextArea.value =
  671. text;
  672.  
  673. temporaryTextArea.setAttribute(
  674. 'readonly',
  675. ''
  676. );
  677.  
  678. Object.assign(
  679. temporaryTextArea.style,
  680. {
  681. position: 'fixed',
  682. left: '-9999px',
  683. top: '0'
  684. }
  685. );
  686.  
  687. document.body.appendChild(
  688. temporaryTextArea
  689. );
  690.  
  691. temporaryTextArea.select();
  692.  
  693. const copied =
  694. document.execCommand(
  695. 'copy'
  696. );
  697.  
  698. temporaryTextArea.remove();
  699.  
  700. if (!copied) {
  701. throw new Error(
  702. 'The browser refused clipboard access.'
  703. );
  704. }
  705. }
  706.  
  707.  
  708. // ==========================================================================
  709. // SAVED BUTTON POSITION
  710. // ==========================================================================
  711.  
  712. function savePosition(widget) {
  713. const rectangle =
  714. widget.getBoundingClientRect();
  715.  
  716. const position = {
  717. left: Math.round(
  718. rectangle.left
  719. ),
  720. top: Math.round(
  721. rectangle.top
  722. )
  723. };
  724.  
  725. try {
  726. localStorage.setItem(
  727. CONFIG.positionStorageKey,
  728. JSON.stringify(position)
  729. );
  730. } catch (error) {
  731. console.warn(
  732. '[Completion script] Could not save button position:',
  733. error
  734. );
  735. }
  736. }
  737.  
  738.  
  739. function restorePosition(widget) {
  740. let restoredPosition = null;
  741.  
  742. try {
  743. const storedValue =
  744. localStorage.getItem(
  745. CONFIG.positionStorageKey
  746. );
  747.  
  748. if (storedValue) {
  749. restoredPosition =
  750. JSON.parse(storedValue);
  751. }
  752. } catch (error) {
  753. console.warn(
  754. '[Completion script] Could not restore button position:',
  755. error
  756. );
  757. }
  758.  
  759. if (
  760. restoredPosition &&
  761. Number.isFinite(
  762. restoredPosition.left
  763. ) &&
  764. Number.isFinite(
  765. restoredPosition.top
  766. )
  767. ) {
  768. widget.style.left =
  769. restoredPosition.left +
  770. 'px';
  771.  
  772. widget.style.top =
  773. restoredPosition.top +
  774. 'px';
  775.  
  776. widget.style.right =
  777. 'auto';
  778.  
  779. return;
  780. }
  781.  
  782. widget.style.top =
  783. CONFIG.defaultPosition.top +
  784. 'px';
  785.  
  786. widget.style.right =
  787. CONFIG.defaultPosition.right +
  788. 'px';
  789. }
  790.  
  791.  
  792. function keepWidgetOnScreen(widget) {
  793. const rectangle =
  794. widget.getBoundingClientRect();
  795.  
  796. const maximumLeft = Math.max(
  797. 0,
  798. window.innerWidth -
  799. rectangle.width
  800. );
  801.  
  802. const maximumTop = Math.max(
  803. 0,
  804. window.innerHeight -
  805. rectangle.height
  806. );
  807.  
  808. const nextLeft = clamp(
  809. rectangle.left,
  810. 0,
  811. maximumLeft
  812. );
  813.  
  814. const nextTop = clamp(
  815. rectangle.top,
  816. 0,
  817. maximumTop
  818. );
  819.  
  820. widget.style.left =
  821. nextLeft + 'px';
  822.  
  823. widget.style.top =
  824. nextTop + 'px';
  825.  
  826. widget.style.right =
  827. 'auto';
  828.  
  829. widget.style.bottom =
  830. 'auto';
  831. }
  832.  
  833.  
  834. function showMessage(
  835. messageElement,
  836. text,
  837. type
  838. ) {
  839. messageElement.textContent =
  840. text;
  841.  
  842. messageElement.style.background =
  843. type === 'error'
  844. ? 'rgba(165, 20, 20, 0.95)'
  845. : 'rgba(0, 105, 45, 0.95)';
  846.  
  847. messageElement.style.display =
  848. 'block';
  849.  
  850. window.clearTimeout(
  851. showMessage.timeoutId
  852. );
  853.  
  854. showMessage.timeoutId =
  855. window.setTimeout(
  856. function () {
  857. messageElement.style.display =
  858. 'none';
  859. },
  860. 6000
  861. );
  862. }
  863.  
  864.  
  865. function clamp(
  866. value,
  867. minimum,
  868. maximum
  869. ) {
  870. return Math.min(
  871. Math.max(
  872. value,
  873. minimum
  874. ),
  875. maximum
  876. );
  877. }
  878. })();
Advertisement
Add Comment
Please, Sign In to add comment