@dayflow/outlook-sync
@dayflow/outlook-sync๋ DayFlow๋ฅผ Microsoft Graph ์บ๋ฆฐ๋ API์ ์ฐ๊ฒฐํฉ๋๋ค. @dayflow/caldav์๋ ๋ณ๊ฐ์ ํจํค์ง๋ก, Microsoft Graph API์ ์ง์ ํต์ ํฉ๋๋ค.
์ค์น
๋น ๋ฅธ ์์
import { useRef, useEffect, useState } from 'react';
import {
DayFlowCalendar,
useCalendarApp,
createMonthView,
} from '@dayflow/react';
import {
attachOutlookSyncToDayFlow,
createOutlookSync,
createOutlookSyncAdapter,
type OutlookDayFlowController,
type OutlookSyncStatus,
} from '@dayflow/outlook-sync';
function MyCalendar() {
const calendar = useCalendarApp({
views: [createMonthView()],
calendars: [],
events: [],
});
const controllerRef = useRef<OutlookDayFlowController | null>(null);
const [syncStatus, setSyncStatus] = useState<OutlookSyncStatus>({
state: 'idle',
});
useEffect(() => {
if (controllerRef.current) return;
const adapter = createOutlookSyncAdapter({
baseUrl: '/api/outlook-calendar',
});
const sync = createOutlookSync(adapter);
const controller = attachOutlookSyncToDayFlow(calendar.app, sync, {
writable: true,
onStatusChange: setSyncStatus,
onWriteError: (error, ctx) =>
console.error(`[outlook-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 {
attachOutlookSyncToDayFlow,
createOutlookSync,
createOutlookSyncAdapter,
} from '@dayflow/outlook-sync';
const calendar = useCalendarApp({
views: [createMonthView()],
calendars: [],
events: [],
});
const syncStatus = ref({ state: 'idle' });
let controller;
onMounted(() => {
const adapter = createOutlookSyncAdapter({
baseUrl: '/api/outlook-calendar',
});
const sync = createOutlookSync(adapter);
controller = attachOutlookSyncToDayFlow(calendar.app, sync, {
writable: true,
onStatusChange: status => {
syncStatus.value = status;
},
onWriteError: (error, ctx) =>
console.error(`[outlook-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 {
attachOutlookSyncToDayFlow,
createOutlookSync,
createOutlookSyncAdapter,
type OutlookDayFlowController,
type OutlookSyncStatus,
} from '@dayflow/outlook-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: OutlookSyncStatus = { state: 'idle' };
private controller?: OutlookDayFlowController;
ngOnInit() {
const adapter = createOutlookSyncAdapter({
baseUrl: '/api/outlook-calendar',
});
const sync = createOutlookSync(adapter);
this.controller = attachOutlookSyncToDayFlow(this.calendar, sync, {
writable: true,
onStatusChange: status => {
this.syncStatus = status;
},
onWriteError: (error, ctx) =>
console.error(`[outlook-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 {
attachOutlookSyncToDayFlow,
createOutlookSync,
createOutlookSyncAdapter,
} from '@dayflow/outlook-sync';
const calendar = useCalendarApp({
views: [createMonthView()],
calendars: [],
events: [],
});
let syncStatus = { state: 'idle' };
let controller;
onMount(() => {
const adapter = createOutlookSyncAdapter({
baseUrl: '/api/outlook-calendar',
});
const sync = createOutlookSync(adapter);
controller = attachOutlookSyncToDayFlow(calendar.app, sync, {
writable: true,
onStatusChange: status => {
syncStatus = status;
},
onWriteError: (error, ctx) =>
console.error(`[outlook-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 ์ฌ์ฉํ๊ธฐ (ํด๋ผ์ด์ธํธ ํ ํฐ์ ๊ถ์ฅ)
getToken ํฉํ ๋ฆฌ๋ฅผ ์ ๋ฌํ๋ฉด ์ด๋ํฐ๊ฐ ๋งค ์์ฒญ ์ ์ ์ ํ ํฐ์ ๊ฐ์ ธ์ต๋๋ค. MSAL์ฒ๋ผ ํ ํฐ ๊ฐฑ์ ์ ๋์ ๊ด๋ฆฌํด ์ฃผ๋ ์ธ์ฆ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ธ ๋ ํนํ ์ ํฉํฉ๋๋ค:
import { PublicClientApplication } from '@azure/msal-browser';
const msalInstance = new PublicClientApplication(msalConfig);
const adapter = createOutlookSyncAdapter({
getToken: async () => {
const result = await msalInstance.acquireTokenSilent({
scopes: ['Calendars.ReadWrite'],
});
return result.accessToken;
},
});๋ฐฑ์๋ ํ๋ก์ ์ฌ์ฉํ๊ธฐ (์ด์ ํ๊ฒฝ์ ๊ถ์ฅ)
OAuth ํ ํฐ์ ์๋ฒ์ ๋๊ณ , ๋ชจ๋ Graph API ์์ฒญ์ ํ๋ก์๋ก ์ฐํ์ํค์ธ์.
const adapter = createOutlookSyncAdapter({
baseUrl: '/api/outlook-calendar',
// No getToken needed โ the proxy injects Authorization
});// proxy.mjs (Node.js example using MSAL Node)
import { createServer } from 'node:http';
import { ConfidentialClientApplication } from '@azure/msal-node';
const msalClient = new ConfidentialClientApplication({
auth: {
clientId: process.env.AZURE_CLIENT_ID,
authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`,
clientSecret: process.env.AZURE_CLIENT_SECRET,
},
});
const GRAPH_BASE = 'https://graph.microsoft.com/v1.0';
const ALLOWED_METHODS = new Set(['GET', 'POST', 'PATCH', 'DELETE']);
async function getToken() {
const result = await msalClient.acquireTokenByClientCredential({
scopes: ['https://graph.microsoft.com/.default'],
});
return result?.accessToken ?? '';
}
createServer(async (req, res) => {
const upstreamPath = req.url.replace(/^\/api\/outlook-calendar/, '');
const upstreamUrl = `${GRAPH_BASE}${upstreamPath}`;
if (!ALLOWED_METHODS.has(req.method ?? 'GET')) {
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 getToken();
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(3003);๋ธํ ํ ํฐ ์์ํ
๊ธฐ๋ณธ์ ์ผ๋ก Outlook ๋๊ธฐํ ํ ํฐ(๋ธํ ํ ํฐ)์ ๋ฉ๋ชจ๋ฆฌ์๋ง ์ ์ฅ๋์ด ํ์ด์ง๋ฅผ ์๋ก ๊ณ ์น๋ฉด ์ฌ๋ผ์ง๋๋ค. OutlookSyncStorage ๊ตฌํ์ ์ ๊ณตํด ์ธ์
๊ฐ์ ํ ํฐ์ ๋ณด์กดํ์ธ์:
import {
createOutlookSync,
type OutlookSyncStorage,
} from '@dayflow/outlook-sync';
const storage: OutlookSyncStorage = {
getDeltaToken: async calendarId =>
localStorage.getItem(`outlook-delta:${calendarId}`),
setDeltaToken: async (calendarId, token) =>
token
? localStorage.setItem(`outlook-delta:${calendarId}`, token)
: localStorage.removeItem(`outlook-delta:${calendarId}`),
};
const sync = createOutlookSync(adapter, { storage });์ ์ฅ์๋ฅผ ์ฐ๊ฒฐํ๋ฉด ๊ฐ ์ธ์ ์ด ์ ์ฒด ์ด๋ฒคํธ๋ฅผ ์ฒ์๋ถํฐ ๊ฐ์ ธ์ค๋ ๋์ ์ฆ๋ถ ๋ธํ ๋๊ธฐํ๋ก ์์ํฉ๋๋ค.
๋ก์ปฌ ์บ์๋ก ์ด๊ธฐํํ๊ธฐ
์ฒซ ์๊ฒฉ ๋๊ธฐํ ์ ์ ๋ก์ปฌ ์ ์ฅ์๋ก DayFlow๋ฅผ ๋ฏธ๋ฆฌ ์ฑ์ฐ๋ฉด ์บ๋ฆฐ๋๊ฐ ์ฆ์ ํ์๋ฉ๋๋ค:
const controller = attachOutlookSyncToDayFlow(calendar.app, sync, {
getInitialSnapshot: async () => {
const { calendars, events } = await loadFromLocalDB();
return { calendars, events };
},
onSyncComplete: delta => {
saveChanges(delta);
},
onWriteComplete: (operation, event) => {
persistEvent(operation, event);
},
});์๊ฒฉ ์ค๋ ์ ์ง์ ์ ์ฉํ๊ธฐ
๋๊ธฐํ ํ๋ฆ์ ์ง์ ๊ตฌ์ฑํ๋ ์ ํ๋ฆฌ์ผ์ด์
์ด๋ผ๋ฉด, applyRemoteSnapshot์ผ๋ก ์๊ฒฉ ์ด๋ฒคํธ ๋ฌถ์์ ์ ๊ณต์์๊ฒ ๋ค์ ์ ์กํ์ง ์๊ณ DayFlow์ ์ ์ฉํ ์ ์์ต๋๋ค:
import { applyRemoteSnapshot, getOutlookMeta } from '@dayflow/outlook-sync';
const delta = await applyRemoteSnapshot(
calendar.app,
{ calendars, events },
{
isOwnedEvent: event => Boolean(getOutlookMeta(event)),
isOwnedCalendar: calendar => calendar.source === 'Outlook',
snapshotMode: 'authoritative',
resolveConflict: (remote, local) =>
mergeLocalEditsOntoRemote(remote, local),
}
);snapshotMode: 'authoritative'๋ ์ ์ฒด ์ ๊ณต์ ์ค๋
์์๋ง ์ฌ์ฉํ์ธ์. ๋ฒ์๊ฐ ์ ํ๋์๊ฑฐ๋ ํํฐ๋งยทํ์ด์ง ์ฒ๋ฆฌ๋ ์ค๋
์์ ๊ธฐ๋ณธ ๋ถ๋ถ ๋ชจ๋๋ฅผ ์ ์งํด์ผ ๋๋ฝ๋ ๋ก์ปฌ ๋ ์ฝ๋๊ฐ ๋ณด์กด๋ฉ๋๋ค.
์ต์ ๋ ํผ๋ฐ์ค
attachOutlookSyncToDayFlow ์ต์
| ์ต์ | ํ์ | ๊ธฐ๋ณธ๊ฐ | ์ค๋ช |
|---|---|---|---|
writable | boolean | true | ๋ก์ปฌ ๋ณ๊ฒฝ ์ฌํญ์ Outlook ์บ๋ฆฐ๋์ ๋ค์ ์ฐ๋๋ก ํ์ฉํฉ๋๋ค. |
onStatusChange | (status: OutlookSyncStatus) => void | โ | ๋๊ธฐํ ์ํ๊ฐ ๋ฐ๋ ๋๋ง๋ค ํธ์ถ๋ฉ๋๋ค. |
onWriteError | (error: Error, ctx) => void | console.error | ๋ค์ ์ฐ๊ธฐ๊ฐ ์คํจํ์ ๋ ํธ์ถ๋ฉ๋๋ค. ctx์๋ action๊ณผ eventId๊ฐ ๋ค์ด ์์ต๋๋ค. |
getInitialSnapshot | () => Promise<{ events, calendars }> | โ | ์ฒซ ์๊ฒฉ ๋๊ธฐํ ์ ์ ๋ก์ปฌ ์บ์๋ก DayFlow๋ฅผ ์ฑ์๋๋ค. |
onSyncComplete | (delta: OutlookSyncDelta) => void | โ | ๋๊ธฐํ๊ฐ ์ฑ๊ณตํ ๋๋ง๋ค ๋ณ๊ฒฝ ๊ฑด์์ ํจ๊ป ํธ์ถ๋ฉ๋๋ค. |
onWriteComplete | (operation, event) => void | โ | ๋ก์ปฌ ๋ณ๊ฒฝ์ด Outlook ์บ๋ฆฐ๋์ ์ฑ๊ณต์ ์ผ๋ก ๋ฐ์๋ ๋ค ํธ์ถ๋ฉ๋๋ค. |
createOutlookSyncAdapter ์ต์
| ์ต์ | ํ์ | ๊ธฐ๋ณธ๊ฐ | ์ค๋ช |
|---|---|---|---|
baseUrl | string | https://graph.microsoft.com/v1.0 | ๋ฐฑ์๋ ํ๋ก์๋ฅผ ๊ฐ๋ฆฌํค๋๋ก ๋ฐ๊ฟ ์ ์์ต๋๋ค. |
fetch | function | globalThis.fetch | ์ฌ์ฉ์ ์ ์ fetch ๊ตฌํ์ ๋๋ค. |
getToken | () => string | Promise<string> | โ | ๋ชจ๋ ์์ฒญ ์ ์ ํธ์ถ๋๋ฉฐ, Authorization: Bearer <token>์ผ๋ก ์ฃผ์
ํ ์ก์ธ์ค ํ ํฐ์ ๋ฐํํฉ๋๋ค. |
OutlookSyncStatus
type OutlookSyncStatus = {
state: 'idle' | 'syncing' | 'error';
lastSyncedAt?: string; // ISO timestamp
error?: {
message: string;
calendarId?: string;
};
};OutlookSyncDelta
type OutlookSyncDelta = {
calendars: { added: number; updated: number; deleted: number };
events: { added: number; updated: number; deleted: number };
};์ปจํธ๋กค๋ฌ API
// Load calendars, sync initial events, and subscribe to 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: 'AAMk...' });
// Re-sync with an explicit range
await controller.refresh({
range: { start: new Date('2025-01-01'), end: new Date('2025-02-01') },
});
// Current sync state
const status = controller.getStatus();๋๊ธฐํ ๋์ ๋ฐฉ์
์บ๋ฆฐ๋ ํ์
controller.start()๋ฅผ ํธ์ถํ๋ฉด ํจํค์ง๊ฐ ์ฌ์ฉ์์ ์บ๋ฆฐ๋ ๋ชฉ๋ก(GET /me/calendars)์ ๊ฐ์ ธ์ DayFlow์ ๊ฐ๊ฐ ๋ฑ๋กํฉ๋๋ค. canEdit๊ฐ false์ธ ์บ๋ฆฐ๋๋ readOnly: true๋ก ํ์๋ฉ๋๋ค.
์ด๋ฒคํธ ๋ก๋ฉ
์ด๋ฒคํธ๋ Microsoft Graph์ calendarView/delta ์๋ํฌ์ธํธ์ startDateTime๊ณผ endDateTime ๋งค๊ฐ๋ณ์๋ฅผ ๋ถ์ฌ ๋ก๋ํฉ๋๋ค. ์ด ์๋ํฌ์ธํธ๋ ํด๋น ๊ธฐ๊ฐ ์์ ๋ฐ๋ณต ์ด๋ฒคํธ๋ฅผ ํผ์ณ ์ค๋๋ค. ์ฌ์ฉ์๊ฐ ์ด๋ํ๋ฉด ์ ๋ฒ์์ ์ด๋ฒคํธ๊ฐ ์๋์ผ๋ก ๋ก๋๋ฉ๋๋ค.
๋ธํ ํ ํฐ์ ์ด์ฉํ ์ฆ๋ถ ๋๊ธฐํ
์ต์ด ๋ก๋ ์ดํ Graph API๋ @odata.deltaLink๋ฅผ ๋ฐํํฉ๋๋ค. ์ดํ ๋๊ธฐํ์์๋ ์ด ๋งํฌ๋ฅผ ๋ฐ๋ผ๊ฐ ์ ์ฒด ๋ฒ์๊ฐ ์๋๋ผ ๋ณ๊ฒฝ๋ ์ด๋ฒคํธ๋ง ๊ฐ์ ธ์ต๋๋ค. OutlookSyncStorage๋ฅผ ์ ๊ณตํ๋ฉด ๋ธํ ํ ํฐ์ด ํ์ด์ง ์๋ก ๊ณ ์นจ ํ์๋ ์ ์ง๋ฉ๋๋ค.
๋ธํ ํ ํฐ์ด ๋ง๋ฃ๋๋ฉด(Graph๊ฐ 410 Gone์ ๋ฐํ) ํจํค์ง๊ฐ ์๋์ผ๋ก ์ ์ฒด ๋ฒ์ ์กฐํ๋ก ๋๋์๊ฐ๋๋ค.
์๊ฒฉ ๋ฐ์(์ฐ๊ธฐ)
writable: true์ด๋ฉด ๋ก์ปฌ ์ด๋ฒคํธ ๋ณ๊ฒฝ์ด Outlook ์บ๋ฆฐ๋์ ๋ฐ์๋ฉ๋๋ค:
- ์์ฑ:
POST /me/calendars/{calendarId}/events - ์์ :
PATCH /me/calendars/{calendarId}/events/{eventId}(If-Match: <etag>ํฌํจ) - ์ญ์ :
DELETE /me/calendars/{calendarId}/events/{eventId}
์์ ์์ฒญ์ด 412 Precondition Failed(ETag ์ถฉ๋)๋ฅผ ๋ฐํํ๋ฉด, ํจํค์ง๊ฐ ์ต์ ETag๋ฅผ ๋ค์ ๊ฐ์ ธ์ ํ ๋ฒ ์ฌ์๋ํฉ๋๋ค.
๋ฐ๋ณต ์ด๋ฒคํธ๋ ์ ๋ ์๊ฒฉ์ ๋ฐ์๋์ง ์์ผ๋ฉฐ ์ฝ๊ธฐ ์ ์ฉ์ ๋๋ค.
์บ๋ฆฐ๋ ์์
Outlook์ 16์ง ์ฝ๋๊ฐ ์๋๋ผ ์ด๋ฆ์ด ์ง์ ๋ ์์(์: lightBlue, darkGreen)์ ์ฌ์ฉํฉ๋๋ค. ํจํค์ง๋ ์ด๋ฅผ ๊ทผ์ฌํ 16์ง ๊ฐ์ผ๋ก ๋งคํํ ๋ค getCalendarColorsForHex์ ํต๊ณผ์์ผ DayFlow ํ
๋ง์ ์ผ๊ด๋๊ฒ ๋ง์ถฅ๋๋ค.
Microsoft Graph API ์ค์ฝํ
OAuth ํ ํฐ์๋ ๋ค์ ์ค์ฝํ ์ค ์ต์ ํ๋๊ฐ ํฌํจ๋์ด์ผ ํฉ๋๋ค:
| ์ค์ฝํ | ์ ๊ทผ ๊ถํ |
|---|---|
Calendars.ReadWrite | ์ฝ๊ธฐยท์ฐ๊ธฐ ์ ์ฒด ๊ถํ |
Calendars.Read | ์ฝ๊ธฐ ์ ์ฉ ๊ถํ(writable: false์ ํจ๊ป ์ฌ์ฉ) |
์ฑ ์ ์ฉ(์๋ฒ ๊ฐ) ํ๋ฆ์์๋ ์๋น์ค ์ฃผ์ฒด์ ํจ๊ป .default ์ค์ฝํ๋ฅผ ์ฌ์ฉํ์ธ์:
https://graph.microsoft.com/.default