Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
9b6f377
chore: initialize Go module and add pinned dependencies
okash1n Jul 3, 2026
808bc15
feat: add model package with normalized types, TimeHash, and idempote…
okash1n Jul 3, 2026
754e19c
build: fix go.mod/go.sum for consistent state with testify direct dep…
okash1n Jul 3, 2026
0cc1e57
fix: restore pinned dependencies removed by accidental go mod tidy
okash1n Jul 3, 2026
f66deea
fix: pin go directive to 1.24 per project constraints
okash1n Jul 3, 2026
9a5416d
feat: add config package with YAML loading, validation, and sync wind…
okash1n Jul 3, 2026
a0eb633
feat(store): add SQLite store with WAL, flock and calendar state ops
okash1n Jul 3, 2026
4147d62
docs: raise Go floor to 1.25 in plan (modernc.org/sqlite v1.53 requir…
okash1n Jul 3, 2026
127e5fd
feat(store): add busy event cache with iCalUID dedupe lookup
okash1n Jul 3, 2026
80fa554
feat(store): add blocker mappings with status transitions and suppres…
okash1n Jul 3, 2026
d902681
feat: add provider interface and in-memory fake provider
okash1n Jul 3, 2026
f4bae6b
fix: release idempotency key when fake blocker is deleted
okash1n Jul 3, 2026
1d60275
feat: add engine event classification helpers
okash1n Jul 3, 2026
c48ad3f
feat: implement engine event processing and blocker distribution
okash1n Jul 3, 2026
31740ae
feat: implement iCalUID same-meeting dedupe and suppressed promotion
okash1n Jul 3, 2026
ddd1eed
feat: add full resync with set-difference and cursor invalidation rec…
okash1n Jul 3, 2026
2bfb481
feat: add full reconcile with orphan adoption, pending and suppressed…
okash1n Jul 3, 2026
dbbad60
test: isolate resolvePending failure test from FullResync recovery path
okash1n Jul 3, 2026
5e54bea
feat(auth): add token store with persisting token source
okash1n Jul 3, 2026
ad0c54d
fix: promote golang.org/x/oauth2 to direct dependency
okash1n Jul 3, 2026
75ac4c5
feat(auth): add loopback PKCE and device code OAuth flows
okash1n Jul 3, 2026
4a2dcfd
fix: gracefully shut down loopback callback server to avoid response …
okash1n Jul 3, 2026
1284c6b
feat(provider/google): implement Changes with sync-token paging, 410 …
okash1n Jul 3, 2026
8b501f7
fix: normalize auth expiry errors to ErrAuthExpired in google provider
okash1n Jul 3, 2026
57f7796
feat(provider/google): add blocker CRUD, tag-based listing and timezo…
okash1n Jul 3, 2026
8dac5ef
feat: add Microsoft Graph delta sync with circuit breaker and retry h…
okash1n Jul 3, 2026
c30d725
feat: add Microsoft Graph blocker CRUD with transactionId idempotency…
okash1n Jul 3, 2026
f9de399
fix: encode spaces as %20 in Graph OData query strings
okash1n Jul 3, 2026
7c28f0d
feat(engine): add polling scheduler with reconcile_at and reauth_requ…
okash1n Jul 3, 2026
ac72029
fix: persist sync status on successful tick and use nil-safe clock in…
okash1n Jul 3, 2026
3feeee7
feat(cli): wire run, sync, reconcile, status, doctor and auth commands
okash1n Jul 3, 2026
0c4410e
fix: promote spf13/cobra to direct dependency
okash1n Jul 3, 2026
282a95c
feat: add accounts remove with distributed/received blocker cleanup a…
okash1n Jul 3, 2026
e772998
build: add multi-stage distroless Dockerfile and docker-compose example
okash1n Jul 3, 2026
5b4b094
docs: add README with Google/Microsoft setup, Docker auth flow and pr…
okash1n Jul 3, 2026
58edfac
fix(provider/google): resurrect cancelled blocker on idempotent recreate
okash1n Jul 3, 2026
0efba52
fix(engine): restore manually deleted blockers during reconcile
okash1n Jul 3, 2026
91d34a8
fix(config): reject account ids containing colon
okash1n Jul 3, 2026
e8bfbd7
docs: add resurrect and transactionId replay checks to spike list
okash1n Jul 3, 2026
0b2798d
fix: derive google calendar timezone from events.list and fetch tz la…
okash1n Jul 3, 2026
34778b0
fix: recreate blocker when UpdateBlocker hits a deleted event
okash1n Jul 3, 2026
4f602e8
fix(engine): attribute target auth expiry to the target account
okash1n Jul 3, 2026
740b5c6
fix(provider/google): send explicit confirmed status when resurrectin…
okash1n Jul 3, 2026
624ef12
docs(provider/microsoft): flag all-day UTC conversion for real-API ve…
okash1n Jul 3, 2026
45e91b7
fix(engine): aggregate errors per row in late reconcile phases
okash1n Jul 3, 2026
e1fa5b8
fix: validate busy_show_as values and persist sync --once status
okash1n Jul 3, 2026
8a8ab5b
docs: align spec scopes and spike checklist with final-review fixes
okash1n Jul 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# syntax=docker/dockerfile:1

FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/calsync ./cmd/calsync

# static-debian12 には CA 証明書と tzdata が含まれる(TZ 環境変数がそのまま効く)
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/calsync /calsync
VOLUME /data
ENTRYPOINT ["/calsync", "run"]
CMD ["--config", "/data/calsync.yaml", "--data", "/data"]
201 changes: 201 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
# calsync

複数の Google カレンダー / Microsoft 365(および個人 Microsoft アカウント)カレンダーを相互監視し、どれかのアカウントに Busy な予定が入ったら他の全アカウントに「予定あり」ブロッカー予定を自動作成する、セルフホスト型の OSS ツールです。Go 製シングルバイナリ、Docker で常駐します。

```mermaid
flowchart LR
A["Google アカウント A"] <--> E["calsync (Docker)"]
B["Microsoft アカウント B"] <--> E
C["Google アカウント C"] <--> E
E --> S[("SQLite (/data)")]
```

- 予定の中身は同期しません。タイトル固定(既定「予定あり」)・詳細なしのブロッカーだけを作ります
- Google ↔ Microsoft 混在でも相互ブロックが成立します
- ブロッカーへの再同期による無限ループは構造的に防止しています
- 同一人物の複数アカウントが同じ会議に招待されているケースは、既定で重複ブロッカーを抑止します(`dedupe_same_meeting`)

## v1 の制約

- Microsoft アカウントは**プライマリカレンダーのみ**監視・書き込み可能です(Google は複数カレンダー可)
- 過去方向の同期はしません。同期対象は現在〜未来 3 ヶ月(`sync_window` で変更可)です
- 同時刻に複数の元予定があってもブロッカーはマージしません(元予定 1 : ブロッカー 1)
- 単一インスタンス前提です。同じデータディレクトリで 2 プロセス目を起動すると flock により起動エラーになります
- `calsync sync` / `calsync reconcile` / `calsync accounts remove` は同じデータディレクトリの排他ロックを取得するため、デーモン稼働中は実行できません(停止してから実行してください)。一方 `calsync status` / `calsync doctor` は読み取り専用で排他ロックを取得しないため、**デーモン稼働中でも実行できます**
- アカウント間でタイムゾーンが異なる場合、終日予定は「同じ日付」の終日ブロッカーとしてミラーされます(同じ絶対時間帯ではありません)

## 必要なもの

- 自分の Google Cloud(GCP)OAuth クライアント、および/または Microsoft Entra ID のアプリ登録(下記手順。calsync は共有クライアントを配布しません)
- Docker / Docker Compose(または Go 1.25 でのローカルビルド)

## セットアップ 1: Google(GCP)

1. GCP プロジェクトを作成し、**Google Calendar API** を有効化します
2. OAuth 同意画面を設定します:
- Google Workspace 組織で使うなら **Internal** が第一推奨です(未検証アプリ警告自体が出ません)
- それ以外は **External** を選び、**公開ステータスを必ず「In production」にしてください**。
**Testing のままだと refresh token が 7 日で失効し、常時同期ツールとして成立しません**(Google の公式仕様です)
- 個人利用(累計 100 ユーザー未満)なら Google の検証審査は不要です。認可時に「Google hasn't verified this app」警告が出ますが、「Advanced」→「Go to ...(unsafe)」でクリックスルーできます
3. 認証情報 → OAuth クライアント ID を **「Desktop app」タイプ**で作成し、**作成直後に JSON をダウンロードして保存してください**(2025 年以降、client secret は作成時にしか表示されません)。JSON をデータディレクトリに置き、`providers.google.credentials_file` にパスを指定します
4. Desktop app の client_secret について: Google 公式ドキュメントは「In this context, the client secret is obviously not treated as a secret.(この文脈では client secret は秘密として扱われない)」と明記しています。JSON がデータディレクトリにあること自体は設計上の問題ではありませんが、他人のアクセスできる場所には置かないでください
5. **6 ヶ月間トークン交換のない OAuth クライアントは Google が自動削除します**。長期停止後に `invalid_client` エラーが出た場合は、クライアントを再作成して JSON を差し替えてください

## セットアップ 2: Microsoft(Entra ID)

1. アプリ登録は**無料テナントで可能で、課金は不要**です。個人 Microsoft アカウント(outlook.com 等)しか持っていない場合は、先に無料の Azure アカウントを作成すると Entra テナントが手に入ります
2. [Entra 管理センター](https://entra.microsoft.com) → App registrations → New registration:
- Supported account types: **「Accounts in any organizational directory and personal Microsoft accounts」**を選択(signInAudience=AzureADandPersonalMicrosoftAccount)。calsync は `/common` エンドポイントで認証します
3. Authentication → Add a platform → **「Mobile and desktop applications」**を選び、Redirect URI に **`http://localhost`** を追加します(localhost はポート番号がマッチング時に無視されるため、ポート指定は不要です)
4. 同じ Authentication ページの Advanced settings で **「Allow public client flows」を Yes** にします。**これを忘れると認可時に `AADSTS7000218` エラーになります**
5. API permissions → Add a permission → Microsoft Graph → Delegated permissions で **`Calendars.ReadWrite`** と **`MailboxSettings.Read`**(終日ブロッカー作成に使うメールボックスのタイムゾーン取得に必要)を追加します(`offline_access` は calsync が要求スコープに含めます)。既定では管理者同意は不要ですが、組織でユーザー同意が無効化されている場合は `AADSTS65001` または `AADSTS90094` が出ます。その場合は管理者に API permissions ページの **「Grant admin consent for <テナント名>」** を押してもらってください
6. Overview の **Application (client) ID** を `providers.microsoft.client_id` に設定します

## 設定ファイル

`./data/calsync.yaml` を作成します:

```yaml
poll_interval: 1m # 差分ポーリング間隔
sync_window: 3mo # 同期ウィンドウ(未来方向)。"90d" のような日数指定も可
blocker_title: "予定あり" # ブロッカーの固定タイトル
reconcile_at: "04:00" # 日次リコンサイル時刻(コンテナの TZ で解釈)
dedupe_same_meeting: true # 同一会議の重複ブロッカー抑止
busy_show_as: [busy, oof, tentative] # Microsoft で Busy 扱いにする showAs 値

providers:
google:
credentials_file: /data/google-client.json # GCP でダウンロードした JSON
microsoft:
client_id: 00000000-0000-0000-0000-000000000000 # Entra の Application (client) ID

accounts:
- id: personal
provider: google
email: user@gmail.com
calendars: [primary] # 監視対象(Google は複数指定可)
blocker_calendar: primary # ブロッカー書き込み先(既定 primary)
- id: work-ms
provider: microsoft
email: user@example365.co.jp
# Microsoft は v1 ではプライマリカレンダーのみ
```

## 認証(auth add)

コンテナにはブラウザがないため、**ホストマシンで実行するのが基本**です。トークンはデータディレクトリ(`./data/tokens/`)に保存され、そのままコンテナから使えます。

### 推奨: ホストで実行

```bash
go build -o calsync ./cmd/calsync
./calsync auth add personal --config ./data/calsync.yaml --data ./data
./calsync auth add work-ms --config ./data/calsync.yaml --data ./data
./calsync auth list --config ./data/calsync.yaml --data ./data
```

ブラウザが開き(開かない場合は表示された URL を手動で開く)、認可後にループバックリダイレクトでトークンが保存されます。

### 代替: Docker 内で実行(ポート公開)

イメージの ENTRYPOINT は `calsync run` なので、`--entrypoint` の上書きが必要です:

```bash
docker compose run --rm --entrypoint /calsync \
-p 127.0.0.1:8484:8484 calsync \
auth add personal --config /data/calsync.yaml --data /data --port 8484
```

表示された認可 URL をホストのブラウザで開きます。`--port 8484` はランダムポートを固定し、公開したポート経由でリダイレクトがコンテナに届くようにするためのものです。

### Microsoft のみの代替: Device Code Flow

```bash
docker compose run --rm --entrypoint /calsync calsync \
auth add work-ms --config /data/calsync.yaml --data /data --device-code
```

コードと URL が表示されるだけで完結します(ポート公開不要)。ただし組織テナントでは条件付きアクセスにより Device Code Flow がブロックされている場合があります。

## 起動

```bash
docker compose up -d --build
docker compose logs -f calsync
```

`calsync status` / `calsync doctor` は読み取り専用でデータディレクトリの排他ロックを取得しないため、**デーモン稼働中でもそのまま実行できます**:

```bash
docker compose run --rm --entrypoint /calsync calsync status --data /data
docker compose run --rm --entrypoint /calsync calsync doctor --config /data/calsync.yaml --data /data
```

一方 `calsync sync` / `calsync reconcile` / `calsync accounts remove` はデータディレクトリの排他ロックを取得するため、実行前にデーモンを停止してください:

```bash
docker compose stop calsync
docker compose run --rm --entrypoint /calsync calsync reconcile --config /data/calsync.yaml --data /data
docker compose start calsync
```

## CLI リファレンス

| コマンド | 説明 |
| --- | --- |
| `calsync run` | デーモン起動(Docker の既定エントリポイント) |
| `calsync sync --once` | 1 回だけ同期して終了 |
| `calsync reconcile` | フルリコンサイル手動実行(ウィンドウスライド・孤児収容・DB 再構築を含む) |
| `calsync status` | 各カレンダーの最終同期時刻・エラー状態(reauth_required 等) |
| `calsync doctor` | 設定・トークン・API 疎通・YAML と DB の不整合診断 |
| `calsync auth add <id> [--port N] [--device-code]` | OAuth フロー(ホスト実行推奨。--device-code は Microsoft のみ) |
| `calsync auth list` | トークン状態一覧 |
| `calsync accounts remove <id> [--force]` | 配布済みブロッカー削除 → 受領ブロッカー削除 → ローカル状態削除 |

共通フラグ: `--config`(既定 `calsync.yaml`)、`--data`(既定 `./data`)

## アカウントの削除

**必ず `calsync accounts remove <id>` を実行してから、calsync.yaml からそのアカウントのエントリを削除してください。順序を逆にしないでください。**

先に calsync.yaml からアカウントを消してしまうと、`accounts remove` はそのアカウント自身の provider を構築できません(provider は calsync.yaml の `accounts` 一覧から組み立てられるため)。その結果、そのアカウントが受け取っていた受領ブロッカーの削除には `--force` が必須になり、`--force` を使った場合、対象アカウントのカレンダーに受領ブロッカーが残ったままになります(手動削除が必要です)。

正しい順序:

```bash
docker compose stop calsync
docker compose run --rm --entrypoint /calsync calsync \
accounts remove old-account --config /data/calsync.yaml --data /data
```

`accounts remove` の完了(配布済みブロッカー削除 → 受領ブロッカー削除 → ローカル状態削除)を確認してから、calsync.yaml から `old-account` のエントリを削除し、`docker compose start calsync` してください。

YAML から消しただけの状態は `calsync doctor` と起動時ログが孤児として警告します。認証切れ等でリモートのブロッカーを消せない場合は `--force` でローカル状態だけ削除できます(ブロッカーは各カレンダーに残るため手動削除が必要です)。

## トークン失効時(reauth_required)

パスワード変更・管理者リセット・長期未使用などで refresh token は失効することがあります。失効したアカウントだけ同期が止まり(`status` に reauth_required と表示)、他のアカウントは同期を継続します。ホストで `calsync auth add <id>` を再実行すれば、次のサイクルとリコンサイルで停止期間中の差分も自動回収されます。

## プライバシーについて

calsync が作成するブロッカー予定には、ループ防止・自己修復のための拡張プロパティ(`calsync-origin` タグ)として**元アカウントの ID と元イベントの ID** が保存されます。カレンダーの UI には表示されませんが、そのカレンダーに API アクセスできる人(組織の管理者を含む)は読み取れます。アカウント ID(YAML の `id`)に見られたくない文字列を使わないでください。タイトル・本文・参加者など予定の中身は一切コピーされません。

## データとバックアップ

- `./data` に SQLite(状態 DB)・OAuth トークン(0600)・設定が入ります。このディレクトリだけバックアップすれば移行できます
- DB を失っても、`calsync reconcile` がカレンダー上の calsync タグからマッピングを再構築します

## トラブルシューティング

| 症状 | 原因と対処 |
| --- | --- |
| Google: 7 日ごとに再認証を求められる | OAuth 同意画面が Testing のまま。**In production に変更**(上記セットアップ 1-2) |
| Google: `invalid_client` | 6 ヶ月間未使用でクライアントが自動削除された。クライアント再作成 + JSON 差し替え |
| Microsoft: `AADSTS7000218` | 「Allow public client flows」が No のまま。Yes に変更(セットアップ 2-4) |
| Microsoft: `AADSTS65001` / `AADSTS90094` | 組織でユーザー同意が無効。管理者に「Grant admin consent」を依頼(セットアップ 2-5) |
| `data directory is locked by another calsync process` | 二重起動、または `sync` / `reconcile` / `accounts remove` をデーモン実行中に実行した。デーモンを止めてから実行(`status` / `doctor` は稼働中でも実行可) |
| ブロッカーを手で消したのに復活する | 仕様です。元予定が生きている限りリコンサイルが再作成します |

## ライセンス

MIT License([LICENSE](LICENSE) を参照)
118 changes: 118 additions & 0 deletions cmd/calsync/cli_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package main

import (
"bytes"
"context"
"errors"
"testing"

"github.com/stretchr/testify/require"
"golang.org/x/oauth2"

"github.com/cloudnative-co/calsync/internal/auth"
"github.com/cloudnative-co/calsync/internal/config"
"github.com/cloudnative-co/calsync/internal/model"
"github.com/cloudnative-co/calsync/internal/store"
)

func TestFindOrphanAccounts(t *testing.T) {
tests := []struct {
name string
cfgIDs []string
dbIDs []string
want []string
}{
{"no orphans", []string{"a", "b"}, []string{"a", "b"}, nil},
{"one orphan", []string{"a"}, []string{"a", "ghost"}, []string{"ghost"}},
{"duplicates collapsed and sorted", []string{"a"}, []string{"z", "ghost", "z"}, []string{"ghost", "z"}},
{"empty db", []string{"a"}, nil, nil},
{"empty config means all orphans", nil, []string{"a"}, []string{"a"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, findOrphanAccounts(tt.cfgIDs, tt.dbIDs))
})
}
}

func TestRenderStatus(t *testing.T) {
// 一時 store に行を入れ、ListCalendars の実データで表出力を検証する
st, err := store.Open(t.TempDir())
require.NoError(t, err)
defer st.Close()

refA := model.CalendarRef{AccountID: "personal", CalendarID: "primary"}
refB := model.CalendarRef{AccountID: "work-ms", CalendarID: "primary"}
require.NoError(t, st.UpsertCalendar(refA))
require.NoError(t, st.UpsertCalendar(refB))
require.NoError(t, st.SetCalendarError(refB, "reauth_required"))

states, err := st.ListCalendars()
require.NoError(t, err)
got := renderStatus(states)

require.Contains(t, got, "ACCOUNT")
require.Contains(t, got, "CALENDAR")
require.Contains(t, got, "LAST SYNC")
require.Contains(t, got, "STATUS")
// 未同期・エラーなしの行: LAST SYNC は "-"、STATUS は "ok"
require.Regexp(t, `personal\s+primary\s+-\s+ok`, got)
// エラーありの行: STATUS にエラー文字列が出る
require.Regexp(t, `work-ms\s+primary\s+\S+\s+reauth_required`, got)
}

func TestRunDoctor(t *testing.T) {
dir := t.TempDir()
tokens := &auth.TokenStore{Dir: dir}
require.NoError(t, tokens.Save("a", &oauth2.Token{AccessToken: "at", RefreshToken: "rt"}))
require.NoError(t, tokens.Save("b", &oauth2.Token{AccessToken: "at", RefreshToken: "rt"}))
// アカウント c はトークン未作成のまま

// DB に YAML に存在しない孤児アカウント ghost の行を作る
st, err := store.Open(dir)
require.NoError(t, err)
require.NoError(t, st.UpsertCalendar(model.CalendarRef{AccountID: "ghost", CalendarID: "primary"}))
require.NoError(t, st.Close()) // 書き込み用ハンドルは閉じる(runDoctor は OpenReadOnly で開く)

cfg := &config.Config{Accounts: []config.Account{
{ID: "a", Provider: "google", Calendars: []string{"primary"}},
{ID: "b", Provider: "microsoft", Calendars: []string{"primary"}},
{ID: "c", Provider: "google", Calendars: []string{"primary"}},
}}
// GetCalendarTimezone 疎通の差し替え: a は成功、b は失敗
probe := func(ctx context.Context, acct config.Account) error {
if acct.ID == "b" {
return errors.New("graph unreachable")
}
return nil
}

var out bytes.Buffer
err = runDoctor(context.Background(), cfg, dir, probe, &out, "calsync.yaml")
require.Error(t, err) // b の疎通失敗 + c のトークン欠如 + ghost 孤児

s := out.String()
require.Contains(t, s, "account a: token ok")
require.Contains(t, s, "account a: API check ok")
require.Contains(t, s, "account b: token ok")
require.Contains(t, s, "account b: API check FAILED")
require.Contains(t, s, "graph unreachable")
require.Contains(t, s, "account c: token MISSING")
require.Contains(t, s, "calsync auth add c")
require.Contains(t, s, "WARNING: account ghost")
require.Contains(t, s, "calsync accounts remove ghost")
}

func TestRunDoctorAllOK(t *testing.T) {
dir := t.TempDir()
tokens := &auth.TokenStore{Dir: dir}
require.NoError(t, tokens.Save("a", &oauth2.Token{AccessToken: "at", RefreshToken: "rt"}))
cfg := &config.Config{Accounts: []config.Account{
{ID: "a", Provider: "google", Calendars: []string{"primary"}},
}}
probe := func(ctx context.Context, acct config.Account) error { return nil }

var out bytes.Buffer
require.NoError(t, runDoctor(context.Background(), cfg, dir, probe, &out, "calsync.yaml"))
require.Contains(t, out.String(), "all checks passed")
}
Loading