calendit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +94 -0
- package/bin/cli.js +13 -0
- package/dist/commands/add.d.ts +3 -0
- package/dist/commands/add.js +51 -0
- package/dist/commands/apply.d.ts +3 -0
- package/dist/commands/apply.js +67 -0
- package/dist/commands/auth.d.ts +3 -0
- package/dist/commands/auth.js +36 -0
- package/dist/commands/cal.d.ts +3 -0
- package/dist/commands/cal.js +53 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.js +88 -0
- package/dist/commands/query.d.ts +3 -0
- package/dist/commands/query.js +64 -0
- package/dist/commands/shared.d.ts +16 -0
- package/dist/commands/shared.js +69 -0
- package/dist/core/applier.d.ts +26 -0
- package/dist/core/applier.js +141 -0
- package/dist/core/auth.d.ts +22 -0
- package/dist/core/auth.js +153 -0
- package/dist/core/config.d.ts +14 -0
- package/dist/core/config.js +75 -0
- package/dist/core/datetime.d.ts +12 -0
- package/dist/core/datetime.js +61 -0
- package/dist/core/errors.d.ts +21 -0
- package/dist/core/errors.js +25 -0
- package/dist/core/formatter.d.ts +27 -0
- package/dist/core/formatter.js +164 -0
- package/dist/core/logger.d.ts +9 -0
- package/dist/core/logger.js +68 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +95 -0
- package/dist/services/base.d.ts +52 -0
- package/dist/services/base.js +24 -0
- package/dist/services/google.d.ts +16 -0
- package/dist/services/google.js +151 -0
- package/dist/services/mock.d.ts +18 -0
- package/dist/services/mock.js +93 -0
- package/dist/services/outlook.d.ts +20 -0
- package/dist/services/outlook.js +163 -0
- package/dist/test_runner.d.ts +1 -0
- package/dist/test_runner.js +195 -0
- package/dist/types/index.d.ts +44 -0
- package/dist/types/index.js +1 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 chromatribe - s.ohara
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# calendit 🗓️
|
|
2
|
+
|
|
3
|
+
ターミナルから Google / Outlook カレンダーを自在に操るための CLI ツール。
|
|
4
|
+
人間のための Markdown 管理と、AI エージェントのための JSON 管理を両立します。
|
|
5
|
+
|
|
6
|
+
[](https://opensource.org/licenses/ISC)
|
|
7
|
+
[](https://www.typescriptlang.org/)
|
|
8
|
+
|
|
9
|
+
## 🌟 特徴
|
|
10
|
+
|
|
11
|
+
- **Markdown 同期**: カレンダーの予定を Markdown で書き出し、メモ帳感覚で編集して一括反映。
|
|
12
|
+
- **コンテキスト管理**: 「仕事」「プライベート」などの用途(カレンダーID、認証アカウント)を `--set` 一つで切り替え。
|
|
13
|
+
- **マルチアカウント対応**: 複数の Google / Outlook アカウントを個別に認証し、シームレスに使い分け可能。
|
|
14
|
+
- **堅牢なエラーハンドリング**: 詳細なエラーメッセージと改善のためのヒントを提供。
|
|
15
|
+
- **自律的テスティング**: 期待動作をドキュメント化し、AI が自ら検証・修正を行う高度な開発フロー。
|
|
16
|
+
- **macOS 最適化**: 永続的なトークン管理とローカル時刻の完全サポート。
|
|
17
|
+
|
|
18
|
+
## 🚀 クイックスタート
|
|
19
|
+
|
|
20
|
+
### 1. インストール
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
git clone https://github.com/YOUR_USERNAME/calendit.git
|
|
24
|
+
cd calendit
|
|
25
|
+
npm install
|
|
26
|
+
npm run build
|
|
27
|
+
# パスを通す(任意)
|
|
28
|
+
npm link
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 2. 認証設定
|
|
32
|
+
|
|
33
|
+
各サービスのセットアップガイドに従って、API 認証情報を設定します。
|
|
34
|
+
|
|
35
|
+
- **Google**: [docs/setup_google.md](docs/setup_google.md)
|
|
36
|
+
- **Outlook**: [docs/setup_outlook.md](docs/setup_outlook.md)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Google の設定例
|
|
40
|
+
calendit config set-google --id YOUR_CLIENT_ID --secret YOUR_CLIENT_SECRET
|
|
41
|
+
calendit auth login google
|
|
42
|
+
|
|
43
|
+
# Outlook の設定例
|
|
44
|
+
calendit config set-outlook --id YOUR_CLIENT_ID
|
|
45
|
+
calendit auth login outlook
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 3. コンテキストの設定
|
|
49
|
+
|
|
50
|
+
用途に応じたカレンダーを「コンテキスト」として登録します。
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# 仕事用カレンダーの登録
|
|
54
|
+
calendit config set-context work --service google --calendar primary
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 4. 基本操作
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# 今日の予定を Markdown に書き出す
|
|
61
|
+
calendit query --set work --format md --out today.md
|
|
62
|
+
|
|
63
|
+
# 予定をカレンダーに反映(新規作成・更新)
|
|
64
|
+
# --dry-run で変更内容を事前に確認できます
|
|
65
|
+
calendit apply --in today.md --dry-run
|
|
66
|
+
|
|
67
|
+
# 単発の予定を追加
|
|
68
|
+
calendit add --summary "ランチミーティング" --start "today 12:00" --set work
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 📖 詳細ドキュメント
|
|
72
|
+
|
|
73
|
+
- [コマンドリファレンス](docs/commands.md)
|
|
74
|
+
- [Google カレンダー セットアップガイド](docs/setup_google.md)
|
|
75
|
+
- [Outlook カレンダー セットアップガイド](docs/setup_outlook.md)
|
|
76
|
+
- [開発者向けガイド (テスト・設計)](docs/development.md)
|
|
77
|
+
|
|
78
|
+
## 🛠️ 開発者向け
|
|
79
|
+
|
|
80
|
+
### テストの実行
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npm test
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`docs/tests.md` に定義されたテストケースに基づき、自律的に検証が走ります。
|
|
87
|
+
|
|
88
|
+
## 📄 ライセンス
|
|
89
|
+
|
|
90
|
+
ISC License. 詳細は [LICENSE](LICENSE) を参照してください。
|
|
91
|
+
|
|
92
|
+
## 👤 著者
|
|
93
|
+
|
|
94
|
+
**chromatribe - s.ohara** (<ivis.klain@chromatri.be>)
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const distPath = path.resolve(__dirname, '../dist/index.js');
|
|
7
|
+
|
|
8
|
+
// Dynamically import the built entry point
|
|
9
|
+
import(distPath).catch((err) => {
|
|
10
|
+
console.error('Error loading calendit. Try reinstalling: npm install -g calendit');
|
|
11
|
+
console.error(err.message);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { formatInTimeZone } from "date-fns-tz";
|
|
2
|
+
import { parseDateTime } from "../core/datetime.js";
|
|
3
|
+
import { getServiceForContext } from "./shared.js";
|
|
4
|
+
import { ValidationError } from "../core/errors.js";
|
|
5
|
+
import { logger } from "../core/logger.js";
|
|
6
|
+
export function registerAddCommand(program, deps) {
|
|
7
|
+
program
|
|
8
|
+
.command("add")
|
|
9
|
+
.description("Add a single event")
|
|
10
|
+
.option("--summary <text>", "Event title")
|
|
11
|
+
.option("--start <dateTime>", "Start date/time (YYYY-MM-DDTHH:mm or HH:mm)")
|
|
12
|
+
.option("--end <dateTime>", "End date/time")
|
|
13
|
+
.option("--location <text>", "Event location")
|
|
14
|
+
.option("--description <text>", "Event description")
|
|
15
|
+
.option("--set <name>", "Use a named context")
|
|
16
|
+
.option("--calendar <id>", "Explicit Calendar ID")
|
|
17
|
+
.option("--dry-run", "Preview addition without applying", false)
|
|
18
|
+
.action(async (options) => {
|
|
19
|
+
if (!options.summary || !options.start) {
|
|
20
|
+
throw new ValidationError("Summary and Start time are required.");
|
|
21
|
+
}
|
|
22
|
+
const { service, calendarId: ctxCalId } = await getServiceForContext(deps, options.set);
|
|
23
|
+
const calendarId = options.calendar || ctxCalId;
|
|
24
|
+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
25
|
+
const startDate = parseDateTime(options.start);
|
|
26
|
+
let endDate = options.end ? parseDateTime(options.end) : new Date(startDate.getTime() + 60 * 60 * 1000);
|
|
27
|
+
if (options.end && endDate < startDate) {
|
|
28
|
+
endDate = new Date(endDate.getTime() + 24 * 60 * 60 * 1000);
|
|
29
|
+
}
|
|
30
|
+
if (startDate >= endDate) {
|
|
31
|
+
throw new ValidationError(`Invalid time range: ${options.summary} (${options.start} - ${options.end || "+1h"})`, "開始時刻より後の終了時刻を指定してください。");
|
|
32
|
+
}
|
|
33
|
+
const formattedStart = formatInTimeZone(startDate, timeZone, "yyyy-MM-dd'T'HH:mm:ssXXX");
|
|
34
|
+
const formattedEnd = formatInTimeZone(endDate, timeZone, "yyyy-MM-dd'T'HH:mm:ssXXX");
|
|
35
|
+
logger.info(`Adding event: ${options.summary}`);
|
|
36
|
+
logger.info(`Start: ${formattedStart}`);
|
|
37
|
+
logger.info(`End: ${formattedEnd}`);
|
|
38
|
+
if (options.dryRun) {
|
|
39
|
+
logger.info(`✅ [Dry Run] Event would be added: "${options.summary}"`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
await service.createEvent(calendarId, {
|
|
43
|
+
summary: options.summary,
|
|
44
|
+
start: formattedStart,
|
|
45
|
+
end: formattedEnd,
|
|
46
|
+
location: options.location,
|
|
47
|
+
description: options.description,
|
|
48
|
+
});
|
|
49
|
+
logger.info(`✅ Event added successfully: "${options.summary}"`);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
import { Formatter } from "../core/formatter.js";
|
|
3
|
+
import { Applier } from "../core/applier.js";
|
|
4
|
+
import { getServiceForContext } from "./shared.js";
|
|
5
|
+
import { ValidationError } from "../core/errors.js";
|
|
6
|
+
import { logger } from "../core/logger.js";
|
|
7
|
+
const COLOR_RESET = "\x1b[0m";
|
|
8
|
+
const COLOR_GREEN = "\x1b[32m";
|
|
9
|
+
const COLOR_BLUE = "\x1b[34m";
|
|
10
|
+
const COLOR_RED = "\x1b[31m";
|
|
11
|
+
export function registerApplyCommand(program, deps) {
|
|
12
|
+
program
|
|
13
|
+
.command("apply")
|
|
14
|
+
.description("Apply events from a file (bulk update/sync)")
|
|
15
|
+
.option("--in <file>", "Input file path")
|
|
16
|
+
.option("--set <name>", "Use a named context")
|
|
17
|
+
.option("--calendar <id>", "Explicit Calendar ID")
|
|
18
|
+
.option("--sync", "Delete events not in the file", false)
|
|
19
|
+
.option("--dry-run", "Preview changes without applying", false)
|
|
20
|
+
.action(async (options) => {
|
|
21
|
+
if (!options.in) {
|
|
22
|
+
throw new ValidationError("Input file required.", "Use --in <file> to specify input.");
|
|
23
|
+
}
|
|
24
|
+
const { service, calendarId: ctxCalId } = await getServiceForContext(deps, options.set);
|
|
25
|
+
const calendarId = options.calendar || ctxCalId;
|
|
26
|
+
const inputData = await fs.readFile(options.in, "utf-8");
|
|
27
|
+
let inputEvents = [];
|
|
28
|
+
if (options.in.endsWith(".md")) {
|
|
29
|
+
const parsed = Formatter.fromMarkdown(inputData);
|
|
30
|
+
inputEvents = parsed.events;
|
|
31
|
+
}
|
|
32
|
+
else if (options.in.endsWith(".csv")) {
|
|
33
|
+
inputEvents = Formatter.fromCsv(inputData);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
inputEvents = JSON.parse(inputData);
|
|
37
|
+
}
|
|
38
|
+
const applier = new Applier(service);
|
|
39
|
+
if (options.dryRun) {
|
|
40
|
+
logger.info("[DRY RUN - 実際の変更は行いません]");
|
|
41
|
+
}
|
|
42
|
+
logger.info(`Applying changes to ${calendarId}...`);
|
|
43
|
+
const results = await applier.apply(calendarId, inputEvents, undefined, {
|
|
44
|
+
dryRun: options.dryRun,
|
|
45
|
+
sync: options.sync,
|
|
46
|
+
});
|
|
47
|
+
logger.info("--- Results ---");
|
|
48
|
+
if (results.created.length > 0) {
|
|
49
|
+
logger.info(`Created (${results.created.length}):`);
|
|
50
|
+
results.created.forEach((e) => logger.info(` + ${e.summary}`));
|
|
51
|
+
}
|
|
52
|
+
if (results.updated.length > 0) {
|
|
53
|
+
logger.info(`Updated (${results.updated.length}):`);
|
|
54
|
+
results.updated.forEach((u) => logger.info(` * ${u.input.summary || u.existing.summary}`));
|
|
55
|
+
}
|
|
56
|
+
if (results.deleted.length > 0) {
|
|
57
|
+
logger.info(`Deleted (${results.deleted.length}):`);
|
|
58
|
+
results.deleted.forEach((e) => logger.info(` - ${e.summary}`));
|
|
59
|
+
}
|
|
60
|
+
if (results.created.length === 0 && results.updated.length === 0 && results.deleted.length === 0) {
|
|
61
|
+
logger.info("No changes detected.");
|
|
62
|
+
}
|
|
63
|
+
logger.info(`${COLOR_GREEN}✅ 作成 ${results.created.length}件${COLOR_RESET} / ` +
|
|
64
|
+
`${COLOR_BLUE}📝 更新 ${results.updated.length}件${COLOR_RESET} / ` +
|
|
65
|
+
`${COLOR_RED}🗑 削除 ${results.deleted.length}件${COLOR_RESET}`);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { loadConfigIfExists } from "./shared.js";
|
|
2
|
+
import { ConfigError, ValidationError } from "../core/errors.js";
|
|
3
|
+
import { logger } from "../core/logger.js";
|
|
4
|
+
export function registerAuthCommands(program, deps) {
|
|
5
|
+
const authCmd = program.command("auth").description("Authentication management");
|
|
6
|
+
authCmd
|
|
7
|
+
.command("login <service>")
|
|
8
|
+
.description("Login to Google or Outlook")
|
|
9
|
+
.option("--set <context>", "Context to associate this login with")
|
|
10
|
+
.option("--account <id>", "Custom account identifier for tokens")
|
|
11
|
+
.action(async (service, options) => {
|
|
12
|
+
await loadConfigIfExists(deps.config);
|
|
13
|
+
const accountId = options.account || options.set;
|
|
14
|
+
if (service === "google") {
|
|
15
|
+
const creds = deps.config.getGoogleCreds();
|
|
16
|
+
if (!creds) {
|
|
17
|
+
throw new ConfigError("Google Client ID / Secret が未設定です。", "docs/setup_google.md を参照、または `calendit config set-google --file <path>` を実行してください。");
|
|
18
|
+
}
|
|
19
|
+
logger.info("Google 認証フローを開始します...");
|
|
20
|
+
await deps.auth.loginGoogle(creds.id, creds.secret, accountId);
|
|
21
|
+
logger.info(`Google authentication complete for account '${accountId || "default"}'!`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (service === "outlook") {
|
|
25
|
+
const creds = deps.config.getOutlookCreds();
|
|
26
|
+
if (!creds) {
|
|
27
|
+
throw new ConfigError("Outlook Client ID が未設定です。", "`calendit config set-outlook --id <id>` を先に実行してください。");
|
|
28
|
+
}
|
|
29
|
+
logger.info("Outlook 認証フローを開始します...");
|
|
30
|
+
await deps.auth.loginOutlook(creds.id, creds.tenantId, accountId);
|
|
31
|
+
logger.info(`Outlook authentication complete for account '${accountId || "default"}'!`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
throw new ValidationError(`Unknown service: ${service}`, "service は google または outlook を指定してください。");
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { getServiceForContext } from "./shared.js";
|
|
2
|
+
import { ValidationError } from "../core/errors.js";
|
|
3
|
+
import { logger } from "../core/logger.js";
|
|
4
|
+
export function registerCalCommands(program, deps) {
|
|
5
|
+
const calCmd = program.command("cal").description("Calendar management");
|
|
6
|
+
calCmd
|
|
7
|
+
.command("list")
|
|
8
|
+
.description("List available calendars")
|
|
9
|
+
.option("--set <name>", "Use a named context")
|
|
10
|
+
.action(async (options) => {
|
|
11
|
+
const { service } = await getServiceForContext(deps, options.set);
|
|
12
|
+
const list = await service.listCalendars();
|
|
13
|
+
logger.info("--- Available Calendars ---");
|
|
14
|
+
list.forEach((c) => {
|
|
15
|
+
logger.info(`- ${c.name} (ID: ${c.id}) ${c.isPrimary ? "[Primary]" : ""} ${c.canEdit ? "" : "[Read-Only]"}`);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
calCmd
|
|
19
|
+
.command("add <name>")
|
|
20
|
+
.description("Create a new calendar")
|
|
21
|
+
.option("--set <name>", "Use a named context")
|
|
22
|
+
.action(async (name, options) => {
|
|
23
|
+
const { service } = await getServiceForContext(deps, options.set);
|
|
24
|
+
const newCal = await service.createCalendar(name);
|
|
25
|
+
logger.info(`Calendar created: ${newCal.name} (ID: ${newCal.id})`);
|
|
26
|
+
});
|
|
27
|
+
calCmd
|
|
28
|
+
.command("delete <id>")
|
|
29
|
+
.description("Delete a calendar")
|
|
30
|
+
.option("--force", "Skip confirmation")
|
|
31
|
+
.option("--set <name>", "Use a named context")
|
|
32
|
+
.action(async (id, options) => {
|
|
33
|
+
if (id === "primary") {
|
|
34
|
+
throw new ValidationError("The 'primary' calendar cannot be deleted for safety.");
|
|
35
|
+
}
|
|
36
|
+
const { service } = await getServiceForContext(deps, options.set);
|
|
37
|
+
const calendars = await service.listCalendars();
|
|
38
|
+
if (!calendars.some((c) => c.id === id)) {
|
|
39
|
+
throw new ValidationError(`Calendar '${id}' not found.`);
|
|
40
|
+
}
|
|
41
|
+
if (!options.force) {
|
|
42
|
+
const { Confirm } = (await import("enquirer")).default;
|
|
43
|
+
const prompt = new Confirm({
|
|
44
|
+
name: "question",
|
|
45
|
+
message: `Are you sure you want to delete calendar ${id}?`,
|
|
46
|
+
});
|
|
47
|
+
if (!(await prompt.run()))
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await service.deleteCalendar(id);
|
|
51
|
+
logger.info(`Calendar ${id} deleted.`);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
import { loadConfigIfExists } from "./shared.js";
|
|
3
|
+
import { ValidationError } from "../core/errors.js";
|
|
4
|
+
import { logger } from "../core/logger.js";
|
|
5
|
+
export function registerConfigCommands(program, deps) {
|
|
6
|
+
const configCmd = program.command("config").description("Configuration management");
|
|
7
|
+
configCmd
|
|
8
|
+
.command("set-google")
|
|
9
|
+
.description("Set Google API credentials (manually or via JSON file)")
|
|
10
|
+
.option("--id <id>", "Client ID")
|
|
11
|
+
.option("--secret <secret>", "Client Secret")
|
|
12
|
+
.option("--file <path>", "Path to Google credentials JSON file")
|
|
13
|
+
.action(async (options) => {
|
|
14
|
+
await loadConfigIfExists(deps.config);
|
|
15
|
+
let id = options.id;
|
|
16
|
+
let secret = options.secret;
|
|
17
|
+
if (options.file) {
|
|
18
|
+
try {
|
|
19
|
+
const fileContent = await fs.readFile(options.file, "utf-8");
|
|
20
|
+
const json = JSON.parse(fileContent);
|
|
21
|
+
const creds = json.installed || json.web;
|
|
22
|
+
if (!creds?.client_id || !creds?.client_secret) {
|
|
23
|
+
throw new ValidationError("Invalid Google credentials JSON format.");
|
|
24
|
+
}
|
|
25
|
+
id = creds.client_id;
|
|
26
|
+
secret = creds.client_secret;
|
|
27
|
+
logger.info(`Successfully extracted credentials from ${options.file}`);
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
throw new ValidationError(`Error reading JSON file: ${e.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!id || !secret) {
|
|
34
|
+
throw new ValidationError("Either --id and --secret, or --file must be provided.");
|
|
35
|
+
}
|
|
36
|
+
deps.config.setGoogleCreds(id, secret);
|
|
37
|
+
await deps.config.save();
|
|
38
|
+
logger.info("Google credentials saved to config.");
|
|
39
|
+
});
|
|
40
|
+
configCmd
|
|
41
|
+
.command("set-outlook")
|
|
42
|
+
.description("Set Outlook (Microsoft Graph) API credentials")
|
|
43
|
+
.requiredOption("--id <id>", "Client ID (Application ID)")
|
|
44
|
+
.option("--tenant <id>", "Tenant ID (default: common)", "common")
|
|
45
|
+
.action(async (options) => {
|
|
46
|
+
await loadConfigIfExists(deps.config);
|
|
47
|
+
deps.config.setOutlookCreds(options.id, options.tenant);
|
|
48
|
+
await deps.config.save();
|
|
49
|
+
logger.info("Outlook credentials saved to config.");
|
|
50
|
+
});
|
|
51
|
+
configCmd
|
|
52
|
+
.command("check")
|
|
53
|
+
.description("Validate current configuration and show diagnostic summary")
|
|
54
|
+
.action(async () => {
|
|
55
|
+
await loadConfigIfExists(deps.config);
|
|
56
|
+
const googleCreds = deps.config.getGoogleCreds();
|
|
57
|
+
const outlookCreds = deps.config.getOutlookCreds();
|
|
58
|
+
const contexts = deps.config.getAllContexts();
|
|
59
|
+
const contextEntries = Object.entries(contexts);
|
|
60
|
+
const mask = (value) => (value.length <= 8 ? value : `${value.slice(0, 3)}...${value.slice(-3)}`);
|
|
61
|
+
logger.info("[CONFIG CHECK]");
|
|
62
|
+
logger.info(` Google credentials : ${googleCreds ? `OK (id: ${mask(googleCreds.id)})` : "NOT SET (run: calendit config set-google --id <id> --secret <secret>)"}`);
|
|
63
|
+
logger.info(` Outlook credentials: ${outlookCreds ? `OK (id: ${mask(outlookCreds.id)})` : "NOT SET (run: calendit config set-outlook --id <id>)"}`);
|
|
64
|
+
logger.info(` Contexts : ${contextEntries.length > 0
|
|
65
|
+
? contextEntries.map(([name, ctx]) => `${name} (${ctx.service}/${ctx.calendarId})`).join(", ")
|
|
66
|
+
: "none"}`);
|
|
67
|
+
logger.info(" Config file : ~/.config/calendit/config.json (or CALENDIT_CONFIG_DIR override)");
|
|
68
|
+
});
|
|
69
|
+
configCmd
|
|
70
|
+
.command("set-context <name>")
|
|
71
|
+
.description("Set a named context (e.g. work, hobby)")
|
|
72
|
+
.requiredOption("--service <service>", "google or outlook")
|
|
73
|
+
.requiredOption("--calendar <id>", "Calendar ID")
|
|
74
|
+
.option("--account <id>", "Custom account identifier for tokens")
|
|
75
|
+
.action(async (name, options) => {
|
|
76
|
+
await loadConfigIfExists(deps.config);
|
|
77
|
+
if (options.service !== "google" && options.service !== "outlook") {
|
|
78
|
+
throw new ValidationError("Service must be 'google' or 'outlook'.");
|
|
79
|
+
}
|
|
80
|
+
deps.config.setContext(name, {
|
|
81
|
+
service: options.service,
|
|
82
|
+
calendarId: options.calendar,
|
|
83
|
+
accountId: options.account,
|
|
84
|
+
});
|
|
85
|
+
await deps.config.save();
|
|
86
|
+
logger.info(`Context '${name}' saved.`);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
import { formatInTimeZone } from "date-fns-tz";
|
|
3
|
+
import { Formatter } from "../core/formatter.js";
|
|
4
|
+
import { parseDateTime } from "../core/datetime.js";
|
|
5
|
+
import { getServiceForContext } from "./shared.js";
|
|
6
|
+
import { ValidationError } from "../core/errors.js";
|
|
7
|
+
import { logger } from "../core/logger.js";
|
|
8
|
+
export function registerQueryCommand(program, deps) {
|
|
9
|
+
program
|
|
10
|
+
.command("query")
|
|
11
|
+
.description("Query calendars and events")
|
|
12
|
+
.option("--set <name>", "Use a named context")
|
|
13
|
+
.option("--calendar <id>", "Explicit Calendar ID")
|
|
14
|
+
.option("--start <iso>", "Start date (YYYY-MM-DD)")
|
|
15
|
+
.option("--end <iso>", "End date (YYYY-MM-DD)")
|
|
16
|
+
.option("--format <fmt>", "Output format (csv, md, json)", "md")
|
|
17
|
+
.option("--out <file>", "Output file path")
|
|
18
|
+
.option("--dry-run", "Preview (no effect for query)", false)
|
|
19
|
+
.action(async (options) => {
|
|
20
|
+
const { service, calendarId: ctxCalId } = await getServiceForContext(deps, options.set);
|
|
21
|
+
const calendarId = options.calendar || ctxCalId;
|
|
22
|
+
const now = new Date();
|
|
23
|
+
let start;
|
|
24
|
+
let end;
|
|
25
|
+
if (options.start && /^\d+[dwm]$/.test(options.start)) {
|
|
26
|
+
start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
27
|
+
const amount = parseInt(options.start, 10);
|
|
28
|
+
const unit = options.start.slice(-1);
|
|
29
|
+
const ms = unit === "d"
|
|
30
|
+
? amount * 24 * 3600 * 1000
|
|
31
|
+
: unit === "w"
|
|
32
|
+
? amount * 7 * 24 * 3600 * 1000
|
|
33
|
+
: amount * 30 * 24 * 3600 * 1000;
|
|
34
|
+
end = new Date(start.getTime() + ms);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
start = parseDateTime(options.start);
|
|
38
|
+
end = options.end ? parseDateTime(options.end) : new Date(start.getTime() + 24 * 60 * 60 * 1000);
|
|
39
|
+
}
|
|
40
|
+
if (end <= start) {
|
|
41
|
+
throw new ValidationError("Invalid time range: end must be after start.");
|
|
42
|
+
}
|
|
43
|
+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
44
|
+
const offset = formatInTimeZone(new Date(), timeZone, "XXX");
|
|
45
|
+
logger.debug(`Local timezone: ${timeZone} (${offset})`);
|
|
46
|
+
logger.info(`Local timezone offset: ${offset}`);
|
|
47
|
+
logger.info(`Fetching events for ${calendarId} from ${start.toISOString()} to ${end.toISOString()}...`);
|
|
48
|
+
const events = await service.listEvents(calendarId, start, end);
|
|
49
|
+
let output = "";
|
|
50
|
+
if (options.format === "md")
|
|
51
|
+
output = Formatter.toMarkdown(events);
|
|
52
|
+
else if (options.format === "csv")
|
|
53
|
+
output = Formatter.toCsv(events);
|
|
54
|
+
else
|
|
55
|
+
output = JSON.stringify(events, null, 2);
|
|
56
|
+
if (options.out) {
|
|
57
|
+
await fs.writeFile(options.out, output, "utf-8");
|
|
58
|
+
logger.info(`Events exported to ${options.out}`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
logger.info(output);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ConfigManager } from "../core/config.js";
|
|
2
|
+
import { AuthManager } from "../core/auth.js";
|
|
3
|
+
import { GoogleCalendarService } from "../services/google.js";
|
|
4
|
+
import { OutlookCalendarService } from "../services/outlook.js";
|
|
5
|
+
import { MockCalendarService } from "../services/mock.js";
|
|
6
|
+
export interface CommandDeps {
|
|
7
|
+
config: ConfigManager;
|
|
8
|
+
auth: AuthManager;
|
|
9
|
+
}
|
|
10
|
+
export interface ResolvedService {
|
|
11
|
+
service: GoogleCalendarService | OutlookCalendarService | MockCalendarService;
|
|
12
|
+
calendarId: string;
|
|
13
|
+
serviceType: "google" | "outlook" | "mock";
|
|
14
|
+
}
|
|
15
|
+
export declare function loadConfigIfExists(config: ConfigManager): Promise<void>;
|
|
16
|
+
export declare function getServiceForContext(deps: CommandDeps, contextName?: string): Promise<ResolvedService>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { GoogleCalendarService } from "../services/google.js";
|
|
2
|
+
import { OutlookCalendarService } from "../services/outlook.js";
|
|
3
|
+
import { MockCalendarService } from "../services/mock.js";
|
|
4
|
+
import { ConfigError } from "../core/errors.js";
|
|
5
|
+
export async function loadConfigIfExists(config) {
|
|
6
|
+
try {
|
|
7
|
+
await config.load();
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
if (err instanceof ConfigError && err.message.includes("見つかりません")) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
throw err;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function getServiceForContext(deps, contextName) {
|
|
17
|
+
const { config, auth } = deps;
|
|
18
|
+
try {
|
|
19
|
+
await config.load();
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (err instanceof ConfigError) {
|
|
23
|
+
throw new ConfigError("Failed to load configuration. Run 'calendit --help' for setup instructions.", "初回は `calendit config set-google` などで設定を作成してください。");
|
|
24
|
+
}
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
const ctx = contextName
|
|
28
|
+
? config.getContext(contextName)
|
|
29
|
+
: { service: "google", calendarId: "primary", accountId: undefined };
|
|
30
|
+
if (!ctx) {
|
|
31
|
+
throw new ConfigError(`Context '${contextName}' が見つかりません。`, `\`calendit config set-context ${contextName} --service google --calendar primary\` を実行してください。`);
|
|
32
|
+
}
|
|
33
|
+
if (process.env.CALENDIT_MOCK === "true") {
|
|
34
|
+
return {
|
|
35
|
+
service: new MockCalendarService(ctx.service),
|
|
36
|
+
calendarId: ctx.calendarId,
|
|
37
|
+
serviceType: "mock",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (ctx.service === "google") {
|
|
41
|
+
const creds = config.getGoogleCreds();
|
|
42
|
+
if (!creds) {
|
|
43
|
+
throw new ConfigError("Google 認証情報が未設定です。", "`calendit config set-google --id <id> --secret <secret>` を実行してください。");
|
|
44
|
+
}
|
|
45
|
+
// Token file is keyed by contextName (used during `auth login --set`), falling back to accountId
|
|
46
|
+
const tokenKey = contextName || ctx.accountId;
|
|
47
|
+
const oauth2Client = await auth.getGoogleAuth(creds.id, creds.secret, tokenKey);
|
|
48
|
+
return {
|
|
49
|
+
service: new GoogleCalendarService(oauth2Client),
|
|
50
|
+
calendarId: ctx.calendarId,
|
|
51
|
+
serviceType: "google",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const creds = config.getOutlookCreds();
|
|
55
|
+
if (!creds) {
|
|
56
|
+
throw new ConfigError("Outlook 認証情報が未設定です。", "`calendit config set-outlook --id <id>` を実行してください。");
|
|
57
|
+
}
|
|
58
|
+
const pca = await auth.getOutlookClient(creds.id, creds.tenantId);
|
|
59
|
+
const accounts = await pca.getTokenCache().getAllAccounts();
|
|
60
|
+
// Match by username (email) or homeAccountId; fall back to first account
|
|
61
|
+
const account = ctx.accountId
|
|
62
|
+
? (accounts.find((a) => a.username === ctx.accountId || a.homeAccountId === ctx.accountId) ?? accounts[0])
|
|
63
|
+
: accounts[0];
|
|
64
|
+
return {
|
|
65
|
+
service: new OutlookCalendarService(pca, account),
|
|
66
|
+
calendarId: ctx.calendarId,
|
|
67
|
+
serviceType: "outlook",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { CalendarEvent } from "../types/index.js";
|
|
2
|
+
import { ICalendarService } from "../services/base.js";
|
|
3
|
+
export interface ApplyOptions {
|
|
4
|
+
dryRun?: boolean;
|
|
5
|
+
sync?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface ApplyResults {
|
|
8
|
+
created: Partial<CalendarEvent>[];
|
|
9
|
+
updated: {
|
|
10
|
+
input: Partial<CalendarEvent>;
|
|
11
|
+
existing: CalendarEvent;
|
|
12
|
+
diffs: string[];
|
|
13
|
+
}[];
|
|
14
|
+
deleted: CalendarEvent[];
|
|
15
|
+
}
|
|
16
|
+
export declare class Applier {
|
|
17
|
+
private service;
|
|
18
|
+
constructor(service: ICalendarService);
|
|
19
|
+
/**
|
|
20
|
+
* カレンダーに予定を適用する
|
|
21
|
+
*/
|
|
22
|
+
apply(calendarId: string, inputEvents: Partial<CalendarEvent>[], timerange?: {
|
|
23
|
+
start: Date;
|
|
24
|
+
end: Date;
|
|
25
|
+
}, options?: ApplyOptions): Promise<ApplyResults>;
|
|
26
|
+
}
|