Guest User

Untitled

a guest
Aug 5th, 2026
44
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 18.55 KB | None | 0 0
  1. // ==UserScript==
  2. // @name Neuroglancer Copy Completion Row
  3. // @namespace eyewire-elite
  4. // @version 2.1.1
  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. stateId = 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+${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. * Tab-separated values paste into adjacent Google Sheets columns:
  480. *
  481. * K = Date Complete
  482. * L = Final SegID
  483. * M = Final Link
  484. */
  485. const clipboardText = [
  486. completionDate,
  487. finalSegmentId,
  488. safeFinalUrl,
  489. ].join('\t');
  490.  
  491. button.textContent = 'Copying…';
  492.  
  493. await copyTextToClipboard(clipboardText);
  494.  
  495. button.textContent = 'Copied ✓';
  496.  
  497. showMessage(
  498. message,
  499. 'Copied:\n' +
  500. completionDate +
  501. ' | ' +
  502. finalSegmentId +
  503. '\nPaste into the row’s column K cell.',
  504. 'success'
  505. );
  506. } catch (error) {
  507. console.error('[Completion script] Copy failed:', error);
  508.  
  509. button.textContent = 'Copy failed';
  510.  
  511. showMessage(
  512. message,
  513. error && error.message ? error.message : String(error),
  514. 'error'
  515. );
  516. } finally {
  517. window.setTimeout(function () {
  518. button.disabled = false;
  519. button.textContent = originalText;
  520. }, 1800);
  521. }
  522. }
  523.  
  524. /**
  525. * Extracts all selected segmentation IDs from the current URL state.
  526. */
  527. function extractSelectedSegmentIds(
  528. url
  529. ) {
  530. const marker = '#!';
  531.  
  532. const markerIndex =
  533. url.indexOf(marker);
  534.  
  535. if (markerIndex === -1) {
  536. throw new Error(
  537. 'The URL does not contain a Neuroglancer #! viewer state.'
  538. );
  539. }
  540.  
  541. const encodedState =
  542. url.substring(
  543. markerIndex +
  544. marker.length
  545. );
  546.  
  547. let decodedState;
  548.  
  549. try {
  550. decodedState =
  551. decodeURIComponent(
  552. encodedState
  553. );
  554. } catch (error) {
  555. throw new Error(
  556. 'The Neuroglancer URL could not be decoded.'
  557. );
  558. }
  559.  
  560. let viewerState;
  561.  
  562. try {
  563. viewerState =
  564. JSON.parse(
  565. decodedState
  566. );
  567. } catch (error) {
  568. throw new Error(
  569. 'The decoded Neuroglancer state is not valid JSON.'
  570. );
  571. }
  572.  
  573. if (
  574. !Array.isArray(
  575. viewerState.layers
  576. )
  577. ) {
  578. throw new Error(
  579. 'The Neuroglancer state does not contain a layer list.'
  580. );
  581. }
  582.  
  583. const segmentIds = [];
  584.  
  585. viewerState.layers.forEach(
  586. function (layer) {
  587. if (
  588. !layer ||
  589. layer.type !==
  590. 'segmentation' ||
  591. !Array.isArray(
  592. layer.segments
  593. )
  594. ) {
  595. return;
  596. }
  597.  
  598. layer.segments.forEach(
  599. function (segmentId) {
  600. const value =
  601. String(
  602. segmentId
  603. ).trim();
  604.  
  605. if (/^\d+$/.test(value)) {
  606. segmentIds.push(value);
  607. }
  608. }
  609. );
  610. }
  611. );
  612.  
  613. return Array.from(
  614. new Set(segmentIds)
  615. );
  616. }
  617.  
  618.  
  619. /**
  620. * Produces dates such as 8/5/2026 using the computer's local date.
  621. */
  622. function formatLocalDate(date) {
  623. return [
  624. date.getMonth() + 1,
  625. date.getDate(),
  626. date.getFullYear()
  627. ].join('/');
  628. }
  629.  
  630.  
  631. async function copyTextToClipboard(
  632. text
  633. ) {
  634. if (
  635. typeof GM_setClipboard ===
  636. 'function'
  637. ) {
  638. GM_setClipboard(
  639. text,
  640. 'text'
  641. );
  642.  
  643. return;
  644. }
  645.  
  646. if (
  647. navigator.clipboard &&
  648. typeof navigator.clipboard
  649. .writeText === 'function'
  650. ) {
  651. await navigator.clipboard
  652. .writeText(text);
  653.  
  654. return;
  655. }
  656.  
  657. const temporaryTextArea =
  658. document.createElement(
  659. 'textarea'
  660. );
  661.  
  662. temporaryTextArea.value =
  663. text;
  664.  
  665. temporaryTextArea.setAttribute(
  666. 'readonly',
  667. ''
  668. );
  669.  
  670. Object.assign(
  671. temporaryTextArea.style,
  672. {
  673. position: 'fixed',
  674. left: '-9999px',
  675. top: '0'
  676. }
  677. );
  678.  
  679. document.body.appendChild(
  680. temporaryTextArea
  681. );
  682.  
  683. temporaryTextArea.select();
  684.  
  685. const copied =
  686. document.execCommand(
  687. 'copy'
  688. );
  689.  
  690. temporaryTextArea.remove();
  691.  
  692. if (!copied) {
  693. throw new Error(
  694. 'The browser refused clipboard access.'
  695. );
  696. }
  697. }
  698.  
  699.  
  700. // ==========================================================================
  701. // SAVED BUTTON POSITION
  702. // ==========================================================================
  703.  
  704. function savePosition(widget) {
  705. const rectangle =
  706. widget.getBoundingClientRect();
  707.  
  708. const position = {
  709. left: Math.round(
  710. rectangle.left
  711. ),
  712. top: Math.round(
  713. rectangle.top
  714. )
  715. };
  716.  
  717. try {
  718. localStorage.setItem(
  719. CONFIG.positionStorageKey,
  720. JSON.stringify(position)
  721. );
  722. } catch (error) {
  723. console.warn(
  724. '[Completion script] Could not save button position:',
  725. error
  726. );
  727. }
  728. }
  729.  
  730.  
  731. function restorePosition(widget) {
  732. let restoredPosition = null;
  733.  
  734. try {
  735. const storedValue =
  736. localStorage.getItem(
  737. CONFIG.positionStorageKey
  738. );
  739.  
  740. if (storedValue) {
  741. restoredPosition =
  742. JSON.parse(storedValue);
  743. }
  744. } catch (error) {
  745. console.warn(
  746. '[Completion script] Could not restore button position:',
  747. error
  748. );
  749. }
  750.  
  751. if (
  752. restoredPosition &&
  753. Number.isFinite(
  754. restoredPosition.left
  755. ) &&
  756. Number.isFinite(
  757. restoredPosition.top
  758. )
  759. ) {
  760. widget.style.left =
  761. restoredPosition.left +
  762. 'px';
  763.  
  764. widget.style.top =
  765. restoredPosition.top +
  766. 'px';
  767.  
  768. widget.style.right =
  769. 'auto';
  770.  
  771. return;
  772. }
  773.  
  774. widget.style.top =
  775. CONFIG.defaultPosition.top +
  776. 'px';
  777.  
  778. widget.style.right =
  779. CONFIG.defaultPosition.right +
  780. 'px';
  781. }
  782.  
  783.  
  784. function keepWidgetOnScreen(widget) {
  785. const rectangle =
  786. widget.getBoundingClientRect();
  787.  
  788. const maximumLeft = Math.max(
  789. 0,
  790. window.innerWidth -
  791. rectangle.width
  792. );
  793.  
  794. const maximumTop = Math.max(
  795. 0,
  796. window.innerHeight -
  797. rectangle.height
  798. );
  799.  
  800. const nextLeft = clamp(
  801. rectangle.left,
  802. 0,
  803. maximumLeft
  804. );
  805.  
  806. const nextTop = clamp(
  807. rectangle.top,
  808. 0,
  809. maximumTop
  810. );
  811.  
  812. widget.style.left =
  813. nextLeft + 'px';
  814.  
  815. widget.style.top =
  816. nextTop + 'px';
  817.  
  818. widget.style.right =
  819. 'auto';
  820.  
  821. widget.style.bottom =
  822. 'auto';
  823. }
  824.  
  825.  
  826. function showMessage(
  827. messageElement,
  828. text,
  829. type
  830. ) {
  831. messageElement.textContent =
  832. text;
  833.  
  834. messageElement.style.background =
  835. type === 'error'
  836. ? 'rgba(165, 20, 20, 0.95)'
  837. : 'rgba(0, 105, 45, 0.95)';
  838.  
  839. messageElement.style.display =
  840. 'block';
  841.  
  842. window.clearTimeout(
  843. showMessage.timeoutId
  844. );
  845.  
  846. showMessage.timeoutId =
  847. window.setTimeout(
  848. function () {
  849. messageElement.style.display =
  850. 'none';
  851. },
  852. 6000
  853. );
  854. }
  855.  
  856.  
  857. function clamp(
  858. value,
  859. minimum,
  860. maximum
  861. ) {
  862. return Math.min(
  863. Math.max(
  864. value,
  865. minimum
  866. ),
  867. maximum
  868. );
  869. }
  870. })();
Advertisement
Add Comment
Please, Sign In to add comment