552 lines
20 KiB
TypeScript
552 lines
20 KiB
TypeScript
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
import type { Database, GoogleConnection, GoogleOauthFlow } from '@pig/db';
|
|
import { activities, googleConnections, googleOauthFlows } from '@pig/db';
|
|
import { and, eq, gt, isNull } from 'drizzle-orm';
|
|
import type { Principal } from '../lib/auth';
|
|
import { decryptSecret, encryptionReady, encryptSecret } from '../lib/secrets';
|
|
import { MutationError } from '../lib/mutation';
|
|
import {
|
|
MAX_IMPORT_CELL_CHARS,
|
|
MAX_IMPORT_COLUMNS,
|
|
MAX_IMPORT_ROWS,
|
|
normaliseTabularRows,
|
|
type ParsedTable,
|
|
} from './tabular-import';
|
|
|
|
export const GOOGLE_OAUTH_SCOPES = [
|
|
'https://www.googleapis.com/auth/drive.metadata.readonly',
|
|
'https://www.googleapis.com/auth/spreadsheets.readonly',
|
|
] as const;
|
|
const GOOGLE_AUTHORIZATION_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
const GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
|
const GOOGLE_REVOCATION_ENDPOINT = 'https://oauth2.googleapis.com/revoke';
|
|
const GOOGLE_DRIVE_FILES_ENDPOINT = 'https://www.googleapis.com/drive/v3/files';
|
|
const GOOGLE_SHEETS_ENDPOINT = 'https://sheets.googleapis.com/v4/spreadsheets';
|
|
const OAUTH_FLOW_TTL_MS = 10 * 60 * 1_000;
|
|
const ACCESS_TOKEN_SKEW_MS = 60 * 1_000;
|
|
const DRIVE_PAGE_SIZE = 50;
|
|
const PKCE_PURPOSE = 'google-oauth:pkce-verifier';
|
|
const REFRESH_TOKEN_PURPOSE = 'google-oauth:refresh-token';
|
|
const ACCESS_TOKEN_PURPOSE = 'google-oauth:access-token';
|
|
|
|
export interface GoogleSheetsConfig {
|
|
clientId?: string;
|
|
clientSecret?: string;
|
|
redirectUri?: string;
|
|
encryptionKey?: string;
|
|
publicUrl: string;
|
|
}
|
|
|
|
export interface GoogleConnectionMetadata {
|
|
configured: boolean;
|
|
connected: boolean;
|
|
connectedAt: string | null;
|
|
scopes: string[];
|
|
}
|
|
|
|
export interface GoogleDriveFile {
|
|
id: string;
|
|
name: string;
|
|
modifiedTime: string | null;
|
|
}
|
|
|
|
export interface GoogleSheetMetadata {
|
|
sheetId: number;
|
|
title: string;
|
|
rowCount: number;
|
|
columnCount: number;
|
|
}
|
|
|
|
interface GoogleTokenResponse {
|
|
access_token?: string;
|
|
expires_in?: number;
|
|
refresh_token?: string;
|
|
scope?: string;
|
|
token_type?: string;
|
|
error?: string;
|
|
}
|
|
|
|
interface DriveListResponse {
|
|
files?: { id?: string; name?: string; modifiedTime?: string }[];
|
|
nextPageToken?: string;
|
|
incompleteSearch?: boolean;
|
|
}
|
|
|
|
interface SpreadsheetMetadataResponse {
|
|
properties?: { title?: string };
|
|
sheets?: {
|
|
properties?: {
|
|
sheetId?: number;
|
|
title?: string;
|
|
sheetType?: string;
|
|
hidden?: boolean;
|
|
gridProperties?: { rowCount?: number; columnCount?: number };
|
|
};
|
|
}[];
|
|
}
|
|
|
|
interface ValuesResponse {
|
|
values?: unknown[][];
|
|
}
|
|
|
|
export class GoogleApiError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly status: number,
|
|
readonly reconnectRequired = false,
|
|
) {
|
|
super(message);
|
|
this.name = 'GoogleApiError';
|
|
}
|
|
}
|
|
|
|
export class GoogleSheetsService {
|
|
constructor(
|
|
private readonly db: Database,
|
|
private readonly config: GoogleSheetsConfig,
|
|
private readonly fetchImpl: typeof fetch = fetch,
|
|
) {}
|
|
|
|
configured(): boolean {
|
|
return Boolean(
|
|
this.config.clientId &&
|
|
this.config.clientSecret &&
|
|
this.config.redirectUri &&
|
|
encryptionReady(this.config.encryptionKey),
|
|
);
|
|
}
|
|
|
|
async connectionMetadata(userId: string): Promise<GoogleConnectionMetadata> {
|
|
const [connection] = await this.db
|
|
.select()
|
|
.from(googleConnections)
|
|
.where(eq(googleConnections.userId, userId))
|
|
.limit(1);
|
|
return googleConnectionMetadata(this.configured(), connection);
|
|
}
|
|
|
|
async beginOAuth(principal: Principal, now = new Date()): Promise<{ authorizationUrl: string; browserBinding: string }> {
|
|
this.requireConfigured();
|
|
const state = randomBytes(32).toString('base64url');
|
|
const browserBinding = randomBytes(32).toString('base64url');
|
|
const verifier = randomBytes(32).toString('base64url');
|
|
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
await this.db.insert(googleOauthFlows).values({
|
|
userId: principal.userId,
|
|
stateHash: oauthStateHash(state),
|
|
browserBindingHash: oauthStateHash(browserBinding),
|
|
pkceVerifierEncrypted: encryptSecret(verifier, this.config.encryptionKey, PKCE_PURPOSE),
|
|
expiresAt: new Date(now.getTime() + OAUTH_FLOW_TTL_MS),
|
|
createdAt: now,
|
|
});
|
|
return {
|
|
authorizationUrl: buildGoogleAuthorizationUrl({
|
|
clientId: this.config.clientId!,
|
|
redirectUri: this.config.redirectUri!,
|
|
state,
|
|
challenge,
|
|
}),
|
|
browserBinding,
|
|
};
|
|
}
|
|
|
|
async completeOAuth(input: { state: string; code: string; browserBinding: string }, now = new Date()): Promise<void> {
|
|
this.requireConfigured();
|
|
const [flow] = await this.db
|
|
.update(googleOauthFlows)
|
|
.set({ consumedAt: now })
|
|
.where(and(
|
|
eq(googleOauthFlows.stateHash, oauthStateHash(input.state)),
|
|
isNull(googleOauthFlows.consumedAt),
|
|
gt(googleOauthFlows.expiresAt, now),
|
|
))
|
|
.returning();
|
|
if (!flow || !oauthFlowMatches(flow, input.state, input.browserBinding, now)) {
|
|
throw new MutationError('invalid_oauth_state', 'The Google authorization request is invalid or expired.', 400);
|
|
}
|
|
const verifier = decryptSecret(
|
|
flow.pkceVerifierEncrypted,
|
|
this.config.encryptionKey,
|
|
PKCE_PURPOSE,
|
|
);
|
|
const token = await this.exchangeToken(new URLSearchParams({
|
|
client_id: this.config.clientId!,
|
|
client_secret: this.config.clientSecret!,
|
|
code: input.code,
|
|
code_verifier: verifier,
|
|
grant_type: 'authorization_code',
|
|
redirect_uri: this.config.redirectUri!,
|
|
}));
|
|
if (!token.access_token) {
|
|
throw new GoogleApiError('Google did not return an access token. Connect again.', 502, true);
|
|
}
|
|
const [existing] = await this.db
|
|
.select()
|
|
.from(googleConnections)
|
|
.where(eq(googleConnections.userId, flow.userId))
|
|
.limit(1);
|
|
const refreshTokenEncrypted = token.refresh_token
|
|
? encryptSecret(token.refresh_token, this.config.encryptionKey, REFRESH_TOKEN_PURPOSE)
|
|
: existing?.refreshTokenEncrypted;
|
|
if (!refreshTokenEncrypted) {
|
|
throw new GoogleApiError('Google did not grant offline access. Connect again and approve access.', 502, true);
|
|
}
|
|
const expiresAt = token.expires_in
|
|
? new Date(now.getTime() + token.expires_in * 1_000)
|
|
: null;
|
|
const scopes = token.scope?.split(/\s+/).filter(Boolean) ?? [...GOOGLE_OAUTH_SCOPES];
|
|
await this.db.transaction(async (tx) => {
|
|
await tx.insert(googleConnections).values({
|
|
userId: flow.userId,
|
|
refreshTokenEncrypted,
|
|
accessTokenEncrypted: encryptSecret(token.access_token!, this.config.encryptionKey, ACCESS_TOKEN_PURPOSE),
|
|
accessTokenExpiresAt: expiresAt,
|
|
scopes,
|
|
connectedAt: now,
|
|
updatedAt: now,
|
|
}).onConflictDoUpdate({
|
|
target: googleConnections.userId,
|
|
set: {
|
|
refreshTokenEncrypted,
|
|
accessTokenEncrypted: encryptSecret(token.access_token!, this.config.encryptionKey, ACCESS_TOKEN_PURPOSE),
|
|
accessTokenExpiresAt: expiresAt,
|
|
scopes,
|
|
connectedAt: now,
|
|
updatedAt: now,
|
|
},
|
|
});
|
|
await tx.insert(activities).values({
|
|
type: 'note',
|
|
subject: 'Connected Google Sheets import',
|
|
actorUserId: flow.userId,
|
|
source: 'manual',
|
|
occurredAt: now,
|
|
meta: { action: 'integration.connected', integration: 'google_sheets' },
|
|
});
|
|
});
|
|
}
|
|
|
|
async disconnect(principal: Principal, now = new Date()): Promise<void> {
|
|
const [connection] = await this.db
|
|
.select()
|
|
.from(googleConnections)
|
|
.where(eq(googleConnections.userId, principal.userId))
|
|
.limit(1);
|
|
if (!connection) return;
|
|
const token = decryptSecret(
|
|
connection.refreshTokenEncrypted,
|
|
this.config.encryptionKey,
|
|
REFRESH_TOKEN_PURPOSE,
|
|
);
|
|
const response = await this.fetchImpl(GOOGLE_REVOCATION_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({ token }),
|
|
});
|
|
if (!response.ok && response.status !== 400) {
|
|
throw new GoogleApiError('Google access could not be revoked. Try again.', response.status);
|
|
}
|
|
await this.db.transaction(async (tx) => {
|
|
await tx.delete(googleConnections).where(eq(googleConnections.userId, principal.userId));
|
|
await tx.insert(activities).values({
|
|
type: 'note',
|
|
subject: 'Disconnected Google Sheets import',
|
|
actorUserId: principal.userId,
|
|
source: 'manual',
|
|
occurredAt: now,
|
|
meta: { action: 'integration.disconnected', integration: 'google_sheets' },
|
|
});
|
|
});
|
|
}
|
|
|
|
async listSpreadsheets(
|
|
userId: string,
|
|
options: { pageToken?: string; search?: string },
|
|
): Promise<{ files: GoogleDriveFile[]; nextPageToken: string | null; incomplete: boolean }> {
|
|
const token = await this.accessToken(userId);
|
|
const query = [
|
|
"mimeType='application/vnd.google-apps.spreadsheet'",
|
|
'trashed=false',
|
|
...(options.search?.trim()
|
|
? [`name contains '${escapeDriveQuery(options.search.trim().slice(0, 100))}'`]
|
|
: []),
|
|
].join(' and ');
|
|
const url = new URL(GOOGLE_DRIVE_FILES_ENDPOINT);
|
|
url.searchParams.set('q', query);
|
|
url.searchParams.set('spaces', 'drive');
|
|
url.searchParams.set('orderBy', 'modifiedTime desc,name_natural');
|
|
url.searchParams.set('pageSize', String(DRIVE_PAGE_SIZE));
|
|
url.searchParams.set('fields', 'files(id,name,modifiedTime),nextPageToken,incompleteSearch');
|
|
url.searchParams.set('supportsAllDrives', 'true');
|
|
url.searchParams.set('includeItemsFromAllDrives', 'true');
|
|
if (options.pageToken) url.searchParams.set('pageToken', options.pageToken);
|
|
const body = await this.googleJson<DriveListResponse>(url, token, 'Google Drive could not list spreadsheets.');
|
|
return {
|
|
files: (body.files ?? []).flatMap((file) => file.id && file.name
|
|
? [{ id: file.id, name: file.name, modifiedTime: file.modifiedTime ?? null }]
|
|
: []),
|
|
nextPageToken: body.nextPageToken ?? null,
|
|
incomplete: body.incompleteSearch === true,
|
|
};
|
|
}
|
|
|
|
async spreadsheetMetadata(
|
|
userId: string,
|
|
spreadsheetId: string,
|
|
): Promise<{ title: string; sheets: GoogleSheetMetadata[] }> {
|
|
const token = await this.accessToken(userId);
|
|
const url = new URL(`${GOOGLE_SHEETS_ENDPOINT}/${encodeURIComponent(spreadsheetId)}`);
|
|
url.searchParams.set(
|
|
'fields',
|
|
'properties(title),sheets(properties(sheetId,title,index,sheetType,hidden,gridProperties(rowCount,columnCount)))',
|
|
);
|
|
const body = await this.googleJson<SpreadsheetMetadataResponse>(url, token, 'Google Sheets could not read spreadsheet metadata.');
|
|
const sheets = (body.sheets ?? []).flatMap((sheet) => {
|
|
const properties = sheet.properties;
|
|
if (
|
|
!properties ||
|
|
properties.sheetType !== 'GRID' ||
|
|
properties.hidden ||
|
|
properties.sheetId == null ||
|
|
!properties.title
|
|
) return [];
|
|
return [{
|
|
sheetId: properties.sheetId,
|
|
title: properties.title,
|
|
rowCount: properties.gridProperties?.rowCount ?? 0,
|
|
columnCount: properties.gridProperties?.columnCount ?? 0,
|
|
}];
|
|
});
|
|
return { title: body.properties?.title ?? 'Google spreadsheet', sheets };
|
|
}
|
|
|
|
async readTable(
|
|
userId: string,
|
|
input: { spreadsheetId: string; sheetId: number; range: string },
|
|
): Promise<ParsedTable> {
|
|
const metadata = await this.spreadsheetMetadata(userId, input.spreadsheetId);
|
|
const sheet = metadata.sheets.find((candidate) => candidate.sheetId === input.sheetId);
|
|
if (!sheet) throw new MutationError('google_sheet_not_found', 'The selected visible grid sheet no longer exists.', 404);
|
|
const bounded = parseBoundedGoogleRange(input.range, {
|
|
rowCount: sheet.rowCount,
|
|
columnCount: sheet.columnCount,
|
|
});
|
|
const a1 = `'${sheet.title.replace(/'/g, "''")}'!${bounded.a1}`;
|
|
const token = await this.accessToken(userId);
|
|
const url = new URL(
|
|
`${GOOGLE_SHEETS_ENDPOINT}/${encodeURIComponent(input.spreadsheetId)}/values/${encodeURIComponent(a1)}`,
|
|
);
|
|
url.searchParams.set('majorDimension', 'ROWS');
|
|
// Formatted values expose cached/display results, never executable formula source.
|
|
url.searchParams.set('valueRenderOption', 'FORMATTED_VALUE');
|
|
const body = await this.googleJson<ValuesResponse>(url, token, 'Google Sheets could not read the selected range.');
|
|
const table = normaliseGoogleValues(body.values ?? [], bounded.columns);
|
|
return {
|
|
fileName: `Google Sheets · ${metadata.title} · ${sheet.title} · ${bounded.a1}`,
|
|
sheetName: sheet.title,
|
|
...table,
|
|
};
|
|
}
|
|
|
|
private async accessToken(userId: string, now = new Date()): Promise<string> {
|
|
const [connection] = await this.db
|
|
.select()
|
|
.from(googleConnections)
|
|
.where(eq(googleConnections.userId, userId))
|
|
.limit(1);
|
|
if (!connection) throw new GoogleApiError('Connect Google Sheets before selecting a spreadsheet.', 409, true);
|
|
if (
|
|
connection.accessTokenEncrypted &&
|
|
connection.accessTokenExpiresAt &&
|
|
connection.accessTokenExpiresAt.getTime() > now.getTime() + ACCESS_TOKEN_SKEW_MS
|
|
) {
|
|
return decryptSecret(connection.accessTokenEncrypted, this.config.encryptionKey, ACCESS_TOKEN_PURPOSE);
|
|
}
|
|
this.requireConfigured();
|
|
const refreshToken = decryptSecret(
|
|
connection.refreshTokenEncrypted,
|
|
this.config.encryptionKey,
|
|
REFRESH_TOKEN_PURPOSE,
|
|
);
|
|
const token = await this.exchangeToken(new URLSearchParams({
|
|
client_id: this.config.clientId!,
|
|
client_secret: this.config.clientSecret!,
|
|
refresh_token: refreshToken,
|
|
grant_type: 'refresh_token',
|
|
}));
|
|
if (!token.access_token) throw new GoogleApiError('Google access expired. Connect again.', 401, true);
|
|
await this.db.update(googleConnections).set({
|
|
accessTokenEncrypted: encryptSecret(token.access_token, this.config.encryptionKey, ACCESS_TOKEN_PURPOSE),
|
|
accessTokenExpiresAt: token.expires_in
|
|
? new Date(now.getTime() + token.expires_in * 1_000)
|
|
: null,
|
|
updatedAt: now,
|
|
}).where(eq(googleConnections.userId, userId));
|
|
return token.access_token;
|
|
}
|
|
|
|
private async exchangeToken(parameters: URLSearchParams): Promise<GoogleTokenResponse> {
|
|
const response = await this.fetchImpl(GOOGLE_TOKEN_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: parameters,
|
|
});
|
|
if (!response.ok) {
|
|
throw new GoogleApiError('Google authorization failed. Connect again.', response.status, true);
|
|
}
|
|
return await response.json() as GoogleTokenResponse;
|
|
}
|
|
|
|
private async googleJson<T>(url: URL, token: string, message: string): Promise<T> {
|
|
const response = await this.fetchImpl(url, {
|
|
headers: { authorization: `Bearer ${token}` },
|
|
});
|
|
if (!response.ok) {
|
|
throw new GoogleApiError(message, response.status, response.status === 401 || response.status === 403);
|
|
}
|
|
return await response.json() as T;
|
|
}
|
|
|
|
private requireConfigured(): void {
|
|
if (!this.configured()) {
|
|
throw new MutationError(
|
|
'google_not_configured',
|
|
'Google Sheets import requires OAuth credentials and the settings encryption key.',
|
|
503,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function oauthStateHash(state: string): string {
|
|
return createHash('sha256').update(state).digest('hex');
|
|
}
|
|
|
|
export function oauthFlowMatches(
|
|
flow: Pick<GoogleOauthFlow, 'stateHash' | 'browserBindingHash' | 'expiresAt' | 'consumedAt'>,
|
|
state: string,
|
|
browserBinding: string,
|
|
now: Date,
|
|
): boolean {
|
|
if (!flow.consumedAt || flow.consumedAt.getTime() !== now.getTime() || flow.expiresAt <= now) return false;
|
|
const expected = Buffer.from(flow.stateHash, 'hex');
|
|
const actual = Buffer.from(oauthStateHash(state), 'hex');
|
|
const expectedBinding = Buffer.from(flow.browserBindingHash, 'hex');
|
|
const actualBinding = Buffer.from(oauthStateHash(browserBinding), 'hex');
|
|
return (
|
|
expected.length === actual.length &&
|
|
timingSafeEqual(expected, actual) &&
|
|
expectedBinding.length === actualBinding.length &&
|
|
timingSafeEqual(expectedBinding, actualBinding)
|
|
);
|
|
}
|
|
|
|
export function buildGoogleAuthorizationUrl(input: {
|
|
clientId: string;
|
|
redirectUri: string;
|
|
state: string;
|
|
challenge: string;
|
|
}): string {
|
|
const url = new URL(GOOGLE_AUTHORIZATION_ENDPOINT);
|
|
url.searchParams.set('client_id', input.clientId);
|
|
url.searchParams.set('redirect_uri', input.redirectUri);
|
|
url.searchParams.set('response_type', 'code');
|
|
url.searchParams.set('scope', GOOGLE_OAUTH_SCOPES.join(' '));
|
|
url.searchParams.set('state', input.state);
|
|
url.searchParams.set('code_challenge', input.challenge);
|
|
url.searchParams.set('code_challenge_method', 'S256');
|
|
url.searchParams.set('access_type', 'offline');
|
|
url.searchParams.set('include_granted_scopes', 'true');
|
|
url.searchParams.set('prompt', 'consent');
|
|
return url.toString();
|
|
}
|
|
|
|
export function googleConnectionMetadata(
|
|
configured: boolean,
|
|
connection: GoogleConnection | undefined,
|
|
): GoogleConnectionMetadata {
|
|
return {
|
|
configured,
|
|
connected: Boolean(connection),
|
|
connectedAt: connection?.connectedAt.toISOString() ?? null,
|
|
scopes: connection?.scopes ?? [],
|
|
};
|
|
}
|
|
|
|
export interface BoundedGoogleRange {
|
|
a1: string;
|
|
rows: number;
|
|
columns: number;
|
|
}
|
|
|
|
export function parseBoundedGoogleRange(
|
|
value: string,
|
|
grid?: { rowCount: number; columnCount: number },
|
|
): BoundedGoogleRange {
|
|
const match = value.trim().match(/^([A-Za-z]{1,3})([1-9]\d*):([A-Za-z]{1,3})([1-9]\d*)$/);
|
|
if (!match) {
|
|
throw new MutationError('invalid_google_range', 'Use a rectangular A1 range such as A1:H500.', 400);
|
|
}
|
|
const startColumn = columnNumber(match[1]!);
|
|
const endColumn = columnNumber(match[3]!);
|
|
const startRow = Number(match[2]);
|
|
const endRow = Number(match[4]);
|
|
if (endColumn < startColumn || endRow < startRow) {
|
|
throw new MutationError('invalid_google_range', 'The range end must follow its start.', 400);
|
|
}
|
|
const columns = endColumn - startColumn + 1;
|
|
const rows = endRow - startRow + 1;
|
|
if (columns > MAX_IMPORT_COLUMNS || rows > MAX_IMPORT_ROWS + 1) {
|
|
throw new MutationError(
|
|
'google_range_too_large',
|
|
`Select at most ${MAX_IMPORT_COLUMNS} columns and ${MAX_IMPORT_ROWS + 1} rows including the header.`,
|
|
400,
|
|
);
|
|
}
|
|
if (grid && (endColumn > grid.columnCount || endRow > grid.rowCount)) {
|
|
throw new MutationError('google_range_outside_sheet', 'The selected range extends beyond the sheet grid.', 400);
|
|
}
|
|
return { a1: `${match[1]!.toUpperCase()}${startRow}:${match[3]!.toUpperCase()}${endRow}`, rows, columns };
|
|
}
|
|
|
|
export function normaliseGoogleValues(
|
|
values: readonly (readonly unknown[])[],
|
|
requestedColumns: number,
|
|
): Omit<ParsedTable, 'fileName' | 'sheetName'> {
|
|
const table = values.map((row) => {
|
|
if (row.length > requestedColumns) {
|
|
throw new MutationError('invalid_google_values', 'Google returned cells outside the requested range.', 502);
|
|
}
|
|
return row.map((value) => normaliseGoogleCell(value));
|
|
});
|
|
return normaliseTabularRows(table, [
|
|
'Google formula source was not imported; formula cells use only their formatted cached result.',
|
|
]);
|
|
}
|
|
|
|
function normaliseGoogleCell(value: unknown): string {
|
|
if (value == null) return '';
|
|
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
if (typeof value === 'number') {
|
|
if (!Number.isFinite(value)) throw new MutationError('invalid_google_values', 'Google returned a non-finite number.', 502);
|
|
return String(value);
|
|
}
|
|
if (typeof value === 'string') {
|
|
if (value.length > MAX_IMPORT_CELL_CHARS) {
|
|
throw new MutationError('google_cell_too_large', `A Google Sheets cell exceeds ${MAX_IMPORT_CELL_CHARS} characters.`, 400);
|
|
}
|
|
return value;
|
|
}
|
|
throw new MutationError('invalid_google_values', 'Google returned an unsupported cell value.', 502);
|
|
}
|
|
|
|
function columnNumber(letters: string): number {
|
|
let result = 0;
|
|
for (const letter of letters.toUpperCase()) result = result * 26 + letter.charCodeAt(0) - 64;
|
|
return result;
|
|
}
|
|
|
|
function escapeDriveQuery(value: string): string {
|
|
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
}
|