@dayflow/google-sync
@dayflow/google-sync๋ DayFlow๋ฅผ Google Calendar REST API v3์ ์ฐ๊ฒฐํฉ๋๋ค. @dayflow/caldav์๋ ๋ณ๊ฐ์ ํจํค์ง๋ก, CalDAV๊ฐ ์๋๋ผ Google Calendar API์ ์ง์ ํต์ ํฉ๋๋ค.
์ค์น
๋น ๋ฅธ ์์
import { useRef, useEffect, useState } from 'react';
import {
DayFlowCalendar,
useCalendarApp,
createMonthView,
} from '@dayflow/react';
import {
attachGoogleSyncToDayFlow,
createGoogleSync,
createGoogleSyncAdapter,
type GoogleDayFlowController,
type GoogleSyncStatus,
} from '@dayflow/google-sync';
function MyCalendar() {
const calendar = useCalendarApp({
views: [createMonthView()],
calendars: [],
events: [],
});
const controllerRef = useRef<GoogleDayFlowController | null>(null);
const [syncStatus, setSyncStatus] = useState<GoogleSyncStatus>({
state: 'idle',
});
useEffect(() => {
if (controllerRef.current) return;
const adapter = createGoogleSyncAdapter({
baseUrl: '/api/google-calendar',
});
const sync = createGoogleSync(adapter);
const controller = attachGoogleSyncToDayFlow(calendar.app, sync, {
writable: true,
onStatusChange: setSyncStatus,
onWriteError: (error, ctx) =>
console.error(`[google-sync] ${ctx.action} failed:`, error.message),
onSyncComplete: delta => {
console.log(
`Sync done: +${delta.events.added} ~${delta.events.updated} -${delta.events.deleted}`
);
},
});
controllerRef.current = controller;
controller.start();
return () => {
controller.stop();
controllerRef.current = null;
};
}, [calendar.app]);
return <DayFlowCalendar calendar={calendar} />;
}<template>
<DayFlowCalendar :calendar="calendar" />
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { DayFlowCalendar, useCalendarApp } from '@dayflow/vue';
import { createMonthView } from '@dayflow/core';
import {
attachGoogleSyncToDayFlow,
createGoogleSync,
createGoogleSyncAdapter,
} from '@dayflow/google-sync';
const calendar = useCalendarApp({
views: [createMonthView()],
calendars: [],
events: [],
});
const syncStatus = ref({ state: 'idle' });
let controller;
onMounted(() => {
const adapter = createGoogleSyncAdapter({
baseUrl: '/api/google-calendar',
});
const sync = createGoogleSync(adapter);
controller = attachGoogleSyncToDayFlow(calendar.app, sync, {
writable: true,
onStatusChange: status => {
syncStatus.value = status;
},
onWriteError: (error, ctx) =>
console.error(`[google-sync] ${ctx.action} failed:`, error.message),
onSyncComplete: delta => {
console.log(
`Sync done: +${delta.events.added} ~${delta.events.updated} -${delta.events.deleted}`
);
},
});
controller.start();
});
onBeforeUnmount(() => {
controller?.stop();
});
</script>import { Component, OnInit, OnDestroy } from '@angular/core';
import { CalendarApp, createMonthView } from '@dayflow/core';
import { DayFlowCalendarModule } from '@dayflow/angular';
import {
attachGoogleSyncToDayFlow,
createGoogleSync,
createGoogleSyncAdapter,
type GoogleDayFlowController,
type GoogleSyncStatus,
} from '@dayflow/google-sync';
@Component({
selector: 'app-root',
standalone: true,
imports: [DayFlowCalendarModule],
template: `<dayflow-calendar [calendar]="calendar"></dayflow-calendar>`,
})
export class AppComponent implements OnInit, OnDestroy {
calendar = new CalendarApp({
views: [createMonthView()],
calendars: [],
events: [],
});
syncStatus: GoogleSyncStatus = { state: 'idle' };
private controller?: GoogleDayFlowController;
ngOnInit() {
const adapter = createGoogleSyncAdapter({
baseUrl: '/api/google-calendar',
});
const sync = createGoogleSync(adapter);
this.controller = attachGoogleSyncToDayFlow(this.calendar, sync, {
writable: true,
onStatusChange: status => {
this.syncStatus = status;
},
onWriteError: (error, ctx) =>
console.error(`[google-sync] ${ctx.action} failed:`, error.message),
onSyncComplete: delta => {
console.log(
`Sync done: +${delta.events.added} ~${delta.events.updated} -${delta.events.deleted}`
);
},
});
this.controller.start();
}
ngOnDestroy() {
this.controller?.stop();
}
}<script>
import { onMount, onDestroy } from 'svelte';
import { DayFlowCalendar, useCalendarApp } from '@dayflow/svelte';
import { createMonthView } from '@dayflow/core';
import {
attachGoogleSyncToDayFlow,
createGoogleSync,
createGoogleSyncAdapter,
} from '@dayflow/google-sync';
const calendar = useCalendarApp({
views: [createMonthView()],
calendars: [],
events: [],
});
let syncStatus = { state: 'idle' };
let controller;
onMount(() => {
const adapter = createGoogleSyncAdapter({
baseUrl: '/api/google-calendar',
});
const sync = createGoogleSync(adapter);
controller = attachGoogleSyncToDayFlow(calendar.app, sync, {
writable: true,
onStatusChange: status => {
syncStatus = status;
},
onWriteError: (error, ctx) =>
console.error(`[google-sync] ${ctx.action} failed:`, error.message),
onSyncComplete: delta => {
console.log(
`Sync done: +${delta.events.added} ~${delta.events.updated} -${delta.events.deleted}`
);
},
});
controller.start();
});
onDestroy(() => {
controller?.stop();
});
</script>
<DayFlowCalendar {calendar} />ํ ํฐ ์ฃผ์
getToken ์ฌ์ฉํ๊ธฐ (ํด๋ผ์ด์ธํธ ํ ํฐ์ ๊ถ์ฅ)
Supabase ์ธ์
์ด๋ ์ธ์ฆ ์ ๊ณต์์์์ฒ๋ผ ๋ฐํ์์ ํ ํฐ์ ์ป์ ์ ์๋ค๋ฉด getToken์ ์ฌ์ฉํ์ธ์. ๋ชจ๋ ์์ฒญ ์ ์ ํธ์ถ๋๋ฏ๋ก ํ ํฐ ๊ฐฑ์ ์ด ํฌ๋ช
ํ๊ฒ ์ฒ๋ฆฌ๋๊ณ , ํ ํฐ์ด ๋ฐ๋์ด๋ ์ด๋ํฐ๋ฅผ ๋ค์ ๋ง๋ค ํ์๊ฐ ์์ต๋๋ค.
const adapter = createGoogleSyncAdapter({
getToken: async () => {
const { data } = await supabase.auth.getSession();
return data.session?.provider_token ?? '';
},
});๋ฐฑ์๋ ํ๋ก์ ์ฌ์ฉํ๊ธฐ (์ด์ ํ๊ฒฝ์ ๊ถ์ฅ)
Google Calendar API๋ CORS๋ฅผ ์ง์ํ์ง๋ง, ๋ธ๋ผ์ฐ์ ์์ OAuth ํ ํฐ์ ๋ณด๋ด๋ฉด ๋คํธ์ํฌ ์์ฒญ์ ๋ค์ฌ๋ค๋ณผ ์ ์๋ ๋๊ตฌ์๊ฒ๋ ํ ํฐ์ด ๋ ธ์ถ๋ฉ๋๋ค. ์ด์ ํ๊ฒฝ์์๋ ํ ํฐ์ ์๋ฒ์ ๋์ธ์:
const adapter = createGoogleSyncAdapter({
baseUrl: '/api/google-calendar',
// No getToken needed โ your proxy injects Authorization
});// proxy.mjs (Node.js example)
import { createServer } from 'node:http';
const {
GOOGLE_ACCESS_TOKEN,
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
GOOGLE_REFRESH_TOKEN,
PORT = '3002',
} = process.env;
const GOOGLE_API_BASE = 'https://www.googleapis.com/calendar/v3';
const ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
let cachedToken = GOOGLE_ACCESS_TOKEN
? { token: GOOGLE_ACCESS_TOKEN, expiresAt: Infinity }
: null;
async function getAccessToken() {
if (cachedToken && cachedToken.expiresAt > Date.now() + 30_000) {
return cachedToken.token;
}
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
refresh_token: GOOGLE_REFRESH_TOKEN,
grant_type: 'refresh_token',
}),
});
const data = await res.json();
cachedToken = {
token: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
return cachedToken.token;
}
createServer(async (req, res) => {
const upstreamPath = req.url.replace(/^\/api\/google-calendar/, '');
const upstreamUrl = `${GOOGLE_API_BASE}${upstreamPath}`;
if (!ALLOWED_METHODS.has(req.method)) {
res.writeHead(405);
res.end();
return;
}
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body =
req.method === 'GET' || req.method === 'DELETE'
? undefined
: Buffer.concat(chunks).toString();
const token = await getAccessToken();
const upstream = await fetch(upstreamUrl, {
method: req.method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(req.headers['if-match']
? { 'If-Match': req.headers['if-match'] }
: {}),
},
body,
});
const responseBody = upstream.status === 204 ? '' : await upstream.text();
res.writeHead(upstream.status, { 'Content-Type': 'application/json' });
res.end(responseBody);
}).listen(Number(PORT));ํ๋ก์๋ฅผ ์คํํฉ๋๋ค:
# Quick test with an access token
GOOGLE_ACCESS_TOKEN=ya29.xxx node proxy.mjs
# Long-lived with refresh token
GOOGLE_CLIENT_ID=xxx \
GOOGLE_CLIENT_SECRET=xxx \
GOOGLE_REFRESH_TOKEN=xxx \
node proxy.mjsํ
์คํธ์ฉ ์ก์ธ์ค ํ ํฐ์ Google OAuth 2.0 Playground์์ ๋ง๋ค ์ ์์ต๋๋ค. https://www.googleapis.com/auth/calendar ์ค์ฝํ๋ฅผ ์ ํํ์ธ์.
๋๊ธฐํ ํ ํฐ ์์ํ
๊ธฐ๋ณธ์ ์ผ๋ก Google Calendar ๋๊ธฐํ ํ ํฐ์ ๋ฉ๋ชจ๋ฆฌ์๋ง ์ ์ฅ๋์ด ํ์ด์ง๋ฅผ ์๋ก ๊ณ ์น๋ฉด ์ฌ๋ผ์ง๊ณ , ๋ค์ ์ธ์
์์ ์ ์ฒด ๋๊ธฐํ๋ฅผ ํ๊ฒ ๋ฉ๋๋ค. GoogleSyncStorage ๊ตฌํ์ ์ ๊ณตํด ํ ํฐ์ ๋ณด์กดํ์ธ์:
import { createGoogleSync, type GoogleSyncStorage } from '@dayflow/google-sync';
const storage: GoogleSyncStorage = {
getSyncToken: async calendarId =>
localStorage.getItem(`google-token:${calendarId}`),
setSyncToken: async (calendarId, token) =>
token
? localStorage.setItem(`google-token:${calendarId}`, token)
: localStorage.removeItem(`google-token:${calendarId}`),
};
const sync = createGoogleSync(adapter, { storage });storage๋ฅผ ์ฐ๊ฒฐํ๋ฉด ๊ฐ ์ธ์
์ด ์ ์ฒด ์ด๋ฒคํธ๋ฅผ ์ฒ์๋ถํฐ ๊ฐ์ ธ์ค๋ ๋์ ์ฆ๋ถ ๋๊ธฐํ๋ก ์์ํฉ๋๋ค.
๋ก์ปฌ ์บ์๋ก ์ด๊ธฐํํ๊ธฐ
๋๊ธฐํ๋ ์ด๋ฒคํธ๋ฅผ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ ๋ก์ปฌ ์ ์ฅ์์ ๋ณด๊ดํ๋ค๋ฉด, ์ฒซ ์๊ฒฉ ๋๊ธฐํ ์ ์ DayFlow๋ฅผ ๋ฏธ๋ฆฌ ์ฑ์ ์บ๋ฆฐ๋๋ฅผ ์ฆ์ ํ์ํ ์ ์์ต๋๋ค:
const controller = attachGoogleSyncToDayFlow(calendar.app, sync, {
getInitialSnapshot: async () => {
const { calendars, events } = await loadFromLocalDB();
return { calendars, events };
},
onSyncComplete: delta => {
// Save what changed to your local store
saveChanges(delta);
},
onWriteComplete: (operation, event) => {
// Update local store after a successful write-back
persistEvent(operation, event);
},
});getInitialSnapshot์ Google Calendar API ์์ฒญ์ด ์ผ์ด๋๊ธฐ ์ , start() ๋์ค ํ ๋ฒ๋ง ํธ์ถ๋ฉ๋๋ค. ๋ฐฑ๊ทธ๋ผ์ด๋์์ ๋๊ธฐํ๊ฐ ๋๋ ๋์ DayFlow๋ ์บ์๋ ๋ฐ์ดํฐ๋ก ๋ ๋๋ง๋ฉ๋๋ค.
์๊ฒฉ ์ค๋ ์ ์ง์ ์ ์ฉํ๊ธฐ
๋๊ธฐํ ํ๋ฆ์ ์ง์ ๊ด๋ฆฌํ๋ ์ ํ๋ฆฌ์ผ์ด์
์ด๋ผ๋ฉด, applyRemoteSnapshot์ผ๋ก ์๊ฒฉ ์ด๋ฒคํธ ๋ฌถ์์ ์ ๊ณต์์๊ฒ ๋ค์ ์ ์กํ์ง ์๊ณ DayFlow์ ์ ์ฉํ ์ ์์ต๋๋ค:
import { applyRemoteSnapshot, getGoogleMeta } from '@dayflow/google-sync';
const delta = await applyRemoteSnapshot(
calendar.app,
{ calendars, events },
{
isOwnedEvent: event => Boolean(getGoogleMeta(event)),
isOwnedCalendar: calendar => calendar.source === 'Google',
snapshotMode: 'authoritative',
// Optionally preserve local in-progress edits during optimistic sync
resolveConflict: (remote, local) =>
mergeLocalEditsOntoRemote(remote, local),
}
);
console.log(delta.events.added, delta.events.updated, delta.events.deleted);applyRemoteSnapshot์ ๋ค์ด์จ ์ค๋
์๊ณผ ํ์ฌ ์ฑ ์ํ์ ์ฐจ์ด๋ฅผ ๊ณ์ฐํ ๋ค, ๋๊ธฐํ ๋ฃจํ๋ฅผ ๋ง๊ธฐ ์ํด source: 'remote'๋ก ๋ณ๊ฒฝ ์ฌํญ์ ์ ์ฉํฉ๋๋ค. ๋ฐํ๊ฐ์ ์์
๋ณ ๊ฑด์๋ฅผ ๋ด์ RemoteSnapshotDelta์
๋๋ค. snapshotMode: 'authoritative'๋ ์ ์ฒด ์ ๊ณต์ ์ค๋
์์๋ง ์ฌ์ฉํ๊ณ , ๋ฒ์๊ฐ ์ ํ๋์๊ฑฐ๋ ํ์ด์ง ๋จ์์ธ ์ค๋
์์ ๊ธฐ๋ณธ ๋ถ๋ถ ๋ชจ๋๋ฅผ ์ ์งํ์ธ์.
์ต์ ๋ ํผ๋ฐ์ค
attachGoogleSyncToDayFlow ์ต์
| ์ต์ | ํ์ | ๊ธฐ๋ณธ๊ฐ | ์ค๋ช |
|---|---|---|---|
writable | boolean | true | ๋ก์ปฌ ๋ณ๊ฒฝ ์ฌํญ์ Google Calendar์ ๋ค์ ์ฐ๋๋ก ํ์ฉํฉ๋๋ค. |
onStatusChange | (status: GoogleSyncStatus) => void | โ | ๋๊ธฐํ ์ํ๊ฐ ๋ฐ๋ ๋๋ง๋ค ํธ์ถ๋ฉ๋๋ค. |
onWriteError | (error: Error, ctx) => void | console.error | ์์ฑยท์์ ยท์ญ์ ๋ฅผ ๋ค์ ์ฐ๋ ๋ฐ ์คํจํ์ ๋ ํธ์ถ๋ฉ๋๋ค. ctx์๋ action๊ณผ eventId๊ฐ ๋ค์ด ์์ต๋๋ค. |
getInitialSnapshot | () => Promise<{ events, calendars }> | โ | ์ฒซ ์๊ฒฉ ๋๊ธฐํ ์ ์ ๋ก์ปฌ ์บ์๋ก DayFlow๋ฅผ ์ฑ์๋๋ค. |
onSyncComplete | (delta: GoogleSyncDelta) => void | โ | ๋๊ธฐํ๊ฐ ์ฑ๊ณตํ ๋๋ง๋ค ๋ณ๊ฒฝ ๊ฑด์์ ํจ๊ป ํธ์ถ๋ฉ๋๋ค. |
onWriteComplete | (operation, event) => void | โ | ๋ก์ปฌ ๋ณ๊ฒฝ์ด Google Calendar์ ์ฑ๊ณต์ ์ผ๋ก ๋ฐ์๋ ๋ค ํธ์ถ๋ฉ๋๋ค. |
createGoogleSyncAdapter ์ต์
| ์ต์ | ํ์ | ๊ธฐ๋ณธ๊ฐ | ์ค๋ช |
|---|---|---|---|
baseUrl | string | https://www.googleapis.com/calendar/v3 | ๋ฐฑ์๋ ํ๋ก์๋ฅผ ๊ฐ๋ฆฌํค๋๋ก ๋ฐ๊ฟ ์ ์์ต๋๋ค. |
fetch | function | globalThis.fetch | ์ฌ์ฉ์ ์ ์ fetch ๊ตฌํ์ ๋๋ค(์: ํ ์คํธ์ฉ). |
getToken | () => string | Promise<string> | โ | ๋ชจ๋ ์์ฒญ ์ ์ ํธ์ถ๋๋ฉฐ, Authorization: Bearer <token>์ผ๋ก ์ฃผ์
ํ ์ก์ธ์ค ํ ํฐ์ ๋ฐํํฉ๋๋ค. fetch๋ฅผ ์ง์ ๊ฐ์ธ๋ ๋์ ์ด ์ต์
์ ์ฌ์ฉํ์ธ์. |
GoogleSyncStatus
type GoogleSyncStatus = {
state: 'idle' | 'syncing' | 'error';
lastSyncedAt?: string; // ISO timestamp
error?: {
message: string;
calendarId?: string;
};
};GoogleSyncDelta
type GoogleSyncDelta = {
calendars: { added: number; updated: number; deleted: number };
events: { added: number; updated: number; deleted: number };
};์ปจํธ๋กค๋ฌ API
// Load calendars, sync initial events, and subscribe to visible-range and event changes
await controller.start();
// Unsubscribe all listeners
controller.stop();
// Re-sync all calendars for the current visible range
await controller.refresh();
// Re-sync a specific calendar
await controller.refresh({ calendarId: 'primary' });
// Re-sync with an explicit range
await controller.refresh({
calendarId: 'primary',
range: { start: new Date('2025-01-01'), end: new Date('2025-02-01') },
});
// Current sync state
const status = controller.getStatus();๋๊ธฐํ ๋์ ๋ฐฉ์
์บ๋ฆฐ๋ ํ์
controller.start()๋ฅผ ํธ์ถํ๋ฉด ํจํค์ง๊ฐ ์ธ์ฆ๋ ์ฌ์ฉ์์ ์บ๋ฆฐ๋ ๋ชฉ๋ก(GET /calendarList)์ ๊ฐ์ ธ์ ๊ฐ ์บ๋ฆฐ๋๋ฅผ DayFlow์ CalendarType์ผ๋ก ๋ฑ๋กํฉ๋๋ค. accessRole์ด 'reader'๋ 'freeBusyReader'์ธ ์บ๋ฆฐ๋๋ readOnly: true๋ก ํ์๋ฉ๋๋ค.
์ด๋ฒคํธ ๋ก๋ฉ
์ด๋ฒคํธ๋ ํ์ฌ ํ์ ์ค์ธ ๋ ์ง ๋ฒ์์ ๋ํด ๋ก๋๋ฉ๋๋ค. ์ฌ์ฉ์๊ฐ ์ด๋ํ๋ฉด(๋ค์ ์ฃผ, ์ด์ ๋ฌ ๋ฑ) ํ์ ๋ฒ์ ๋ณ๊ฒฝ ๋ฆฌ์ค๋๋ฅผ ํตํด ์ ๋ฒ์์ ์ด๋ฒคํธ๊ฐ ์๋์ผ๋ก ๋ก๋๋ฉ๋๋ค.
์ฆ๋ถ ๋๊ธฐํ
์ต์ด ๋ก๋ ์ดํ์๋ Google Calendar์ syncToken์ ์ฌ์ฉํด ์ฆ๋ถ ๋๊ธฐํํฉ๋๋ค. ์ดํ ๋๊ธฐํ์์๋ ๋ณ๊ฒฝ๋ ์ด๋ฒคํธ๋ง ๊ฐ์ ธ์ต๋๋ค. GoogleSyncStorage๋ฅผ ์ ๊ณตํ๋ฉด ๋๊ธฐํ ํ ํฐ์ด ํ์ด์ง ์๋ก ๊ณ ์นจ ํ์๋ ์ ์ง๋์ด ๋ค์ ์ธ์
๋ ์ฆ๋ถ์ผ๋ก ์์ํฉ๋๋ค.
์๊ฒฉ ๋ฐ์(์ฐ๊ธฐ)
writable: true์ด๋ฉด ๋ก์ปฌ ์ด๋ฒคํธ ๋ณ๊ฒฝ(์์ฑ, ์์ , ์ญ์ )์ด Google Calendar์ ์๋์ผ๋ก ๋ฐ์๋ฉ๋๋ค:
- ์์ฑ:
POST /calendars/{calendarId}/events - ์์ :
PUT /calendars/{calendarId}/events/{eventId}(If-Match: <etag>ํฌํจ) - ์ญ์ :
DELETE /calendars/{calendarId}/events/{eventId}
์์ ์์ฒญ์ด 412 Precondition Failed(๋ค๋ฅธ ๊ธฐ๊ธฐ์์ ์ด๋ฒคํธ๊ฐ ๋ณ๊ฒฝ๋์ด ๋ฐ์ํ ETag ์ถฉ๋)๋ฅผ ๋ฐํํ๋ฉด, ํจํค์ง๊ฐ ์ต์ ETag๋ฅผ ๋ค์ ๊ฐ์ ธ์ ํ ๋ฒ ์ฌ์๋ํฉ๋๋ค.
๋ฐ๋ณต ์ด๋ฒคํธ๋ ์ ๋ ์๊ฒฉ์ ๋ฐ์๋์ง ์์ผ๋ฉฐ ์ฝ๊ธฐ ์ ์ฉ์ ๋๋ค.
Google Calendar API ์ค์ฝํ
OAuth ํ ํฐ์๋ ๋ค์ ์ค์ฝํ ์ค ์ต์ ํ๋๊ฐ ํฌํจ๋์ด์ผ ํฉ๋๋ค:
| ์ค์ฝํ | ์ ๊ทผ ๊ถํ |
|---|---|
https://www.googleapis.com/auth/calendar | ์ฝ๊ธฐยท์ฐ๊ธฐ ์ ์ฒด ๊ถํ |
https://www.googleapis.com/auth/calendar.readonly | ์ฝ๊ธฐ ์ ์ฉ ๊ถํ(writable: false์ ํจ๊ป ์ฌ์ฉ) |