/** * 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) { // 리프레시 토큰 자체가 만료/철회된 경우 — raw 구글 에러("Token has been // expired or revoked.")만 보여주면 사용자가 복구 방법을 알 수 없다. // 재연결 명령 + 흔한 근본 원인(OAuth 동의 화면 '테스트' 모드 = 7일 만료) 안내. if (/expired|revoked|invalid_grant/i.test(r.error)) { return { ok: false, error: 'Google OAuth 토큰이 만료/철회되었습니다 — "Astra: Google Calendar OAuth 연결 (쓰기)" 명령으로 재연결하세요. ' + "(자주 반복되면: Google Cloud Console → OAuth 동의 화면이 '테스트' 모드인지 확인 — 테스트 모드는 리프레시 토큰이 7일마다 만료되며, '앱 게시'로 프로덕션 전환 시 만료되지 않습니다.) " + `원인: ${r.error}`, }; } 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;