v2.2.15: Astra Office Refactor & Multi-Service Integration
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Google Calendar API v3 — event create/list 호출.
|
||||
*
|
||||
* access token 은 caller 가 직접 주입한다. 만료 처리는 `withFreshAccessToken`
|
||||
* 헬퍼가 refresh token 으로 갱신 → 호출 → 401 발생 시 한 번 더 갱신 + 재시도.
|
||||
*
|
||||
* 외부 라이브러리(googleapis) 안 씀 — Calendar API 는 REST 라 native fetch 면 충분.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { refreshAccessToken } from './oauth';
|
||||
import { readCalendarConfig, writeCalendarConfig } from './calendarCache';
|
||||
|
||||
const API_BASE = 'https://www.googleapis.com/calendar/v3';
|
||||
|
||||
export interface CalendarEventInput {
|
||||
/** 일정 제목 (필수). */
|
||||
title: string;
|
||||
/** ISO 시작 시각 — 'YYYY-MM-DDTHH:MM' (로컬) 또는 'YYYY-MM-DDTHH:MM:SS±HH:MM' (timezone 포함). */
|
||||
start: string;
|
||||
/** ISO 종료 시각. 없으면 duration(분) 으로부터 계산. duration 도 없으면 60분. */
|
||||
end?: string;
|
||||
/** end 없을 때 시작부터 이만큼 (분 단위, default 60). */
|
||||
durationMinutes?: number;
|
||||
description?: string;
|
||||
location?: string;
|
||||
/** all-day 일정 여부 — true 면 start 는 'YYYY-MM-DD' 만 받음. */
|
||||
allDay?: boolean;
|
||||
}
|
||||
|
||||
export interface CreatedEvent {
|
||||
/** Google 이 발급한 event id. */
|
||||
id: string;
|
||||
/** Google Calendar 웹에서 열 수 있는 URL. */
|
||||
htmlLink: string;
|
||||
/** API 가 echo 해준 시작 시각. */
|
||||
startIso: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 생성. config 에 refresh token 이 있어야 함. access token 자동 갱신.
|
||||
*
|
||||
* 반환값:
|
||||
* ok: true → CreatedEvent
|
||||
* ok: false → 에러 메시지 (UI 표시용)
|
||||
*/
|
||||
export async function createCalendarEvent(
|
||||
context: vscode.ExtensionContext,
|
||||
input: CalendarEventInput,
|
||||
): Promise<{ ok: true; event: CreatedEvent } | { ok: false; error: string }> {
|
||||
const cfg = readCalendarConfig(context);
|
||||
const tokenResult = await _getFreshAccessToken(context);
|
||||
if (!tokenResult.ok) return { ok: false, error: tokenResult.error };
|
||||
|
||||
const body = _buildEventBody(input, cfg.defaultDurationMinutes ?? 60);
|
||||
if (!body.ok) return { ok: false, error: body.error };
|
||||
|
||||
const calId = (cfg.calendarId || 'primary').trim() || 'primary';
|
||||
const url = `${API_BASE}/calendars/${encodeURIComponent(calId)}/events`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenResult.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body.event),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const json: any = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg = json?.error?.message || `HTTP ${res.status}`;
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
event: {
|
||||
id: json.id,
|
||||
htmlLink: json.htmlLink,
|
||||
startIso: json.start?.dateTime ?? json.start?.date ?? input.start,
|
||||
title: input.title,
|
||||
},
|
||||
};
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Calendar API 요청 body 빌더 — 단위테스트 가능하도록 분리. */
|
||||
export function _buildEventBody(
|
||||
input: CalendarEventInput,
|
||||
fallbackDurationMin: number,
|
||||
): { ok: true; event: any } | { ok: false; error: string } {
|
||||
if (!input.title || !input.title.trim()) return { ok: false, error: 'title 비어있음' };
|
||||
if (!input.start || !input.start.trim()) return { ok: false, error: 'start 비어있음' };
|
||||
|
||||
if (input.allDay) {
|
||||
// all-day: date 형식만 (YYYY-MM-DD). end 는 exclusive 라 다음 날.
|
||||
const startDate = input.start.slice(0, 10);
|
||||
const endDate = input.end ? input.end.slice(0, 10) : _addDaysDate(startDate, 1);
|
||||
return {
|
||||
ok: true,
|
||||
event: {
|
||||
summary: input.title.trim(),
|
||||
description: input.description || undefined,
|
||||
location: input.location || undefined,
|
||||
start: { date: startDate },
|
||||
end: { date: endDate },
|
||||
reminders: { useDefault: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let endIso: string | undefined = input.end;
|
||||
if (!endIso) {
|
||||
const dur = (input.durationMinutes && input.durationMinutes > 0) ? input.durationMinutes : fallbackDurationMin;
|
||||
const computed = _addMinutesIso(input.start, dur);
|
||||
if (!computed) return { ok: false, error: `start 시각 형식 오류: ${input.start}` };
|
||||
endIso = computed;
|
||||
}
|
||||
|
||||
// Google Calendar 는 timezone 정보가 없으면 timeZone 필드 별도 필요.
|
||||
// 'YYYY-MM-DDTHH:MM' 처럼 timezone 빠진 입력은 OS 로컬 timezone 으로 가정.
|
||||
const hasOffset = /([+-]\d{2}:\d{2}|Z)$/.test(input.start);
|
||||
const timeZone = hasOffset ? undefined : Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
event: {
|
||||
summary: input.title.trim(),
|
||||
description: input.description || undefined,
|
||||
location: input.location || undefined,
|
||||
start: { dateTime: input.start, ...(timeZone ? { timeZone } : {}) },
|
||||
end: { dateTime: endIso, ...(timeZone ? { timeZone } : {}) },
|
||||
reminders: {
|
||||
useDefault: false,
|
||||
overrides: [
|
||||
{ method: 'popup', minutes: 5 },
|
||||
{ method: 'popup', minutes: 60 },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DDTHH:MM[:SS][±HH:MM|Z]' 에 분 더해 ISO 반환. 잘못된 형식이면 null. */
|
||||
export function _addMinutesIso(startIso: string, minutes: number): string | null {
|
||||
// 안전한 파싱: 명시적 정규식 → Date → ISO 재조립. timezone 정보 보존.
|
||||
const m = startIso.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})(:\d{2})?([+-]\d{2}:\d{2}|Z)?$/);
|
||||
if (!m) return null;
|
||||
const [, base, sec, tz] = m;
|
||||
const full = `${base}${sec ?? ':00'}${tz ?? ''}`;
|
||||
const t = new Date(full);
|
||||
if (Number.isNaN(t.getTime())) return null;
|
||||
const out = new Date(t.getTime() + minutes * 60 * 1000);
|
||||
// 원본이 timezone 정보 없는 로컬 시각이면 같은 포맷으로 돌려준다.
|
||||
if (!tz) {
|
||||
const yy = out.getFullYear();
|
||||
const MM = String(out.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(out.getDate()).padStart(2, '0');
|
||||
const hh = String(out.getHours()).padStart(2, '0');
|
||||
const mm = String(out.getMinutes()).padStart(2, '0');
|
||||
const ss = String(out.getSeconds()).padStart(2, '0');
|
||||
return `${yy}-${MM}-${dd}T${hh}:${mm}:${ss}`;
|
||||
}
|
||||
return out.toISOString();
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' + N 일. all-day 일정 end 계산용. */
|
||||
export function _addDaysDate(yyyymmdd: string, days: number): string {
|
||||
const m = yyyymmdd.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!m) return yyyymmdd;
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
d.setDate(d.getDate() + days);
|
||||
const yy = d.getFullYear(), MM = String(d.getMonth() + 1).padStart(2, '0'), dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${yy}-${MM}-${dd}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 access token 이 유효하면 그대로, 아니면 refresh. config 의 만료 시각 사용.
|
||||
* 갱신 후 만료 시각도 config 에 기록 — 다음 호출 때 불필요한 갱신 방지.
|
||||
*
|
||||
* Calendar / Sheets API 양쪽이 같은 token 을 공유한다 (scope 가 모두 같은 OAuth 에 포함).
|
||||
* 그래서 `_` prefix 떼고 export — Sheets API client 가 직접 호출.
|
||||
*/
|
||||
export async function getFreshAccessToken(
|
||||
context: vscode.ExtensionContext,
|
||||
): Promise<{ ok: true; accessToken: string } | { ok: false; error: string }> {
|
||||
const cfg = readCalendarConfig(context);
|
||||
if (!cfg.clientId || !cfg.clientSecret || !cfg.refreshToken) {
|
||||
return { ok: false, error: 'Google OAuth 가 설정되지 않았습니다. "Astra: Google Calendar OAuth 연결 (쓰기)" 명령으로 한 번 로그인하세요. (Calendar 와 Sheets 권한이 함께 발급됩니다.)' };
|
||||
}
|
||||
const now = Date.now();
|
||||
if (cfg.accessToken && cfg.accessTokenExpiresAt && cfg.accessTokenExpiresAt > now) {
|
||||
return { ok: true, accessToken: cfg.accessToken };
|
||||
}
|
||||
const r = await refreshAccessToken(cfg.clientId, cfg.clientSecret, cfg.refreshToken);
|
||||
if (!r.ok) return { ok: false, error: r.error };
|
||||
await writeCalendarConfig(context, { accessToken: r.accessToken, accessTokenExpiresAt: r.expiresAt });
|
||||
return { ok: true, accessToken: r.accessToken };
|
||||
}
|
||||
|
||||
// 내부 호출용 alias 유지 — 한 줄짜리라 비용 없음.
|
||||
const _getFreshAccessToken = getFreshAccessToken;
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Google Calendar (iCal) 캐시 — fetch + parse + 회사 _shared/calendar_cache.md 에 저장.
|
||||
*
|
||||
* Connect_origin 의 google_calendar.py 를 TypeScript / native fetch 로 옮김. OAuth 없음.
|
||||
* 사용자가 Google Calendar 설정 → "비공개 주소(iCal 형식)" 복사 → 본 모듈에 입력 한 번이면
|
||||
* 모든 agent 가 매 turn 자동으로 다가오는 일정 컨텍스트를 받는다.
|
||||
*
|
||||
* 보안:
|
||||
* - iCal URL 은 ExtensionContext.globalState 에 저장 (machine-local, git 침범 X).
|
||||
* - 캐시 파일은 회사 디렉토리 `_shared/calendar_cache.md` 에 평문 마크다운으로 저장.
|
||||
* 이 파일은 .gitignore 대상은 아니지만 일정 제목/시각이 들어있음 — 사용자가 commit
|
||||
* 안 하도록 가이드 문구를 README/명령에서 안내한다.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { parseIcs, selectUpcoming, IcsEvent } from './icsParser';
|
||||
|
||||
/** globalState 키 — iCal URL 과 부수 설정 한 묶음. */
|
||||
export const CAL_CONFIG_KEY = 'g1nation.calendar.ical';
|
||||
|
||||
export interface CalendarConfig {
|
||||
/** Google Calendar 비공개 iCal URL. 빈 문자열이면 iCal 읽기 비활성. */
|
||||
icalUrl: string;
|
||||
/** 며칠치 미리 가져올지 (default 14). */
|
||||
daysAhead: number;
|
||||
/** 마지막 성공 fetch ISO timestamp (자동 표시용). */
|
||||
lastFetchAt?: string;
|
||||
// ── OAuth (쓰기) 관련 필드 — Google Calendar API v3 호출에 사용. ──
|
||||
// 모두 ExtensionContext.globalState 에만 저장 (machine-local). 옵션이라 비어있어도 iCal 읽기는 동작.
|
||||
/** Google Cloud Console 에서 발급한 OAuth Client ID. */
|
||||
clientId?: string;
|
||||
/** 같은 페이지의 Client Secret (Desktop app 의 secret 은 공개 가능한 식별자). */
|
||||
clientSecret?: string;
|
||||
/** OAuth 로 받은 refresh token — 진짜 비밀. machine-local. */
|
||||
refreshToken?: string;
|
||||
/** Calendar API 가 쓰는 캘린더 식별자 — 'primary' 또는 특정 calendarId. */
|
||||
calendarId?: string;
|
||||
/** end 없는 이벤트 default 길이 (분). */
|
||||
defaultDurationMinutes?: number;
|
||||
/** 캐시된 access token (만료 전까지 재사용). */
|
||||
accessToken?: string;
|
||||
/** access token 만료 epoch ms. */
|
||||
accessTokenExpiresAt?: number;
|
||||
/** 연결된 Google 계정 이메일 (UI 표시용). */
|
||||
connectedAs?: string;
|
||||
/** OAuth 연결 시각 ISO. */
|
||||
connectedAt?: string;
|
||||
}
|
||||
|
||||
export function readCalendarConfig(context: vscode.ExtensionContext): CalendarConfig {
|
||||
const raw = context.globalState.get(CAL_CONFIG_KEY) as Partial<CalendarConfig> | undefined;
|
||||
return {
|
||||
icalUrl: typeof raw?.icalUrl === 'string' ? raw.icalUrl : '',
|
||||
daysAhead: typeof raw?.daysAhead === 'number' && raw.daysAhead > 0 ? raw.daysAhead : 14,
|
||||
lastFetchAt: typeof raw?.lastFetchAt === 'string' ? raw.lastFetchAt : undefined,
|
||||
clientId: typeof raw?.clientId === 'string' ? raw.clientId : undefined,
|
||||
clientSecret: typeof raw?.clientSecret === 'string' ? raw.clientSecret : undefined,
|
||||
refreshToken: typeof raw?.refreshToken === 'string' ? raw.refreshToken : undefined,
|
||||
calendarId: typeof raw?.calendarId === 'string' ? raw.calendarId : undefined,
|
||||
defaultDurationMinutes: typeof raw?.defaultDurationMinutes === 'number' ? raw.defaultDurationMinutes : undefined,
|
||||
accessToken: typeof raw?.accessToken === 'string' ? raw.accessToken : undefined,
|
||||
accessTokenExpiresAt: typeof raw?.accessTokenExpiresAt === 'number' ? raw.accessTokenExpiresAt : undefined,
|
||||
connectedAs: typeof raw?.connectedAs === 'string' ? raw.connectedAs : undefined,
|
||||
connectedAt: typeof raw?.connectedAt === 'string' ? raw.connectedAt : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeCalendarConfig(context: vscode.ExtensionContext, patch: Partial<CalendarConfig>): Promise<void> {
|
||||
const cur = readCalendarConfig(context);
|
||||
const next: CalendarConfig = { ...cur, ...patch };
|
||||
await context.globalState.update(CAL_CONFIG_KEY, next);
|
||||
}
|
||||
|
||||
/** 회사 디렉토리 내부 캐시 파일 경로. workspace 없으면 globalStorage 로 fallback. */
|
||||
function _cachePath(context: vscode.ExtensionContext): string {
|
||||
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
if (ws) return path.join(ws, '.astra', 'company', '_shared', 'calendar_cache.md');
|
||||
return path.join(context.globalStorageUri.fsPath, 'company', '_shared', 'calendar_cache.md');
|
||||
}
|
||||
|
||||
export interface RefreshResult {
|
||||
ok: boolean;
|
||||
count: number;
|
||||
error?: string;
|
||||
cachePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* iCal URL 에서 fetch → ICS 파싱 → upcoming 필터 → 마크다운 캐시 파일에 쓰기.
|
||||
* URL 비어있으면 즉시 ok:false 반환 (사용자 안내는 호출자가).
|
||||
*/
|
||||
export async function refreshCalendarCache(context: vscode.ExtensionContext): Promise<RefreshResult> {
|
||||
const cfg = readCalendarConfig(context);
|
||||
const cachePath = _cachePath(context);
|
||||
if (!cfg.icalUrl) {
|
||||
return { ok: false, count: 0, error: 'iCal URL 이 설정되지 않았습니다. 명령 팔레트에서 "Astra: Google Calendar 연결" 을 먼저 실행하세요.', cachePath };
|
||||
}
|
||||
if (!/^https?:\/\//.test(cfg.icalUrl)) {
|
||||
return { ok: false, count: 0, error: 'URL 이 http:// 또는 https:// 로 시작하지 않습니다.', cachePath };
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
// Node 18+ 의 native fetch 사용 — axios / node-fetch 의존성 없이.
|
||||
const res = await fetch(cfg.icalUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': 'Astra-Calendar/1.0' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { ok: false, count: 0, error: `HTTP ${res.status} — URL 이 잘못됐거나 만료됐을 수 있습니다.`, cachePath };
|
||||
}
|
||||
raw = await res.text();
|
||||
} catch (e: any) {
|
||||
return { ok: false, count: 0, error: `다운로드 실패: ${e?.message ?? String(e)}`, cachePath };
|
||||
}
|
||||
|
||||
const events = parseIcs(raw);
|
||||
const upcoming = selectUpcoming(events, cfg.daysAhead);
|
||||
|
||||
const now = new Date();
|
||||
const md = _renderMarkdown(upcoming, cfg.daysAhead, now);
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||
fs.writeFileSync(cachePath, md, 'utf8');
|
||||
} catch (e: any) {
|
||||
return { ok: false, count: 0, error: `캐시 저장 실패: ${e?.message ?? String(e)}`, cachePath };
|
||||
}
|
||||
|
||||
await writeCalendarConfig(context, { lastFetchAt: now.toISOString() });
|
||||
return { ok: true, count: upcoming.length, cachePath };
|
||||
}
|
||||
|
||||
/** Agent prompt 에 주입할 캐시 본문 읽기. 없으면 빈 문자열. */
|
||||
export function readCalendarCache(context: vscode.ExtensionContext): string {
|
||||
const cachePath = _cachePath(context);
|
||||
try {
|
||||
if (!fs.existsSync(cachePath)) return '';
|
||||
return fs.readFileSync(cachePath, 'utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function _renderMarkdown(events: IcsEvent[], daysAhead: number, now: Date): string {
|
||||
const tsLabel = (d: Date, allDay: boolean) => {
|
||||
const yy = d.getFullYear(), mm = String(d.getMonth() + 1).padStart(2, '0'), dd = String(d.getDate()).padStart(2, '0');
|
||||
const wk = ['일', '월', '화', '수', '목', '금', '토'][d.getDay()];
|
||||
if (allDay) return `${yy}-${mm}-${dd} (${wk})`;
|
||||
const HH = String(d.getHours()).padStart(2, '0'), MM = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${yy}-${mm}-${dd} (${wk}) ${HH}:${MM}`;
|
||||
};
|
||||
const lines: string[] = [
|
||||
'# 📅 다가오는 일정 (Google Calendar)',
|
||||
`_업데이트: ${tsLabel(now, false)} · 향후 ${daysAhead}일_`,
|
||||
'',
|
||||
];
|
||||
if (events.length === 0) {
|
||||
lines.push('_없음_');
|
||||
} else {
|
||||
for (const ev of events) {
|
||||
const ts = tsLabel(ev.start, ev.allDay);
|
||||
const loc = ev.location ? ` — 📍 ${ev.location}` : '';
|
||||
lines.push(`- **${ts}** · ${ev.summary}${loc}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Minimal ICS parser — no library deps. Connect_origin 의 Python 버전을
|
||||
* 그대로 옮겼고, 본 함수는 *pure* 라서 단위테스트가 쉽다.
|
||||
*
|
||||
* 처리 범위:
|
||||
* - VEVENT 블록 추출
|
||||
* - line continuation (다음 줄이 공백 시작) 펼치기
|
||||
* - SUMMARY / DESCRIPTION / LOCATION / DTSTART / DTEND 필드
|
||||
* - DTSTART;VALUE=DATE → all-day 이벤트 표시
|
||||
* - YYYYMMDD / YYYYMMDDTHHMMSS / ...Z (UTC) 포맷 모두 처리
|
||||
*
|
||||
* 처리 안 함 (필요해지면 v2):
|
||||
* - RRULE 반복 일정 (단일 instance 만 표시)
|
||||
* - TZID 타임존 변환 (UTC 가 아니면 로컬로 가정)
|
||||
* - VTIMEZONE 블록
|
||||
* - 첨부 / 참석자 / 알림
|
||||
*/
|
||||
|
||||
export interface IcsEvent {
|
||||
/** 시작 시각 (로컬 Date 기준). all-day 일정은 자정. */
|
||||
start: Date;
|
||||
/** 종료 시각. 없으면 undefined. */
|
||||
end?: Date;
|
||||
/** 제목 (없으면 '(제목 없음)'). */
|
||||
summary: string;
|
||||
location: string;
|
||||
description: string;
|
||||
/** DTSTART 가 VALUE=DATE 형식이었으면 true — 시각은 무시하고 날짜만 의미. */
|
||||
allDay: boolean;
|
||||
}
|
||||
|
||||
/** 한 문자열의 ICS 본문을 받아 VEVENT 들을 배열로 반환. 잘못된 입력은 빈 배열. */
|
||||
export function parseIcs(raw: string): IcsEvent[] {
|
||||
if (!raw || typeof raw !== 'string') return [];
|
||||
// Line continuation: ICS 는 75자 wrap 시 다음 줄이 공백/탭으로 시작 → 합쳐줘야 한다.
|
||||
const unfolded = raw.replace(/\r?\n[ \t]/g, '');
|
||||
const events: IcsEvent[] = [];
|
||||
let cur: Record<string, string> | null = null;
|
||||
let curDateOnly: Record<'start' | 'end', boolean> = { start: false, end: false };
|
||||
for (const rawLine of unfolded.split('\n')) {
|
||||
const line = rawLine.replace(/\r$/, '');
|
||||
if (line === 'BEGIN:VEVENT') {
|
||||
cur = {};
|
||||
curDateOnly = { start: false, end: false };
|
||||
} else if (line === 'END:VEVENT') {
|
||||
if (cur) {
|
||||
const ev = _toEvent(cur, curDateOnly);
|
||||
if (ev) events.push(ev);
|
||||
}
|
||||
cur = null;
|
||||
} else if (cur && line.includes(':')) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
const keyPart = line.slice(0, colonIdx);
|
||||
const value = line.slice(colonIdx + 1);
|
||||
const base = keyPart.split(';', 1)[0];
|
||||
if (base === 'SUMMARY' || base === 'DESCRIPTION' || base === 'LOCATION'
|
||||
|| base === 'DTSTART' || base === 'DTEND') {
|
||||
cur[base] = value;
|
||||
if ((base === 'DTSTART' || base === 'DTEND') && keyPart.includes(';VALUE=DATE')) {
|
||||
curDateOnly[base === 'DTSTART' ? 'start' : 'end'] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/** 시작 시각 기준 오름차순 정렬 + 현재 시각 - 1시간 ~ 미래 cutoffDays 범위만 필터. */
|
||||
export function selectUpcoming(events: IcsEvent[], daysAhead: number, now: Date = new Date()): IcsEvent[] {
|
||||
const past = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
|
||||
const cutoff = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000);
|
||||
return events
|
||||
.filter((e) => e.start >= past && e.start <= cutoff)
|
||||
.sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
}
|
||||
|
||||
function _toEvent(raw: Record<string, string>, dateOnly: { start: boolean; end: boolean }): IcsEvent | null {
|
||||
const start = _parseDt(raw.DTSTART ?? '');
|
||||
if (!start) return null;
|
||||
const end = _parseDt(raw.DTEND ?? '');
|
||||
return {
|
||||
start,
|
||||
end: end ?? undefined,
|
||||
summary: _unescape(raw.SUMMARY ?? '(제목 없음)'),
|
||||
location: _unescape(raw.LOCATION ?? ''),
|
||||
description: _unescape(raw.DESCRIPTION ?? ''),
|
||||
allDay: dateOnly.start,
|
||||
};
|
||||
}
|
||||
|
||||
function _parseDt(s: string): Date | null {
|
||||
if (!s) return null;
|
||||
const trimmed = s.trim();
|
||||
// Strip trailing Z (UTC marker — Date 파싱 시 자동 처리되지 않으므로 명시 변환).
|
||||
const utc = trimmed.endsWith('Z');
|
||||
const core = utc ? trimmed.slice(0, -1) : trimmed;
|
||||
// Two valid forms: YYYYMMDDTHHMMSS or YYYYMMDD
|
||||
const m = core.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2}))?$/);
|
||||
if (!m) return null;
|
||||
const [, yy, mm, dd, HH, MM, SS] = m;
|
||||
const year = parseInt(yy, 10), month = parseInt(mm, 10) - 1, day = parseInt(dd, 10);
|
||||
const hour = HH ? parseInt(HH, 10) : 0;
|
||||
const min = MM ? parseInt(MM, 10) : 0;
|
||||
const sec = SS ? parseInt(SS, 10) : 0;
|
||||
if (utc) {
|
||||
return new Date(Date.UTC(year, month, day, hour, min, sec));
|
||||
}
|
||||
return new Date(year, month, day, hour, min, sec);
|
||||
}
|
||||
|
||||
function _unescape(s: string): string {
|
||||
// ICS literal escapes: \, \; \n \\
|
||||
return s.replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\n/g, ' ').replace(/\\\\/g, '\\');
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export {
|
||||
parseIcs,
|
||||
selectUpcoming,
|
||||
IcsEvent,
|
||||
} from './icsParser';
|
||||
|
||||
export {
|
||||
CAL_CONFIG_KEY,
|
||||
CalendarConfig,
|
||||
readCalendarConfig,
|
||||
writeCalendarConfig,
|
||||
refreshCalendarCache,
|
||||
readCalendarCache,
|
||||
RefreshResult,
|
||||
} from './calendarCache';
|
||||
|
||||
export {
|
||||
runOAuthLoopback,
|
||||
refreshAccessToken,
|
||||
fetchUserEmail,
|
||||
OAuthResult,
|
||||
OAuthFailure,
|
||||
} from './oauth';
|
||||
|
||||
export {
|
||||
createCalendarEvent,
|
||||
CalendarEventInput,
|
||||
CreatedEvent,
|
||||
_buildEventBody,
|
||||
_addMinutesIso,
|
||||
_addDaysDate,
|
||||
} from './calendarApi';
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Google OAuth 2.0 — loopback (Desktop app) 흐름.
|
||||
*
|
||||
* Google 은 Desktop 앱 OAuth client 에 대해 http://127.0.0.1:<ephemeral_port>
|
||||
* redirect URI 를 허용한다. 본 모듈은:
|
||||
* 1. ephemeral port 에 일회용 HTTP 서버 띄움
|
||||
* 2. 사용자 브라우저로 Google 로그인 페이지 열기
|
||||
* 3. 콜백으로 code 받기
|
||||
* 4. code → access/refresh token 교환
|
||||
* 5. 서버 종료
|
||||
*
|
||||
* 보안: refresh token 은 호출자가 globalState 에 저장 (machine-local).
|
||||
* Client ID/Secret 도 같이 저장하지만, Desktop app 의 client secret 은
|
||||
* Google 가이드에 따라 *공개 가능* (혼동 방지: 진짜 비밀이 아니라 식별자).
|
||||
* refresh token 이 사실상 진짜 비밀.
|
||||
*/
|
||||
|
||||
import * as http from 'http';
|
||||
import * as crypto from 'crypto';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
// Calendar 와 Sheets 양쪽 권한을 한 번에 요청 — 사용자가 OAuth 한 번 하면 둘 다 동작.
|
||||
// 옛 사용자(Calendar 만 연결)는 Sheets 사용 시 권한 부족 에러 → 재연결 필요.
|
||||
const SCOPE = [
|
||||
'https://www.googleapis.com/auth/calendar.events',
|
||||
'https://www.googleapis.com/auth/spreadsheets',
|
||||
'openid',
|
||||
'email',
|
||||
].join(' ');
|
||||
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
|
||||
export interface OAuthResult {
|
||||
ok: true;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
/** Google 가 동의한 scope 들 (공백 구분). */
|
||||
scope: string;
|
||||
/** access token 만료 epoch ms. */
|
||||
expiresAt: number;
|
||||
}
|
||||
export interface OAuthFailure { ok: false; error: string; }
|
||||
|
||||
/**
|
||||
* 풀 OAuth flow — 사용자가 cancel 누르면 cancelToken trip → ok:false.
|
||||
*
|
||||
* @param clientId Google Cloud 의 OAuth Client ID
|
||||
* @param clientSecret 같은 페이지의 Client Secret
|
||||
* @param cancelToken VS Code Progress 의 취소 토큰
|
||||
*/
|
||||
export async function runOAuthLoopback(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
cancelToken: vscode.CancellationToken,
|
||||
): Promise<OAuthResult | OAuthFailure> {
|
||||
return new Promise<OAuthResult | OAuthFailure>((resolve) => {
|
||||
let _settled = false;
|
||||
const settle = (v: OAuthResult | OAuthFailure) => { if (_settled) return; _settled = true; resolve(v); };
|
||||
|
||||
// CSRF 방어 — state 파라미터 검증.
|
||||
const expectedState = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
||||
const code = url.searchParams.get('code');
|
||||
const err = url.searchParams.get('error');
|
||||
const stateParam = url.searchParams.get('state');
|
||||
// favicon / 빈 callback 요청은 무시 (브라우저 자동 요청)
|
||||
if (!code && !err) { res.writeHead(204); res.end(); return; }
|
||||
if (stateParam !== expectedState) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('state mismatch — possible CSRF. 다시 시도하세요.');
|
||||
server.close();
|
||||
settle({ ok: false, error: 'state mismatch' });
|
||||
return;
|
||||
}
|
||||
if (err) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(_renderHtml('실패', err, false));
|
||||
server.close();
|
||||
settle({ ok: false, error: err });
|
||||
return;
|
||||
}
|
||||
// Got the code — exchange for tokens.
|
||||
const port = (server.address() as any)?.port;
|
||||
const redirectUri = `http://127.0.0.1:${port}`;
|
||||
const body = new URLSearchParams({
|
||||
code: code!,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
try {
|
||||
const tokenRes = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const json: any = await tokenRes.json().catch(() => ({}));
|
||||
if (!tokenRes.ok || !json.access_token || !json.refresh_token) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(_renderHtml('토큰 교환 실패', JSON.stringify(json).slice(0, 300), false));
|
||||
server.close();
|
||||
settle({ ok: false, error: json.error_description || json.error || 'no refresh_token in response' });
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(_renderHtml('연결 완료', '이 탭은 닫아도 됩니다.', true));
|
||||
server.close();
|
||||
settle({
|
||||
ok: true,
|
||||
accessToken: json.access_token,
|
||||
refreshToken: json.refresh_token,
|
||||
scope: json.scope ?? '',
|
||||
expiresAt: Date.now() + (Number(json.expires_in ?? 3600) - 60) * 1000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(_renderHtml('네트워크 오류', e?.message ?? String(e), false));
|
||||
server.close();
|
||||
settle({ ok: false, error: e?.message ?? String(e) });
|
||||
}
|
||||
} catch (e: any) {
|
||||
try { res.writeHead(500); res.end(); } catch { /* ignore */ }
|
||||
server.close();
|
||||
settle({ ok: false, error: e?.message ?? String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
// ephemeral port (0) — Desktop OAuth client 는 어떤 localhost port 도 허용.
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = (server.address() as any)?.port;
|
||||
const redirectUri = `http://127.0.0.1:${port}`;
|
||||
const authUrl = `${AUTH_URL}?` + new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: SCOPE,
|
||||
access_type: 'offline',
|
||||
prompt: 'consent', // refresh_token 을 *항상* 발급받기 위해 강제 (Google 의 default 는 처음 한 번만 발급).
|
||||
state: expectedState,
|
||||
}).toString();
|
||||
void vscode.env.openExternal(vscode.Uri.parse(authUrl));
|
||||
});
|
||||
|
||||
// 사용자 cancel 시 서버 닫고 종료.
|
||||
cancelToken.onCancellationRequested(() => {
|
||||
try { server.close(); } catch { /* ignore */ }
|
||||
settle({ ok: false, error: 'cancelled' });
|
||||
});
|
||||
|
||||
// 안전망 — 5분 무응답 시 자동 종료.
|
||||
const tHandle = setTimeout(() => {
|
||||
try { server.close(); } catch { /* ignore */ }
|
||||
settle({ ok: false, error: 'timeout (5분)' });
|
||||
}, 5 * 60 * 1000);
|
||||
cancelToken.onCancellationRequested(() => clearTimeout(tHandle));
|
||||
});
|
||||
}
|
||||
|
||||
/** refresh_token 으로 새 access_token 발급. 만료된 access token 자동 갱신용. */
|
||||
export async function refreshAccessToken(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
refreshToken: string,
|
||||
): Promise<{ ok: true; accessToken: string; expiresAt: number } | { ok: false; error: string }> {
|
||||
const body = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
});
|
||||
try {
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const json: any = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !json.access_token) {
|
||||
return { ok: false, error: json.error_description || json.error || `HTTP ${res.status}` };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
accessToken: json.access_token,
|
||||
expiresAt: Date.now() + (Number(json.expires_in ?? 3600) - 60) * 1000,
|
||||
};
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/** access token 으로 사용자 이메일 조회 — 누가 연결됐는지 보여주기 위함. */
|
||||
export async function fetchUserEmail(accessToken: string): Promise<string> {
|
||||
try {
|
||||
const res = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return '';
|
||||
const json: any = await res.json();
|
||||
return json?.email ?? json?.name ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 셋업 완료 페이지 — 깔끔한 메시지 + 자동으로 탭 닫기 안내. */
|
||||
function _renderHtml(title: string, msg: string, success: boolean): string {
|
||||
const color = success ? '#10b981' : '#ef4444';
|
||||
const icon = success ? '✅' : '⚠️';
|
||||
return `<!doctype html><html lang="ko"><head><meta charset="utf-8">
|
||||
<title>Astra · ${title}</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0b1018;color:#e2e8f0;font-family:-apple-system,system-ui,sans-serif}
|
||||
.card{background:rgba(20,28,40,.96);border:1px solid ${color};border-radius:14px;padding:36px 32px;max-width:420px;text-align:center;box-shadow:0 24px 80px rgba(0,0,0,.6)}
|
||||
.icon{font-size:48px;margin-bottom:12px}
|
||||
h1{font-size:20px;margin-bottom:12px;color:${color}}
|
||||
p{color:#94a3b8;font-size:13px;line-height:1.6}
|
||||
small{color:#475569;font-size:11px;margin-top:18px;display:block}
|
||||
</style></head><body>
|
||||
<div class="card"><div class="icon">${icon}</div><h1>${title}</h1><p>${_esc(msg)}</p><small>이 탭은 닫아도 됩니다.</small></div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function _esc(s: string): string {
|
||||
return String(s).replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } as Record<string, string>)[c],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user