Files
simstudioai--sim/apps/sim/tools/microsoft_excel/utils.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

273 lines
9.1 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { validatePathSegment } from '@/lib/core/security/input-validation'
import type { ExcelCellValue } from '@/tools/microsoft_excel/types'
const logger = createLogger('MicrosoftExcelUtils')
/**
* Extract a developer-readable message from a parsed Microsoft Graph error body.
* Graph errors follow the documented shape:
* { error: { code, message, innerError: { code, message, ... }, details: [...] } }
* See https://learn.microsoft.com/en-us/graph/errors
*
* Walks the nested innerError chain (capped at depth 5) and appends details[].message.
* Returns undefined when no message-like field is present so callers can fall back.
*/
export function parseGraphErrorFromData(data: unknown): string | undefined {
if (!data || typeof data !== 'object') return undefined
const root = (
data as {
error?: {
code?: unknown
message?: unknown
innerError?: unknown
innererror?: unknown
details?: unknown
}
}
).error
if (root && typeof root === 'object') {
const messages: string[] = []
if (typeof root.message === 'string' && root.message.trim()) {
messages.push(root.message.trim())
}
// Walk the (possibly nested) innerError chain. Spec uses `innererror`
// but Graph commonly returns `innerError` — accept both.
let inner: any = (root as any).innererror ?? (root as any).innerError
let depth = 0
while (inner && depth < 5) {
if (typeof inner.message === 'string' && inner.message.trim()) {
const msg = inner.message.trim()
if (!messages.includes(msg)) messages.push(msg)
}
inner = inner.innererror ?? inner.innerError
depth++
}
if (Array.isArray((root as any).details)) {
for (const detail of (root as any).details) {
if (detail && typeof detail.message === 'string' && detail.message.trim()) {
const msg = detail.message.trim()
if (!messages.includes(msg)) messages.push(msg)
}
}
}
if (messages.length > 0) return messages.join(' — ')
if (typeof root.code === 'string' && root.code.trim()) {
return root.code.trim()
}
}
const topMessage = (data as { message?: unknown }).message
if (typeof topMessage === 'string' && topMessage.trim()) {
return topMessage.trim()
}
return undefined
}
/**
* Parse a Microsoft Graph error response body into a string message.
* Used by API routes that have a Response object rather than parsed data.
*/
export function parseGraphErrorMessage(
status: number,
statusText: string,
errorText: string
): string {
try {
const data = JSON.parse(errorText)
const message = parseGraphErrorFromData(data)
if (message) {
// If the only thing we found was the bare error code, append status for context.
const root = data?.error
if (
root &&
message === root.code?.trim?.() &&
!(typeof root.message === 'string' && root.message.trim())
) {
return `${message} (${status} ${statusText})`
}
return message
}
} catch {
if (errorText?.trim()) return errorText.trim()
}
return statusText ? `${status} ${statusText}` : `Microsoft Graph request failed (${status})`
}
/**
* Read an error response body and produce a developer-readable message.
* Safely handles non-JSON bodies and read failures. Used by internal API routes.
*/
export async function extractGraphError(response: Response): Promise<string> {
const errorText = await response.text().catch(() => '')
return parseGraphErrorMessage(response.status, response.statusText, errorText)
}
/**
* Escape a string for use inside an OData single-quoted literal (e.g. a
* `worksheets('...')` or `tables('...')` key). OData escapes an embedded single
* quote by doubling it, so a name like `O'Brien` becomes `O''Brien`; without this
* the apostrophe terminates the literal early and breaks the request URL.
* `encodeURIComponent` leaves apostrophes untouched, so the doubling must happen here.
*/
export function escapeODataString(value: string): string {
return value.replace(/'/g, "''")
}
/** Pattern for Microsoft Graph item/drive IDs: alphanumeric, hyphens, underscores, and ! (for SharePoint b!<base64> format) */
export const GRAPH_ID_PATTERN = /^[a-zA-Z0-9!_-]+$/
/**
* Returns the Graph API base path for an Excel item.
* When driveId is provided, uses /drives/{driveId}/items/{itemId} (SharePoint/shared drives).
* When driveId is omitted, uses /me/drive/items/{itemId} (personal OneDrive).
*/
export function getItemBasePath(spreadsheetId: string, driveId?: string): string {
const spreadsheetValidation = validatePathSegment(spreadsheetId, {
paramName: 'spreadsheetId',
customPattern: GRAPH_ID_PATTERN,
})
if (!spreadsheetValidation.isValid) {
throw new Error(spreadsheetValidation.error)
}
if (driveId) {
const driveValidation = validatePathSegment(driveId, {
paramName: 'driveId',
customPattern: GRAPH_ID_PATTERN,
})
if (!driveValidation.isValid) {
throw new Error(driveValidation.error)
}
return `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${spreadsheetId}`
}
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}`
}
/**
* Resolves a worksheet name and cell address from either an explicit sheet name
* plus address, or a combined "Sheet1!A1:B2" range string.
*
* - When `sheetName` is provided, `address` is treated as a bare cell address (e.g. "A1:B2").
* - When `sheetName` is omitted, `address` must be a combined "Sheet1!A1:B2" range.
*
* Throws when a worksheet name cannot be determined or the combined format is invalid.
*/
export function resolveSheetAndAddress(
address: string | undefined,
sheetName?: string
): { sheetName: string; address: string } {
const trimmedAddress = address?.trim()
if (!trimmedAddress) {
throw new Error('A cell range is required (e.g., "A1:B2" or "Sheet1!A1:B2")')
}
const trimmedSheet = sheetName?.trim()
if (trimmedSheet) {
return { sheetName: trimmedSheet, address: trimmedAddress }
}
const match = trimmedAddress.match(/^([^!]+)!(.+)$/)
if (!match) {
throw new Error(
`Invalid range format: "${address}". Provide a sheet name, or use the combined format "Sheet1!A1:B2"`
)
}
return { sheetName: match[1], address: match[2] }
}
/**
* Builds the Graph API URL for a worksheet range object, resolving the sheet name
* from either an explicit `sheetName` param or a combined "Sheet1!A1:B2" address.
* The returned URL has no trailing segment, so callers append `/clear`, `/sort/apply`,
* `/format/fill`, `/format/font`, or nothing for the range object itself.
*/
export function buildWorksheetRangeUrl(
basePath: string,
address: string | undefined,
sheetName?: string
): string {
const resolved = resolveSheetAndAddress(address, sheetName)
const encodedSheet = encodeURIComponent(escapeODataString(resolved.sheetName))
const encodedAddress = encodeURIComponent(resolved.address)
return `${basePath}/workbook/worksheets('${encodedSheet}')/range(address='${encodedAddress}')`
}
export function trimTrailingEmptyRowsAndColumns(matrix: ExcelCellValue[][]): ExcelCellValue[][] {
if (!Array.isArray(matrix) || matrix.length === 0) return []
const isEmptyValue = (v: ExcelCellValue) => v === null || v === ''
// Determine last non-empty row
let lastNonEmptyRowIndex = -1
for (let r = 0; r < matrix.length; r++) {
const row = matrix[r] || []
const hasData = row.some((cell: ExcelCellValue) => !isEmptyValue(cell))
if (hasData) lastNonEmptyRowIndex = r
}
if (lastNonEmptyRowIndex === -1) return []
const trimmedRows = matrix.slice(0, lastNonEmptyRowIndex + 1)
// Determine last non-empty column across trimmed rows
let lastNonEmptyColIndex = -1
for (let r = 0; r < trimmedRows.length; r++) {
const row = trimmedRows[r] || []
for (let c = 0; c < row.length; c++) {
if (!isEmptyValue(row[c])) {
if (c > lastNonEmptyColIndex) lastNonEmptyColIndex = c
}
}
}
if (lastNonEmptyColIndex === -1) return []
return trimmedRows.map((row) => (row || []).slice(0, lastNonEmptyColIndex + 1))
}
/**
* Fetches the browser-accessible web URL for an Excel spreadsheet.
* This URL can be opened in a browser if the user is logged into OneDrive/Microsoft,
* unlike the Graph API URL which requires an access token.
*/
export async function getSpreadsheetWebUrl(
spreadsheetId: string,
accessToken: string,
driveId?: string
): Promise<string> {
const basePath = getItemBasePath(spreadsheetId, driveId)
try {
const response = await fetch(`${basePath}?$select=id,webUrl`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
logger.warn('Failed to fetch spreadsheet webUrl, using Graph API URL as fallback', {
spreadsheetId,
status: response.status,
})
return basePath
}
const data = await response.json()
return data.webUrl || basePath
} catch (error) {
logger.warn('Error fetching spreadsheet webUrl, using Graph API URL as fallback', {
spreadsheetId,
error,
})
return basePath
}
}