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;
|
||||
Reference in New Issue
Block a user