v2.2.15: Astra Office Refactor & Multi-Service Integration
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
readSheetRange,
|
||||
writeSheetRange,
|
||||
appendSheetRows,
|
||||
parseTsvBody,
|
||||
valuesToMarkdownTable,
|
||||
SheetCell,
|
||||
SheetValues,
|
||||
ReadResult,
|
||||
WriteResult,
|
||||
AppendResult,
|
||||
ApiFailure,
|
||||
} from './sheetsApi';
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Google Sheets API v4 — read / write / append.
|
||||
*
|
||||
* 토큰은 calendar 와 공유 (같은 OAuth 에 spreadsheets scope 포함). 별도 셋업 없음 —
|
||||
* "Astra: Google Calendar OAuth 연결" 명령으로 한 번 로그인하면 둘 다 동작한다.
|
||||
*
|
||||
* 외부 라이브러리 안 씀 — Sheets API REST + native fetch.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { getFreshAccessToken } from '../calendar/calendarApi';
|
||||
|
||||
const API_BASE = 'https://sheets.googleapis.com/v4/spreadsheets';
|
||||
|
||||
/** 2D 값 배열 — 각 셀은 string | number | boolean (Sheets API valueType). */
|
||||
export type SheetCell = string | number | boolean | null;
|
||||
export type SheetValues = SheetCell[][];
|
||||
|
||||
export interface ReadResult { ok: true; values: SheetValues; range: string; }
|
||||
export interface WriteResult { ok: true; updatedCells: number; updatedRange: string; }
|
||||
export interface AppendResult extends WriteResult { appendedRange: string; }
|
||||
export interface ApiFailure { ok: false; error: string; }
|
||||
|
||||
/**
|
||||
* range 의 셀들을 2D 배열로 읽기. 빈 셀은 빈 문자열로 패딩되지 않음 — Sheets API 는
|
||||
* 마지막 비어있지 않은 셀까지만 반환. caller 가 필요하면 normalize.
|
||||
*/
|
||||
export async function readSheetRange(
|
||||
context: vscode.ExtensionContext,
|
||||
spreadsheetId: string,
|
||||
range: string,
|
||||
): Promise<ReadResult | ApiFailure> {
|
||||
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
|
||||
if (!range.trim()) return { ok: false, error: 'range 비어있음' };
|
||||
const tok = await getFreshAccessToken(context);
|
||||
if (!tok.ok) return { ok: false, error: tok.error };
|
||||
const url = `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${tok.accessToken}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const json: any = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: json?.error?.message || `HTTP ${res.status}` };
|
||||
}
|
||||
return { ok: true, values: (json.values ?? []) as SheetValues, range: json.range ?? range };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* range 의 셀을 values 로 덮어쓰기. range 의 좌상단부터 values 배열만큼 채움.
|
||||
* Sheets API 의 valueInputOption='USER_ENTERED' 사용 → "=A1+B1" 같은 수식은 수식으로,
|
||||
* 숫자/날짜 문자열은 Sheets 가 자동 파싱.
|
||||
*/
|
||||
export async function writeSheetRange(
|
||||
context: vscode.ExtensionContext,
|
||||
spreadsheetId: string,
|
||||
range: string,
|
||||
values: SheetValues,
|
||||
): Promise<WriteResult | ApiFailure> {
|
||||
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
|
||||
if (!range.trim()) return { ok: false, error: 'range 비어있음' };
|
||||
if (!Array.isArray(values) || values.length === 0) return { ok: false, error: 'values 비어있음' };
|
||||
const tok = await getFreshAccessToken(context);
|
||||
if (!tok.ok) return { ok: false, error: tok.error };
|
||||
const url = `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}` +
|
||||
`?valueInputOption=USER_ENTERED`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tok.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ range, values, majorDimension: 'ROWS' }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const json: any = await res.json().catch(() => ({}));
|
||||
if (!res.ok) return { ok: false, error: json?.error?.message || `HTTP ${res.status}` };
|
||||
return { ok: true, updatedCells: Number(json.updatedCells ?? 0), updatedRange: json.updatedRange ?? range };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 값 추가 — range 안에서 *가장 마지막으로 데이터가 있는 행 아래* 에 새 행으로 append.
|
||||
* 로그·일지·트래킹 시트에 유용.
|
||||
*/
|
||||
export async function appendSheetRows(
|
||||
context: vscode.ExtensionContext,
|
||||
spreadsheetId: string,
|
||||
range: string,
|
||||
values: SheetValues,
|
||||
): Promise<AppendResult | ApiFailure> {
|
||||
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
|
||||
if (!range.trim()) return { ok: false, error: 'range 비어있음' };
|
||||
if (!Array.isArray(values) || values.length === 0) return { ok: false, error: 'values 비어있음' };
|
||||
const tok = await getFreshAccessToken(context);
|
||||
if (!tok.ok) return { ok: false, error: tok.error };
|
||||
const url = `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}` +
|
||||
`:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tok.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ range, values, majorDimension: 'ROWS' }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const json: any = await res.json().catch(() => ({}));
|
||||
if (!res.ok) return { ok: false, error: json?.error?.message || `HTTP ${res.status}` };
|
||||
const upd = json.updates ?? {};
|
||||
return {
|
||||
ok: true,
|
||||
updatedCells: Number(upd.updatedCells ?? 0),
|
||||
updatedRange: upd.updatedRange ?? range,
|
||||
appendedRange: upd.updatedRange ?? range,
|
||||
};
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────── 파싱 헬퍼 — action tag 본문 TSV 해석 ──────────────
|
||||
|
||||
/**
|
||||
* action tag 본문(여러 줄, 탭/파이프 구분) → 2D SheetValues 변환.
|
||||
* - 우선 탭(\t) 으로 split — 진짜 TSV. LLM 이 보통 이걸 emit.
|
||||
* - 탭이 한 칸도 없으면 ` | ` 파이프 구분으로 fallback (양 옆 공백 1개씩).
|
||||
* - 빈 줄은 무시 (trailing newline 안전).
|
||||
* 셀 값은 모두 문자열 — Sheets API 가 USER_ENTERED 로 자동 형변환.
|
||||
*/
|
||||
export function parseTsvBody(body: string): SheetValues {
|
||||
if (!body || typeof body !== 'string') return [];
|
||||
// 공백·탭·개행만 있는 입력은 빈 배열로 — LLM 이 빈 본문 emit 했을 때 안전.
|
||||
if (!body.trim()) return [];
|
||||
const trimmed = body.replace(/^\s*\n+/, '').replace(/\n+\s*$/, '');
|
||||
const lines = trimmed.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
||||
if (lines.length === 0) return [];
|
||||
const useTab = lines.some((l) => l.includes('\t'));
|
||||
return lines.map((line) =>
|
||||
useTab ? line.split('\t') : line.split(/\s*\|\s*/),
|
||||
);
|
||||
}
|
||||
|
||||
/** 결과 2D 배열을 LLM 친화적 짧은 마크다운 테이블로 (read 결과를 chat 에 주입할 때). */
|
||||
export function valuesToMarkdownTable(values: SheetValues, maxRows: number = 50): string {
|
||||
if (!values.length) return '_(empty)_';
|
||||
const truncated = values.slice(0, maxRows);
|
||||
const rendered = truncated.map((row) => '| ' + row.map((c) => String(c ?? '').replace(/\|/g, '\\|')).join(' | ') + ' |');
|
||||
if (rendered.length === 0) return '_(empty)_';
|
||||
// 헤더 구분선 — 첫 행을 헤더로 가정.
|
||||
const cols = truncated[0]?.length ?? 1;
|
||||
const sep = '|' + Array(cols).fill('---').join('|') + '|';
|
||||
const result = [rendered[0], sep, ...rendered.slice(1)].join('\n');
|
||||
if (values.length > maxRows) {
|
||||
return result + `\n_(... ${values.length - maxRows} more rows truncated)_`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user