├── web ├── public │ └── favicon.png ├── src │ ├── app │ │ ├── api │ │ │ ├── groq │ │ │ │ ├── allowedModels.js │ │ │ │ ├── route.js │ │ │ │ └── [...path] │ │ │ │ │ └── route.js │ │ │ ├── mcp-auth │ │ │ │ └── callback │ │ │ │ │ └── route.js │ │ │ └── proxy │ │ │ │ └── route.js │ │ ├── page.jsx │ │ ├── layout.jsx │ │ └── oauth │ │ │ └── callback │ │ │ └── page.jsx │ ├── ui │ │ ├── builtinTools.js │ │ ├── ScriptEditor.jsx │ │ ├── spreadsheetMcp.js │ │ ├── mcpClient.js │ │ ├── FileManager.jsx │ │ └── Grid.jsx │ └── styles.css ├── next.config.mjs └── package.json ├── src ├── index.js └── lib │ ├── errors.js │ ├── builtins │ ├── utils.js │ └── index.js │ ├── registry.js │ ├── parser.js │ └── engine.js ├── package.json ├── .github └── workflows │ ├── stale.yaml │ └── code-freeze-bypass.yaml ├── test ├── groqProxy.test.js └── engine.test.js ├── README.md ├── .gitignore └── LICENSE /web/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/groq/groq-autosheet/HEAD/web/public/favicon.png -------------------------------------------------------------------------------- /web/src/app/api/groq/allowedModels.js: -------------------------------------------------------------------------------- 1 | export const ALLOWED_MODELS = new Set([ 2 | 'openai/gpt-oss-20b', 3 | 'openai/gpt-oss-120b', 4 | ]); 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { SpreadsheetEngine } from './lib/engine.js'; 2 | export { registerBuiltins } from './lib/builtins/index.js'; 3 | export { getBuiltinFunctionNames } from './lib/registry.js'; 4 | 5 | 6 | -------------------------------------------------------------------------------- /web/src/app/page.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import React from 'react' 3 | import dynamic from 'next/dynamic' 4 | import '../styles.css' 5 | 6 | const AppNoSSR = dynamic(() => import('../ui/App.jsx'), { ssr: false }) 7 | 8 | export default function Page() { 9 | return 10 | } 11 | 12 | 13 | -------------------------------------------------------------------------------- /web/src/app/api/mcp-auth/callback/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | export const runtime = 'nodejs' 4 | 5 | export async function GET(req) { 6 | // This route is a placeholder for future server-side auth exchanges if needed. 7 | // Currently, `use-mcp` completes auth on the client and uses window opener messaging. 8 | return NextResponse.json({ ok: true }) 9 | } 10 | 11 | 12 | -------------------------------------------------------------------------------- /web/src/app/layout.jsx: -------------------------------------------------------------------------------- 1 | import '../styles.css' 2 | export const metadata = { 3 | title: 'Autosheet', 4 | description: 'JavaScript-powered spreadsheet with chat and MCP', 5 | icons: [{ rel: 'icon', url: '/favicon.png' }], 6 | } 7 | 8 | export default function RootLayout({ children }) { 9 | return ( 10 | 11 | 12 | {children} 13 | 14 | 15 | ) 16 | } 17 | 18 | 19 | -------------------------------------------------------------------------------- /web/next.config.mjs: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { fileURLToPath } from 'url' 3 | 4 | const __filename = fileURLToPath(import.meta.url) 5 | const __dirname = path.dirname(__filename) 6 | /** @type {import('next').NextConfig} */ 7 | const nextConfig = { 8 | reactStrictMode: true, 9 | webpack: (config) => { 10 | config.resolve.alias = { 11 | ...(config.resolve.alias || {}), 12 | autosheet: path.resolve(__dirname, '../src/index.js'), 13 | } 14 | return config 15 | }, 16 | } 17 | 18 | export default nextConfig 19 | 20 | 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "autosheet", 3 | "version": "0.1.0", 4 | "description": "Pure JavaScript spreadsheet calculation engine with custom function support", 5 | "license": "Apache-2.0", 6 | "type": "module", 7 | "main": "src/index.js", 8 | "exports": { 9 | ".": "./src/index.js" 10 | }, 11 | "scripts": { 12 | "test": "vitest run", 13 | "test:watch": "vitest", 14 | "lint": "echo 'No linter configured'", 15 | "dev": "npm --prefix web run dev" 16 | }, 17 | "devDependencies": { 18 | "vitest": "^3.2.4" 19 | } 20 | } 21 | 22 | 23 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "autosheet-web", 3 | "private": true, 4 | "version": "0.2.0", 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@codemirror/lang-javascript": "^6.2.2", 12 | "@uiw/react-codemirror": "^4.23.7", 13 | "acorn": "^8.12.1", 14 | "use-mcp": "^0.0.21", 15 | "groq-sdk": "^0.6.0", 16 | "next": "^14.2.35", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0", 19 | "react-markdown": "^10.1.0", 20 | "remark-gfm": "^4.0.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/errors.js: -------------------------------------------------------------------------------- 1 | export class CellError { 2 | constructor(code, message = '') { 3 | this.code = code; // e.g. #NAME?, #REF!, #VALUE!, #DIV/0!, #N/A, #NUM!, #CYCLE! 4 | this.message = message; 5 | } 6 | 7 | toString() { 8 | return this.code; 9 | } 10 | } 11 | 12 | export const ERROR = { 13 | NAME: '#NAME?', 14 | REF: '#REF!', 15 | VALUE: '#VALUE!', 16 | DIV0: '#DIV/0!', 17 | NA: '#N/A', 18 | NUM: '#NUM!', 19 | CYCLE: '#CYCLE!' 20 | }; 21 | 22 | export function isCellError(v) { 23 | return v instanceof CellError; 24 | } 25 | 26 | export function err(code, message) { 27 | return new CellError(code, message); 28 | } 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/lib/builtins/utils.js: -------------------------------------------------------------------------------- 1 | export function ensureArray(args, n) { 2 | const out = new Array(n).fill(undefined); 3 | for (let i = 0; i < n; i++) out[i] = args[i]; 4 | return out; 5 | } 6 | 7 | export function flattenArgsToValues(args) { 8 | const out = []; 9 | for (const a of args) { 10 | if (Array.isArray(a)) { 11 | for (const v of a) out.push(v); 12 | } else { 13 | out.push(a); 14 | } 15 | } 16 | return out; 17 | } 18 | 19 | export function coerceToNumberArray(values) { 20 | const out = []; 21 | for (const v of values) { 22 | if (typeof v === 'number' && Number.isFinite(v)) out.push(v); 23 | else if (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v))) out.push(Number(v)); 24 | } 25 | return out; 26 | } 27 | 28 | export function truthy(v) { 29 | return !!v; 30 | } 31 | 32 | 33 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | ##################################### 2 | # DO NOT EDIT DIRECTLY. # 3 | # This file is managed by Terraform # 4 | ##################################### 5 | 6 | name: "Close stale PRs" 7 | on: 8 | schedule: 9 | - cron: "30 1 * * *" 10 | 11 | jobs: 12 | stale: 13 | runs-on: ubuntu-latest 14 | # Read repo and write to PRs 15 | permissions: 16 | contents: read 17 | pull-requests: write 18 | issues: write 19 | steps: 20 | - uses: actions/stale@v10 21 | with: 22 | stale-pr-message: "This PR is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days." 23 | close-pr-message: "This PR was closed because it has been stalled for 7 days with no activity." 24 | days-before-pr-stale: 30 25 | days-before-pr-close: 7 26 | exempt-pr-labels: "dependencies,security" 27 | operations-per-run: 60 # Default is 30 28 | -------------------------------------------------------------------------------- /src/lib/registry.js: -------------------------------------------------------------------------------- 1 | export class BuiltinRegistry { 2 | constructor() { 3 | // Internal storage is case-insensitive via uppercase key, while preserving 4 | // the original registration name for display and tooling. 5 | this.map = new Map(); // UPPERCASE name -> function(argsArray, ctx) 6 | this.originalNames = new Map(); // UPPERCASE name -> originalCaseName 7 | } 8 | 9 | register(name, fn) { 10 | const key = name.toUpperCase(); 11 | this.map.set(key, fn); 12 | this.originalNames.set(key, name); 13 | } 14 | 15 | has(name) { 16 | return this.map.has(name.toUpperCase()); 17 | } 18 | 19 | get(name) { 20 | return this.map.get(name.toUpperCase()); 21 | } 22 | 23 | names() { 24 | return Array.from(this.originalNames.values()); 25 | } 26 | } 27 | 28 | 29 | export function getBuiltinFunctionNames(registry) { 30 | if (!registry || typeof registry.names !== 'function') return []; 31 | try { 32 | return registry.names().filter((n) => n && n !== 'BUILTINS'); 33 | } catch { 34 | return []; 35 | } 36 | } 37 | 38 | 39 | -------------------------------------------------------------------------------- /web/src/ui/builtinTools.js: -------------------------------------------------------------------------------- 1 | export function getBuiltinTools() { 2 | return [ 3 | { 4 | type: 'function', 5 | function: { 6 | name: 'sleep', 7 | description: 'Call this tool if you need to wait for a process to end, estimate the desired wait time and enter it as seconds (integer).', 8 | parameters: { 9 | type: 'object', 10 | properties: { 11 | seconds: { type: 'integer', description: 'How long to wait in seconds.' }, 12 | }, 13 | required: ['seconds'], 14 | }, 15 | }, 16 | }, 17 | ] 18 | } 19 | 20 | export function isBuiltinToolName(name) { 21 | return name === 'sleep' 22 | } 23 | 24 | export async function runBuiltinTool(name, args) { 25 | if (name === 'sleep') { 26 | const secondsRaw = args && (args.seconds ?? args.secs ?? args.time) 27 | const secondsNum = Number(secondsRaw) 28 | const seconds = Number.isFinite(secondsNum) ? Math.max(0, Math.floor(secondsNum)) : 0 29 | await new Promise((resolve) => setTimeout(resolve, seconds * 1000)) 30 | return { ok: true, waited_seconds: seconds } 31 | } 32 | return null 33 | } 34 | 35 | 36 | -------------------------------------------------------------------------------- /test/groqProxy.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest' 2 | import { POST as groqProxy } from '../web/src/app/api/groq/[...path]/route.js' 3 | 4 | const originalFetch = global.fetch 5 | 6 | describe('Groq proxy model enforcement', () => { 7 | beforeEach(() => { 8 | process.env.GROQ_API_KEY = 'test-key' 9 | }) 10 | 11 | afterEach(() => { 12 | global.fetch = originalFetch 13 | }) 14 | 15 | it('rejects requests with disallowed model', async () => { 16 | const req = new Request('http://example.com/api/groq/openai/v1/chat/completions', { 17 | method: 'POST', 18 | headers: { 'content-type': 'application/json' }, 19 | body: JSON.stringify({ model: 'invalid', messages: [] }), 20 | }) 21 | const res = await groqProxy(req) 22 | expect(res.status).toBe(400) 23 | const data = await res.json() 24 | expect(data.error).toBeDefined() 25 | }) 26 | 27 | it('allows requests with approved model', async () => { 28 | global.fetch = async () => new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }) 29 | const req = new Request('http://example.com/api/groq/openai/v1/chat/completions', { 30 | method: 'POST', 31 | headers: { 'content-type': 'application/json' }, 32 | body: JSON.stringify({ model: 'openai/gpt-oss-20b', messages: [] }), 33 | }) 34 | const res = await groqProxy(req) 35 | expect(res.status).toBe(200) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Autosheet (a GroqLabs project) 2 | 3 | autosheet-ui 4 | 5 | 6 | Autosheet is a lightweight, hackable browser spreadsheet with an integrated AI copilot (chat + tools/MCP) running on Groq’s blazing-fast inference. Use it as: 7 | 8 | - A reference implementation for GPT-OSS reasoning and function-calling on Groq 9 | - A playground to build custom tools/functions and experiment with remote MCP servers 10 | - A simple spreadsheet you can fork and extend 11 | 12 | Try it online: https://autosheet.groqlabs.com/ 13 | 14 | ## Quick start 15 | 16 | Prereqs: Node 18+. 17 | 18 | 1) Install and run the web app 19 | 20 | ```bash 21 | npm install 22 | npm run dev 23 | ``` 24 | 25 | 2) Set your Groq API key (for the proxy that forwards chat completions): 26 | 27 | ```bash 28 | export GROQ_API_KEY=your_key_here 29 | ``` 30 | 31 | Then open the dev server URL printed in your terminal (Next.js dev). The in-browser chat will call the `/api/groq` proxy which forwards to `https://api.groq.com/openai/v1/chat/completions` and only allows approved models. 32 | 33 | ## Project layout 34 | 35 | - `src/` – Minimal spreadsheet engine and function registry 36 | - `web/` – Next.js app (UI: grid, chat, script editor, MCP client) 37 | - `web/src/app/api/groq/` – Proxy to Groq API (reads `GROQ_API_KEY`) 38 | 39 | ## Hack on it 40 | 41 | - Add built-in spreadsheet functions in `src/lib/builtins/` 42 | - Create new chat tools/MCP integrations in `web/src/ui/builtinTools.js` 43 | - Adjust allowed models in `web/src/app/api/groq/allowedModels.js` 44 | 45 | PRs welcome. This repo aims to stay small, readable, and easy to fork. 46 | 47 | ## License 48 | 49 | Apache 2.0. See `LICENSE`. 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | # Diagnostic reports 11 | report.*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Dependency directories 20 | node_modules/ 21 | web/node_modules/ 22 | bower_components/ 23 | 24 | # Build outputs 25 | build/ 26 | dist/ 27 | web/dist/ 28 | coverage/ 29 | 30 | # Caches 31 | .cache/ 32 | .parcel-cache/ 33 | .eslintcache 34 | .stylelintcache 35 | .rpt2_cache/ 36 | .fusebox/ 37 | .nyc_output/ 38 | .tmp/ 39 | tmp/ 40 | temp/ 41 | 42 | # Vite 43 | node_modules/.vite/ 44 | web/node_modules/.vite/ 45 | .vite/ 46 | web/.vite/ 47 | 48 | # Next/Nuxt/Gatsby (harmless if unused) 49 | .next/ 50 | .nuxt/ 51 | _out/ 52 | public/dist/ 53 | .cache-loader/ 54 | 55 | # TypeScript 56 | *.tsbuildinfo 57 | tsconfig.tsbuildinfo 58 | 59 | # Environment variables 60 | .env 61 | .env.* 62 | !.env.example 63 | 64 | # OS artifacts 65 | .DS_Store 66 | .DS_Store? 67 | .AppleDouble 68 | .LSOverride 69 | Icon? 70 | ._* 71 | .Spotlight-V100 72 | .Trashes 73 | Thumbs.db 74 | ehthumbs.db 75 | 76 | # Editors/IDEs 77 | .idea/ 78 | .vscode/* 79 | !.vscode/extensions.json 80 | !.vscode/settings.json 81 | !.vscode/tasks.json 82 | !.vscode/launch.json 83 | *.sw? 84 | *.swo 85 | *.swn 86 | *.swp 87 | *~ 88 | 89 | # Test outputs 90 | junit.xml 91 | jest-junit.xml 92 | coverage-final.json 93 | .jest/ 94 | playwright-report/ 95 | test-results/ 96 | cypress/videos/ 97 | cypress/screenshots/ 98 | 99 | # Archives 100 | *.tgz 101 | *.tar 102 | *.gz 103 | *.zip 104 | 105 | # PnP 106 | .pnp.* 107 | 108 | # Local overrides 109 | *.local 110 | 111 | # Mac code signing 112 | *.dSYM/ 113 | -------------------------------------------------------------------------------- /.github/workflows/code-freeze-bypass.yaml: -------------------------------------------------------------------------------- 1 | ##################################### 2 | # DO NOT EDIT DIRECTLY. # 3 | # This file is managed by Terraform # 4 | ##################################### 5 | 6 | name: "Code Freeze Bypass Status" 7 | 8 | on: 9 | pull_request_target: 10 | types: 11 | - labeled 12 | - unlabeled 13 | 14 | permissions: 15 | contents: read 16 | pull-requests: read 17 | 18 | jobs: 19 | update-status: 20 | runs-on: ubuntu-latest 21 | env: 22 | BYPASS_LABEL: bypass-code-freeze 23 | steps: 24 | - name: Emit bypass status 25 | uses: actions/github-script@v8 26 | with: 27 | github-token: ${{ secrets.GITHUB_TOKEN }} 28 | script: | 29 | const action = context.payload.action; 30 | const label = context.payload.label?.name || ""; 31 | if (!["labeled", "unlabeled"].includes(action)) { 32 | core.info(`Unhandled action: ${action}`); 33 | return; 34 | } 35 | const owner = context.payload.repository.owner.login; 36 | const repo = context.payload.repository.name; 37 | const topicsResponse = await github.rest.repos.getAllTopics({ owner, repo }); 38 | const topics = topicsResponse.data.names || []; 39 | if (!topics.includes("code-freeze")) { 40 | core.info(`Repository ${owner}/${repo} is not marked with code-freeze topic; skipping enforcement.`); 41 | return; 42 | } 43 | 44 | if (label !== process.env.BYPASS_LABEL) { 45 | core.setFailed( 46 | `Label '${label}' is not allowed while ${owner}/${repo} is in code freeze. Only '${process.env.BYPASS_LABEL}' may change.`, 47 | ); 48 | return; 49 | } 50 | 51 | if (action === "labeled") { 52 | core.info("Bypass label applied; workflow passing."); 53 | return; 54 | } 55 | 56 | core.setFailed("Bypass label removed; code freeze enforced."); 57 | -------------------------------------------------------------------------------- /web/src/app/api/groq/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import { ALLOWED_MODELS } from './allowedModels.js' 3 | 4 | const BASE = 'https://api.groq.com/openai/v1' 5 | 6 | export const runtime = 'nodejs' 7 | 8 | function sanitizeHeaders(headers) { 9 | const out = new Headers() 10 | for (const [key, value] of headers) { 11 | const lower = String(key).toLowerCase() 12 | if (lower === 'content-encoding' || lower === 'transfer-encoding') continue 13 | let v = String(value).replace(/[\u00B5\u03BC]/g, 'us') 14 | if (!/^[\x00-\x7F]*$/.test(v)) continue 15 | out.set(key, v) 16 | } 17 | return out 18 | } 19 | 20 | export async function POST(req) { 21 | try { 22 | const url = new URL(req.url) 23 | // Support sub-path forwarding: /api/groq/chat/completions → /openai/v1/chat/completions 24 | const forwardPath = url.pathname.replace(/^.*\/api\/groq/, '') || '' 25 | // Only allow chat completions; block other paths like /models 26 | const allowed = forwardPath === '/chat/completions' || forwardPath === '' 27 | if (!allowed) { 28 | return NextResponse.json({ error: 'Not found' }, { status: 404 }) 29 | } 30 | const target = `${BASE}${'/chat/completions'}` 31 | const apiKey = process.env.GROQ_API_KEY 32 | if (!apiKey) { 33 | return NextResponse.json({ error: 'Missing GROQ_API_KEY' }, { status: 500 }) 34 | } 35 | const bodyText = await req.text() 36 | let model 37 | try { 38 | model = JSON.parse(bodyText)?.model 39 | } catch {} 40 | if (!ALLOWED_MODELS.has(String(model))) { 41 | return NextResponse.json({ error: 'Model not allowed' }, { status: 400 }) 42 | } 43 | const res = await fetch(target, { 44 | method: 'POST', 45 | headers: { 46 | 'Authorization': `Bearer ${apiKey}`, 47 | 'Content-Type': 'application/json', 48 | }, 49 | body: bodyText, 50 | }) 51 | // Pass through status and stream if present 52 | const headers = sanitizeHeaders(res.headers) 53 | headers.set('access-control-allow-origin', '*') 54 | headers.set('access-control-allow-methods', 'POST, OPTIONS') 55 | headers.set('access-control-allow-headers', 'authorization, content-type') 56 | return new Response(res.body, { 57 | status: res.status, 58 | statusText: res.statusText, 59 | headers, 60 | }) 61 | } catch (err) { 62 | return NextResponse.json({ error: String(err && (err.message || err)) }, { status: 500 }) 63 | } 64 | } 65 | 66 | export async function OPTIONS() { 67 | return new Response(null, { 68 | status: 204, 69 | headers: { 70 | 'access-control-allow-origin': '*', 71 | 'access-control-allow-methods': 'POST, OPTIONS', 72 | 'access-control-allow-headers': 'authorization, content-type', 73 | }, 74 | }) 75 | } 76 | 77 | 78 | -------------------------------------------------------------------------------- /web/src/app/api/groq/[...path]/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import { ALLOWED_MODELS } from '../allowedModels.js' 3 | 4 | // Use the root API base; the client SDK supplies '/openai/v1/...' 5 | const BASE = 'https://api.groq.com' 6 | export const runtime = 'nodejs' 7 | 8 | function sanitizeHeaders(headers) { 9 | const out = new Headers() 10 | for (const [key, value] of headers) { 11 | const lower = String(key).toLowerCase() 12 | if (lower === 'content-encoding' || lower === 'transfer-encoding') continue 13 | // Replace common non-ASCII micro symbol with ASCII 'us' 14 | let v = String(value).replace(/[\u00B5\u03BC]/g, 'us') 15 | // Drop any headers that still contain non-ASCII 16 | if (!/^[\x00-\x7F]*$/.test(v)) continue 17 | out.set(key, v) 18 | } 19 | return out 20 | } 21 | 22 | async function forward(req) { 23 | try { 24 | const apiKey = process.env.GROQ_API_KEY 25 | if (!apiKey) return NextResponse.json({ error: 'Missing GROQ_API_KEY' }, { status: 500 }) 26 | const url = new URL(req.url) 27 | const relative = url.pathname.replace(/^.*\/api\/groq/, '') || '/' 28 | // Only allow chat completions, deny everything else (e.g., /models) 29 | const allowed = relative === '/openai/v1/chat/completions' || relative === '/chat/completions' 30 | if (!allowed) { 31 | return NextResponse.json({ error: 'Not found' }, { status: 404 }) 32 | } 33 | const target = `${BASE}${relative}${url.search}` 34 | const headers = new Headers(req.headers) 35 | headers.set('authorization', `Bearer ${apiKey}`) 36 | headers.set('content-type', 'application/json') 37 | headers.delete('host') 38 | headers.delete('content-length') 39 | let bodyText 40 | if (req.method !== 'GET' && req.method !== 'HEAD') { 41 | bodyText = await req.text() 42 | let model 43 | try { 44 | model = JSON.parse(bodyText)?.model 45 | } catch {} 46 | if (!ALLOWED_MODELS.has(String(model))) { 47 | return NextResponse.json({ error: 'Model not allowed' }, { status: 400 }) 48 | } 49 | } 50 | const res = await fetch(target, { 51 | method: req.method, 52 | headers, 53 | body: bodyText, 54 | duplex: 'half', 55 | }) 56 | const out = sanitizeHeaders(res.headers) 57 | // CORS headers (safe for same-origin and helpful for tools) 58 | out.set('access-control-allow-origin', '*') 59 | out.set('access-control-allow-methods', 'POST, OPTIONS') 60 | out.set('access-control-allow-headers', 'authorization, content-type') 61 | return new Response(res.body, { status: res.status, statusText: res.statusText, headers: out }) 62 | } catch (err) { 63 | return NextResponse.json({ error: String(err && (err.message || err)) }, { status: 500 }) 64 | } 65 | } 66 | 67 | export const POST = forward 68 | 69 | export async function OPTIONS() { 70 | return new Response(null, { 71 | status: 204, 72 | headers: { 73 | 'access-control-allow-origin': '*', 74 | 'access-control-allow-methods': 'POST, OPTIONS', 75 | 'access-control-allow-headers': 'authorization, content-type', 76 | }, 77 | }) 78 | } 79 | 80 | 81 | -------------------------------------------------------------------------------- /web/src/app/oauth/callback/page.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import React, { useEffect, useRef } from 'react' 3 | import { auth } from '@modelcontextprotocol/sdk/client/auth.js' 4 | import { ProxyingBrowserOAuthProvider, proxyFetch } from '../../../ui/mcpClient.js' 5 | 6 | export default function OAuthCallback() { 7 | const [status, setStatus] = React.useState('processing') 8 | const [error, setError] = React.useState(null) 9 | const processedRef = useRef(false) 10 | 11 | useEffect(() => { 12 | if (processedRef.current) return // Prevent double execution in StrictMode 13 | processedRef.current = true 14 | 15 | ;(async () => { 16 | const params = new URLSearchParams(window.location.search) 17 | const code = params.get('code') 18 | const state = params.get('state') 19 | const error = params.get('error') 20 | const errorDescription = params.get('error_description') 21 | const logPrefix = '[mcp-callback]' 22 | try { 23 | if (error) throw new Error(`OAuth error: ${error} - ${errorDescription || ''}`) 24 | if (!code) throw new Error('Missing authorization code') 25 | if (!state) throw new Error('Missing state') 26 | 27 | // Try to find the state with any prefix pattern 28 | // The state might be stored as mcp:auth:state_... or mcp:autosheet:N:state_... 29 | let storedStateJSON = null 30 | let stateKey = null 31 | 32 | // First try the default pattern 33 | stateKey = `mcp:auth:state_${state}` 34 | storedStateJSON = localStorage.getItem(stateKey) 35 | 36 | // If not found, search for it with other prefixes 37 | if (!storedStateJSON) { 38 | const allKeys = Object.keys(localStorage) 39 | const possibleKeys = allKeys.filter(k => k.includes(`:state_${state}`)) 40 | console.log(`${logPrefix} Looking for state ${state}, found possible keys:`, possibleKeys) 41 | 42 | if (possibleKeys.length > 0) { 43 | stateKey = possibleKeys[0] 44 | storedStateJSON = localStorage.getItem(stateKey) 45 | } 46 | } 47 | 48 | if (!storedStateJSON) { 49 | const allStateKeys = Object.keys(localStorage).filter(k => k.includes(':state_')) 50 | console.log(`${logPrefix} State ${state} not found. Available state keys:`, allStateKeys) 51 | throw new Error('Invalid or expired state') 52 | } 53 | let stored 54 | try { stored = JSON.parse(storedStateJSON) } catch { throw new Error('Corrupt stored state') } 55 | if (!stored.expiry || stored.expiry < Date.now()) { 56 | localStorage.removeItem(stateKey) 57 | throw new Error('State expired') 58 | } 59 | const { providerOptions } = stored || {} 60 | const serverUrl = providerOptions?.serverUrl 61 | if (!serverUrl) throw new Error('Missing serverUrl in state') 62 | const provider = new ProxyingBrowserOAuthProvider(serverUrl, providerOptions || {}) 63 | console.log(`${logPrefix} Exchanging code for token...`) 64 | const result = await auth(provider, { serverUrl, authorizationCode: code, fetchFn: proxyFetch }) 65 | console.log(`${logPrefix} Auth result:`, result) 66 | if (result === 'AUTHORIZED') { 67 | localStorage.removeItem(stateKey) 68 | setStatus('success') 69 | if (window.opener && !window.opener.closed) { 70 | console.log(`${logPrefix} Sending success message to opener`) 71 | window.opener.postMessage({ type: 'mcp_auth_callback', success: true }, window.location.origin) 72 | setTimeout(() => window.close(), 100) 73 | } else { 74 | console.log(`${logPrefix} No opener, redirecting to home`) 75 | setTimeout(() => { window.location.href = '/' }, 1000) 76 | } 77 | } else { 78 | throw new Error(`Unexpected auth result: ${result}`) 79 | } 80 | } catch (err) { 81 | console.error(`${logPrefix} Error:`, err) 82 | const errorMsg = String(err && (err.message || err)) 83 | setStatus('error') 84 | setError(errorMsg) 85 | if (window.opener && !window.opener.closed) { 86 | window.opener.postMessage({ type: 'mcp_auth_callback', success: false, error: errorMsg }, window.location.origin) 87 | } 88 | } 89 | })() 90 | }, []) 91 | 92 | return ( 93 |
94 |

OAuth Callback

95 | {status === 'processing' &&

Completing authentication… You can close this window.

} 96 | {status === 'success' &&

Authentication successful! This window will close automatically.

} 97 | {status === 'error' && ( 98 |
99 |

Authentication failed:

100 |
{error}
101 |

You can close this window and try again.

102 |
103 | )} 104 |
105 | ) 106 | } 107 | 108 | 109 | -------------------------------------------------------------------------------- /src/lib/parser.js: -------------------------------------------------------------------------------- 1 | // Minimal formula parser: 2 | // Supports: 3 | // - Literals: numbers (int/float), strings in double quotes 4 | // - Cell refs: A1, a1, $A$1, $A1, A$1 5 | // - Ranges: A1:B2 6 | // - Function calls: NAME(arg1, arg2, ...) 7 | // - Commas as argument separators 8 | // - Unary +/- before number literals 9 | 10 | export function parseFormula(input) { 11 | const ctx = { s: input.trim(), i: 0 }; 12 | const node = parseExpr(ctx); 13 | skipWs(ctx); 14 | if (ctx.i !== ctx.s.length) { 15 | throw new Error('Unexpected input at position ' + ctx.i); 16 | } 17 | return node; 18 | } 19 | 20 | function parseExpr(ctx) { 21 | // expression with precedence: (*,/) over (+,-) 22 | skipWs(ctx); 23 | return parseAddSub(ctx); 24 | } 25 | 26 | function parseAddSub(ctx) { 27 | let node = parseMulDiv(ctx); 28 | while (true) { 29 | skipWs(ctx); 30 | const ch = peek(ctx); 31 | if (ch === '+' || ch === '-') { 32 | next(ctx); 33 | const right = parseMulDiv(ctx); 34 | node = { type: 'BinaryOp', op: ch, left: node, right }; 35 | continue; 36 | } 37 | break; 38 | } 39 | return node; 40 | } 41 | 42 | function parseMulDiv(ctx) { 43 | let node = parseTerm(ctx); 44 | while (true) { 45 | skipWs(ctx); 46 | const ch = peek(ctx); 47 | if (ch === '*' || ch === '/') { 48 | next(ctx); 49 | const right = parseTerm(ctx); 50 | node = { type: 'BinaryOp', op: ch, left: node, right }; 51 | continue; 52 | } 53 | break; 54 | } 55 | return node; 56 | } 57 | 58 | function parseTerm(ctx) { 59 | skipWs(ctx); 60 | // Function or Cell/Range or Literal 61 | const start = ctx.i; 62 | // Parenthesized expression 63 | if (peek(ctx) === '(') { 64 | next(ctx); 65 | const inner = parseExpr(ctx); 66 | skipWs(ctx); 67 | expect(ctx, ')'); 68 | return inner; 69 | } 70 | // String literal 71 | if (peek(ctx) === '"') return parseString(ctx); 72 | // Number (with optional unary +/-) 73 | if (peek(ctx) === '+' || peek(ctx) === '-' || isDigit(peek(ctx))) { 74 | const tryNum = tryParseNumber(ctx); 75 | if (tryNum) return tryNum; 76 | } 77 | // Try sheet-qualified cell/range e.g., Sheet1!$A$1 or Sheet1!A1:B2 78 | const trySheet = tryParseSheetQualified(ctx); 79 | if (trySheet) return trySheet; 80 | // Identifier or cell 81 | if (isAlpha(peek(ctx)) || peek(ctx) === '$') { 82 | // Look ahead to see if this is a cell/range or a function name 83 | const ident = readWhile(ctx, (ch) => isAlpha(ch) || ch === '$'); 84 | // If next is a digit -> it's a cell reference (supporting $) 85 | if (isDigit(peek(ctx))) { 86 | const rowAbs = readWhile(ctx, (ch) => ch === '$'); 87 | const row = readWhile(ctx, (ch) => isDigit(ch)); 88 | const cell = (ident + rowAbs + row).toUpperCase(); 89 | skipWs(ctx); 90 | if (peek(ctx) === ':') { 91 | next(ctx); // consume ':' 92 | skipWs(ctx); 93 | const endRef = parseCellRef(ctx); 94 | return { type: 'Range', start: cell, end: endRef }; 95 | } 96 | return { type: 'Cell', ref: cell }; 97 | } 98 | // If next non-ws is '(' it's a function call 99 | skipWs(ctx); 100 | if (peek(ctx) === '(') { 101 | next(ctx); // consume '(' 102 | const args = []; 103 | skipWs(ctx); 104 | if (peek(ctx) === ')') { 105 | next(ctx); 106 | return { type: 'Call', name: ident.toUpperCase(), args }; 107 | } 108 | while (true) { 109 | const arg = parseExpr(ctx); 110 | args.push(arg); 111 | skipWs(ctx); 112 | if (peek(ctx) === ',') { 113 | next(ctx); 114 | continue; 115 | } 116 | if (peek(ctx) === ')') { 117 | next(ctx); 118 | break; 119 | } 120 | throw new Error('Expected , or ) in function call at ' + ctx.i); 121 | } 122 | return { type: 'Call', name: ident.toUpperCase(), args }; 123 | } 124 | // Boolean literals TRUE/FALSE 125 | const upperIdent = ident.toUpperCase(); 126 | if (upperIdent === 'TRUE') return { type: 'Literal', value: true }; 127 | if (upperIdent === 'FALSE') return { type: 'Literal', value: false }; 128 | // Otherwise treat as bare identifier string literal 129 | ctx.i = start; 130 | } 131 | throw new Error('Unable to parse term at ' + ctx.i); 132 | } 133 | 134 | function parseCellRef(ctx) { 135 | const col = readWhile(ctx, (ch) => isAlpha(ch) || ch === '$'); 136 | const rowAbs = readWhile(ctx, (ch) => ch === '$'); 137 | const row = readWhile(ctx, (ch) => isDigit(ch)); 138 | if (!col || !row) throw new Error('Invalid cell reference at ' + ctx.i); 139 | return (col + rowAbs + row).toUpperCase(); 140 | } 141 | 142 | function parseString(ctx) { 143 | expect(ctx, '"'); 144 | let out = ''; 145 | while (ctx.i < ctx.s.length) { 146 | const ch = next(ctx); 147 | if (ch === '"') break; 148 | if (ch === '\\') { 149 | const esc = next(ctx); 150 | if (esc === '"') out += '"'; 151 | else if (esc === '\\') out += '\\'; 152 | else if (esc === 'n') out += '\n'; 153 | else if (esc === 't') out += '\t'; 154 | else out += esc; 155 | } else { 156 | out += ch; 157 | } 158 | } 159 | return { type: 'Literal', value: out }; 160 | } 161 | 162 | function tryParseNumber(ctx) { 163 | const start = ctx.i; 164 | if (peek(ctx) === '+' || peek(ctx) === '-') next(ctx); 165 | let seenDigit = false; 166 | while (isDigit(peek(ctx))) { 167 | seenDigit = true; 168 | next(ctx); 169 | } 170 | if (peek(ctx) === '.') { 171 | next(ctx); 172 | while (isDigit(peek(ctx))) { 173 | seenDigit = true; 174 | next(ctx); 175 | } 176 | } 177 | if (!seenDigit) { 178 | ctx.i = start; 179 | return null; 180 | } 181 | const numStr = ctx.s.slice(start, ctx.i); 182 | const num = Number(numStr); 183 | if (Number.isNaN(num)) { 184 | ctx.i = start; 185 | return null; 186 | } 187 | return { type: 'Literal', value: num }; 188 | } 189 | 190 | function tryParseSheetQualified(ctx) { 191 | const start = ctx.i; 192 | // sheet name: letters, digits, underscore (no spaces for now) 193 | const sheet = readWhile(ctx, (ch) => isAlpha(ch) || isDigit(ch) || ch === '_'); 194 | if (!sheet) { 195 | ctx.i = start; 196 | return null; 197 | } 198 | if (peek(ctx) !== '!') { 199 | ctx.i = start; 200 | return null; 201 | } 202 | next(ctx); // consume '!' 203 | // After '!' must be a cell ref (with optional $), possibly a range 204 | const cellStart = parseCellRef(ctx); 205 | skipWs(ctx); 206 | if (peek(ctx) === ':') { 207 | next(ctx); 208 | skipWs(ctx); 209 | const cellEnd = parseCellRef(ctx); 210 | return { type: 'Range', start: `${sheet}!${cellStart}`, end: `${sheet}!${cellEnd}` }; 211 | } 212 | return { type: 'Cell', ref: `${sheet}!${cellStart}` }; 213 | } 214 | 215 | // Helpers 216 | function skipWs(ctx) { 217 | while (ctx.i < ctx.s.length && /\s/.test(ctx.s[ctx.i])) ctx.i++; 218 | } 219 | function readWhile(ctx, pred) { 220 | let out = ''; 221 | while (ctx.i < ctx.s.length && pred(ctx.s[ctx.i])) out += ctx.s[ctx.i++]; 222 | return out; 223 | } 224 | function isDigit(ch) { 225 | return ch >= '0' && ch <= '9'; 226 | } 227 | function isAlpha(ch) { 228 | const c = ch || ''; 229 | return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); 230 | } 231 | function peek(ctx) { 232 | return ctx.s[ctx.i]; 233 | } 234 | function next(ctx) { 235 | return ctx.s[ctx.i++]; 236 | } 237 | function expect(ctx, ch) { 238 | const got = next(ctx); 239 | if (got !== ch) throw new Error(`Expected ${ch} got ${got}`); 240 | } 241 | 242 | 243 | -------------------------------------------------------------------------------- /web/src/ui/ScriptEditor.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import React, { useMemo, useState, useEffect, useCallback } from 'react' 3 | import CodeMirror from '@uiw/react-codemirror' 4 | import { javascript } from '@codemirror/lang-javascript' 5 | import { EditorView } from '@codemirror/view' 6 | 7 | const STORAGE_KEY = 'autosheet.scriptFiles.v1' 8 | 9 | export function loadScriptsFromStorage() { 10 | try { 11 | const raw = localStorage.getItem(STORAGE_KEY) 12 | if (raw) { 13 | const arr = JSON.parse(raw) 14 | if (Array.isArray(arr) && arr.length > 0) return sanitizeScripts(arr) 15 | } 16 | } catch {} 17 | return [ 18 | { 19 | id: crypto.randomUUID(), 20 | name: 'script1.js', 21 | content: initialTemplate() 22 | } 23 | ] 24 | } 25 | 26 | export function saveScriptsToStorage(scripts) { 27 | try { localStorage.setItem(STORAGE_KEY, JSON.stringify(sanitizeScripts(scripts))) } catch {} 28 | } 29 | 30 | function sanitizeScripts(scripts) { 31 | return scripts.map((s) => ({ id: s.id || crypto.randomUUID(), name: s.name || 'script.js', content: s.content || '' })) 32 | } 33 | 34 | export default function ScriptEditor({ scripts, setScripts, activeId, setActiveId, onChangeContent, onBlurContent, liveReload, setLiveReload, error, onReloadNow }) { 35 | const [renamingId, setRenamingId] = useState(null) 36 | 37 | const active = useMemo(() => scripts.find((s) => s.id === activeId) || scripts[0], [scripts, activeId]) 38 | 39 | useEffect(() => { 40 | if (!active && scripts.length > 0) setActiveId(scripts[0].id) 41 | if (scripts.length === 0) { 42 | try { localStorage.removeItem(STORAGE_KEY) } catch {} 43 | } 44 | }, [active, scripts, setActiveId]) 45 | 46 | const addFile = useCallback(() => { 47 | const base = 'script' 48 | let i = scripts.length + 1 49 | let name = `${base}${i}.js` 50 | const used = new Set(scripts.map((s) => s.name)) 51 | while (used.has(name)) { i++; name = `${base}${i}.js` } 52 | const next = { id: crypto.randomUUID(), name, content: scripts.length === 0 ? initialTemplate() : helperTextTemplate() } 53 | const arr = [...scripts, next] 54 | setScripts(arr) 55 | saveScriptsToStorage(arr) 56 | setActiveId(next.id) 57 | }, [scripts, setScripts, setActiveId]) 58 | 59 | const removeFile = useCallback((id) => { 60 | const idx = scripts.findIndex((s) => s.id === id) 61 | if (idx === -1) return 62 | const file = scripts[idx] 63 | const ok = window.confirm(`Delete ${file.name}? This cannot be undone.`) 64 | if (!ok) return 65 | let arr = scripts.filter((s) => s.id !== id) 66 | if (arr.length === 0) { 67 | // Return to clean state with no scripts; next add will recreate initial example 68 | arr = [] 69 | } 70 | setScripts(arr) 71 | saveScriptsToStorage(arr) 72 | if (arr.length === 0) { 73 | setActiveId(null) 74 | } else if (activeId === id) { 75 | setActiveId(arr[Math.max(0, idx - 1)].id) 76 | } 77 | }, [scripts, activeId, setScripts, setActiveId]) 78 | 79 | const startRename = useCallback((id) => setRenamingId(id), []) 80 | 81 | const commitRename = useCallback((id, name) => { 82 | if (!name || !/^[^\s]+\.js$/.test(name)) return setRenamingId(null) 83 | const arr = scripts.map((s) => (s.id === id ? { ...s, name } : s)) 84 | setScripts(arr) 85 | saveScriptsToStorage(arr) 86 | setRenamingId(null) 87 | }, [scripts, setScripts]) 88 | 89 | return ( 90 |
91 |
92 |
Scripts
93 |
94 | 97 | {!liveReload && } 98 |
99 |
100 |
101 |
102 | 103 |
104 |
105 | {scripts.map((file) => ( 106 |
107 | {renamingId === file.id ? ( 108 | commitRename(file.id, e.target.value.trim())} 112 | onKeyDown={(e) => { if (e.key === 'Enter') commitRename(file.id, e.currentTarget.value.trim()); if (e.key === 'Escape') setRenamingId(null) }} 113 | /> 114 | ) : ( 115 | 116 | )} 117 |
118 | 119 | 120 |
121 |
122 | ))} 123 |
124 |
125 |
126 | {active && ( 127 |
128 | { 144 | const arr = scripts.map((s) => (s.id === active.id ? { ...s, content: val } : s)) 145 | setScripts(arr) 146 | saveScriptsToStorage(arr) 147 | onChangeContent && onChangeContent(arr) 148 | }} 149 | onBlur={() => onBlurContent && onBlurContent(scripts)} 150 | theme={undefined} 151 | basicSetup={{ 152 | lineNumbers: true, 153 | foldGutter: true, 154 | highlightActiveLine: true, 155 | bracketMatching: true, 156 | closeBrackets: true, 157 | autocompletion: true 158 | }} 159 | /> 160 |
161 | )} 162 |
163 |
164 | {error && ( 165 |
{String(error.message || error)}
166 | )} 167 |
168 | ) 169 | } 170 | 171 | function helperTextTemplate() { 172 | return `// Custom functions guide 173 | // - Define top-level functions: function Name(args) { ... } 174 | // - They become available in formulas by name; names starting with '_' are ignored. 175 | // - args: array of evaluated arguments from the cell formula 176 | // - Use built-ins via the BUILTINS helper injected into your script's scope. 177 | // Example: function DoubleSum(args) { return BUILTINS.SUM(args) * 2 } 178 | ` 179 | } 180 | 181 | function initialTemplate() { 182 | return `${helperTextTemplate()} 183 | // Example function: 184 | function Abc(args) { 185 | const x = Number(args?.[0] ?? 0) 186 | return x + 1 187 | } 188 | ` 189 | } 190 | 191 | 192 | -------------------------------------------------------------------------------- /test/engine.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { SpreadsheetEngine, registerBuiltins } from '../src/index.js'; 3 | 4 | describe('SpreadsheetEngine', () => { 5 | it('evaluates literals and cell refs', () => { 6 | const engine = new SpreadsheetEngine(); 7 | registerBuiltins(engine.registry); 8 | engine.addSheet('Sheet1'); 9 | engine.setCell('Sheet1', 'A1', 2); 10 | engine.setCell('Sheet1', 'A2', '=A1'); 11 | expect(engine.evaluateCell('Sheet1', 'A2')).toBe(2); 12 | }); 13 | 14 | it('handles SUM, AVERAGE, MIN, MAX, COUNT, COUNTA', () => { 15 | const engine = new SpreadsheetEngine(); 16 | registerBuiltins(engine.registry); 17 | engine.addSheet('S'); 18 | engine.setCell('S', 'A1', 1); 19 | engine.setCell('S', 'A2', 2); 20 | engine.setCell('S', 'A3', 3); 21 | engine.setCell('S', 'B1', '=SUM(A1:A3)'); 22 | engine.setCell('S', 'B2', '=AVERAGE(A1:A3)'); 23 | engine.setCell('S', 'B3', '=MIN(A1:A3)'); 24 | engine.setCell('S', 'B4', '=MAX(A1:A3)'); 25 | engine.setCell('S', 'B5', '=COUNT(A1:A3)'); 26 | engine.setCell('S', 'B6', '=COUNTA(A1:A3)'); 27 | expect(engine.evaluateCell('S', 'B1')).toBe(6); 28 | expect(engine.evaluateCell('S', 'B2')).toBe(2); 29 | expect(engine.evaluateCell('S', 'B3')).toBe(1); 30 | expect(engine.evaluateCell('S', 'B4')).toBe(3); 31 | expect(engine.evaluateCell('S', 'B5')).toBe(3); 32 | expect(engine.evaluateCell('S', 'B6')).toBe(3); 33 | }); 34 | 35 | it('handles logicals IF/AND/OR/NOT', () => { 36 | const engine = new SpreadsheetEngine(); 37 | registerBuiltins(engine.registry); 38 | engine.addSheet('S'); 39 | engine.setCell('S', 'A1', '=IF(1, "yes", "no")'); 40 | engine.setCell('S', 'A2', '=AND(1, 2, 3)'); 41 | engine.setCell('S', 'A3', '=OR(0, 0, 1)'); 42 | engine.setCell('S', 'A4', '=NOT(0)'); 43 | expect(engine.evaluateCell('S', 'A1')).toBe('yes'); 44 | expect(engine.evaluateCell('S', 'A2')).toBe(true); 45 | expect(engine.evaluateCell('S', 'A3')).toBe(true); 46 | expect(engine.evaluateCell('S', 'A4')).toBe(true); 47 | }); 48 | 49 | it('handles text functions', () => { 50 | const engine = new SpreadsheetEngine(); 51 | registerBuiltins(engine.registry); 52 | engine.addSheet('S'); 53 | engine.setCell('S', 'A1', '=CONCAT("a","b",1)'); 54 | engine.setCell('S', 'A2', '=LEN("hello")'); 55 | engine.setCell('S', 'A3', '=UPPER("abC")'); 56 | engine.setCell('S', 'A4', '=LOWER("AbC")'); 57 | expect(engine.evaluateCell('S', 'A1')).toBe('ab1'); 58 | expect(engine.evaluateCell('S', 'A2')).toBe(5); 59 | expect(engine.evaluateCell('S', 'A3')).toBe('ABC'); 60 | expect(engine.evaluateCell('S', 'A4')).toBe('abc'); 61 | }); 62 | 63 | it('supports custom functions calling built-ins without ctx', () => { 64 | const engine = new SpreadsheetEngine(); 65 | registerBuiltins(engine.registry); 66 | engine.addSheet('S'); 67 | const SUM = engine.registry.get('SUM'); 68 | engine.registerFunction('MYCUSTOMFUNCTION', (args) => SUM(args) * -1); 69 | engine.setCell('S', 'B1', 5); 70 | engine.setCell('S', 'B2', '=MYCUSTOMFUNCTION(1, B1)'); 71 | expect(engine.evaluateCell('S', 'B2')).toBe(-6); 72 | }); 73 | 74 | it('detects circular dependencies', () => { 75 | const engine = new SpreadsheetEngine(); 76 | registerBuiltins(engine.registry); 77 | engine.addSheet('S'); 78 | engine.setCell('S', 'A1', '=A2'); 79 | engine.setCell('S', 'A2', '=A1'); 80 | const v = engine.evaluateCell('S', 'A1'); 81 | expect(String(v)).toMatch(/#CYCLE!/); 82 | }); 83 | 84 | it('supports sheet-qualified and absolute refs', () => { 85 | const engine = new SpreadsheetEngine(); 86 | registerBuiltins(engine.registry); 87 | engine.addSheet('Sheet1'); 88 | engine.addSheet('Sheet2'); 89 | engine.setCell('Sheet1', 'A1', 10); 90 | engine.setCell('Sheet2', 'A1', '=Sheet1!$A$1'); 91 | expect(engine.evaluateCell('Sheet2', 'A1')).toBe(10); 92 | }); 93 | 94 | it('COUNTIF, SUMIF', () => { 95 | const engine = new SpreadsheetEngine(); 96 | registerBuiltins(engine.registry); 97 | engine.addSheet('S'); 98 | engine.setCell('S', 'A1', 1); 99 | engine.setCell('S', 'A2', 5); 100 | engine.setCell('S', 'A3', 10); 101 | engine.setCell('S', 'B1', '=COUNTIF(A1:A3, ">=5")'); 102 | engine.setCell('S', 'B2', '=SUMIF(A1:A3, ">=5")'); 103 | expect(engine.evaluateCell('S', 'B1')).toBe(2); 104 | expect(engine.evaluateCell('S', 'B2')).toBe(15); 105 | }); 106 | 107 | it('MATCH exact and approximate', () => { 108 | const engine = new SpreadsheetEngine(); 109 | registerBuiltins(engine.registry); 110 | engine.addSheet('S'); 111 | engine.setCell('S', 'A1', 1); 112 | engine.setCell('S', 'A2', 3); 113 | engine.setCell('S', 'A3', 5); 114 | engine.setCell('S', 'B1', '=MATCH(3, A1:A3, 0)'); 115 | engine.setCell('S', 'B2', '=MATCH(4, A1:A3, 1)'); 116 | expect(engine.evaluateCell('S', 'B1')).toBe(2); 117 | expect(engine.evaluateCell('S', 'B2')).toBe(2); 118 | }); 119 | 120 | it('INDEX 1D', () => { 121 | const engine = new SpreadsheetEngine(); 122 | registerBuiltins(engine.registry); 123 | engine.addSheet('S'); 124 | engine.setCell('S', 'A1', 10); 125 | engine.setCell('S', 'A2', 20); 126 | engine.setCell('S', 'A3', 30); 127 | engine.setCell('S', 'B1', '=INDEX(A1:A3, 2)'); 128 | expect(engine.evaluateCell('S', 'B1')).toBe(20); 129 | }); 130 | 131 | it('VLOOKUP exact and approximate', () => { 132 | const engine = new SpreadsheetEngine(); 133 | registerBuiltins(engine.registry); 134 | engine.addSheet('S'); 135 | // Emulate 2-column table using adjacent columns; our range returns 1D so build rows via custom data loader 136 | // For test simplicity, build via custom function to form rows 137 | engine.registerFunction('TABLE', (args) => args); 138 | const rows = [ 139 | [1, 'a'], 140 | [3, 'b'], 141 | [5, 'c'] 142 | ]; 143 | engine.setCell('S', 'A1', rows); 144 | engine.setCell('S', 'B1', '=VLOOKUP(3, A1, 2, FALSE)'); 145 | engine.setCell('S', 'B2', '=VLOOKUP(4, A1, 2, TRUE)'); 146 | expect(engine.evaluateCell('S', 'B1')).toBe('b'); 147 | expect(engine.evaluateCell('S', 'B2')).toBe('b'); 148 | }); 149 | 150 | it('getRange returns matrix with computed values by default', () => { 151 | const engine = new SpreadsheetEngine(); 152 | registerBuiltins(engine.registry); 153 | engine.addSheet('Sheet1'); 154 | engine.setCell('Sheet1', 'A1', 1); 155 | engine.setCell('Sheet1', 'A2', '=A1+1'); 156 | engine.setCell('Sheet1', 'B1', '=SUM(A1:A2)'); 157 | const res = engine.getRange('Sheet1', 'A1:B2'); 158 | expect(res.sheet).toBe('Sheet1'); 159 | expect(res.range).toBe('A1:B2'); 160 | // rows[0][0] -> A1, rows[1][0] -> A2, rows[0][1] -> B1 161 | expect(res.rows[0][0].address).toBe('A1'); 162 | expect(res.rows[0][0].computed).toBe(1); 163 | expect(res.rows[1][0].address).toBe('A2'); 164 | expect(res.rows[1][0].computed).toBe(2); 165 | expect(res.rows[0][1].address).toBe('B1'); 166 | expect(res.rows[0][1].computed).toBe(3); 167 | }); 168 | 169 | it('setRange writes values and returns echo with computed values', () => { 170 | const engine = new SpreadsheetEngine(); 171 | registerBuiltins(engine.registry); 172 | engine.addSheet('S'); 173 | const write = [ 174 | [1, 2], 175 | ['=A1+B1', '=SUM(A1:B1)'] 176 | ]; 177 | const res = engine.setRange('S', 'A1:B2', write); 178 | expect(res.ok).toBe(true); 179 | expect(res.sheet).toBe('S'); 180 | expect(res.range).toBe('A1:B2'); 181 | // Ensure raw and computed are present 182 | expect(res.rows[0][0].raw).toBe(1); 183 | expect(res.rows[0][1].raw).toBe(2); 184 | expect(String(engine.getCell('S', 'A2'))).toBe('=A1+B1'); 185 | expect(res.rows[1][0].computed).toBe(3); 186 | expect(String(engine.getCell('S', 'B2'))).toBe('=SUM(A1:B1)'); 187 | expect(res.rows[1][1].computed).toBe(3); 188 | }); 189 | }); 190 | 191 | 192 | -------------------------------------------------------------------------------- /web/src/app/api/proxy/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | export const runtime = 'nodejs' 4 | 5 | function sanitizeTarget(t) { 6 | try { 7 | const u = new URL(t) 8 | // Disallow loopback to our own internal APIs 9 | if (u.origin === 'null') throw new Error('Invalid URL') 10 | return u.toString() 11 | } catch { 12 | throw new Error('Invalid target URL') 13 | } 14 | } 15 | 16 | export async function GET(req) { 17 | return handleProxy(req) 18 | } 19 | 20 | export async function POST(req) { 21 | return handleProxy(req) 22 | } 23 | 24 | export async function OPTIONS() { 25 | return new Response(null, { status: 204 }) 26 | } 27 | 28 | async function handleProxy(req) { 29 | try { 30 | const url = new URL(req.url) 31 | const target = url.searchParams.get('target') 32 | if (!target) return NextResponse.json({ error: 'Missing target' }, { status: 400 }) 33 | const targetUrl = sanitizeTarget(target) 34 | 35 | // Magic substitution for Exa MCP default token 36 | // If the target is the Exa MCP endpoint and exaApiKey is set to the magic value 37 | // replace it with the server-side EXA_API_KEY to avoid exposing secrets client-side 38 | let finalTargetUrl = targetUrl 39 | try { 40 | const u = new URL(finalTargetUrl) 41 | if (u.hostname === 'mcp.exa.ai') { 42 | const exaApiKeyParam = u.searchParams.get('exaApiKey') 43 | if (exaApiKeyParam === '') { 44 | const envKey = process.env.EXA_API_KEY 45 | if (envKey && typeof envKey === 'string' && envKey.length > 0) { 46 | u.searchParams.set('exaApiKey', envKey) 47 | finalTargetUrl = u.toString() 48 | } 49 | } 50 | } 51 | } catch {} 52 | 53 | // Forward method, headers and body with strict header allowlist 54 | const method = req.method 55 | // For debugging 5xx errors, we need to read the body first if it's a POST 56 | let body = undefined 57 | let bodyText = undefined 58 | if (method !== 'GET' && method !== 'HEAD') { 59 | if (method === 'POST') { 60 | // Read the body for potential debugging 61 | try { 62 | bodyText = await req.text() 63 | body = bodyText 64 | } catch { 65 | body = req.body 66 | } 67 | } else { 68 | body = req.body 69 | } 70 | } 71 | const incoming = req.headers 72 | const forwardedHeaders = new Headers() 73 | const allow = new Set([ 74 | 'accept', 75 | 'authorization', 76 | 'content-type', 77 | 'mcp-session-id', 78 | 'mcp-transport', 79 | 'mcp-protocol-version', 80 | 'x-mcp-session-id', 81 | ]) 82 | for (const [k, v] of incoming) { 83 | const key = String(k).toLowerCase() 84 | if (!allow.has(key)) continue 85 | if ((method === 'GET' || method === 'HEAD') && key === 'content-type') continue 86 | forwardedHeaders.set(key, v) 87 | } 88 | 89 | // For GET, avoid sending a body and keep headers; for POST allow body 90 | // Important for event-stream endpoints (e.g., SSE or other Streamable HTTP) 91 | const isGet = method === 'GET' 92 | const fetchInit = isGet ? { method, headers: forwardedHeaders } : { method, headers: forwardedHeaders, body, duplex: 'half' } 93 | const res = await fetch(finalTargetUrl, fetchInit) 94 | 95 | // Get content type for various checks 96 | const contentType = res.headers.get('content-type') 97 | 98 | // Debug event-stream connections (SSE or other Streamable HTTP) 99 | if (contentType && contentType.includes('text/event-stream')) { 100 | console.log('[proxy] Event-stream connection established (SSE or Streamable HTTP):', { 101 | url: finalTargetUrl, 102 | status: res.status, 103 | contentType, 104 | headers: Object.fromEntries(res.headers.entries()) 105 | }) 106 | } 107 | 108 | // Debug log for specific URLs 109 | if (finalTargetUrl.includes('.well-known/oauth')) { 110 | console.log('[proxy] Fetched OAuth URL:', { 111 | url: finalTargetUrl, 112 | status: res.status, 113 | contentLength: res.headers.get('content-length'), 114 | contentType 115 | }) 116 | } 117 | 118 | // Debug logging for upstream 5xx responses 119 | if (res.status >= 500) { 120 | if (!contentType || !contentType.includes('text/event-stream')) { 121 | let text = '' 122 | try { 123 | text = await res.text() 124 | } catch (e) { 125 | // ignore read errors, still return original status 126 | } 127 | 128 | console.error('[proxy] Upstream 5xx error:', { 129 | url: finalTargetUrl, 130 | method, 131 | status: res.status, 132 | contentType, 133 | requestHeaders: Object.fromEntries(forwardedHeaders.entries()), 134 | requestBody: bodyText ? bodyText.substring(0, 1000) : '[No body]', 135 | responseBody: text.substring(0, 2000) 136 | }) 137 | const headers = new Headers(res.headers) 138 | headers.delete('content-encoding') 139 | headers.delete('transfer-encoding') 140 | headers.set('content-length', String(text.length)) 141 | return new Response(text, { status: res.status, statusText: res.statusText, headers }) 142 | } else { 143 | console.error('[proxy] Upstream 5xx error with event-stream content-type; streaming without buffering', { 144 | url: targetUrl, 145 | status: res.status, 146 | contentType 147 | }) 148 | } 149 | } 150 | 151 | // Debug logging for 401 responses (only for debugging, don't consume body in production) 152 | if (res.status === 401 && finalTargetUrl.includes('githubcopilot')) { 153 | const text = await res.text() 154 | console.log('[proxy] 401 response from GitHub Copilot:', { 155 | status: res.status, 156 | contentType: res.headers.get('content-type'), 157 | bodyLength: text.length, 158 | body: text.substring(0, 300) 159 | }) 160 | const headers = new Headers(res.headers) 161 | headers.delete('content-encoding') 162 | headers.delete('transfer-encoding') 163 | // Update content-length to match actual text length 164 | headers.set('content-length', String(text.length)) 165 | return new Response(text, { status: res.status, statusText: res.statusText, headers }) 166 | } 167 | 168 | // For non-streaming responses, fully consume the body to avoid truncation issues 169 | // This is especially important for JSON responses 170 | // IMPORTANT: Don't buffer text/event-stream responses — these are event streams (SSE or other Streamable HTTP) and need to stream! 171 | const shouldBuffer = contentType && ( 172 | contentType.includes('application/json') || 173 | (contentType.includes('text/') && !contentType.includes('text/event-stream')) 174 | ) 175 | if (shouldBuffer) { 176 | const text = await res.text() 177 | // Debug logging for OAuth responses 178 | if (finalTargetUrl.includes('.well-known/oauth')) { 179 | console.log('[proxy] OAuth response:', { 180 | url: finalTargetUrl, 181 | contentType, 182 | textLength: text.length, 183 | preview: text.substring(0, 100) + '...', 184 | last100: '...' + text.substring(text.length - 100) 185 | }) 186 | } 187 | const headers = new Headers(res.headers) 188 | headers.delete('content-encoding') 189 | headers.delete('transfer-encoding') 190 | // Update content-length to match actual text length 191 | headers.set('content-length', String(text.length)) 192 | return new Response(text, { status: res.status, statusText: res.statusText, headers }) 193 | } 194 | 195 | // For other content types (including text/event-stream), stream the response 196 | const headers = new Headers(res.headers) 197 | headers.delete('content-encoding') 198 | headers.delete('transfer-encoding') 199 | 200 | // Log when streaming event-stream 201 | if (contentType && contentType.includes('text/event-stream')) { 202 | console.log('[proxy] Streaming text/event-stream response to client') 203 | } 204 | 205 | return new Response(res.body, { status: res.status, statusText: res.statusText, headers }) 206 | } catch (err) { 207 | return NextResponse.json({ error: String(err && (err.message || err)) }, { status: 500 }) 208 | } 209 | } 210 | 211 | 212 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 10 | 11 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 12 | 13 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 14 | 15 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 16 | 17 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 18 | 19 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 20 | 21 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 22 | 23 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 24 | 25 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." 26 | 27 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 28 | 29 | 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 30 | 31 | 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 32 | 33 | 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 34 | 35 | (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and 36 | 37 | (b) You must cause any modified files to carry prominent notices stating that You changed the files; and 38 | 39 | (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 40 | 41 | (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. 42 | 43 | You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 44 | 45 | 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 46 | 47 | 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 48 | 49 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 50 | 51 | 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 52 | 53 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 54 | 55 | END OF TERMS AND CONDITIONS 56 | 57 | APPENDIX: How to apply the Apache License to your work. 58 | 59 | To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. 60 | 61 | Copyright 2024 Groq, Inc. 62 | 63 | Licensed under the Apache License, Version 2.0 (the "License"); 64 | you may not use this file except in compliance with the License. 65 | You may obtain a copy of the License at 66 | 67 | http://www.apache.org/licenses/LICENSE-2.0 68 | 69 | Unless required by applicable law or agreed to in writing, software 70 | distributed under the License is distributed on an "AS IS" BASIS, 71 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 72 | See the License for the specific language governing permissions and 73 | limitations under the License. 74 | 75 | 76 | -------------------------------------------------------------------------------- /src/lib/builtins/index.js: -------------------------------------------------------------------------------- 1 | import { flattenArgsToValues, coerceToNumberArray, truthy, ensureArray } from './utils.js'; 2 | import { ERROR, err, isCellError } from '../errors.js'; 3 | 4 | export function registerBuiltins(registry) { 5 | // Math/aggregate 6 | registry.register('SUM', (args) => { 7 | const values = coerceToNumberArray(flattenArgsToValues(args)); 8 | return values.reduce((a, b) => a + b, 0); 9 | }); 10 | 11 | registry.register('AVERAGE', (args) => { 12 | const values = coerceToNumberArray(flattenArgsToValues(args)); 13 | if (values.length === 0) return 0; 14 | return values.reduce((a, b) => a + b, 0) / values.length; 15 | }); 16 | 17 | registry.register('MIN', (args) => { 18 | const values = coerceToNumberArray(flattenArgsToValues(args)); 19 | if (values.length === 0) return 0; 20 | return Math.min(...values); 21 | }); 22 | 23 | registry.register('MAX', (args) => { 24 | const values = coerceToNumberArray(flattenArgsToValues(args)); 25 | if (values.length === 0) return 0; 26 | return Math.max(...values); 27 | }); 28 | 29 | registry.register('COUNT', (args) => { 30 | const values = flattenArgsToValues(args); 31 | // COUNT counts numbers only (like Sheets) 32 | return values.filter((v) => typeof v === 'number' && Number.isFinite(v)).length; 33 | }); 34 | 35 | registry.register('COUNTA', (args) => { 36 | const values = flattenArgsToValues(args); 37 | return values.filter((v) => v !== null && v !== undefined && v !== '').length; 38 | }); 39 | 40 | // Logical 41 | registry.register('IF', (args) => { 42 | const [cond, thenVal, elseVal] = ensureArray(args, 3); 43 | return truthy(cond) ? thenVal : elseVal; 44 | }); 45 | 46 | registry.register('AND', (args) => { 47 | for (const a of flattenArgsToValues(args)) { 48 | if (!truthy(a)) return false; 49 | } 50 | return true; 51 | }); 52 | 53 | registry.register('OR', (args) => { 54 | for (const a of flattenArgsToValues(args)) { 55 | if (truthy(a)) return true; 56 | } 57 | return false; 58 | }); 59 | 60 | registry.register('NOT', (args) => { 61 | const [val] = ensureArray(args, 1); 62 | return !truthy(val); 63 | }); 64 | 65 | // Comparison helpers (equal, gt, etc.) 66 | registry.register('EQ', (args) => { 67 | const [a, b] = ensureArray(args, 2); 68 | return a === b; 69 | }); 70 | registry.register('NE', (args) => { 71 | const [a, b] = ensureArray(args, 2); 72 | return a !== b; 73 | }); 74 | registry.register('GT', (args) => { 75 | const [a, b] = ensureArray(args, 2); 76 | return a > b; 77 | }); 78 | registry.register('GTE', (args) => { 79 | const [a, b] = ensureArray(args, 2); 80 | return a >= b; 81 | }); 82 | registry.register('LT', (args) => { 83 | const [a, b] = ensureArray(args, 2); 84 | return a < b; 85 | }); 86 | registry.register('LTE', (args) => { 87 | const [a, b] = ensureArray(args, 2); 88 | return a <= b; 89 | }); 90 | 91 | // Text 92 | registry.register('CONCAT', (args) => { 93 | return flattenArgsToValues(args).map((v) => (v == null ? '' : String(v))).join(''); 94 | }); 95 | 96 | registry.register('LEN', (args) => { 97 | const [v] = ensureArray(args, 1); 98 | return (v == null ? '' : String(v)).length; 99 | }); 100 | 101 | registry.register('UPPER', (args) => { 102 | const [v] = ensureArray(args, 1); 103 | return (v == null ? '' : String(v)).toUpperCase(); 104 | }); 105 | 106 | registry.register('LOWER', (args) => { 107 | const [v] = ensureArray(args, 1); 108 | return (v == null ? '' : String(v)).toLowerCase(); 109 | }); 110 | 111 | // Basic wrappers to call built-ins from user JS conveniently 112 | registry.register('BUILTINS', (_args) => { 113 | // Returns a helper object exposing built-ins by name 114 | const helper = {}; 115 | for (const name of registry.names()) { 116 | if (name === 'BUILTINS') continue; 117 | helper[name] = (...fnArgs) => registry.get(name)(fnArgs); 118 | } 119 | return helper; 120 | }); 121 | 122 | // Conditional/lookup functions 123 | registry.register('COUNTIF', (args) => { 124 | const [range, criterion] = ensureArray(args, 2); 125 | const arr = Array.isArray(range) ? range : [range]; 126 | let pred = buildCriterion(criterion); 127 | return arr.filter((v) => pred(v)).length; 128 | }); 129 | 130 | registry.register('SUMIF', (args) => { 131 | const [range, criterion, sumRange] = ensureArray(args, 3); 132 | const base = Array.isArray(range) ? range : [range]; 133 | const sumArr = Array.isArray(sumRange) ? sumRange : base; 134 | const pred = buildCriterion(criterion); 135 | let total = 0; 136 | const n = Math.min(base.length, sumArr.length); 137 | for (let i = 0; i < n; i++) { 138 | if (pred(base[i])) { 139 | const val = sumArr[i]; 140 | if (typeof val === 'number' && Number.isFinite(val)) total += val; 141 | } 142 | } 143 | return total; 144 | }); 145 | 146 | registry.register('MATCH', (args) => { 147 | const [lookupValue, lookupArray, matchType = 1] = ensureArray(args, 3); 148 | const arr = Array.isArray(lookupArray) ? lookupArray : [lookupArray]; 149 | if (matchType === 0) { 150 | // exact 151 | for (let i = 0; i < arr.length; i++) if (equals(lookupValue, arr[i])) return i + 1; 152 | return err(ERROR.NA, 'MATCH not found'); 153 | } 154 | // approximate (1 or -1). For simplicity assume sorted ascending for 1, descending for -1 155 | if (matchType === 1) { 156 | let idx = -1; 157 | for (let i = 0; i < arr.length; i++) { 158 | if (compare(arr[i], lookupValue) <= 0) idx = i; 159 | else break; 160 | } 161 | if (idx === -1) return err(ERROR.NA, 'MATCH not found'); 162 | return idx + 1; 163 | } 164 | if (matchType === -1) { 165 | let idx = -1; 166 | for (let i = 0; i < arr.length; i++) { 167 | if (compare(arr[i], lookupValue) >= 0) idx = i; 168 | else break; 169 | } 170 | if (idx === -1) return err(ERROR.NA, 'MATCH not found'); 171 | return idx + 1; 172 | } 173 | return err(ERROR.VALUE, 'Invalid matchType'); 174 | }); 175 | 176 | registry.register('INDEX', (args) => { 177 | const [array, row, column] = ensureArray(args, 3); 178 | if (Array.isArray(array) && array.length > 0 && Array.isArray(array[0])) { 179 | // 2D array; minimal support from ranges currently returns 1D. Keep simple: if 2D, index [row-1][column-1] 180 | const r = (row || 1) - 1; 181 | const c = (column || 1) - 1; 182 | if (r < 0 || c < 0) return err(ERROR.REF, 'INDEX out of bounds'); 183 | const rowArr = array[r]; 184 | if (!rowArr || rowArr[c] === undefined) return err(ERROR.REF, 'INDEX out of bounds'); 185 | return rowArr[c]; 186 | } else { 187 | // 1D array 188 | const r = (row || 1) - 1; 189 | if (!Array.isArray(array)) return err(ERROR.VALUE, 'INDEX expects array'); 190 | if (r < 0 || r >= array.length) return err(ERROR.REF, 'INDEX out of bounds'); 191 | return array[r]; 192 | } 193 | }); 194 | 195 | registry.register('VLOOKUP', (args) => { 196 | const [searchKey, tableArray, index, isSorted = true] = ensureArray(args, 4); 197 | if (!Array.isArray(tableArray)) return err(ERROR.VALUE, 'VLOOKUP expects array'); 198 | // Accept tableArray as array of rows; if range produced 1D, treat as list of rows with 1 column 199 | const rows = Array.isArray(tableArray[0]) ? tableArray : tableArray.map((v) => [v]); 200 | const idx = Number(index); 201 | if (!Number.isInteger(idx) || idx < 1) return err(ERROR.VALUE, 'Invalid index'); 202 | 203 | if (isSorted) { 204 | // approximate: find last row with first column <= searchKey 205 | let pick = -1; 206 | for (let r = 0; r < rows.length; r++) { 207 | const key = rows[r][0]; 208 | if (compare(key, searchKey) <= 0) pick = r; 209 | else break; 210 | } 211 | if (pick === -1) return err(ERROR.NA, 'Not found'); 212 | return rows[pick][idx - 1] ?? err(ERROR.REF, 'Index out of bounds'); 213 | } else { 214 | // exact 215 | for (let r = 0; r < rows.length; r++) { 216 | if (equals(rows[r][0], searchKey)) return rows[r][idx - 1] ?? err(ERROR.REF, 'Index out of bounds'); 217 | } 218 | return err(ERROR.NA, 'Not found'); 219 | } 220 | }); 221 | 222 | // AI(prompt) 223 | // Returns cached value if available; otherwise triggers async fetch and returns a placeholder. 224 | registry.register('AI', (args, engine) => { 225 | const [prompt] = ensureArray(args, 1); 226 | const text = prompt == null ? '' : String(prompt); 227 | if (!engine || typeof engine.requestAi !== 'function') return '#AI_UNAVAILABLE'; 228 | const cached = engine.getAiCached(text); 229 | if (cached !== undefined) return cached; 230 | engine.requestAi(text); 231 | return '(loading…)'; 232 | }); 233 | 234 | // Snapshot builtin names for downstream consumers (e.g., UI/system prompt) 235 | try { 236 | if (registry && typeof registry.names === 'function') { 237 | const names = registry.names().filter((n) => n && n !== 'BUILTINS'); 238 | registry._builtinNames = new Set(names); 239 | } 240 | } catch {} 241 | } 242 | 243 | function buildCriterion(criterion) { 244 | // Supports numbers/strings directly and simple operators like ">=10", "<5", "<>a" 245 | if (typeof criterion === 'number') return (v) => toNumberIfPossible(v) === criterion; 246 | const s = String(criterion); 247 | const m = /^(>=|<=|<>|=|>|<)?(.*)$/.exec(s); 248 | const op = m[1] || '='; 249 | const raw = m[2]; 250 | const cmpVal = toNumberIfPossible(raw); 251 | return (v) => { 252 | const left = toNumberIfPossible(v); 253 | switch (op) { 254 | case '=': return equals(left, cmpVal); 255 | case '<>': return !equals(left, cmpVal); 256 | case '>': return compare(left, cmpVal) > 0; 257 | case '>=': return compare(left, cmpVal) >= 0; 258 | case '<': return compare(left, cmpVal) < 0; 259 | case '<=': return compare(left, cmpVal) <= 0; 260 | default: return equals(left, cmpVal); 261 | } 262 | }; 263 | } 264 | 265 | function toNumberIfPossible(v) { 266 | if (typeof v === 'number') return v; 267 | if (v == null) return 0; 268 | const n = Number(v); 269 | return Number.isNaN(n) ? v : n; 270 | } 271 | 272 | function equals(a, b) { 273 | return a === b; 274 | } 275 | 276 | function compare(a, b) { 277 | if (a === b) return 0; 278 | if (typeof a === 'number' && typeof b === 'number') return a < b ? -1 : 1; 279 | const as = String(a); 280 | const bs = String(b); 281 | return as < bs ? -1 : as > bs ? 1 : 0; 282 | } 283 | 284 | 285 | -------------------------------------------------------------------------------- /src/lib/engine.js: -------------------------------------------------------------------------------- 1 | import { parseFormula } from './parser.js'; 2 | import { BuiltinRegistry } from './registry.js'; 3 | import { CellError, ERROR, err, isCellError } from './errors.js'; 4 | 5 | /** 6 | * SpreadsheetEngine 7 | * - Pure calculation engine 8 | * - Cell addressing in A1 notation (case-insensitive) 9 | * - Supports literals, ranges (A1:B2), built-in functions, and custom functions 10 | * - Async-safe API (evaluation is synchronous for now, but can be extended) 11 | */ 12 | export class SpreadsheetEngine { 13 | constructor() { 14 | this.sheets = new Map(); // sheetName -> Map(cellAddrUpper -> value or formula string) 15 | this.registry = new BuiltinRegistry(); 16 | // Built-in async/cached helpers (e.g., AI()) 17 | this._aiCache = new Map(); // prompt(string) -> value(string) 18 | this._aiInFlight = new Map(); // prompt -> Promise 19 | this.onAsyncChange = null; // optional callback when async state updates 20 | this._aiFetcher = defaultAiFetcher; // overridable fetcher 21 | } 22 | 23 | addSheet(sheetName = 'Sheet1') { 24 | if (this.sheets.has(sheetName)) return sheetName; 25 | this.sheets.set(sheetName, new Map()); 26 | return sheetName; 27 | } 28 | 29 | setCell(sheetName, address, valueOrFormula) { 30 | if (!this.sheets.has(sheetName)) this.addSheet(sheetName); 31 | const sheet = this.sheets.get(sheetName); 32 | sheet.set(address.toUpperCase(), valueOrFormula); 33 | } 34 | 35 | getCell(sheetName, address) { 36 | const sheet = this.sheets.get(sheetName); 37 | if (!sheet) return undefined; 38 | return sheet.get(address.toUpperCase()); 39 | } 40 | 41 | registerFunction(name, fn) { 42 | this.registry.register(name, fn); 43 | } 44 | 45 | hasFunction(name) { 46 | return this.registry.has(name); 47 | } 48 | 49 | evaluateCell(sheetName, address, visiting = new Set()) { 50 | // Support absolute refs $A$1 and sheet-qualified refs in address 51 | const { sheet: resolvedSheet, addr: normalized } = normalizeAddress(address, sheetName); 52 | const key = `${resolvedSheet}!${normalized}`; 53 | if (visiting.has(key)) { 54 | return err(ERROR.CYCLE, 'Circular reference at ' + key); 55 | } 56 | visiting.add(key); 57 | 58 | const raw = this.getCell(resolvedSheet, normalized); 59 | let result; 60 | try { 61 | if (typeof raw === 'string' && raw.startsWith('=')) { 62 | const ast = parseFormula(raw.slice(1)); 63 | result = this.evaluateAst(resolvedSheet, ast, visiting); 64 | } else { 65 | result = raw; 66 | } 67 | } catch (e) { 68 | result = err(ERROR.VALUE, String(e && (e.message || e))); 69 | } finally { 70 | visiting.delete(key); 71 | } 72 | return result; 73 | } 74 | 75 | evaluateAst(sheetName, node, visiting) { 76 | switch (node.type) { 77 | case 'Literal': 78 | return node.value; 79 | case 'Cell': { 80 | // node.ref may be sheet-qualified or absolute already 81 | return this.evaluateCell(sheetName, node.ref, visiting); 82 | } 83 | case 'Range': { 84 | const start = qualifyIfNeeded(node.start, sheetName); 85 | const end = qualifyIfNeeded(node.end, sheetName); 86 | const { sheet: sheetStart, addr: a1 } = normalizeAddress(start, sheetName); 87 | const { sheet: sheetEnd, addr: a2 } = normalizeAddress(end, sheetName); 88 | if (sheetStart !== sheetEnd) { 89 | return err(ERROR.REF, 'Cross-sheet ranges not supported'); 90 | } 91 | const cells = expandRange(a1, a2); 92 | return cells.map((addr) => this.evaluateCell(sheetStart, addr, visiting)); 93 | } 94 | case 'Call': { 95 | const fnName = node.name; 96 | const evaluatedArgs = node.args.map((arg) => { 97 | const val = this.evaluateAst(sheetName, arg, visiting); 98 | return val; 99 | }); 100 | const fn = this.registry.get(fnName); 101 | if (!fn) return err(ERROR.NAME, `Unknown function: ${fnName}`); 102 | try { 103 | // Pass engine as second argument for built-ins that need context (e.g., AI()) 104 | const res = fn(evaluatedArgs, this); 105 | return res; 106 | } catch (e) { 107 | return err(ERROR.VALUE, `Function ${fnName} error: ${String(e && (e.message || e))}`); 108 | } 109 | } 110 | case 'BinaryOp': { 111 | const leftVal = this.evaluateAst(sheetName, node.left, visiting); 112 | const rightVal = this.evaluateAst(sheetName, node.right, visiting); 113 | const left = coerceNumber(leftVal); 114 | const right = coerceNumber(rightVal); 115 | if (!Number.isFinite(left) || !Number.isFinite(right)) { 116 | return err(ERROR.VALUE, 'Arithmetic with non-numeric values'); 117 | } 118 | switch (node.op) { 119 | case '+': return left + right; 120 | case '-': return left - right; 121 | case '*': return left * right; 122 | case '/': return right === 0 ? err(ERROR.DIV0, 'Division by zero') : left / right; 123 | default: return err(ERROR.VALUE, 'Unknown operator ' + node.op); 124 | } 125 | } 126 | default: 127 | return err(ERROR.VALUE, 'Unknown AST node: ' + node.type); 128 | } 129 | } 130 | 131 | // Range APIs 132 | getRange(sheetName, rangeStr, mode = 'computed') { 133 | const { sheet, start, end, rowsMin, rowsMax, colsMin, colsMax } = parseRangeRef(rangeStr, sheetName); 134 | const matrix = []; 135 | for (let r = rowsMin; r <= rowsMax; r++) { 136 | const rowArr = []; 137 | for (let c = colsMin; c <= colsMax; c++) { 138 | const address = rowColToA1(r, c); 139 | const cell = { address }; 140 | if (mode === 'raw' || mode === 'both') cell.raw = this.getCell(sheet, address); 141 | if (mode === 'computed' || mode === 'both') cell.computed = this.evaluateCell(sheet, address); 142 | rowArr.push(cell); 143 | } 144 | matrix.push(rowArr); 145 | } 146 | return { 147 | sheet, 148 | range: `${rowColToA1(rowsMin, colsMin)}:${rowColToA1(rowsMax, colsMax)}`, 149 | rows: matrix, 150 | }; 151 | } 152 | 153 | setRange(sheetName, rangeStr, values) { 154 | if (!Array.isArray(values) || values.length === 0 || !values.every((row) => Array.isArray(row))) { 155 | throw new Error('values must be a non-empty 2D array'); 156 | } 157 | const { sheet, start, end, rowsMin, rowsMax, colsMin, colsMax } = parseRangeRef(rangeStr, sheetName); 158 | const rowCount = rowsMax - rowsMin + 1; 159 | const colCount = colsMax - colsMin + 1; 160 | if (values.length !== rowCount || values.some((row) => row.length !== colCount)) { 161 | const providedCols = values[0] ? values[0].length : 0; 162 | throw new Error(`values shape ${values.length}x${providedCols} does not match range size ${rowCount}x${colCount}`); 163 | } 164 | 165 | for (let i = 0; i < rowCount; i++) { 166 | for (let j = 0; j < colCount; j++) { 167 | const r = rowsMin + i; 168 | const c = colsMin + j; 169 | const address = rowColToA1(r, c); 170 | this.setCell(sheet, address, values[i][j]); 171 | } 172 | } 173 | 174 | const resultRows = []; 175 | for (let i = 0; i < rowCount; i++) { 176 | const rowArr = []; 177 | for (let j = 0; j < colCount; j++) { 178 | const r = rowsMin + i; 179 | const c = colsMin + j; 180 | const address = rowColToA1(r, c); 181 | rowArr.push({ address, raw: this.getCell(sheet, address), computed: this.evaluateCell(sheet, address) }); 182 | } 183 | resultRows.push(rowArr); 184 | } 185 | 186 | return { 187 | ok: true, 188 | sheet, 189 | range: `${rowColToA1(rowsMin, colsMin)}:${rowColToA1(rowsMax, colsMax)}`, 190 | rows: resultRows, 191 | }; 192 | } 193 | 194 | // ===== AI cache/fetch helpers ===== 195 | setAiFetcher(fetcherFn) { 196 | if (typeof fetcherFn === 'function') this._aiFetcher = fetcherFn; 197 | } 198 | 199 | getAiCached(prompt) { 200 | const key = String(prompt || ''); 201 | return this._aiCache.has(key) ? this._aiCache.get(key) : undefined; 202 | } 203 | 204 | hasAiCached(prompt) { 205 | const key = String(prompt || ''); 206 | return this._aiCache.has(key); 207 | } 208 | 209 | async _fetchAndCacheAi(prompt) { 210 | const key = String(prompt || ''); 211 | if (this._aiInFlight.has(key)) return this._aiInFlight.get(key); 212 | const p = (async () => { 213 | try { 214 | const val = await this._aiFetcher(key); 215 | this._aiCache.set(key, val == null ? '' : String(val)); 216 | } catch (e) { 217 | // Store an error string so subsequent evaluations are stable 218 | this._aiCache.set(key, String(e && (e.message || e))); 219 | } finally { 220 | this._aiInFlight.delete(key); 221 | if (typeof this.onAsyncChange === 'function') { 222 | try { this.onAsyncChange({ type: 'AI_CACHE_UPDATED', prompt: key }); } catch {} 223 | } 224 | } 225 | })(); 226 | this._aiInFlight.set(key, p); 227 | return p; 228 | } 229 | 230 | requestAi(prompt) { 231 | const key = String(prompt || ''); 232 | if (this._aiCache.has(key) || this._aiInFlight.has(key)) return; 233 | void this._fetchAndCacheAi(key); 234 | } 235 | } 236 | 237 | // Utilities 238 | export function a1ToRowCol(a1) { 239 | const m = parseAbsoluteA1(a1); 240 | if (!m) throw new Error('Invalid A1 ref: ' + a1); 241 | const colStr = m.col.toUpperCase(); 242 | const row = parseInt(m.row, 10); 243 | let col = 0; 244 | for (let i = 0; i < colStr.length; i++) { 245 | col = col * 26 + (colStr.charCodeAt(i) - 64); 246 | } 247 | return { row, col }; 248 | } 249 | 250 | export function rowColToA1(row, col) { 251 | let c = col; 252 | let colStr = ''; 253 | while (c > 0) { 254 | const rem = (c - 1) % 26; 255 | colStr = String.fromCharCode(65 + rem) + colStr; 256 | c = Math.floor((c - 1) / 26); 257 | } 258 | return `${colStr}${row}`; 259 | } 260 | 261 | export function expandRange(startA1, endA1) { 262 | const { row: r1, col: c1 } = a1ToRowCol(startA1); 263 | const { row: r2, col: c2 } = a1ToRowCol(endA1); 264 | const rows = [Math.min(r1, r2), Math.max(r1, r2)]; 265 | const cols = [Math.min(c1, c2), Math.max(c1, c2)]; 266 | const out = []; 267 | for (let r = rows[0]; r <= rows[1]; r++) { 268 | for (let c = cols[0]; c <= cols[1]; c++) { 269 | out.push(rowColToA1(r, c)); 270 | } 271 | } 272 | return out; 273 | } 274 | 275 | // Address utilities 276 | export function parseAbsoluteA1(a1) { 277 | // Supports optional $ for column and/or row 278 | const match = /^(\$?)([A-Za-z]+)(\$?)(\d+)$/.exec(a1); 279 | if (!match) return null; 280 | const [, colAbs, col, rowAbs, row] = match; 281 | return { colAbs: !!colAbs, col, rowAbs: !!rowAbs, row }; 282 | } 283 | 284 | export function normalizeAddress(address, defaultSheet) { 285 | // Handle sheet-qualified refs like Sheet1!A1 or 'My Sheet'!A1 (quotes not supported yet) 286 | const m = /^([^!]+)!([^!]+)$/.exec(address); 287 | if (m) { 288 | const sheet = m[1]; 289 | const a1 = m[2]; 290 | const abs = parseAbsoluteA1(a1); 291 | if (!abs) throw new Error('Invalid A1: ' + a1); 292 | return { sheet, addr: `${abs.col.toUpperCase()}${parseInt(abs.row, 10)}` }; 293 | } 294 | const abs = parseAbsoluteA1(address); 295 | if (!abs) throw new Error('Invalid A1: ' + address); 296 | return { sheet: defaultSheet, addr: `${abs.col.toUpperCase()}${parseInt(abs.row, 10)}` }; 297 | } 298 | 299 | export function qualifyIfNeeded(address, defaultSheet) { 300 | if (/^[^!]+![^!]+$/.test(address)) return address; 301 | return `${defaultSheet}!${address}`; 302 | } 303 | 304 | // Parses ranges like "A1:C3" or "Sheet1!A1:C3". 305 | // Returns normalized sheet name and numeric bounds for iteration. 306 | function parseRangeRef(rangeStr, defaultSheet) { 307 | const input = String(rangeStr).trim(); 308 | if (!input) throw new Error('Missing range'); 309 | 310 | let sheet = defaultSheet; 311 | let startStr; 312 | let endStr; 313 | const sheetMatch = /^([^!]+)!([^:]+):([^:]+)$/.exec(input); 314 | if (sheetMatch) { 315 | sheet = sheetMatch[1]; 316 | startStr = sheetMatch[2]; 317 | endStr = sheetMatch[3]; 318 | } else { 319 | const parts = input.split(':'); 320 | if (parts.length !== 2) throw new Error('Invalid range (expected A1:B2)'); 321 | startStr = parts[0].trim(); 322 | endStr = parts[1].trim(); 323 | } 324 | 325 | const { addr: start } = normalizeAddress(startStr, sheet); 326 | const { addr: end } = normalizeAddress(endStr, sheet); 327 | 328 | const { row: r1, col: c1 } = a1ToRowCol(start); 329 | const { row: r2, col: c2 } = a1ToRowCol(end); 330 | const rowsMin = Math.min(r1, r2); 331 | const rowsMax = Math.max(r1, r2); 332 | const colsMin = Math.min(c1, c2); 333 | const colsMax = Math.max(c1, c2); 334 | 335 | return { sheet, start, end, rowsMin, rowsMax, colsMin, colsMax }; 336 | } 337 | 338 | function coerceNumber(v) { 339 | if (typeof v === 'number') return v; 340 | if (v == null) return NaN; 341 | if (typeof v === 'string') { 342 | const n = Number(v); 343 | return Number.isNaN(n) ? NaN : n; 344 | } 345 | return NaN; 346 | } 347 | 348 | 349 | // Default AI fetcher: calls the app's Groq proxy using the same model as Chat settings 350 | async function defaultAiFetcher(prompt) { 351 | // Attempt to read model from localStorage when in browser 352 | let model = 'openai/gpt-oss-20b'; 353 | try { 354 | if (typeof window !== 'undefined' && window.localStorage) { 355 | const m = window.localStorage.getItem('autosheet.chat.model'); 356 | if (m) model = m; 357 | } 358 | } catch {} 359 | 360 | if (typeof fetch !== 'function') { 361 | throw new Error('fetch unavailable for AI'); 362 | } 363 | const base = (typeof window !== 'undefined' && window.location && window.location.origin) ? window.location.origin : ''; 364 | const url = (base ? base : '') + '/api/groq/openai/v1/chat/completions'; 365 | const body = { 366 | model, 367 | messages: [ 368 | { role: 'system', content: 'You are asked to produce a value output that will be rendered directly in the cell of a spreadsheet so keep it brief.' }, 369 | { role: 'user', content: String(prompt || '') }, 370 | ], 371 | }; 372 | const res = await fetch(url, { 373 | method: 'POST', 374 | headers: { 'content-type': 'application/json' }, 375 | body: JSON.stringify(body), 376 | }); 377 | if (!res.ok) { 378 | const txt = await res.text().catch(() => ''); 379 | throw new Error('AI request failed: ' + res.status + (txt ? ' ' + txt : '')); 380 | } 381 | const json = await res.json(); 382 | const content = json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.content; 383 | return content == null ? '' : String(content); 384 | } 385 | 386 | -------------------------------------------------------------------------------- /web/src/styles.css: -------------------------------------------------------------------------------- 1 | :root { color-scheme: light; } 2 | * { box-sizing: border-box; } 3 | html, body, #root { height: 100%; margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow-x: hidden; } 4 | .app { display: flex; flex-direction: column; height: 100%; } 5 | .toolbar { padding: 8px 12px; border-bottom: 1px solid #ddd; background: #ffffff; color: #111; display: flex; align-items: center; gap: 8px; } 6 | .toolbar-file-info { display: flex; align-items: center; gap: 8px; margin-right: 16px; } 7 | .toolbar-file-info .file-name { font-weight: 500; color: #333; } 8 | .toolbar-file-info .file-name.unsaved { color: #888; font-style: italic; } 9 | .toolbar-file-info .save-indicator { display: inline-flex; align-items: center; justify-content: center; margin-left: 6px; } 10 | .toolbar-file-info .save-indicator.saving .saving-spinner { 11 | width: 14px; height: 14px; border: 2px solid #cfe0ff; border-top-color: #1d4ed8; border-radius: 50%; 12 | animation: autosheet-spin 0.8s linear infinite; 13 | } 14 | .toolbar-file-info .save-indicator.saved svg { display: block; } 15 | @keyframes autosheet-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } 16 | .title { font-weight: 600; } 17 | .menubar { display: flex; align-items: center; gap: 8px; font-size: 14px; } 18 | .menu { position: relative; } 19 | .menu-button { padding: 6px 10px; border: 1px solid #ccc; background: #f7f7f7; border-radius: 4px; cursor: pointer; font: inherit; } 20 | .menu-dropdown { position: absolute; top: calc(100% + 4px); left: 0; min-width: 220px; background: #fff; border: 1px solid #ddd; border-radius: 6px; box-shadow: 0 6px 18px rgba(0,0,0,0.12); z-index: 100; padding: 6px; } 21 | .menu-item { display: block; width: 100%; text-align: left; padding: 6px 8px; background: transparent; border: none; border-radius: 4px; cursor: pointer; color: #111; font: inherit; } 22 | .menu-item:hover { background: #f5f5f5; } 23 | .menu-item.disabled { color: #888; cursor: default; } 24 | .menu-divider { height: 1px; background: #e0e0e0; margin: 4px 8px; } 25 | .submenu-dropdown { position: absolute; top: -6px; left: calc(100% - 1px); min-width: 180px; background: #fff; border: 1px solid #ddd; border-radius: 6px; box-shadow: 0 6px 18px rgba(0,0,0,0.12); z-index: 101; padding: 6px; } 26 | .tabs { display: flex; gap: 4px; } 27 | .tab { padding: 6px 10px; border: 1px solid #ccc; background: #f7f7f7; border-radius: 4px; cursor: pointer; } 28 | .tab.active { background: #e8eefc; border-color: #a8c1ff; } 29 | .github-badge { position: relative; } 30 | .github-badge .hack-hint { position: absolute; left: 50%; transform: translate(-50%, 8px); bottom: -24px; background: #24292e; color: #fff; padding: 4px 8px; border-radius: 6px; font-size: 12px; line-height: 1; pointer-events: none; white-space: nowrap; opacity: 0; transition: opacity 180ms ease, transform 180ms ease; font-family: "Comic Sans MS", "Comic Sans", "Bradley Hand", "Segoe Print", "Chalkboard SE", cursive; } 31 | .github-badge:hover .hack-hint { opacity: 1; transform: translate(-50%, 0); } 32 | .formula-bar { display: flex; gap: 8px; padding: 8px; border-bottom: 1px solid #eee; background: #ffffff; } 33 | .formula-bar .name { width: 40px; display: flex; align-items: center; justify-content: center; background: #f6f6f6; border: 1px solid #ddd; border-radius: 4px; color: #111; font-size: 12px; } 34 | .formula-bar input { flex: 1; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; background: #fff; color: #111; } 35 | .formula-bar button { background: #f0f0f0; border: 1px solid #ccc; border-radius: 4px; padding: 6px 10px; cursor: pointer; } 36 | .main { flex: 1; display: flex; min-height: 0; min-width: 0; overflow: hidden; } 37 | .main > * { min-width: 0; } 38 | .pane { min-width: 0; display: flex; flex-direction: column; flex: 0 0 auto; } 39 | .sheet-pane { min-width: 320px; } 40 | .editor-pane { min-width: 280px; } 41 | .split-resizer { width: 6px; flex: 0 0 6px; height: 100%; cursor: col-resize; background: transparent; position: relative; } 42 | .split-resizer::before { content: ""; position: absolute; top: 0; bottom: 0; left: 2px; right: 2px; background: #e9e9e9; } 43 | .main:not(.split) .sheet-pane, .main:not(.split) .editor-pane { flex: 1; display: flex; min-width: 0; } 44 | .grid { flex: 1; overflow: auto; background: #fff; color: #111; position: relative; font-size: 12px; } 45 | /* Remove default focus outline that appears when the grid gains focus on click */ 46 | .grid:focus, .grid:focus-visible { outline: none; } 47 | table { border-collapse: separate; border-spacing: 0; background: #fff; table-layout: fixed; } 48 | th, td { border: 1px solid #e0e0e0; background: #fff; color: #111; overflow: hidden; min-width: 0; } 49 | th { padding: 4px 6px; position: relative; overflow: hidden; min-width: 0; } 50 | td { padding: 1px; overflow: hidden; min-width: 0; } 51 | /* Sticky headers */ 52 | thead th { background: #fafafa; position: sticky; top: 0; z-index: 3; color: #555; } 53 | /* Sticky first column (row numbers) */ 54 | tbody th { position: sticky; left: 0; background: #fafafa; z-index: 2; color: #555; } 55 | /* Sticky corner cell should sit above both */ 56 | thead .corner-cell { position: sticky; left: 0; top: 0; z-index: 4; background: #fafafa; } 57 | /* Ensure column headers stay above row header cells at their intersection */ 58 | thead .col-header { z-index: 3; background: #fafafa; } 59 | td.sel { outline: 2px solid #3b82f6; outline-offset: -2px; } 60 | td.sel-range { background: rgba(59, 130, 246, 0.12); position: relative; } 61 | td.sel-anchor { outline: 2px solid #3b82f6; outline-offset: -2px; } 62 | 63 | .selection-outline { position: absolute; pointer-events: none; border: 1px solid #3b82f6; box-sizing: border-box; z-index: 4; } 64 | 65 | /* Resizers */ 66 | .col-resizer { position: absolute; top: 0; right: -3px; width: 6px; height: 100%; cursor: col-resize; z-index: 3; } 67 | .row-resizer { position: absolute; bottom: -3px; left: 0; width: 100%; height: 6px; cursor: row-resize; z-index: 3; } 68 | .col-header { position: relative; } 69 | .col-header-label { user-select: none; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: 6px; } 70 | 71 | /* Clip cell content when column is narrower than content */ 72 | .cell-display { width: 100%; height: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 73 | .cell-text { display: block; overflow: hidden; text-overflow: ellipsis; } 74 | 75 | /* Sheet tabs (bottom of sheet pane) */ 76 | .sheet-tabs { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px; border-top: 1px solid #eee; background: #fafafa; } 77 | .sheet-tabs-list { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; } 78 | .sheet-tab { flex: 0 0 auto; padding: 4px 10px; border: 1px solid #ccc; background: #f7f7f7; border-radius: 14px; cursor: pointer; font-size: 12px; color: #111; } 79 | .sheet-tab.active { background: #e8eefc; border-color: #a8c1ff; } 80 | .sheet-tabs-actions { display: flex; align-items: center; gap: 6px; } 81 | .sheet-add-btn { flex: 0 0 auto; padding: 4px 8px; border: 1px solid #ccc; background: #f0f0f0; border-radius: 6px; cursor: pointer; color: #111; } 82 | .sheet-action-btn { flex: 0 0 auto; padding: 4px 8px; border: 1px solid #ccc; background: #fff; border-radius: 6px; cursor: pointer; color: #111; } 83 | 84 | /* Scripts panel */ 85 | .scripts-pane { display: flex; flex-direction: column; height: 100%; background: #fff; color: #111; } 86 | .scripts-toolbar { display: flex; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid #eee; } 87 | .scripts-title { font-weight: 600; } 88 | .scripts-body { 89 | flex: 1; 90 | min-height: 0; 91 | display: flex; 92 | flex-direction: column; 93 | overflow: hidden; 94 | position: relative; 95 | } 96 | .file-tree { width: 100%; border-right: 0; border-bottom: 1px solid #eee; display: flex; flex-direction: row; align-items: center; gap: 8px; padding: 6px; flex: 0 0 auto; } 97 | .file-tree-header { padding: 0; border-bottom: 0; margin-right: 8px; flex: 0 0 auto; } 98 | .file-list { flex: 1; overflow-x: auto; overflow-y: hidden; display: flex; gap: 6px; white-space: nowrap; } 99 | .file-item { display: flex; align-items: center; gap: 6px; padding: 6px 8px; flex: 0 0 auto; } 100 | .file-item.active { background: #f5f8ff; } 101 | .file-name { flex: 0 0 auto; text-align: left; background: transparent; border: none; color: #111; cursor: pointer; } 102 | .file-actions .icon { background: transparent; border: none; cursor: pointer; } 103 | .editor-area { 104 | flex: 1; 105 | min-width: 0; 106 | min-height: 0; 107 | display: flex; 108 | flex-direction: column; 109 | } 110 | .editor-area .cm-editor { 111 | font-size: 12px; 112 | } 113 | .btn { padding: 4px 8px; border: 1px solid #ccc; background: #f7f7f7; border-radius: 4px; cursor: pointer; } 114 | .toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #333; } 115 | .scripts-error { padding: 6px 8px; background: #fff3f3; color: #a10000; border-top: 1px solid #ffd0d0; font-size: 12px; } 116 | 117 | 118 | /* Chat panel */ 119 | .chat-pane { display: flex; flex-direction: column; height: 100%; background: #fff; color: #111; padding-left: 6px; padding-right: 6px; } 120 | .chat-toolbar { display: flex; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid #eee; } 121 | .chat-title { font-weight: 600; } 122 | .chat-select { padding: 4px 8px; border: 1px solid #ccc; background: #f7f7f7; color: #111; border-radius: 4px; font: inherit; font-weight: 400; } 123 | .chat-select:disabled { opacity: 0.7; } 124 | .chat-toolbar .chat-select { font-size: 14px; } 125 | .chat-toolbar .btn, .chat-toolbar .chat-select { height: 32px; padding-top: 0; padding-bottom: 0; } 126 | .chat-toolbar .btn { display: inline-flex; align-items: center; } 127 | .chat-apikey { width: 280px; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; background: #fff; color: #111; } 128 | .chat-system { display: flex; gap: 8px; align-items: center; padding: 8px; border-bottom: 1px solid #f3f3f3; } 129 | .chat-system input { flex: 1; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; background: #fff; color: #111; } 130 | .chat-error { padding: 6px 8px; background: #fff3f3; color: #a10000; border-bottom: 1px solid #ffd0d0; font-size: 12px; } 131 | .chat-body { flex: 1; display: flex; min-height: 0; flex-direction: column; } 132 | .chat-messages { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 10px; display: flex; flex-direction: column; gap: 8px; } 133 | .msg { max-width: 80%; min-width: 0; border: 1px solid #e0e0e0; border-radius: 8px; padding: 8px 10px; background: #fafafa; overflow-wrap: anywhere; word-break: break-word; } 134 | .msg-user { margin-left: auto; background: #eef6ff; border-color: #c7e1ff; } 135 | .msg-assistant { margin-right: auto; background: #f7f7f7; } 136 | .msg-system { margin: 0 auto; background: #fffbe6; border-color: #ffe58f; } 137 | .msg-tool { margin-right: auto; background: #f4fff5; border-color: #bfeac8; } 138 | .msg-reasoning { margin-right: auto; background: #f9fbff; border-color: #c9ddff; } 139 | .msg-role { font-size: 11px; color: #666; margin-bottom: 4px; text-transform: uppercase; } 140 | .msg-content { white-space: pre-wrap; word-break: break-word; color: #111; } 141 | /* Compact markdown spacing inside message bubbles */ 142 | /* Improve overall readability for long, structured content */ 143 | .msg-content { line-height: 1.3; } 144 | .msg-content p { margin: 0.2em 0; padding: 0; } 145 | .msg-content ul, .msg-content ol { margin: 0.2em 0; padding-left: 1.2em; list-style-position: outside; } 146 | /* Collapse whitespace inside lists so preserved newlines don't push text to next line */ 147 | .msg-content ul, .msg-content ol, .msg-content li { white-space: normal; } 148 | /* Use outside markers for all levels; we align the first line via inline first-child */ 149 | .msg-content > ul, .msg-content > ol { list-style-position: outside; } 150 | /* Hide bullets on the outermost list that often acts as a container in LLM replies */ 151 | /* Only hide markers for explicitly tagged root lists rendered by the chat */ 152 | .msg-content ul.msg-root-list { list-style: none; padding-left: 0; } 153 | /* Zero spacing for list items and nested lists */ 154 | .msg-content li { margin: 0.1em 0; padding: 0; } 155 | /* Ensure the first piece of content sits on the same line as the marker */ 156 | .msg-content li > *:first-child { display: inline; margin: 0; padding: 0; } 157 | .msg-content li > p { margin: 0; padding: 0; } 158 | .msg-content li > ul, .msg-content li > ol { margin: 0.1em 0; padding-left: 1.2em; list-style-position: outside; } 159 | .msg-content pre { margin: 0.25em 0; max-width: 100%; overflow: auto; background: transparent; border: 1px solid #eee; border-radius: 4px; padding: 4px; } 160 | .msg-content blockquote { margin: 0.25em 0; padding: 0; } 161 | .msg-content h1, .msg-content h2, .msg-content h3, .msg-content h4, .msg-content h5, .msg-content h6 { margin: 0.3em 0 0.15em; padding: 0; } 162 | .msg-content > :first-child { margin-top: 0; } 163 | .msg-content > :last-child { margin-bottom: 0; } 164 | /* Prevent oversized markdown content from breaking layout */ 165 | .msg-content { overflow-wrap: anywhere; } 166 | .msg-content code { white-space: break-spaces; word-break: break-word; } 167 | .msg-content table { display: block; max-width: 100%; overflow: auto; } 168 | .msg-content img { max-width: 100%; height: auto; } 169 | .msg-content a { word-break: break-all; } 170 | .reasoning-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } 171 | .reasoning-loading { color: #335; font-size: 12px; background: #eef3ff; border: 1px solid #d9e6ff; padding: 2px 6px; border-radius: 999px; } 172 | .reasoning-loading .dot { animation: blink 1.2s infinite; } 173 | .reasoning-loading .dot:nth-child(2) { animation-delay: 0.2s; } 174 | .reasoning-loading .dot:nth-child(3) { animation-delay: 0.4s; } 175 | @keyframes blink { 0% { opacity: 0.2 } 50% { opacity: 1 } 100% { opacity: 0.2 } } 176 | .reasoning-content { max-height: 300px; overflow: auto; background: #ffffff; color: #111; border: 1px solid #d9e6ff; border-radius: 6px; padding: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; white-space: pre-wrap; } 177 | .tool-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; flex-wrap: wrap; } 178 | .tool-header .msg-role { margin-bottom: 0; display: inline-flex; align-items: center; } 179 | .tool-name { font-weight: 600; color: #0b7a28; flex: 0 0 auto; } 180 | .tool-args { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; color: #2d6a4f; background: #e7f7eb; padding: 2px 4px; border-radius: 4px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 auto; } 181 | .tool-preview { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: #0b7a28; background: #edfff0; padding: 6px; border-radius: 4px; border: 1px dashed #bfeac8; max-width: 100%; overflow: hidden; overflow-wrap: anywhere; word-break: break-word; } 182 | .tool-details { margin-top: 6px; max-height: 240px; overflow: auto; background: #ffffff; color: #111; border: 1px solid #d5f0db; border-radius: 6px; padding: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; white-space: pre-wrap; max-width: 100%; overflow-wrap: anywhere; } 183 | .expand-btn { margin-left: auto; padding: 2px 6px; border: 1px solid #cdebd4; background: #f3fff6; border-radius: 4px; cursor: pointer; font-size: 12px; } 184 | .chat-input { display: flex; gap: 8px; padding: 8px; border-top: 1px solid #eee; } 185 | .chat-input textarea { flex: 1; min-height: 64px; max-height: 200px; padding: 8px; border: 1px solid #ccc; border-radius: 6px; resize: vertical; background: #fff; color: #111; font: inherit; } 186 | 187 | 188 | /* Modal */ 189 | .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.25); display: flex; align-items: center; justify-content: center; z-index: 1000; } 190 | .modal { width: 760px; max-width: calc(100% - 32px); max-height: calc(100% - 64px); background: #fff; color: #111; border: 1px solid #ddd; border-radius: 12px; box-shadow: 0 12px 36px rgba(0,0,0,0.22); display: flex; flex-direction: column; overflow: hidden; } 191 | .modal-header { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid #eee; } 192 | .modal-title { font-weight: 700; font-size: 16px; } 193 | .modal-body { padding: 16px; display: flex; flex-direction: column; gap: 14px; overflow: auto; } 194 | .modal-footer { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-top: 1px solid #eee; } 195 | 196 | /* Forms */ 197 | .form-row { display: flex; flex-direction: column; gap: 6px; } 198 | .form-row label { font-size: 12px; color: #444; } 199 | .form-row input[type="text"], 200 | .form-row input[type="password"], 201 | .form-row select, 202 | .form-row textarea { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 6px; background: #fff; color: #111; font: inherit; } 203 | .form-row textarea { resize: vertical; min-height: 72px; } 204 | 205 | /* Subtle help text below labels */ 206 | .help-text { font-size: 12px; color: #666; margin-top: -2px; margin-bottom: 6px; } 207 | 208 | /* Server row "card" styling for better separation */ 209 | .server-row { margin-bottom: 8px; padding: 8px; border: 1px solid #eee; border-radius: 8px; background: #fafafa; } 210 | .server-row:hover { border-color: #e2e2e2; background: #f7f7f7; } 211 | 212 | /* Connections list spacing */ 213 | .connections-list { display: flex; flex-direction: column; gap: 6px; } 214 | .connection-row { padding: 6px 8px; border: 1px dashed #e6e6e6; border-radius: 8px; background: #fbfdff; } 215 | 216 | /* Slightly smaller, consistent control typography inside modal to avoid truncation */ 217 | .modal .form-row input[type="text"], 218 | .modal .form-row input[type="password"], 219 | .modal .form-row select, 220 | .modal .form-row textarea { font-size: 14px; } 221 | 222 | /* Helpers */ 223 | .inline-input-action { display: flex; gap: 8px; align-items: center; } 224 | .inline-input-action input { flex: 1; } 225 | 226 | -------------------------------------------------------------------------------- /web/src/ui/spreadsheetMcp.js: -------------------------------------------------------------------------------- 1 | import * as acorn from 'acorn' 2 | import { registerBuiltins } from 'autosheet' 3 | import { loadScriptsFromStorage, saveScriptsToStorage } from './ScriptEditor.jsx' 4 | 5 | // Resolve sheet-qualified single cell address like "Sheet2!A1" 6 | function resolveSheetAndAddress(defaultSheet, address) { 7 | const m = /^([^!]+)!([^!]+)$/.exec(String(address)) 8 | if (m) { 9 | return { sheet: String(m[1]), addr: String(m[2]).trim() } 10 | } 11 | return { sheet: String(defaultSheet), addr: String(address).trim() } 12 | } 13 | 14 | // Resolve sheet-qualified range like "Sheet2!A1:C3" 15 | function resolveSheetAndRange(defaultSheet, rangeStr) { 16 | const m = /^([^!]+)!(.+)$/.exec(String(rangeStr)) 17 | if (m) { 18 | return { sheet: String(m[1]), range: String(m[2]).trim() } 19 | } 20 | return { sheet: String(defaultSheet), range: String(rangeStr).trim() } 21 | } 22 | 23 | // Walk a result object and ensure cumulative string content <= limit chars. 24 | // If exceeded, truncate the last string segment and append " TRUNCATED". 25 | function enforceCharLimit(result, limit = 1000) { 26 | let used = 0 27 | let didTruncate = false 28 | const seen = new WeakSet() 29 | 30 | function walk(value) { 31 | if (value == null) return value 32 | if (typeof value === 'string') { 33 | const remaining = limit - used 34 | if (remaining <= 0) { 35 | if (!didTruncate) { 36 | didTruncate = true 37 | return 'TRUNCATED' 38 | } 39 | return '' 40 | } 41 | if (value.length <= remaining) { 42 | used += value.length 43 | return value 44 | } 45 | const suffix = ' TRUNCATED' 46 | const keep = Math.max(0, remaining - suffix.length) 47 | const truncated = value.slice(0, keep) + suffix 48 | used += truncated.length 49 | didTruncate = true 50 | return truncated 51 | } 52 | if (Array.isArray(value)) { 53 | return value.map((v) => walk(v)) 54 | } 55 | if (typeof value === 'object') { 56 | if (seen.has(value)) return value 57 | seen.add(value) 58 | const out = {} 59 | for (const k of Object.keys(value)) { 60 | out[k] = walk(value[k]) 61 | } 62 | return out 63 | } 64 | return value 65 | } 66 | 67 | const walked = walk(result) 68 | if (didTruncate && walked && typeof walked === 'object' && !Array.isArray(walked)) { 69 | // Append a clear note at the end of the object to indicate truncation 70 | // Property insertion order is preserved in JSON.stringify in practice 71 | walked.note = 'TRUNCATED' 72 | } 73 | return walked 74 | } 75 | 76 | // Keep only cells that have content and drop blank/invalid addresses. 77 | function hasContent(value) { 78 | if (value === undefined || value === null) return false 79 | if (typeof value === 'string') return value.length > 0 80 | return true 81 | } 82 | 83 | function filterRangeResult(res, mode) { 84 | const checkRaw = mode === 'raw' || mode === 'both' 85 | const checkComputed = mode === 'computed' || mode === 'both' 86 | const filteredRows = [] 87 | for (const row of Array.isArray(res?.rows) ? res.rows : []) { 88 | const newRow = [] 89 | for (const cell of Array.isArray(row) ? row : []) { 90 | const adr = cell && typeof cell.address === 'string' ? cell.address.trim() : '' 91 | if (!adr) continue 92 | const rawOk = checkRaw && hasContent(cell.raw) 93 | const compOk = checkComputed && hasContent(cell.computed) 94 | if (rawOk || compOk) { 95 | const kept = { address: adr } 96 | if (checkRaw && 'raw' in cell) kept.raw = cell.raw 97 | if (checkComputed && 'computed' in cell) kept.computed = cell.computed 98 | newRow.push(kept) 99 | } 100 | } 101 | if (newRow.length > 0) filteredRows.push(newRow) 102 | } 103 | return { sheet: res.sheet, range: res.range, rows: filteredRows } 104 | } 105 | 106 | export function getSpreadsheetTools() { 107 | return [ 108 | { 109 | type: 'function', 110 | function: { 111 | name: 'spreadsheet_get_cell', 112 | description: 'Reads a cell by A1 address from the spreadsheet. Supports raw value, computed value, or both.', 113 | parameters: { 114 | type: 'object', 115 | properties: { 116 | sheet: { type: 'string', description: 'Sheet name. Defaults to the active sheet.' }, 117 | address: { type: 'string', description: 'A1 address, e.g., "A1".' }, 118 | mode: { type: 'string', description: 'What to return: raw, computed, or both. Defaults to computed.', enum: ['raw','computed','both'] }, 119 | }, 120 | required: ['address'], 121 | }, 122 | }, 123 | }, 124 | { 125 | type: 'function', 126 | function: { 127 | name: 'spreadsheet_set_cell', 128 | description: 'Writes a value or formula to a cell. Formulas must start with "=".', 129 | parameters: { 130 | type: 'object', 131 | properties: { 132 | sheet: { type: 'string', description: 'Sheet name. Defaults to the active sheet.' }, 133 | address: { type: 'string', description: 'A1 address, e.g., "B2".' }, 134 | value: { description: 'Value or formula string (e.g., "=SUM(A1:A3)").', anyOf: [ { type: 'string' }, { type: 'number' }, { type: 'boolean' }, { type: 'null' } ] }, 135 | }, 136 | required: ['address','value'], 137 | }, 138 | }, 139 | }, 140 | { 141 | type: 'function', 142 | function: { 143 | name: 'spreadsheet_get_range', 144 | description: 'Reads a rectangular range (e.g., "A1:C3"). Returns only cells that have content according to the selected mode (raw, computed, or both). Empty/non-existent cells are omitted.', 145 | parameters: { 146 | type: 'object', 147 | properties: { 148 | sheet: { type: 'string', description: 'Sheet name. Defaults to the active sheet.' }, 149 | range: { type: 'string', description: 'A1 range like "A1:C3".' }, 150 | mode: { type: 'string', description: 'What to return per cell: raw, computed, or both. Defaults to computed.', enum: ['raw','computed','both'] }, 151 | }, 152 | required: ['range'], 153 | }, 154 | }, 155 | }, 156 | { 157 | type: 'function', 158 | function: { 159 | name: 'spreadsheet_set_range', 160 | description: 'Writes a 2D array of values (or formulas) into a rectangular range (e.g., "A1:C3"). The provided matrix shape must match the range size. Response includes only cells that have content; empty/non-existent cells are omitted.', 161 | parameters: { 162 | type: 'object', 163 | properties: { 164 | sheet: { type: 'string', description: 'Sheet name. Defaults to the active sheet.' }, 165 | range: { type: 'string', description: 'A1 range like "A1:C3".' }, 166 | values: { 167 | type: 'array', 168 | description: '2D array of values to write. Outer array is rows; inner arrays are columns.', 169 | items: { 170 | type: 'array', 171 | items: { anyOf: [ { type: 'string' }, { type: 'number' }, { type: 'boolean' }, { type: 'null' } ] } 172 | } 173 | } 174 | }, 175 | required: ['range','values'], 176 | }, 177 | }, 178 | }, 179 | // ===== Script management tools ===== 180 | { 181 | type: 'function', 182 | function: { 183 | name: 'spreadsheet_sheets_list', 184 | description: 'Lists all sheet names currently defined in the engine.', 185 | parameters: { type: 'object', properties: {} }, 186 | }, 187 | }, 188 | { 189 | type: 'function', 190 | function: { 191 | name: 'spreadsheet_scripts_list', 192 | description: 'Lists all user scripts (id and name only). Call this before creating a new script.', 193 | parameters: { type: 'object', properties: {} }, 194 | }, 195 | }, 196 | { 197 | type: 'function', 198 | function: { 199 | name: 'spreadsheet_scripts_get', 200 | description: 'Reads a single script by id or name. Returns id, name, and content.', 201 | parameters: { 202 | type: 'object', 203 | properties: { 204 | id: { type: 'string', description: 'Script id.' }, 205 | name: { type: 'string', description: 'Script file name.' }, 206 | }, 207 | }, 208 | }, 209 | }, 210 | { 211 | type: 'function', 212 | function: { 213 | name: 'spreadsheet_scripts_get_all', 214 | description: 'Reads all scripts with full content.', 215 | parameters: { type: 'object', properties: {} }, 216 | }, 217 | }, 218 | { 219 | type: 'function', 220 | function: { 221 | name: 'spreadsheet_scripts_create', 222 | description: 'Creates a new script in full mode. By default, add new functions to an existing script file (choose the most logical one, find out which ones exist using the spreadsheet_scripts_list tool) and only create a new file if the user explicitly asks for it. Provide the entire file content; name must end with .js and be unique.\n\nCustom functions guide\n- Define top-level functions: function Name(args) { ... }\n- They become available in formulas by name; names starting with \'_\' are ignored.\n- args: array of evaluated arguments from the cell formula\n- Use built-ins via the BUILTINS helper injected into your script\'s scope.\n Example: function DoubleSum(args) { return BUILTINS.SUM(args) * 2 }\n\nExample function:\nfunction Abc(args) {\n const x = Number(args?.[0] ?? 0)\n return x + 1\n}', 223 | parameters: { 224 | type: 'object', 225 | properties: { 226 | name: { type: 'string', description: 'File name, e.g., script2.js' }, 227 | content: { type: 'string', description: 'Full script content.' }, 228 | }, 229 | required: ['name','content'], 230 | }, 231 | }, 232 | }, 233 | { 234 | type: 'function', 235 | function: { 236 | name: 'spreadsheet_scripts_update', 237 | description: 'Edits an existing script in full mode. This is the preferred way to add new functions: update an existing logical script file unless the user explicitly requests creating a new file. Identify by id or name. Provide the entire new content for the file; optionally rename with new_name (must end with .js).\n\nCustom functions guide\n- Define top-level functions: function Name(args) { ... }\n- They become available in formulas by name; names starting with \'_\' are ignored.\n- args: array of evaluated arguments from the cell formula\n- Use built-ins via the BUILTINS helper injected into your script\'s scope.\n Example: function DoubleSum(args) { return BUILTINS.SUM(args) * 2 }\n\nExample function:\nfunction Abc(args) {\n const x = Number(args?.[0] ?? 0)\n return x + 1\n}', 238 | parameters: { 239 | type: 'object', 240 | properties: { 241 | id: { type: 'string', description: 'Script id.' }, 242 | name: { type: 'string', description: 'Script file name (alternative to id).' }, 243 | content: { type: 'string', description: 'Full new content to replace the script with.' }, 244 | new_name: { type: 'string', description: 'Optional new file name ending with .js' }, 245 | }, 246 | required: ['content'], 247 | }, 248 | }, 249 | }, 250 | { 251 | type: 'function', 252 | function: { 253 | name: 'spreadsheet_scripts_delete', 254 | description: 'Deletes a script by id or name.', 255 | parameters: { 256 | type: 'object', 257 | properties: { 258 | id: { type: 'string', description: 'Script id.' }, 259 | name: { type: 'string', description: 'Script file name.' }, 260 | }, 261 | }, 262 | }, 263 | }, 264 | ] 265 | } 266 | 267 | export function isSpreadsheetToolName(name) { 268 | return name === 'spreadsheet_get_cell' 269 | || name === 'spreadsheet_set_cell' 270 | || name === 'spreadsheet_get_range' 271 | || name === 'spreadsheet_set_range' 272 | || name === 'spreadsheet_sheets_list' 273 | || name === 'spreadsheet_scripts_list' 274 | || name === 'spreadsheet_scripts_get' 275 | || name === 'spreadsheet_scripts_get_all' 276 | || name === 'spreadsheet_scripts_create' 277 | || name === 'spreadsheet_scripts_update' 278 | || name === 'spreadsheet_scripts_delete' 279 | } 280 | 281 | export async function runSpreadsheetTool(name, args, ctx) { 282 | let sheet = String(args?.sheet || ctx?.activeSheet || '') 283 | const { engine } = ctx || {} 284 | if (!engine) throw new Error('Spreadsheet engine unavailable') 285 | if (!sheet) { 286 | const first = (engine && engine.sheets && typeof engine.sheets.keys === 'function') ? engine.sheets.keys().next().value : null 287 | sheet = first || 'Sheet1' 288 | } 289 | 290 | if (name === 'spreadsheet_get_cell') { 291 | const addressInput = String(args?.address || '').trim() 292 | if (!addressInput) throw new Error('Missing address') 293 | const mode = (args?.mode === 'raw' || args?.mode === 'both') ? args.mode : 'computed' 294 | const { sheet: resolvedSheet, addr } = resolveSheetAndAddress(sheet, addressInput) 295 | const out = { sheet: resolvedSheet, address: addr } 296 | 297 | // Read values first so we can explicitly signal emptiness and avoid undefined keys being omitted 298 | let rawVal 299 | let compVal 300 | if (mode === 'raw' || mode === 'both') rawVal = engine.getCell(resolvedSheet, addr) 301 | if (mode === 'computed' || mode === 'both') compVal = engine.evaluateCell(resolvedSheet, addr) 302 | 303 | if (mode === 'raw' || mode === 'both') { 304 | const rawEmpty = rawVal === undefined || (typeof rawVal === 'string' && rawVal.length === 0) 305 | out.raw = rawEmpty ? null : rawVal 306 | if (rawEmpty) out.emptyRaw = true 307 | } 308 | if (mode === 'computed' || mode === 'both') { 309 | const compEmpty = compVal === undefined || (typeof compVal === 'string' && compVal.length === 0) 310 | out.computed = compEmpty ? null : compVal 311 | if (compEmpty) out.emptyComputed = true 312 | } 313 | 314 | if (mode === 'raw') { 315 | out.empty = !!out.emptyRaw 316 | } else if (mode === 'computed') { 317 | out.empty = !!out.emptyComputed 318 | } else { 319 | out.empty = !!(out.emptyRaw && out.emptyComputed) 320 | } 321 | return enforceCharLimit(out) 322 | } 323 | 324 | if (name === 'spreadsheet_set_cell') { 325 | const addressInput = String(args?.address || '').trim() 326 | if (!addressInput) throw new Error('Missing address') 327 | if (!('value' in args)) throw new Error('Missing value') 328 | const value = args.value 329 | const { sheet: resolvedSheet, addr } = resolveSheetAndAddress(sheet, addressInput) 330 | // If the target sheet does not exist, return an MCP error result instead of creating it implicitly 331 | const sheetExists = !!(engine && engine.sheets && typeof engine.sheets.has === 'function' && engine.sheets.has(resolvedSheet)) 332 | if (!sheetExists) { 333 | return { error: `Sheet '${resolvedSheet}' does not exist` } 334 | } 335 | engine.setCell(resolvedSheet, addr, value) 336 | if (typeof ctx?.onEngineMutated === 'function') ctx.onEngineMutated() 337 | const computed = engine.evaluateCell(resolvedSheet, addr) 338 | return enforceCharLimit({ ok: true, sheet: resolvedSheet, address: addr, raw: engine.getCell(resolvedSheet, addr), computed }) 339 | } 340 | 341 | if (name === 'spreadsheet_get_range') { 342 | const rangeInput = String(args?.range || '').trim() 343 | if (!rangeInput) throw new Error('Missing range') 344 | const mode = (args?.mode === 'raw' || args?.mode === 'both') ? args.mode : 'computed' 345 | const { sheet: resolvedSheet, range } = resolveSheetAndRange(sheet, rangeInput) 346 | const res = engine.getRange(resolvedSheet, range, mode) 347 | const filtered = filterRangeResult(res, mode) 348 | return enforceCharLimit(filtered) 349 | } 350 | 351 | if (name === 'spreadsheet_set_range') { 352 | const rangeInput = String(args?.range || '').trim() 353 | if (!rangeInput) throw new Error('Missing range') 354 | const values = Array.isArray(args?.values) ? args.values : null 355 | if (!values || values.length === 0 || !values.every((row) => Array.isArray(row))) { 356 | throw new Error('values must be a non-empty 2D array') 357 | } 358 | const { sheet: resolvedSheet, range } = resolveSheetAndRange(sheet, rangeInput) 359 | // If the target sheet does not exist, return an MCP error result instead of creating it implicitly 360 | const sheetExists = !!(engine && engine.sheets && typeof engine.sheets.has === 'function' && engine.sheets.has(resolvedSheet)) 361 | if (!sheetExists) { 362 | return { error: `Sheet '${resolvedSheet}' does not exist` } 363 | } 364 | const res = engine.setRange(resolvedSheet, range, values) 365 | if (typeof ctx?.onEngineMutated === 'function') ctx.onEngineMutated() 366 | const filtered = filterRangeResult(res, 'both') 367 | return enforceCharLimit(filtered) 368 | } 369 | 370 | if (name === 'spreadsheet_sheets_list') { 371 | const names = Array.from((engine && engine.sheets && typeof engine.sheets.keys === 'function') ? engine.sheets.keys() : []) 372 | return names.map((n) => String(n)) 373 | } 374 | 375 | // ===== Script management handlers ===== 376 | if (name === 'spreadsheet_scripts_list') { 377 | const scripts = safeLoadScripts() 378 | return scripts.map((s) => ({ id: s.id, name: s.name })) 379 | } 380 | 381 | if (name === 'spreadsheet_scripts_get') { 382 | const scripts = safeLoadScripts() 383 | const id = typeof args?.id === 'string' ? args.id : null 384 | const nm = typeof args?.name === 'string' ? args.name : null 385 | if (!id && !nm) throw new Error('Provide id or name') 386 | const script = scripts.find((s) => (id && s.id === id) || (nm && s.name === nm)) 387 | if (!script) throw new Error('Script not found') 388 | return { id: script.id, name: script.name, content: script.content || '' } 389 | } 390 | 391 | if (name === 'spreadsheet_scripts_get_all') { 392 | const scripts = safeLoadScripts() 393 | return scripts.map((s) => ({ id: s.id, name: s.name, content: s.content || '' })) 394 | } 395 | 396 | if (name === 'spreadsheet_scripts_create') { 397 | const scripts = safeLoadScripts() 398 | const nameArg = String(args?.name || '').trim() 399 | const content = String(args?.content ?? '') 400 | if (!nameArg || !/^[^\s]+\.js$/i.test(nameArg)) throw new Error('Invalid name (must end with .js and contain no spaces)') 401 | if (scripts.some((s) => s.name === nameArg)) throw new Error('A script with that name already exists') 402 | const id = crypto.randomUUID() 403 | const next = [...scripts, { id, name: nameArg, content }] 404 | saveScriptsToStorage(next) 405 | emitScriptsUpdated(next) 406 | await compileAndRegisterScripts(engine, next) 407 | if (typeof ctx?.onEngineMutated === 'function') ctx.onEngineMutated() 408 | return { ok: true, id, name: nameArg } 409 | } 410 | 411 | if (name === 'spreadsheet_scripts_update') { 412 | const scripts = safeLoadScripts() 413 | const id = typeof args?.id === 'string' ? args.id : null 414 | const nm = typeof args?.name === 'string' ? args.name : null 415 | if (!id && !nm) throw new Error('Provide id or name') 416 | const idx = scripts.findIndex((s) => (id && s.id === id) || (nm && s.name === nm)) 417 | if (idx === -1) throw new Error('Script not found') 418 | const full = String(args?.content ?? '') 419 | const newNameRaw = args?.new_name 420 | let newName = scripts[idx].name 421 | if (typeof newNameRaw === 'string' && newNameRaw.trim()) { 422 | if (!/^[^\s]+\.js$/i.test(newNameRaw)) throw new Error('new_name must end with .js and contain no spaces') 423 | const duplicate = scripts.some((s, i) => i !== idx && s.name === newNameRaw) 424 | if (duplicate) throw new Error('A script with that name already exists') 425 | newName = newNameRaw 426 | } 427 | const updated = scripts.slice() 428 | updated[idx] = { ...updated[idx], name: newName, content: full } 429 | saveScriptsToStorage(updated) 430 | emitScriptsUpdated(updated) 431 | await compileAndRegisterScripts(engine, updated) 432 | if (typeof ctx?.onEngineMutated === 'function') ctx.onEngineMutated() 433 | return { ok: true, id: updated[idx].id, name: updated[idx].name } 434 | } 435 | 436 | if (name === 'spreadsheet_scripts_delete') { 437 | const scripts = safeLoadScripts() 438 | const id = typeof args?.id === 'string' ? args.id : null 439 | const nm = typeof args?.name === 'string' ? args.name : null 440 | if (!id && !nm) throw new Error('Provide id or name') 441 | const idx = scripts.findIndex((s) => (id && s.id === id) || (nm && s.name === nm)) 442 | if (idx === -1) throw new Error('Script not found') 443 | const remaining = scripts.filter((_, i) => i !== idx) 444 | saveScriptsToStorage(remaining) 445 | emitScriptsUpdated(remaining) 446 | await compileAndRegisterScripts(engine, remaining) 447 | if (typeof ctx?.onEngineMutated === 'function') ctx.onEngineMutated() 448 | return { ok: true } 449 | } 450 | 451 | return null 452 | } 453 | 454 | // ===== Helpers ===== 455 | function safeLoadScripts() { 456 | try { 457 | const arr = loadScriptsFromStorage() 458 | if (Array.isArray(arr)) return arr 459 | } catch {} 460 | return [] 461 | } 462 | 463 | async function compileAndRegisterScripts(engine, allScripts) { 464 | // Build a combined script, gather top-level function declarations, and safely rebuild registry 465 | const combined = allScripts.map((s) => String(s.content || '')).join('\n\n') 466 | // Parse for function names (ignore names starting with '_') 467 | const ast = acorn.parse(combined, { ecmaVersion: 'latest', sourceType: 'script' }) 468 | const functionNames = [] 469 | for (const node of ast.body) { 470 | if (node.type === 'FunctionDeclaration' && node.id && node.id.name && !node.id.name.startsWith('_')) { 471 | functionNames.push(node.id.name) 472 | } 473 | } 474 | const exportList = functionNames.map((n) => `${n}: typeof ${n} !== 'undefined' ? ${n} : undefined`).join(', ') 475 | const wrapper = `"use strict";\n${combined}\n;return { ${exportList} };` 476 | 477 | const NewRegistryClass = engine.registry.constructor 478 | const newRegistry = new NewRegistryClass() 479 | registerBuiltins(newRegistry) 480 | 481 | const builtinsHelper = {} 482 | for (const name of newRegistry.names()) { 483 | if (name === 'BUILTINS') continue 484 | builtinsHelper[name] = (...fnArgs) => newRegistry.get(name)(fnArgs) 485 | } 486 | const bag = Function('BUILTINS', wrapper)(builtinsHelper) 487 | for (const fnName of functionNames) { 488 | const fn = bag[fnName] 489 | if (typeof fn === 'function') newRegistry.register(fnName, fn) 490 | } 491 | engine.registry = newRegistry 492 | } 493 | 494 | function emitScriptsUpdated(scripts) { 495 | try { 496 | const evt = new CustomEvent('autosheet:scripts_updated', { detail: { scripts } }) 497 | window.dispatchEvent(evt) 498 | } catch {} 499 | } 500 | 501 | 502 | -------------------------------------------------------------------------------- /web/src/ui/mcpClient.js: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 3 | import { Client } from '@modelcontextprotocol/sdk/client/index.js' 4 | import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' 5 | import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' 6 | import { auth, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' 7 | 8 | function sanitizeUrl(u) { 9 | try { return new URL(u).toString() } catch { return '' } 10 | } 11 | 12 | function hashString(str) { 13 | let hash = 0 14 | for (let i = 0; i < str.length; i++) { 15 | const char = str.charCodeAt(i) 16 | hash = (hash << 5) - hash + char 17 | hash = hash & hash 18 | } 19 | return Math.abs(hash).toString(16) 20 | } 21 | 22 | class ProxyingBrowserOAuthProvider { 23 | constructor(serverUrl, options = {}) { 24 | this.serverUrl = sanitizeUrl(serverUrl) 25 | this.storageKeyPrefix = options.storageKeyPrefix || 'mcp:auth' 26 | this.serverUrlHash = hashString(this.serverUrl) 27 | this.clientName = options.clientName || 'Autosheet MCP Client' 28 | this.clientUri = options.clientUri || (typeof window !== 'undefined' ? window.location.origin : '') 29 | this.callbackUrl = options.callbackUrl || (typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback') 30 | this.preventAutoAuth = !!options.preventAutoAuth 31 | this.onPopupWindow = options.onPopupWindow 32 | // For pre-registered OAuth apps (like GitHub) 33 | this.preRegisteredClientId = options.clientId 34 | this.preRegisteredClientSecret = options.clientSecret 35 | } 36 | get redirectUrl() { 37 | return this.callbackUrl 38 | } 39 | get clientMetadata() { 40 | return { 41 | redirect_uris: [this.redirectUrl], 42 | token_endpoint_auth_method: 'none', 43 | grant_types: ['authorization_code','refresh_token'], 44 | response_types: ['code'], 45 | client_name: this.clientName, 46 | client_uri: this.clientUri, 47 | } 48 | } 49 | async clientInformation() { 50 | // If pre-registered client credentials are provided, use them 51 | if (this.preRegisteredClientId) { 52 | return { 53 | client_id: this.preRegisteredClientId, 54 | client_secret: this.preRegisteredClientSecret, 55 | // GitHub doesn't require client_secret for public clients 56 | token_endpoint_auth_method: this.preRegisteredClientSecret ? 'client_secret_post' : 'none', 57 | } 58 | } 59 | // Otherwise, check for dynamically registered client 60 | const key = this._key('client_info') 61 | const raw = localStorage.getItem(key) 62 | if (!raw) return undefined 63 | try { return JSON.parse(raw) } catch { localStorage.removeItem(key); return undefined } 64 | } 65 | async saveClientInformation(info) { 66 | localStorage.setItem(this._key('client_info'), JSON.stringify(info)) 67 | } 68 | async tokens() { 69 | const key = this._key('tokens') 70 | const raw = localStorage.getItem(key) 71 | if (!raw) return undefined 72 | try { return JSON.parse(raw) } catch { localStorage.removeItem(key); return undefined } 73 | } 74 | async saveTokens(tokens) { 75 | localStorage.setItem(this._key('tokens'), JSON.stringify(tokens)) 76 | localStorage.removeItem(this._key('code_verifier')) 77 | localStorage.removeItem(this._key('last_auth_url')) 78 | } 79 | async saveCodeVerifier(verifier) { 80 | localStorage.setItem(this._key('code_verifier'), verifier) 81 | } 82 | async codeVerifier() { 83 | const v = localStorage.getItem(this._key('code_verifier')) 84 | if (!v) throw new Error(`[${this.storageKeyPrefix}] Missing code_verifier`) 85 | return v 86 | } 87 | async prepareAuthorizationUrl(authorizationUrl) { 88 | const state = crypto.randomUUID() 89 | const stateKey = `${this.storageKeyPrefix}:state_${state}` 90 | const stateData = { 91 | serverUrlHash: this.serverUrlHash, 92 | expiry: Date.now() + 10 * 60 * 1000, 93 | providerOptions: { 94 | serverUrl: this.serverUrl, 95 | storageKeyPrefix: this.storageKeyPrefix, 96 | clientName: this.clientName, 97 | clientUri: this.clientUri, 98 | callbackUrl: this.callbackUrl, 99 | }, 100 | } 101 | localStorage.setItem(stateKey, JSON.stringify(stateData)) 102 | authorizationUrl.searchParams.set('state', state) 103 | const urlStr = authorizationUrl.toString() 104 | localStorage.setItem(this._key('last_auth_url'), urlStr) 105 | return urlStr 106 | } 107 | async redirectToAuthorization(authorizationUrl) { 108 | if (this.preventAutoAuth) return 109 | const urlStr = await this.prepareAuthorizationUrl(authorizationUrl) 110 | const features = 'width=600,height=700,resizable=yes,scrollbars=yes,status=yes' 111 | try { 112 | const popup = window.open(urlStr, `mcp_auth_${this.serverUrlHash}`, features) 113 | if (this.onPopupWindow) this.onPopupWindow(urlStr, features, popup) 114 | if (popup && !popup.closed) popup.focus() 115 | } catch {} 116 | } 117 | getLastAttemptedAuthUrl() { 118 | return localStorage.getItem(this._key('last_auth_url')) 119 | } 120 | clearStorage() { 121 | const prefixPattern = `${this.storageKeyPrefix}_${this.serverUrlHash}_` 122 | const statePattern = `${this.storageKeyPrefix}:state_` 123 | const remove = [] 124 | for (let i = 0; i < localStorage.length; i++) { 125 | const k = localStorage.key(i) 126 | if (!k) continue 127 | if (k.startsWith(prefixPattern)) remove.push(k) 128 | else if (k.startsWith(statePattern)) { 129 | try { 130 | const s = localStorage.getItem(k) 131 | if (s) { 132 | const obj = JSON.parse(s) 133 | if (obj.serverUrlHash === this.serverUrlHash) remove.push(k) 134 | } 135 | } catch {} 136 | } 137 | } 138 | let count = 0 139 | for (const k of new Set(remove)) { localStorage.removeItem(k); count++ } 140 | return count 141 | } 142 | _key(suffix) { return `${this.storageKeyPrefix}_${this.serverUrlHash}_${suffix}` } 143 | } 144 | 145 | 146 | 147 | async function proxyFetch(input, init = {}) { 148 | let urlStr = '' 149 | if (typeof input === 'string') urlStr = input 150 | else if (input && typeof input.url === 'string') urlStr = input.url 151 | else if (input && typeof input.href === 'string') urlStr = input.href 152 | else { 153 | try { urlStr = String(input) } catch { urlStr = '' } 154 | } 155 | 156 | // Check if URL is already proxied to avoid double-proxying 157 | try { 158 | const curOrigin = typeof window !== 'undefined' ? window.location.origin : '' 159 | const abs = new URL(urlStr, curOrigin) 160 | if (abs.origin === curOrigin && abs.pathname.startsWith('/api/proxy')) { 161 | // Already proxied, use as-is 162 | return fetch(abs.toString(), init) 163 | } 164 | } catch {} 165 | 166 | // Only proxy absolute HTTP(S) URLs 167 | let absolute = '' 168 | try { 169 | const u = new URL(urlStr) 170 | if (u.protocol !== 'http:' && u.protocol !== 'https:') { 171 | // Not HTTP(S), don't proxy 172 | return fetch(urlStr, init) 173 | } 174 | absolute = u.toString() 175 | } catch { 176 | // Not an absolute URL; use normal fetch (likely internal path) 177 | return fetch(urlStr, init) 178 | } 179 | 180 | const target = `/api/proxy?target=${encodeURIComponent(absolute)}` 181 | const method = init.method || (typeof input === 'object' && input.method) || 'GET' 182 | const headers = new Headers(init.headers || (typeof input === 'object' && input.headers) || {}) 183 | // Don't send body for GET/HEAD requests 184 | const body = (method === 'GET' || method === 'HEAD') ? undefined : 185 | (init.body || (typeof input === 'object' && input.body) || undefined) 186 | 187 | const response = await fetch(target, { method, headers, body }) 188 | 189 | // Debug OAuth metadata responses 190 | if (absolute.includes('.well-known/oauth')) { 191 | const clonedResponse = response.clone() 192 | try { 193 | const text = await clonedResponse.text() 194 | console.log('[proxyFetch] OAuth metadata response:', { 195 | url: absolute, 196 | status: response.status, 197 | bodyLength: text.length, 198 | bodyPreview: text.substring(0, 250) 199 | }) 200 | } catch (e) { 201 | console.log('[proxyFetch] Could not read OAuth response:', e) 202 | } 203 | } 204 | 205 | return response 206 | } 207 | 208 | export function useMcpClient(options) { 209 | const { 210 | url, 211 | clientName, 212 | clientUri, 213 | callbackUrl = (typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback'), 214 | storageKeyPrefix = 'mcp:auth', 215 | customHeaders = {}, 216 | autoReconnect = 3000, 217 | autoRetry = false, 218 | transportType = 'auto', 219 | preventAutoAuth = false, 220 | clientConfig = {}, 221 | } = options || {} 222 | 223 | const [state, setState] = useState('discovering') 224 | const [tools, setTools] = useState([]) 225 | const [error, setError] = useState(undefined) 226 | const [authUrl, setAuthUrl] = useState(undefined) 227 | 228 | const clientRef = useRef(null) 229 | const transportRef = useRef(null) 230 | const providerRef = useRef(null) 231 | const connectingRef = useRef(false) 232 | const isMountedRef = useRef(true) 233 | const stateRef = useRef(state) 234 | 235 | useEffect(() => { stateRef.current = state }, [state]) 236 | 237 | 238 | 239 | const disconnect = useCallback(async () => { 240 | connectingRef.current = false 241 | const t = transportRef.current 242 | transportRef.current = null 243 | clientRef.current = null 244 | if (t && t.close) { try { await t.close() } catch {} } 245 | if (isMountedRef.current) { 246 | setState('discovering') 247 | setTools([]) 248 | setError(undefined) 249 | setAuthUrl(undefined) 250 | } 251 | }, []) 252 | 253 | const ensureProvider = useCallback(() => { 254 | if (!providerRef.current || providerRef.current.serverUrl !== url) { 255 | // Check if this is GitHub and we have a client ID configured 256 | let clientId = undefined 257 | if (url.includes('githubcopilot')) { 258 | // Try to get GitHub OAuth client ID from localStorage 259 | clientId = localStorage.getItem('github_oauth_client_id') 260 | if (!clientId) { 261 | console.log('[MCP] GitHub Copilot requires a pre-registered OAuth app.') 262 | console.log('[MCP] Please create one at: https://github.com/settings/applications/new') 263 | console.log('[MCP] Then set it with: localStorage.setItem("github_oauth_client_id", "YOUR_CLIENT_ID")') 264 | } 265 | } 266 | 267 | providerRef.current = new ProxyingBrowserOAuthProvider(url, { 268 | storageKeyPrefix, 269 | clientName, 270 | clientUri, 271 | callbackUrl, 272 | preventAutoAuth, 273 | clientId, 274 | }) 275 | } 276 | return providerRef.current 277 | }, [url, storageKeyPrefix, clientName, clientUri, callbackUrl, preventAutoAuth]) 278 | 279 | const connect = useCallback(async () => { 280 | if (connectingRef.current) return 281 | connectingRef.current = true 282 | setError(undefined) 283 | setAuthUrl(undefined) 284 | setState('connecting') 285 | try { 286 | ensureProvider() 287 | if (!clientRef.current) { 288 | clientRef.current = new Client({ name: clientConfig.name || 'autosheet-mcp', version: clientConfig.version || '0.1.0' }, { capabilities: {} }) 289 | } 290 | const tryWith = async (mode) => { 291 | if (transportRef.current) { try { await transportRef.current.close() } catch {} transportRef.current = null } 292 | // Use raw server URL, let proxyFetch handle the proxying 293 | const serverUrl = new URL(url) 294 | 295 | // For OAuth discovery, we need to use the origin only (no path) 296 | // The SDK incorrectly appends the server path to discovery URLs 297 | const resourceMetadataUrl = new URL('/.well-known/oauth-protected-resource', serverUrl.origin).toString() 298 | 299 | // Transport config with proxyFetch for ALL network requests 300 | const transportConfig = { 301 | authProvider: providerRef.current, 302 | requestInit: { 303 | headers: { 304 | Accept: 'application/json, text/event-stream', 305 | ...customHeaders, 306 | }, 307 | }, 308 | // Use proxyFetch for all transport and auth requests 309 | fetch: proxyFetch, 310 | // For SSE, also pass the eventSourceInit with fetch 311 | eventSourceInit: { 312 | fetch: proxyFetch, 313 | headers: customHeaders, 314 | }, 315 | } 316 | const t = mode === 'http' ? new StreamableHTTPClientTransport(serverUrl, transportConfig) : new SSEClientTransport(serverUrl, transportConfig) 317 | 318 | // Monkey-patch the transport to use the correct resource metadata URL 319 | // The SDK doesn't accept this in options, so we have to set it manually 320 | t._resourceMetadataUrl = resourceMetadataUrl 321 | 322 | // Also override the _authThenStart method to ensure it uses our resourceMetadataUrl 323 | const originalAuthThenStart = t._authThenStart 324 | if (originalAuthThenStart) { 325 | t._authThenStart = async function() { 326 | // Ensure our resourceMetadataUrl is set before calling the original method 327 | this._resourceMetadataUrl = resourceMetadataUrl 328 | return originalAuthThenStart.call(this) 329 | } 330 | } 331 | 332 | transportRef.current = t 333 | t.onmessage = (msg) => { 334 | console.log('[MCP] Transport message:', msg) 335 | // The SSE transport already parses messages, just pass them through 336 | clientRef.current?.handleMessage?.(msg) 337 | } 338 | t.onerror = (e) => { 339 | console.warn(`Transport error for ${mode}:`, e) 340 | // Don't propagate 405 errors for streamable HTTP - it's expected when SSE isn't supported 341 | if (mode === 'http' && e && e.code === 405) return 342 | // Check if this is an auth error that should trigger OAuth 343 | const errorMsg = e && (e.message || String(e)) 344 | if (errorMsg && (errorMsg.includes('401') || errorMsg.includes('Unauthorized') || errorMsg.includes('Authorization header') || errorMsg.includes('JSON'))) { 345 | console.log(`[MCP ${mode}] Auth error detected in transport, will trigger OAuth`) 346 | // Don't log the full error, it will be handled by the catch block 347 | return 348 | } 349 | // Log more details about the error 350 | if (e) { 351 | console.error(`[MCP ${mode}] Transport error details:`, { 352 | code: e.code, 353 | message: e.message, 354 | error: e, 355 | url: url, 356 | state: stateRef.current 357 | }) 358 | } 359 | } 360 | t.onclose = () => { 361 | if (!isMountedRef.current) return 362 | if (state !== 'ready') return 363 | if (autoReconnect) setTimeout(() => { isMountedRef.current && connect() }, typeof autoReconnect === 'number' ? autoReconnect : 3000) 364 | } 365 | 366 | try { 367 | console.log(`[MCP] Connecting client with ${mode} transport...`) 368 | await clientRef.current.connect(t) 369 | console.log(`[MCP] Client connected with ${mode} transport, state:`, clientRef.current) 370 | setState('loading') 371 | } catch (connectError) { 372 | const errorMsg = connectError && (connectError.message || String(connectError)) 373 | console.log(`[MCP] Connection failed during connect:`, errorMsg) 374 | // Check if this is an auth-related error 375 | if (errorMsg && (errorMsg.includes('401') || errorMsg.includes('Unauthorized') || errorMsg.includes('Authorization') || errorMsg.includes('JSON'))) { 376 | throw new Error('AUTH_REQUIRED') 377 | } 378 | throw connectError 379 | } 380 | } 381 | 382 | if (transportType === 'http') { 383 | try { 384 | await tryWith('http') 385 | } catch (e) { 386 | const msg = e && (e.message || String(e)) 387 | if (String(msg).includes('405')) { 388 | await tryWith('sse') 389 | } else { 390 | throw e 391 | } 392 | } 393 | } else if (transportType === 'sse') { 394 | await tryWith('sse') 395 | } else { 396 | try { await tryWith('http') } catch { await tryWith('sse') } 397 | } 398 | 399 | // Load tools with timeout 400 | console.log('[MCP] Requesting tools list...') 401 | try { 402 | // Add a timeout for the tools request 403 | const toolsPromise = clientRef.current.listTools() 404 | const timeoutPromise = new Promise((_, reject) => 405 | setTimeout(() => reject(new Error('Tools request timed out after 10s')), 10000) 406 | ) 407 | 408 | const toolsResp = await Promise.race([toolsPromise, timeoutPromise]) 409 | console.log('[MCP] Tools response:', toolsResp) 410 | if (isMountedRef.current) { 411 | setTools(toolsResp.tools || []) 412 | setState('ready') 413 | console.log(`[MCP] Connection ready with ${(toolsResp.tools || []).length} tools`) 414 | } 415 | } catch (toolsError) { 416 | console.error('[MCP] Failed to load tools:', toolsError) 417 | // Log more details about the error for debugging 418 | if (toolsError.message && toolsError.message.includes('500')) { 419 | console.error('[MCP] Server returned 500 error. The server may have an internal issue or may not be ready to handle requests.') 420 | console.error('[MCP] Check that the server properly handles the initialized notification and is ready for subsequent requests.') 421 | } 422 | throw toolsError 423 | } 424 | } catch (e) { 425 | const msg = e && (e.message || String(e)) 426 | console.log('[MCP] Connection error:', msg, 'Full error:', e) 427 | 428 | // Special handling for GitHub's lack of dynamic registration 429 | if (String(msg).includes('does not support dynamic client registration')) { 430 | console.error('[MCP] GitHub OAuth Setup Required:') 431 | console.log('1. Go to https://github.com/settings/applications/new') 432 | console.log('2. Create an OAuth App with:') 433 | console.log(' - Homepage URL: http://localhost:3000') 434 | console.log(' - Callback URL: http://localhost:3000/oauth/callback') 435 | console.log('3. Copy the Client ID and run in console:') 436 | console.log(' localStorage.setItem("github_oauth_client_id", "YOUR_CLIENT_ID")') 437 | console.log('4. Reload the page and try connecting again') 438 | setState('failed') 439 | setError('GitHub OAuth app registration required. See console for instructions.') 440 | return 441 | } 442 | 443 | // Check for various 401/auth error patterns, including JSON parse errors on auth responses 444 | if (e instanceof UnauthorizedError || 445 | String(msg).includes('AUTH_REQUIRED') || 446 | String(msg).includes('401') || 447 | String(msg).includes('Unauthorized') || 448 | String(msg).includes('missing required Authorization header') || 449 | (String(msg).includes('JSON') && String(msg).includes('parse'))) { 450 | console.log('[MCP] Auth error detected, starting OAuth flow...') 451 | try { 452 | setState('authenticating') 453 | // GitHub Copilot uses /mcp/ path in its OAuth metadata URLs 454 | // The SDK should extract this from the WWW-Authenticate header, but for manual auth 455 | // we need to provide the correct URL 456 | let resourceMetadataUrl 457 | if (url.includes('githubcopilot')) { 458 | // GitHub Copilot specific path 459 | resourceMetadataUrl = 'https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp/' 460 | } else { 461 | // Default pattern for other servers 462 | const serverOrigin = new URL(url).origin 463 | resourceMetadataUrl = new URL('/.well-known/oauth-protected-resource', serverOrigin).toString() 464 | } 465 | console.log('[MCP] Attempting OAuth with resourceMetadataUrl:', resourceMetadataUrl) 466 | const result = await auth(ensureProvider(), { 467 | serverUrl: url, 468 | resourceMetadataUrl, 469 | fetchFn: proxyFetch 470 | }) 471 | console.log('[MCP] Auth result:', result) 472 | if (!isMountedRef.current) return 473 | if (result === 'AUTHORIZED') { 474 | connectingRef.current = false 475 | connect() 476 | return 477 | } 478 | if (result === 'REDIRECT') { 479 | // Wait for popup callback listener to reconnect 480 | const authUrl = providerRef.current.getLastAttemptedAuthUrl?.() 481 | console.log('[MCP] OAuth redirect required, auth URL:', authUrl) 482 | setAuthUrl(authUrl) 483 | return 484 | } 485 | } catch (authErr) { 486 | console.error('[MCP] OAuth failed:', authErr) 487 | if (isMountedRef.current) { 488 | setState('failed') 489 | const errorMsg = authErr && (authErr.message || String(authErr)) 490 | setError(errorMsg) 491 | // If it's a registration error, provide more context 492 | if (errorMsg && errorMsg.includes('register')) { 493 | console.log('[MCP] Note: This server may require pre-registered OAuth clients. Dynamic registration failed.') 494 | } 495 | } 496 | return 497 | } 498 | } 499 | if (isMountedRef.current) { 500 | setState('failed') 501 | setError(msg) 502 | } 503 | } finally { 504 | connectingRef.current = false 505 | } 506 | }, [url, transportType, autoReconnect, customHeaders, clientConfig, ensureProvider, state]) 507 | 508 | const authenticate = useCallback(async () => { 509 | try { 510 | setState('authenticating') 511 | // GitHub Copilot uses /mcp/ path in its OAuth metadata URLs 512 | let resourceMetadataUrl 513 | if (url.includes('githubcopilot')) { 514 | // GitHub Copilot specific path 515 | resourceMetadataUrl = 'https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp/' 516 | } else { 517 | // Default pattern for other servers 518 | const serverOrigin = new URL(url).origin 519 | resourceMetadataUrl = new URL('/.well-known/oauth-protected-resource', serverOrigin).toString() 520 | } 521 | console.log('[MCP] Manual auth triggered, resourceMetadataUrl:', resourceMetadataUrl) 522 | const res = await auth(ensureProvider(), { 523 | serverUrl: url, 524 | resourceMetadataUrl, 525 | fetchFn: proxyFetch 526 | }) 527 | console.log('[MCP] Manual auth result:', res) 528 | if (!isMountedRef.current) return 529 | if (res === 'AUTHORIZED') { 530 | connect() 531 | } else if (res === 'REDIRECT') { 532 | const authUrl = providerRef.current.getLastAttemptedAuthUrl?.() 533 | console.log('[MCP] Manual auth redirect, URL:', authUrl) 534 | setAuthUrl(authUrl) 535 | // If auto redirect is disabled, open popup manually 536 | if (providerRef.current?.preventAutoAuth && authUrl && typeof window !== 'undefined') { 537 | const features = 'width=800,height=600' 538 | const popup = window.open(authUrl, `mcp_auth_${providerRef.current.serverUrlHash}`, features) 539 | if (popup && providerRef.current.onPopupWindow) { 540 | try { providerRef.current.onPopupWindow(authUrl, features, popup) } catch {} 541 | } 542 | } 543 | } 544 | } catch (e) { 545 | console.error('[MCP] Manual auth failed:', e) 546 | if (isMountedRef.current) { setState('failed'); setError(e && (e.message || String(e))) } 547 | } 548 | }, [url, ensureProvider, connect]) 549 | 550 | const callTool = useCallback(async (name, args) => { 551 | if (!clientRef.current) throw new Error('MCP client not connected') 552 | return clientRef.current.callTool({ name, arguments: args }) 553 | }, []) 554 | 555 | const retry = useCallback(() => { if (stateRef.current === 'failed') connect() }, [connect]) 556 | 557 | useEffect(() => { 558 | isMountedRef.current = true 559 | connect() 560 | return () => { isMountedRef.current = false; disconnect() } 561 | }, [url]) 562 | 563 | useEffect(() => { 564 | const handler = (event) => { 565 | if (event.origin !== (typeof window !== 'undefined' ? window.location.origin : '')) return 566 | if (event.data?.type === 'mcp_auth_callback') { 567 | console.log('[MCP] Received auth callback:', event.data) 568 | if (event.data.success) { 569 | console.log('[MCP] Auth successful, reconnecting...') 570 | // Reset state and reconnect with auth 571 | setState('connecting') 572 | setError(undefined) 573 | connect() 574 | } else { 575 | setState('failed') 576 | setError(event.data.error || 'Authentication failed') 577 | } 578 | } 579 | } 580 | if (typeof window !== 'undefined') window.addEventListener('message', handler) 581 | return () => { if (typeof window !== 'undefined') window.removeEventListener('message', handler) } 582 | }, [connect]) 583 | 584 | return { state, tools, error, authUrl, callTool, retry, disconnect, authenticate } 585 | } 586 | 587 | export { ProxyingBrowserOAuthProvider, proxyFetch } 588 | 589 | 590 | -------------------------------------------------------------------------------- /web/src/ui/FileManager.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import React, { useState, useCallback, useEffect } from 'react' 3 | 4 | const FILES_STORAGE_KEY = 'autosheet.files.v2' 5 | const CURRENT_FILE_KEY = 'autosheet.currentFile.v2' 6 | 7 | // File format structure 8 | function createNewFile(name) { 9 | return { 10 | id: crypto.randomUUID(), 11 | name: name || 'Untitled', 12 | createdAt: Date.now(), 13 | updatedAt: Date.now(), 14 | data: { 15 | sheets: {}, 16 | activeSheet: 'Sheet1', 17 | cellFormats: {}, 18 | sizes: {}, 19 | scripts: [], 20 | activeScriptId: null, 21 | chats: [], 22 | activeChatId: null, 23 | // UI state 24 | showSheet: true, 25 | showScripts: true, 26 | showChat: true, 27 | paneWidths: [40, 30, 30], 28 | systemPrompt: 'You are a helpful assistant.', 29 | model: 'openai/gpt-oss-20b', 30 | } 31 | } 32 | } 33 | 34 | // Load all files from localStorage 35 | export function loadFiles() { 36 | try { 37 | const raw = localStorage.getItem(FILES_STORAGE_KEY) 38 | if (raw) { 39 | const files = JSON.parse(raw) 40 | if (Array.isArray(files) && files.length > 0) { 41 | return files 42 | } 43 | } 44 | } catch (e) { 45 | console.error('Failed to load files:', e) 46 | } 47 | return [] 48 | } 49 | 50 | // Save files to localStorage 51 | export function saveFiles(files) { 52 | try { 53 | const data = JSON.stringify(files) 54 | localStorage.setItem(FILES_STORAGE_KEY, data) 55 | // Verify the save 56 | const saved = localStorage.getItem(FILES_STORAGE_KEY) 57 | if (!saved) { 58 | throw new Error('Failed to save files - localStorage returned null') 59 | } 60 | return true 61 | } catch (e) { 62 | alert('Failed to save files: ' + e.message) 63 | return false 64 | } 65 | } 66 | 67 | // Get current file ID 68 | export function getCurrentFileId() { 69 | try { 70 | return localStorage.getItem(CURRENT_FILE_KEY) || null 71 | } catch { 72 | return null 73 | } 74 | } 75 | 76 | // Set current file ID 77 | export function setCurrentFileId(fileId) { 78 | try { 79 | if (fileId) { 80 | localStorage.setItem(CURRENT_FILE_KEY, fileId) 81 | } else { 82 | localStorage.removeItem(CURRENT_FILE_KEY) 83 | } 84 | } catch (e) { 85 | console.error('Failed to set current file:', e) 86 | } 87 | } 88 | 89 | // Collect all current state from various localStorage keys 90 | export function collectCurrentState() { 91 | const state = { 92 | sheets: {}, 93 | activeSheet: 'Sheet1', 94 | cellFormats: {}, 95 | sizes: {}, 96 | scripts: [], 97 | activeScriptId: null, 98 | chats: [], 99 | activeChatId: null, 100 | showSheet: true, 101 | showScripts: true, 102 | showChat: true, 103 | paneWidths: [40, 30, 30], 104 | systemPrompt: 'You are a helpful assistant.', 105 | model: 'openai/gpt-oss-20b', 106 | } 107 | 108 | try { 109 | // Sheets data 110 | const sheetsRaw = localStorage.getItem('autosheet.sheets.v1') 111 | if (sheetsRaw) { 112 | const parsed = JSON.parse(sheetsRaw) 113 | state.sheets = parsed.sheets || {} 114 | state.activeSheet = parsed.activeSheet || 'Sheet1' 115 | state.cellFormats = parsed.formats || {} 116 | } 117 | 118 | // Cell formats (if stored separately) 119 | const formatsRaw = localStorage.getItem('autosheet.cellFormats.v1') 120 | if (formatsRaw) { 121 | state.cellFormats = JSON.parse(formatsRaw) || {} 122 | } 123 | 124 | // Sizes 125 | const sizesRaw = localStorage.getItem('autosheet.sizes.v1') 126 | if (sizesRaw) { 127 | state.sizes = JSON.parse(sizesRaw) || {} 128 | } 129 | 130 | // Scripts 131 | const scriptsRaw = localStorage.getItem('autosheet.scriptFiles.v1') 132 | if (scriptsRaw) { 133 | state.scripts = JSON.parse(scriptsRaw) || [] 134 | } 135 | 136 | // Chats 137 | const chatsRaw = localStorage.getItem('autosheet.chats.v1') 138 | if (chatsRaw) { 139 | state.chats = JSON.parse(chatsRaw) || [] 140 | } 141 | state.activeChatId = localStorage.getItem('autosheet.chats.activeId') || null 142 | 143 | // UI state 144 | state.showSheet = localStorage.getItem('autosheet.showSheet') !== 'false' 145 | state.showScripts = localStorage.getItem('autosheet.showScripts') !== 'false' 146 | state.showChat = localStorage.getItem('autosheet.showChat') !== 'false' 147 | 148 | const paneWidthsRaw = localStorage.getItem('autosheet.paneWidths') 149 | if (paneWidthsRaw) { 150 | try { 151 | state.paneWidths = JSON.parse(paneWidthsRaw) 152 | } catch {} 153 | } 154 | 155 | state.systemPrompt = localStorage.getItem('autosheet.chat.systemPrompt') || 'You are a helpful assistant.' 156 | state.model = localStorage.getItem('autosheet.chat.model') || 'openai/gpt-oss-20b' 157 | 158 | } catch (e) { 159 | console.error('Failed to collect current state:', e) 160 | } 161 | 162 | return state 163 | } 164 | 165 | // Apply file state to localStorage 166 | export function applyFileState(fileData) { 167 | try { 168 | // Sheets and formats 169 | localStorage.setItem('autosheet.sheets.v1', JSON.stringify({ 170 | sheets: fileData.sheets || {}, 171 | activeSheet: fileData.activeSheet || 'Sheet1', 172 | formats: fileData.cellFormats || {} 173 | })) 174 | localStorage.setItem('autosheet.activeSheet', fileData.activeSheet || 'Sheet1') 175 | localStorage.setItem('autosheet.cellFormats.v1', JSON.stringify(fileData.cellFormats || {})) 176 | localStorage.setItem('autosheet.sizes.v1', JSON.stringify(fileData.sizes || {})) 177 | 178 | // Scripts 179 | if (fileData.scripts && fileData.scripts.length > 0) { 180 | localStorage.setItem('autosheet.scriptFiles.v1', JSON.stringify(fileData.scripts)) 181 | } else { 182 | localStorage.removeItem('autosheet.scriptFiles.v1') 183 | } 184 | 185 | // Chats 186 | if (fileData.chats && fileData.chats.length > 0) { 187 | localStorage.setItem('autosheet.chats.v1', JSON.stringify(fileData.chats)) 188 | } else { 189 | localStorage.removeItem('autosheet.chats.v1') 190 | } 191 | if (fileData.activeChatId) { 192 | localStorage.setItem('autosheet.chats.activeId', fileData.activeChatId) 193 | } else { 194 | localStorage.removeItem('autosheet.chats.activeId') 195 | } 196 | 197 | // UI state 198 | localStorage.setItem('autosheet.showSheet', String(fileData.showSheet !== false)) 199 | localStorage.setItem('autosheet.showScripts', String(fileData.showScripts !== false)) 200 | localStorage.setItem('autosheet.showChat', String(fileData.showChat !== false)) 201 | 202 | if (fileData.paneWidths) { 203 | localStorage.setItem('autosheet.paneWidths', JSON.stringify(fileData.paneWidths)) 204 | } 205 | 206 | localStorage.setItem('autosheet.chat.systemPrompt', fileData.systemPrompt || 'You are a helpful assistant.') 207 | localStorage.setItem('autosheet.chat.model', fileData.model || 'openai/gpt-oss-20b') 208 | 209 | } catch (e) { 210 | console.error('Failed to apply file state:', e) 211 | throw e 212 | } 213 | } 214 | 215 | export default function FileManager({ isOpen, onClose, currentFileName, onFileChange }) { 216 | const [files, setFiles] = useState(() => loadFiles()) 217 | const [selectedFileId, setSelectedFileId] = useState(null) 218 | const [renamingId, setRenamingId] = useState(null) 219 | const [renamingValue, setRenamingValue] = useState('') 220 | const [showNewFileDialog, setShowNewFileDialog] = useState(false) 221 | const [newFileName, setNewFileName] = useState('') 222 | 223 | // Refresh files when dialog opens 224 | useEffect(() => { 225 | if (isOpen) { 226 | setFiles(loadFiles()) 227 | setSelectedFileId(getCurrentFileId()) 228 | } 229 | }, [isOpen]) 230 | 231 | const handleNewFile = useCallback(() => { 232 | // Check if there's unsaved work 233 | const currentId = getCurrentFileId() 234 | if (!currentId && currentFileName) { 235 | // There's work but no saved file 236 | if (!window.confirm('You have unsaved work. Creating a new file will save your current work first. Continue?')) { 237 | return 238 | } 239 | } 240 | setNewFileName('Untitled') 241 | setShowNewFileDialog(true) 242 | }, [currentFileName]) 243 | 244 | const createFile = useCallback(() => { 245 | try { 246 | // First, save current work if there's any unsaved content 247 | const currentId = getCurrentFileId() 248 | let latestFiles = loadFiles() // Get fresh files from localStorage 249 | 250 | if (currentId) { 251 | // Update existing file with current state before creating new one 252 | const state = collectCurrentState() 253 | latestFiles = latestFiles.map(f => 254 | f.id === currentId 255 | ? { ...f, data: state, updatedAt: Date.now() } 256 | : f 257 | ) 258 | } else if (currentFileName) { 259 | // If there's work but no current file ID, save it as a new file first 260 | const state = collectCurrentState() 261 | const currentFile = { 262 | ...createNewFile(currentFileName), 263 | data: state 264 | } 265 | latestFiles = [...latestFiles, currentFile] 266 | } 267 | 268 | // Now create the new file 269 | const name = newFileName.trim() || 'Untitled' 270 | const file = createNewFile(name) 271 | const updatedFiles = [...latestFiles, file] 272 | 273 | // Save all files including the new one 274 | const saveSuccess = saveFiles(updatedFiles) 275 | if (!saveSuccess) { 276 | return 277 | } 278 | setFiles(updatedFiles) 279 | 280 | // Apply the new file's clean state to localStorage 281 | applyFileState(file.data) 282 | setCurrentFileId(file.id) 283 | 284 | // Close dialog first 285 | setShowNewFileDialog(false) 286 | setNewFileName('') 287 | onFileChange(file.name, file.id) 288 | onClose() // Close the FileManager dialog 289 | 290 | // Add a small delay to ensure all localStorage operations complete 291 | setTimeout(() => { 292 | // Force a page reload to ensure all components pick up the new state 293 | window.location.reload() 294 | }, 100) 295 | } catch (error) { 296 | alert('Failed to create file: ' + error.message) 297 | } 298 | }, [newFileName, onFileChange, currentFileName, onClose]) 299 | 300 | const handleSave = useCallback(() => { 301 | const currentId = getCurrentFileId() 302 | const state = collectCurrentState() 303 | 304 | if (currentId) { 305 | // Update existing file 306 | const updatedFiles = files.map(f => 307 | f.id === currentId 308 | ? { ...f, data: state, updatedAt: Date.now() } 309 | : f 310 | ) 311 | setFiles(updatedFiles) 312 | saveFiles(updatedFiles) 313 | } else { 314 | // Create new file for current work 315 | const name = currentFileName || 'Untitled' 316 | const file = { 317 | ...createNewFile(name), 318 | data: state 319 | } 320 | const updatedFiles = [...files, file] 321 | setFiles(updatedFiles) 322 | saveFiles(updatedFiles) 323 | setCurrentFileId(file.id) 324 | onFileChange(file.name, file.id) 325 | } 326 | onClose() 327 | }, [files, currentFileName, onFileChange, onClose]) 328 | 329 | const handleSaveAs = useCallback(() => { 330 | setNewFileName(currentFileName || 'Untitled Copy') 331 | setShowNewFileDialog(true) 332 | }, [currentFileName]) 333 | 334 | const createSaveAsFile = useCallback(() => { 335 | const name = newFileName.trim() || 'Untitled' 336 | const state = collectCurrentState() 337 | const file = { 338 | ...createNewFile(name), 339 | data: state 340 | } 341 | const updatedFiles = [...files, file] 342 | setFiles(updatedFiles) 343 | saveFiles(updatedFiles) 344 | setCurrentFileId(file.id) 345 | onFileChange(file.name, file.id) 346 | setShowNewFileDialog(false) 347 | setNewFileName('') 348 | onClose() 349 | }, [files, newFileName, onFileChange, onClose]) 350 | 351 | const handleLoad = useCallback((fileId) => { 352 | const file = files.find(f => f.id === fileId) 353 | if (!file) return 354 | 355 | if (!window.confirm(`Load "${file.name}"? Any unsaved changes will be lost.`)) { 356 | return 357 | } 358 | 359 | try { 360 | applyFileState(file.data) 361 | setCurrentFileId(file.id) 362 | onFileChange(file.name, file.id) 363 | 364 | // Small delay then reload 365 | setTimeout(() => { 366 | // Force a page reload to ensure all components pick up the new state 367 | window.location.reload() 368 | }, 50) 369 | } catch (e) { 370 | alert('Failed to load file: ' + e.message) 371 | } 372 | }, [files, onFileChange]) 373 | 374 | const handleDelete = useCallback((fileId) => { 375 | const file = files.find(f => f.id === fileId) 376 | if (!file) return 377 | 378 | if (!window.confirm(`Delete "${file.name}"? This cannot be undone.`)) { 379 | return 380 | } 381 | 382 | const updatedFiles = files.filter(f => f.id !== fileId) 383 | setFiles(updatedFiles) 384 | saveFiles(updatedFiles) 385 | 386 | // If deleting current file, clear current file ID 387 | if (getCurrentFileId() === fileId) { 388 | setCurrentFileId(null) 389 | onFileChange(null, null) 390 | } 391 | }, [files, onFileChange]) 392 | 393 | const handleRename = useCallback((fileId) => { 394 | const file = files.find(f => f.id === fileId) 395 | if (!file) return 396 | setRenamingId(fileId) 397 | setRenamingValue(file.name) 398 | }, [files]) 399 | 400 | const commitRename = useCallback(() => { 401 | if (!renamingId || !renamingValue.trim()) { 402 | setRenamingId(null) 403 | return 404 | } 405 | 406 | const updatedFiles = files.map(f => 407 | f.id === renamingId 408 | ? { ...f, name: renamingValue.trim(), updatedAt: Date.now() } 409 | : f 410 | ) 411 | setFiles(updatedFiles) 412 | saveFiles(updatedFiles) 413 | 414 | // Update current file name if renaming current file 415 | if (getCurrentFileId() === renamingId) { 416 | const file = updatedFiles.find(f => f.id === renamingId) 417 | onFileChange(file.name, file.id) 418 | } 419 | 420 | setRenamingId(null) 421 | setRenamingValue('') 422 | }, [files, renamingId, renamingValue, onFileChange]) 423 | 424 | const formatDate = (timestamp) => { 425 | return new Date(timestamp).toLocaleString() 426 | } 427 | 428 | if (!isOpen) return null 429 | 430 | return ( 431 |
432 |
433 |
434 |

File Manager

435 | 436 |
437 | 438 |
439 | 440 | 441 | 442 |
443 | 444 |
445 | {files.length === 0 ? ( 446 |
No saved files
447 | ) : ( 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | {files.map(file => ( 458 | setSelectedFileId(file.id)} 462 | > 463 | 484 | 485 | 501 | 502 | ))} 503 | 504 |
NameModifiedActions
464 | {renamingId === file.id ? ( 465 | setRenamingValue(e.target.value)} 469 | onBlur={commitRename} 470 | onKeyDown={(e) => { 471 | if (e.key === 'Enter') commitRename() 472 | if (e.key === 'Escape') setRenamingId(null) 473 | }} 474 | onClick={(e) => e.stopPropagation()} 475 | autoFocus 476 | /> 477 | ) : ( 478 | 479 | {file.name}.as 480 | {getCurrentFileId() === file.id && (current)} 481 | 482 | )} 483 | {formatDate(file.updatedAt)} 486 |
487 | 490 | 493 | 499 |
500 |
505 | )} 506 |
507 | 508 | {showNewFileDialog && ( 509 |
510 |
511 |

{newFileName.includes('Copy') ? 'Save As' : 'New File'}

512 | setNewFileName(e.target.value)} 516 | placeholder="Enter file name" 517 | autoFocus 518 | onKeyDown={(e) => { 519 | if (e.key === 'Enter') { 520 | if (newFileName.includes('Copy')) { 521 | createSaveAsFile() 522 | } else { 523 | createFile() 524 | } 525 | } 526 | if (e.key === 'Escape') { 527 | setShowNewFileDialog(false) 528 | setNewFileName('') 529 | } 530 | }} 531 | /> 532 |
533 | 542 | 545 |
546 |
547 |
548 | )} 549 |
550 | 551 | 787 |
788 | ) 789 | } 790 | -------------------------------------------------------------------------------- /web/src/ui/Grid.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import React, { useRef, useEffect, useState } from 'react' 3 | 4 | export function Grid({ rows, cols, selection, setSelection, getCellDisplay, getCellRaw, getCellFormat, onEdit, onApplyFormat, initialColWidths, initialRowHeights, onColumnWidthsChange, onRowHeightsChange }) { 5 | const tableRef = useRef(null) 6 | const [editing, setEditing] = useState(null) 7 | const [editValue, setEditValue] = useState('') 8 | const hasCommittedRef = useRef(false) 9 | const isSelectingRef = useRef(false) 10 | const dragStartRef = useRef(null) 11 | const [selectionRect, setSelectionRect] = useState(null) 12 | const lastCopyRef = useRef(null) 13 | const lastCopyTextRef = useRef('') 14 | 15 | const DEFAULT_COL_WIDTH = 100 16 | const DEFAULT_ROW_HEIGHT = 22 17 | const ROW_HEADER_WIDTH = 40 18 | 19 | const [colWidths, setColWidths] = useState(() => { 20 | if (Array.isArray(initialColWidths)) { 21 | const base = initialColWidths.map((n) => (Number.isFinite(n) ? Math.max(10, n) : DEFAULT_COL_WIDTH)) 22 | return base.length >= cols 23 | ? base.slice(0, cols) 24 | : [...base, ...Array.from({ length: cols - base.length }, () => DEFAULT_COL_WIDTH)] 25 | } 26 | return Array.from({ length: cols }, () => DEFAULT_COL_WIDTH) 27 | }) 28 | const [rowHeights, setRowHeights] = useState(() => { 29 | if (Array.isArray(initialRowHeights)) { 30 | const base = initialRowHeights.map((n) => (Number.isFinite(n) ? Math.max(18, n) : DEFAULT_ROW_HEIGHT)) 31 | return base.length >= rows 32 | ? base.slice(0, rows) 33 | : [...base, ...Array.from({ length: rows - base.length }, () => DEFAULT_ROW_HEIGHT)] 34 | } 35 | return Array.from({ length: rows }, () => DEFAULT_ROW_HEIGHT) 36 | }) 37 | 38 | useEffect(() => { 39 | setColWidths((prev) => { 40 | if (prev.length === cols) return prev 41 | if (prev.length < cols) return [...prev, ...Array.from({ length: cols - prev.length }, () => DEFAULT_COL_WIDTH)] 42 | return prev.slice(0, cols) 43 | }) 44 | }, [cols]) 45 | 46 | useEffect(() => { 47 | setRowHeights((prev) => { 48 | if (prev.length === rows) return prev 49 | if (prev.length < rows) return [...prev, ...Array.from({ length: rows - prev.length }, () => DEFAULT_ROW_HEIGHT)] 50 | return prev.slice(0, rows) 51 | }) 52 | }, [rows]) 53 | 54 | // Emit size changes for persistence 55 | useEffect(() => { 56 | if (typeof onColumnWidthsChange === 'function' && Array.isArray(colWidths) && colWidths.length === cols) { 57 | onColumnWidthsChange(colWidths) 58 | } 59 | }, [colWidths, cols, onColumnWidthsChange]) 60 | 61 | useEffect(() => { 62 | if (typeof onRowHeightsChange === 'function' && Array.isArray(rowHeights) && rowHeights.length === rows) { 63 | onRowHeightsChange(rowHeights) 64 | } 65 | }, [rowHeights, rows, onRowHeightsChange]) 66 | 67 | const startEditing = (row, col, initialValue) => { 68 | hasCommittedRef.current = false 69 | setEditing({ row, col }) 70 | setEditValue(initialValue ?? '') 71 | } 72 | 73 | const commitEditing = (row, col, value, move) => { 74 | if (hasCommittedRef.current) return 75 | hasCommittedRef.current = true 76 | onEdit(row, col, value) 77 | setEditing(null) 78 | if (move === 'down') { 79 | setSelection({ row: Math.min(rows, row + 1), col }) 80 | } else if (move === 'up') { 81 | setSelection({ row: Math.max(1, row - 1), col }) 82 | } else if (move === 'right') { 83 | setSelection({ row, col: Math.min(cols, col + 1) }) 84 | } else if (move === 'left') { 85 | setSelection({ row, col: Math.max(1, col - 1) }) 86 | } 87 | // Ensure grid regains focus so arrow keys work 88 | setTimeout(() => { 89 | if (tableRef.current) tableRef.current.focus() 90 | }, 0) 91 | } 92 | 93 | const cancelEditing = () => { 94 | if (hasCommittedRef.current) return 95 | hasCommittedRef.current = true 96 | setEditing(null) 97 | setTimeout(() => { 98 | if (tableRef.current) tableRef.current.focus() 99 | }, 0) 100 | } 101 | 102 | useEffect(() => { 103 | const el = tableRef.current 104 | if (!el) return 105 | const handleKey = (e) => { 106 | // Ignore grid navigation when typing inside an input 107 | if (e.target && e.target.tagName === 'INPUT') return 108 | let { row, col, focus } = selection 109 | const isMeta = e.metaKey || (e.ctrlKey && !e.shiftKey && !e.altKey) 110 | // Handle formatting shortcuts 111 | if (isMeta && !e.shiftKey && !e.altKey) { 112 | if (e.key === 'b' || e.key === 'B') { 113 | e.preventDefault() 114 | onApplyFormat && onApplyFormat('bold') 115 | return 116 | } 117 | if (e.key === 'i' || e.key === 'I') { 118 | e.preventDefault() 119 | onApplyFormat && onApplyFormat('italic') 120 | return 121 | } 122 | if (e.key === 'u' || e.key === 'U') { 123 | e.preventDefault() 124 | onApplyFormat && onApplyFormat('underline') 125 | return 126 | } 127 | } 128 | if (e.key === 'Enter') { 129 | e.preventDefault() 130 | const raw = getCellRaw ? getCellRaw(row, col) : '' 131 | startEditing(row, col, raw ?? '') 132 | return 133 | } 134 | if (e.key === '=') { 135 | e.preventDefault() 136 | startEditing(row, col, '=') 137 | return 138 | } 139 | if (e.key === 'Tab') { 140 | e.preventDefault() 141 | if (e.shiftKey) { 142 | setSelection({ row, col: Math.max(1, col - 1) }) 143 | } else { 144 | setSelection({ row, col: Math.min(cols, col + 1) }) 145 | } 146 | return 147 | } 148 | if (e.key === 'F2') { 149 | e.preventDefault() 150 | const raw = getCellRaw ? getCellRaw(row, col) : '' 151 | startEditing(row, col, raw ?? '') 152 | return 153 | } 154 | if (e.key === 'Delete' || e.key === 'Backspace') { 155 | e.preventDefault() 156 | // Clear all cells in selection range if present; otherwise only the anchor cell 157 | const hasRange = focus && (focus.row !== row || focus.col !== col) 158 | if (hasRange) { 159 | const top = Math.max(1, Math.min(row, focus.row)) 160 | const left = Math.max(1, Math.min(col, focus.col)) 161 | const bottom = Math.min(rows, Math.max(row, focus.row)) 162 | const right = Math.min(cols, Math.max(col, focus.col)) 163 | for (let r = top; r <= bottom; r++) { 164 | for (let c = left; c <= right; c++) { 165 | onEdit(r, c, null) 166 | } 167 | } 168 | } else { 169 | onEdit(row, col, null) 170 | } 171 | return 172 | } 173 | if (!e.ctrlKey && !e.metaKey && !e.altKey) { 174 | const ch = e.key 175 | if (/^[a-z0-9]$/i.test(ch)) { 176 | e.preventDefault() 177 | startEditing(row, col, ch) 178 | return 179 | } 180 | } 181 | const hasRange = focus && (focus.row !== row || focus.col !== col) 182 | const move = (dr, dc) => { 183 | const nextRow = Math.max(1, Math.min(rows, row + dr)) 184 | const nextCol = Math.max(1, Math.min(cols, col + dc)) 185 | setSelection({ row: nextRow, col: nextCol }) 186 | } 187 | const isNonEmpty = (r, c) => { 188 | if (!getCellRaw) return false 189 | const v = getCellRaw(r, c) 190 | return v != null && String(v) !== '' 191 | } 192 | const jumpHorizontal = (dir) => { 193 | if (dir > 0) { 194 | const next = col + 1 195 | if (next <= cols && isNonEmpty(row, next)) { 196 | // Move to the end of the contiguous non-empty block to the right 197 | let c = next 198 | while (c + 1 <= cols && isNonEmpty(row, c + 1)) c++ 199 | setSelection({ row, col: c }) 200 | return 201 | } 202 | // Otherwise, move to the next non-empty cell; if none, to the last column 203 | let c = next 204 | while (c <= cols && !isNonEmpty(row, c)) c++ 205 | setSelection({ row, col: c <= cols ? c : cols }) 206 | } else { 207 | const prev = col - 1 208 | if (prev >= 1 && isNonEmpty(row, prev)) { 209 | // Move to the start of the contiguous non-empty block to the left 210 | let c = prev 211 | while (c - 1 >= 1 && isNonEmpty(row, c - 1)) c-- 212 | setSelection({ row, col: c }) 213 | return 214 | } 215 | // Otherwise, move to the previous non-empty cell; if none, to the first column 216 | let c = prev 217 | while (c >= 1 && !isNonEmpty(row, c)) c-- 218 | setSelection({ row, col: c >= 1 ? c : 1 }) 219 | } 220 | } 221 | const jumpVertical = (dir) => { 222 | if (dir > 0) { 223 | const next = row + 1 224 | if (next <= rows && isNonEmpty(next, col)) { 225 | // Move to the end of the contiguous non-empty block downward 226 | let r = next 227 | while (r + 1 <= rows && isNonEmpty(r + 1, col)) r++ 228 | setSelection({ row: r, col }) 229 | return 230 | } 231 | // Otherwise, move to the next non-empty cell; if none, to the last row 232 | let r = next 233 | while (r <= rows && !isNonEmpty(r, col)) r++ 234 | setSelection({ row: r <= rows ? r : rows, col }) 235 | } else { 236 | const prev = row - 1 237 | if (prev >= 1 && isNonEmpty(prev, col)) { 238 | // Move to the start of the contiguous non-empty block upward 239 | let r = prev 240 | while (r - 1 >= 1 && isNonEmpty(r - 1, col)) r-- 241 | setSelection({ row: r, col }) 242 | return 243 | } 244 | // Otherwise, move to the previous non-empty cell; if none, to the first row 245 | let r = prev 246 | while (r >= 1 && !isNonEmpty(r, col)) r-- 247 | setSelection({ row: r >= 1 ? r : 1, col }) 248 | } 249 | } 250 | // Expand selection using the same jump logic, extending from current focus (or anchor if none) 251 | const expandJumpHorizontal = (dir) => { 252 | const base = { row, col } 253 | const cur = (focus && (focus.row || focus.col)) ? focus : base 254 | if (dir > 0) { 255 | const next = cur.col + 1 256 | if (next <= cols && isNonEmpty(cur.row, next)) { 257 | let c = next 258 | while (c + 1 <= cols && isNonEmpty(cur.row, c + 1)) c++ 259 | setSelection({ row, col, focus: { row: cur.row, col: c } }) 260 | return 261 | } 262 | let c = next 263 | while (c <= cols && !isNonEmpty(cur.row, c)) c++ 264 | setSelection({ row, col, focus: { row: cur.row, col: c <= cols ? c : cols } }) 265 | } else { 266 | const prev = cur.col - 1 267 | if (prev >= 1 && isNonEmpty(cur.row, prev)) { 268 | let c = prev 269 | while (c - 1 >= 1 && isNonEmpty(cur.row, c - 1)) c-- 270 | setSelection({ row, col, focus: { row: cur.row, col: c } }) 271 | return 272 | } 273 | let c = prev 274 | while (c >= 1 && !isNonEmpty(cur.row, c)) c-- 275 | setSelection({ row, col, focus: { row: cur.row, col: c >= 1 ? c : 1 } }) 276 | } 277 | } 278 | const expandJumpVertical = (dir) => { 279 | const base = { row, col } 280 | const cur = (focus && (focus.row || focus.col)) ? focus : base 281 | if (dir > 0) { 282 | const next = cur.row + 1 283 | if (next <= rows && isNonEmpty(next, cur.col)) { 284 | let r = next 285 | while (r + 1 <= rows && isNonEmpty(r + 1, cur.col)) r++ 286 | setSelection({ row, col, focus: { row: r, col: cur.col } }) 287 | return 288 | } 289 | let r = next 290 | while (r <= rows && !isNonEmpty(r, cur.col)) r++ 291 | setSelection({ row, col, focus: { row: r <= rows ? r : rows, col: cur.col } }) 292 | } else { 293 | const prev = cur.row - 1 294 | if (prev >= 1 && isNonEmpty(prev, cur.col)) { 295 | let r = prev 296 | while (r - 1 >= 1 && isNonEmpty(r - 1, cur.col)) r-- 297 | setSelection({ row, col, focus: { row: r, col: cur.col } }) 298 | return 299 | } 300 | let r = prev 301 | while (r >= 1 && !isNonEmpty(r, cur.col)) r-- 302 | setSelection({ row, col, focus: { row: r >= 1 ? r : 1, col: cur.col } }) 303 | } 304 | } 305 | const expand = (dr, dc) => { 306 | const base = { row, col } 307 | const cur = focus && (focus.row || focus.col) ? focus : base 308 | const nextFocus = { 309 | row: Math.max(1, Math.min(rows, (cur.row ?? base.row) + dr)), 310 | col: Math.max(1, Math.min(cols, (cur.col ?? base.col) + dc)), 311 | } 312 | setSelection({ row, col, focus: nextFocus }) 313 | } 314 | if (e.key === 'ArrowUp') { 315 | e.preventDefault() 316 | if (isMeta && e.shiftKey) expandJumpVertical(-1) 317 | else if (isMeta) jumpVertical(-1) 318 | else if (e.shiftKey) expand(-1, 0) 319 | else if (hasRange) move(-1, 0) 320 | else move(-1, 0) 321 | } 322 | if (e.key === 'ArrowDown') { 323 | e.preventDefault() 324 | if (isMeta && e.shiftKey) expandJumpVertical(1) 325 | else if (isMeta) jumpVertical(1) 326 | else if (e.shiftKey) expand(1, 0) 327 | else if (hasRange) move(1, 0) 328 | else move(1, 0) 329 | } 330 | if (e.key === 'ArrowLeft') { 331 | e.preventDefault() 332 | if (isMeta && e.shiftKey) expandJumpHorizontal(-1) 333 | else if (isMeta) jumpHorizontal(-1) 334 | else if (e.shiftKey) expand(0, -1) 335 | else if (hasRange) move(0, -1) 336 | else move(0, -1) 337 | } 338 | if (e.key === 'ArrowRight') { 339 | e.preventDefault() 340 | if (isMeta && e.shiftKey) expandJumpHorizontal(1) 341 | else if (isMeta) jumpHorizontal(1) 342 | else if (e.shiftKey) expand(0, 1) 343 | else if (hasRange) move(0, 1) 344 | else move(0, 1) 345 | } 346 | } 347 | el.addEventListener('keydown', handleKey) 348 | return () => el.removeEventListener('keydown', handleKey) 349 | }, [selection, setSelection, rows, cols, getCellRaw, onEdit, onApplyFormat]) 350 | 351 | // Keep active cell in view when selection changes 352 | useEffect(() => { 353 | const gridEl = tableRef.current 354 | if (!gridEl) return 355 | const { row, col } = selection || {} 356 | if (!row || !col) return 357 | const cell = gridEl.querySelector(`td[data-r="${row}"][data-c="${col}"]`) 358 | if (!cell) return 359 | 360 | const gridRect = gridEl.getBoundingClientRect() 361 | const cellRect = cell.getBoundingClientRect() 362 | 363 | // Compute cell coordinates relative to the scrollable content space 364 | const cellTop = cellRect.top - gridRect.top + gridEl.scrollTop 365 | const cellBottom = cellRect.bottom - gridRect.top + gridEl.scrollTop 366 | const cellLeft = cellRect.left - gridRect.left + gridEl.scrollLeft 367 | const cellRight = cellRect.right - gridRect.left + gridEl.scrollLeft 368 | 369 | // Measure sticky header sizes 370 | const thead = gridEl.querySelector('thead') 371 | const headerHeight = thead ? thead.getBoundingClientRect().height : 0 372 | const rowHeaderWidth = ROW_HEADER_WIDTH 373 | 374 | // Visible bounds for body cells considering sticky regions 375 | const visibleTop = gridEl.scrollTop + headerHeight 376 | const visibleLeft = gridEl.scrollLeft + rowHeaderWidth 377 | const visibleBottom = gridEl.scrollTop + gridEl.clientHeight 378 | const visibleRight = gridEl.scrollLeft + gridEl.clientWidth 379 | 380 | let nextScrollTop = gridEl.scrollTop 381 | let nextScrollLeft = gridEl.scrollLeft 382 | 383 | // Vertical adjustment 384 | if (cellTop < visibleTop) { 385 | nextScrollTop = Math.max(0, cellTop - headerHeight) 386 | } else if (cellBottom > visibleBottom) { 387 | nextScrollTop = Math.max(0, cellBottom - gridEl.clientHeight) 388 | } 389 | 390 | // Horizontal adjustment 391 | if (cellLeft < visibleLeft) { 392 | nextScrollLeft = Math.max(0, cellLeft - rowHeaderWidth) 393 | } else if (cellRight > visibleRight) { 394 | nextScrollLeft = Math.max(0, cellRight - gridEl.clientWidth) 395 | } 396 | 397 | if (nextScrollTop !== gridEl.scrollTop || nextScrollLeft !== gridEl.scrollLeft) { 398 | gridEl.scrollTo({ top: nextScrollTop, left: nextScrollLeft }) 399 | } 400 | }, [selection, rowHeights, colWidths]) 401 | 402 | // End selection drag on global mouseup 403 | useEffect(() => { 404 | const onUp = () => { 405 | isSelectingRef.current = false 406 | dragStartRef.current = null 407 | } 408 | window.addEventListener('mouseup', onUp) 409 | return () => window.removeEventListener('mouseup', onUp) 410 | }, []) 411 | 412 | // Compute selection rectangle for outer border 413 | useEffect(() => { 414 | const gridEl = tableRef.current 415 | if (!gridEl) return 416 | const focus = selection && selection.focus 417 | const hasRange = !!(focus && (focus.row !== selection.row || focus.col !== selection.col)) 418 | const compute = () => { 419 | if (!hasRange) { setSelectionRect(null); return } 420 | const topR = Math.min(selection.row, focus.row) 421 | const leftC = Math.min(selection.col, focus.col) 422 | const bottomR = Math.max(selection.row, focus.row) 423 | const rightC = Math.max(selection.col, focus.col) 424 | const tl = gridEl.querySelector(`td[data-r="${topR}"][data-c="${leftC}"]`) 425 | const br = gridEl.querySelector(`td[data-r="${bottomR}"][data-c="${rightC}"]`) 426 | if (!tl || !br) { setSelectionRect(null); return } 427 | const gridRect = gridEl.getBoundingClientRect() 428 | const tlRect = tl.getBoundingClientRect() 429 | const brRect = br.getBoundingClientRect() 430 | const top = tlRect.top - gridRect.top + gridEl.scrollTop 431 | const left = tlRect.left - gridRect.left + gridEl.scrollLeft 432 | const width = brRect.right - tlRect.left 433 | const height = brRect.bottom - tlRect.top 434 | // Align the selection outline precisely with the gridlines by 435 | // using the exact bounding rect extents of the selected cells 436 | // without any outward expansion. 437 | setSelectionRect({ top, left, width, height }) 438 | } 439 | compute() 440 | const onScroll = () => compute() 441 | const onResize = () => compute() 442 | gridEl.addEventListener('scroll', onScroll) 443 | window.addEventListener('resize', onResize) 444 | return () => { 445 | gridEl.removeEventListener('scroll', onScroll) 446 | window.removeEventListener('resize', onResize) 447 | } 448 | }, [selection, rowHeights, colWidths]) 449 | 450 | const beginColumnResize = (colIndex, startClientX) => { 451 | const startWidth = colWidths[colIndex] 452 | const onMove = (e) => { 453 | const dx = e.clientX - startClientX 454 | const next = Math.max(10, startWidth + dx) // Allow columns as small as 10px 455 | setColWidths((w) => w.map((val, i) => (i === colIndex ? next : val))) 456 | } 457 | const onUp = () => { 458 | window.removeEventListener('mousemove', onMove) 459 | window.removeEventListener('mouseup', onUp) 460 | } 461 | window.addEventListener('mousemove', onMove) 462 | window.addEventListener('mouseup', onUp) 463 | } 464 | 465 | const beginRowResize = (rowIndex, startClientY) => { 466 | const startHeight = rowHeights[rowIndex] 467 | const onMove = (e) => { 468 | const dy = e.clientY - startClientY 469 | const next = Math.max(18, startHeight + dy) 470 | setRowHeights((h) => h.map((val, i) => (i === rowIndex ? next : val))) 471 | } 472 | const onUp = () => { 473 | window.removeEventListener('mousemove', onMove) 474 | window.removeEventListener('mouseup', onUp) 475 | } 476 | window.addEventListener('mousemove', onMove) 477 | window.addEventListener('mouseup', onUp) 478 | } 479 | 480 | const autoFitColumn = (colIndex) => { 481 | const table = tableRef.current 482 | if (!table) return 483 | let maxWidth = 10 // Minimum width for auto-fit 484 | const headerRow = table.querySelector('thead tr') 485 | if (headerRow && headerRow.children[colIndex + 1]) { 486 | const th = headerRow.children[colIndex + 1] 487 | maxWidth = Math.max(maxWidth, measureElementWidth(th, true)) 488 | } 489 | const body = table.querySelector('tbody') 490 | if (body) { 491 | for (let r = 0; r < rows; r++) { 492 | const tr = body.children[r] 493 | if (!tr) continue 494 | const td = tr.children[colIndex + 1] 495 | if (!td) continue 496 | maxWidth = Math.max(maxWidth, measureElementWidth(td, false)) 497 | } 498 | } 499 | setColWidths((w) => w.map((val, i) => (i === colIndex ? maxWidth : val))) 500 | } 501 | 502 | const autoFitRow = (rowIndex) => { 503 | const table = tableRef.current 504 | if (!table) return 505 | let maxHeight = 18 506 | const body = table.querySelector('tbody') 507 | const tr = body && body.children[rowIndex] 508 | if (!tr) return 509 | for (let c = 0; c < cols; c++) { 510 | const td = tr.children[c + 1] 511 | if (!td) continue 512 | maxHeight = Math.max(maxHeight, measureElementHeight(td)) 513 | } 514 | setRowHeights((h) => h.map((val, i) => (i === rowIndex ? maxHeight : val))) 515 | } 516 | 517 | const measureElementWidth = (cellOrHeaderEl, isHeader) => { 518 | if (isHeader) { 519 | const label = cellOrHeaderEl.querySelector('.col-header-label') || cellOrHeaderEl 520 | const width = getIntrinsicScrollWidth(label) 521 | // Add cell horizontal padding (8 left + 8 right) 522 | return Math.ceil(width + 16) 523 | } 524 | const input = cellOrHeaderEl.querySelector('input') 525 | if (input) { 526 | const text = input.value ?? '' 527 | const width = measureTextUsingCanvas(text, input) 528 | return Math.ceil(width + 16) 529 | } 530 | const textSpan = cellOrHeaderEl.querySelector('.cell-text') 531 | if (textSpan) { 532 | const width = getIntrinsicScrollWidth(textSpan) 533 | return Math.ceil(width + 16) 534 | } 535 | const content = cellOrHeaderEl 536 | const width = getIntrinsicScrollWidth(content) 537 | return Math.ceil(width + 16) 538 | } 539 | 540 | const getIntrinsicScrollWidth = (el) => { 541 | // Use scrollWidth where possible; it reflects the unwrapped content width for inline-block/nowrap 542 | return el.scrollWidth || el.clientWidth || el.offsetWidth || 0 543 | } 544 | 545 | const measureTextUsingCanvas = (text, refEl) => { 546 | const canvas = document.createElement('canvas') 547 | const ctx = canvas.getContext('2d') 548 | const cs = window.getComputedStyle(refEl) 549 | const font = cs.font || `${cs.fontStyle} ${cs.fontVariant} ${cs.fontWeight} ${cs.fontSize} / ${cs.lineHeight} ${cs.fontFamily}` 550 | ctx.font = font 551 | const metrics = ctx.measureText(text) 552 | return metrics.width 553 | } 554 | 555 | const measureElementHeight = (cellEl) => { 556 | const input = cellEl.querySelector('input') 557 | if (input) return Math.ceil(input.scrollHeight + 8) 558 | const content = cellEl.querySelector('.cell-display') || cellEl 559 | return Math.ceil(content.scrollHeight + 8) 560 | } 561 | 562 | return ( 563 |
{ 568 | // Allow native copy inside inputs 569 | if (document.activeElement && document.activeElement.tagName === 'INPUT') return 570 | const { row, col, focus } = selection 571 | const top = focus ? Math.min(row, focus.row) : row 572 | const left = focus ? Math.min(col, focus.col) : col 573 | const bottom = focus ? Math.max(row, focus.row) : row 574 | const right = focus ? Math.max(col, focus.col) : col 575 | const height = bottom - top + 1 576 | const width = right - left + 1 577 | const data = [] 578 | for (let r = 0; r < height; r++) { 579 | const arr = [] 580 | for (let c = 0; c < width; c++) { 581 | const raw = getCellRaw ? getCellRaw(top + r, left + c) : '' 582 | arr.push(raw == null ? '' : raw) 583 | } 584 | data.push(arr) 585 | } 586 | const tsv = data.map((rowArr) => rowArr.map((v) => String(v ?? '')).join('\t')).join('\n') 587 | try { 588 | if (e && e.clipboardData) { 589 | e.clipboardData.setData('text/plain', tsv) 590 | e.preventDefault() 591 | } else if (navigator.clipboard && navigator.clipboard.writeText) { 592 | navigator.clipboard.writeText(tsv).catch(() => {}) 593 | } 594 | } catch {} 595 | lastCopyRef.current = { top, left, height, width, data } 596 | lastCopyTextRef.current = tsv 597 | }} 598 | onPaste={(e) => { 599 | // Allow native paste inside inputs 600 | if (document.activeElement && document.activeElement.tagName === 'INPUT') return 601 | const doPaste = (text) => { 602 | const lines = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n') 603 | // Drop a single trailing empty line often present from other apps 604 | if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop() 605 | const matrix = lines.map((line) => line.split('\t')) 606 | if (matrix.length === 0) return 607 | const destTop = selection.row 608 | const destLeft = selection.col 609 | const src = lastCopyRef.current 610 | const samePayload = lastCopyTextRef.current === String(text || '') 611 | const deltaRow = src && samePayload ? (destTop - src.top) : 0 612 | const deltaCol = src && samePayload ? (destLeft - src.left) : 0 613 | const height = matrix.length 614 | const width = Math.max(0, ...matrix.map((r) => r.length)) 615 | for (let r = 0; r < height; r++) { 616 | for (let c = 0; c < width; c++) { 617 | const tr = Math.min(rows, destTop + r) 618 | const tc = Math.min(cols, destLeft + c) 619 | let val = matrix[r][c] ?? '' 620 | if (typeof val === 'string' && val.startsWith('=') && src && samePayload) { 621 | val = adjustFormula(val, deltaRow, deltaCol) 622 | } 623 | onEdit(tr, tc, val) 624 | } 625 | } 626 | } 627 | if (e && e.clipboardData) { 628 | const text = e.clipboardData.getData('text/plain') 629 | e.preventDefault() 630 | doPaste(text) 631 | } else if (navigator.clipboard && navigator.clipboard.readText) { 632 | e && e.preventDefault() 633 | navigator.clipboard.readText().then(doPaste).catch(() => {}) 634 | } 635 | }} 636 | > 637 | sum + w, 0)}px` }}> 638 | 639 | 640 | {colWidths.map((w, i) => ( 641 | 642 | ))} 643 | 644 | 645 | 646 | 647 | {Array.from({ length: cols }, (_, c) => ( 648 | 664 | ))} 665 | 666 | 667 | 668 | {Array.from({ length: rows }, (_, r) => ( 669 | 670 | 686 | {Array.from({ length: cols }, (_, c) => { 687 | const rr = r + 1 688 | const cc = c + 1 689 | const isAnchor = selection.row === rr && selection.col === cc 690 | const hasFocus = !!selection.focus 691 | const top = hasFocus ? Math.min(selection.row, selection.focus.row) : selection.row 692 | const left = hasFocus ? Math.min(selection.col, selection.focus.col) : selection.col 693 | const bottom = hasFocus ? Math.max(selection.row, selection.focus.row) : selection.row 694 | const right = hasFocus ? Math.max(selection.col, selection.focus.col) : selection.col 695 | const inRange = hasFocus && rr >= top && rr <= bottom && cc >= left && cc <= right 696 | const className = hasFocus 697 | ? (inRange ? (isAnchor ? 'sel-range sel-anchor' : 'sel-range') : '') 698 | : (isAnchor ? 'sel' : '') 699 | const isEditing = editing && editing.row === rr && editing.col === cc 700 | return ( 701 | 779 | ) 780 | })} 781 | 782 | ))} 783 | 784 |
649 | {colLabel(c + 1)} 650 |
{ 653 | e.preventDefault() 654 | e.stopPropagation() 655 | beginColumnResize(c, e.clientX) 656 | }} 657 | onDoubleClick={(e) => { 658 | e.preventDefault() 659 | e.stopPropagation() 660 | autoFitColumn(c) 661 | }} 662 | /> 663 |
671 | {r + 1} 672 |
{ 675 | e.preventDefault() 676 | e.stopPropagation() 677 | beginRowResize(r, e.clientY) 678 | }} 679 | onDoubleClick={(e) => { 680 | e.preventDefault() 681 | e.stopPropagation() 682 | autoFitRow(r) 683 | }} 684 | /> 685 |
{ 707 | // If clicking inside the active input, allow caret placement and do not exit editing 708 | if (isEditing && e.target && e.target.tagName === 'INPUT') { 709 | return 710 | } 711 | e.preventDefault() 712 | if (e.shiftKey) { 713 | // Expand from existing anchor to this cell 714 | setSelection({ row: selection.row, col: selection.col, focus: { row: rr, col: cc } }) 715 | } else { 716 | setSelection({ row: rr, col: cc }) 717 | isSelectingRef.current = true 718 | dragStartRef.current = { row: rr, col: cc } 719 | } 720 | if (tableRef.current) tableRef.current.focus() 721 | }} 722 | onMouseEnter={() => { 723 | if (isSelectingRef.current && dragStartRef.current) { 724 | const start = dragStartRef.current 725 | setSelection({ row: start.row, col: start.col, focus: { row: rr, col: cc } }) 726 | } 727 | }} 728 | onDoubleClick={(e) => { 729 | setSelection({ row: rr, col: cc }) 730 | const raw = getCellRaw ? getCellRaw(rr, cc) : '' 731 | startEditing(rr, cc, raw ?? '') 732 | }} 733 | > 734 | {isEditing ? ( 735 | setEditValue(e.target.value)} 739 | onMouseDown={(e) => { e.stopPropagation() }} 740 | onDoubleClick={(e) => { e.stopPropagation() }} 741 | onKeyDown={(e) => { 742 | e.stopPropagation() 743 | if (e.key === 'Enter') { 744 | e.preventDefault() 745 | commitEditing(rr, cc, editValue, e.shiftKey ? 'up' : 'down') 746 | } else if (e.key === 'Escape') { 747 | e.preventDefault() 748 | cancelEditing() 749 | } else if (e.key === 'Tab') { 750 | e.preventDefault() 751 | commitEditing(rr, cc, editValue, e.shiftKey ? 'left' : 'right') 752 | } 753 | }} 754 | onBlur={() => commitEditing(rr, cc, editValue)} 755 | style={{ width: '100%', height: '100%', boxSizing: 'border-box', border: 'none', outline: 'none', font: 'inherit', padding: '0 1px', margin: 0 }} 756 | /> 757 | ) : ( 758 |
759 | { 765 | const format = getCellFormat && getCellFormat(rr, cc) 766 | if (!format) return 'none' 767 | const decorations = [] 768 | if (format.underline) decorations.push('underline') 769 | if (format.strikethrough) decorations.push('line-through') 770 | return decorations.length > 0 ? decorations.join(' ') : 'none' 771 | })() 772 | }} 773 | > 774 | {getCellDisplay(rr, cc)} 775 | 776 |
777 | )} 778 |
785 | {selectionRect && ( 786 |
790 | )} 791 |
792 | ) 793 | } 794 | 795 | function colLabel(n) { 796 | let c = n 797 | let s = '' 798 | while (c > 0) { 799 | const rem = (c - 1) % 26 800 | s = String.fromCharCode(65 + rem) + s 801 | c = Math.floor((c - 1) / 26) 802 | } 803 | return s 804 | } 805 | 806 | // ===== Clipboard/formula helpers ===== 807 | function adjustFormula(formula, deltaRow, deltaCol) { 808 | const input = String(formula || '') 809 | if (!input.startsWith('=')) return input 810 | const body = input.slice(1) 811 | // Avoid altering content inside quoted strings 812 | let out = '' 813 | let i = 0 814 | while (i < body.length) { 815 | const ch = body[i] 816 | if (ch === '"') { 817 | // copy string literal verbatim 818 | let j = i + 1 819 | while (j < body.length) { 820 | const cj = body[j] 821 | if (cj === '"') { j++; break } 822 | if (cj === '\\') { j += 2; continue } 823 | j++ 824 | } 825 | out += body.slice(i, j) 826 | i = j 827 | continue 828 | } 829 | // Try to match range or single ref at this position 830 | const rangeMatch = matchA1Range(body, i) 831 | if (rangeMatch) { 832 | const { text, len, left, right } = rangeMatch 833 | const adjLeft = adjustCellRef(left, deltaRow, deltaCol) 834 | const adjRight = adjustCellRef(right, deltaRow, deltaCol) 835 | out += adjLeft + ':' + adjRight 836 | i += len 837 | continue 838 | } 839 | const cellMatch = matchA1Cell(body, i) 840 | if (cellMatch) { 841 | const { text, len } = cellMatch 842 | out += adjustCellRef(text, deltaRow, deltaCol) 843 | i += len 844 | continue 845 | } 846 | out += ch 847 | i++ 848 | } 849 | return '=' + out 850 | } 851 | 852 | function matchA1Cell(s, start) { 853 | // Optional sheet: letters/digits/underscore 854 | const re = /^(?:([A-Za-z0-9_]+)!){0,1}(\$?[A-Za-z]+\$?\d+)/ 855 | const m = re.exec(s.slice(start)) 856 | if (!m) return null 857 | return { text: (m[1] ? m[1] + '!' : '') + m[2], len: m[0].length } 858 | } 859 | 860 | function matchA1Range(s, start) { 861 | const re = /^(?:([A-Za-z0-9_]+)!){0,1}(\$?[A-Za-z]+\$?\d+)\s*:\s*(?:([A-Za-z0-9_]+)!){0,1}(\$?[A-Za-z]+\$?\d+)/ 862 | const m = re.exec(s.slice(start)) 863 | if (!m) return null 864 | const left = (m[1] ? m[1] + '!' : '') + m[2] 865 | const right = (m[3] ? m[3] + '!' : '') + m[4] 866 | return { text: m[0], len: m[0].length, left, right } 867 | } 868 | 869 | function adjustCellRef(ref, dRow, dCol) { 870 | const { sheet, colLetters, rowNumber, colAbs, rowAbs } = parseCellRef(ref) 871 | if (!colLetters || !rowNumber) return ref 872 | const colNum = colLettersToNumber(colLetters) 873 | let newCol = colAbs ? colNum : colNum + dCol 874 | let newRow = rowAbs ? rowNumber : rowNumber + dRow 875 | if (!Number.isFinite(newCol) || newCol < 1) newCol = 1 876 | if (!Number.isFinite(newRow) || newRow < 1) newRow = 1 877 | const colOut = (colAbs ? '$' : '') + numberToColLetters(newCol) 878 | const rowOut = (rowAbs ? '$' : '') + String(newRow) 879 | return (sheet ? sheet + '!' : '') + colOut + rowOut 880 | } 881 | 882 | function parseCellRef(ref) { 883 | const str = String(ref || '') 884 | const parts = str.split('!') 885 | const sheet = parts.length === 2 ? parts[0] : '' 886 | const core = parts.length === 2 ? parts[1] : parts[0] 887 | const m = /^(\$?)([A-Za-z]+)(\$?)(\d+)$/.exec(core) 888 | if (!m) return { sheet: sheet || '', colLetters: '', rowNumber: 0, colAbs: false, rowAbs: false } 889 | return { 890 | sheet: sheet || '', 891 | colLetters: m[2].toUpperCase(), 892 | rowNumber: parseInt(m[4], 10), 893 | colAbs: !!m[1], 894 | rowAbs: !!m[3], 895 | } 896 | } 897 | 898 | function colLettersToNumber(letters) { 899 | let n = 0 900 | const s = String(letters || '').toUpperCase() 901 | for (let i = 0; i < s.length; i++) { 902 | n = n * 26 + (s.charCodeAt(i) - 64) 903 | } 904 | return n 905 | } 906 | 907 | function numberToColLetters(n) { 908 | let c = Math.max(1, Math.floor(n)) 909 | let s = '' 910 | while (c > 0) { 911 | const rem = (c - 1) % 26 912 | s = String.fromCharCode(65 + rem) + s 913 | c = Math.floor((c - 1) / 26) 914 | } 915 | return s 916 | } 917 | 918 | 919 | --------------------------------------------------------------------------------