chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user