@dayflow/outlook-sync

@dayflow/outlook-sync は、DayFlow を Microsoft Graph Calendar API に接続します。これは @dayflow/caldav とは別のパッケージであり、CalDAV ではなく Microsoft Graph API を直接使用します。

インストール

npm install @dayflow/outlook-sync
pnpm add @dayflow/outlook-sync
yarn add @dayflow/outlook-sync
bun add @dayflow/outlook-sync

クイックスタート

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} 失敗:`, error.message),
      onSyncComplete: delta => {
        console.log(
          `同期完了: +${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} 失敗:`, error.message),
      onSyncComplete: delta => {
        console.log(
          `同期完了: +${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} 失敗:`, error.message),
      onSyncComplete: delta => {
        console.log(
          `同期完了: +${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} 失敗:`, error.message),
      onSyncComplete: delta => {
        console.log(
          `同期完了: +${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',
  // getToken は不要 — プロキシが Authorization を注入します
});
// proxy.mjs (MSAL Node を使用した Node.js の例)
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' を使用してください。表示範囲が限られているスナップショット、フィルターされたスナップショット、またはページネーションされたスナップショットについては、デフォルトの部分モード(partial)を維持し、不足しているローカルレコードが保持されるようにしてください。

オプションリファレンス

attachOutlookSyncToDayFlow オプション

オプションデフォルト説明
writablebooleantrueローカルの変更を Outlook カレンダーに書き戻すことを許可します。
onStatusChange(status: OutlookSyncStatus) => void同期状態が変更されるたびに呼び出されます。
onWriteError(error: Error, ctx) => voidconsole.error書き戻しが失敗したときに呼び出されます。ctx には actioneventId が含まれます。
getInitialSnapshot() => Promise<{ events, calendars }>初回リモート同期の前にローカルキャッシュから DayFlow をシードします。
onSyncComplete(delta: OutlookSyncDelta) => void同期が成功するたびに、変更内容のカウントとともに呼び出されます。
onWriteComplete(operation, event) => voidローカルでの変更が Outlook カレンダーへの書き戻しに成功した後に呼び出されます。

createOutlookSyncAdapter オプション

オプションデフォルト説明
baseUrlstringhttps://graph.microsoft.com/v1.0バックエンドプロキシを指すようにオーバーライドします。
fetchfunctionglobalThis.fetchカスタムの fetch 実装。
getToken() => string | Promise<string>リクエストの前に呼び出されます。Authorization: Bearer <token> として注入されるアクセストークンを返します。

OutlookSyncStatus

type OutlookSyncStatus = {
  state: 'idle' | 'syncing' | 'error';
  lastSyncedAt?: string; // ISO タイムスタンプ
  error?: {
    message: string;
    calendarId?: string;
  };
};

OutlookSyncDelta

type OutlookSyncDelta = {
  calendars: { added: number; updated: number; deleted: number };
  events: { added: number; updated: number; deleted: number };
};

コントローラー API

// カレンダーを読み込み、初期イベントを同期し、変更を購読(サブスクライブ)します
await controller.start();

// すべてのリスナーの購読を解除します
controller.stop();

// 現在表示されている範囲のすべてのカレンダーを再同期します
await controller.refresh();

// 特定のカレンダーを再同期します
await controller.refresh({ calendarId: 'AAMk...' });

// 明示的な範囲で再同期します
await controller.refresh({
  range: { start: new Date('2025-01-01'), end: new Date('2025-02-01') },
});

// 現在の同期ステータスを取得します
const status = controller.getStatus();

同期の仕組み

カレンダーの検出

controller.start() が呼び出されると、ユーザーのカレンダーリスト (GET /me/calendars) をフェッチし、各カレンダーを DayFlow に登録します。canEditfalse のカレンダーは readOnly: true としてマークされます。

イベントの読み込み

イベントは、Microsoft Graph の calendarView/delta エンドポイントを介して startDateTime および endDateTime パラメータとともに読み込まれます。これにより、時間枠内の繰り返しイベントが展開されます。ユーザーがナビゲートすると、新しい範囲 of イベントが自動的に読み込まれます。

デルタトークンによる増分同期

初期ロードの後、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 進コードではなく名前付きの色(例:lightBluedarkGreen)を使用します。パッケージはこれらを近似の 16 進値にマッピングし、DayFlow のテーマを一貫させるために getCalendarColorsForHex に通します。

Microsoft Graph API スコープ

OAuth トークンには、少なくとも以下のスコープのいずれかが含まれている必要があります。

スコープアクセス
Calendars.ReadWrite完全な読み取り/書き込みアクセス
Calendars.Read読み取り専用アクセス (writable: false と併用)

アプリ専用(サーバー間)フローの場合は、サービスプリンシパルで .default スコープを使用します。

https://graph.microsoft.com/.default

On this page