v2.2.15: Astra Office Refactor & Multi-Service Integration

This commit is contained in:
g1nation
2026-05-16 22:07:06 +09:00
parent 9dcc98ad33
commit 9ca95ab997
46 changed files with 5648 additions and 1299 deletions
+13
View File
@@ -0,0 +1,13 @@
export {
readSheetRange,
writeSheetRange,
appendSheetRows,
parseTsvBody,
valuesToMarkdownTable,
SheetCell,
SheetValues,
ReadResult,
WriteResult,
AppendResult,
ApiFailure,
} from './sheetsApi';
+166
View File
@@ -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;
}