floq 1.2.0 → 1.3.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/README.ja.md CHANGED
@@ -16,6 +16,7 @@ MS-DOSスタイルのテーマを備えたターミナルベースのGTD(Getti
16
16
  - **タスク検索**: `/` キーで全タスクを素早く検索
17
17
  - **コメント**: タスクにメモやコメントを追加
18
18
  - **クラウド同期**: [Turso](https://turso.tech/)のembedded replicasによるオプションの同期機能
19
+ - **Googleカレンダー**: iCal URLまたはOAuth連携で今日の予定を表示
19
20
  - **テーマ**: MS-DOSノスタルジックスタイルやドラクエRPG風を含む複数テーマ
20
21
  - **スプラッシュ画面**: 起動時のスプラッシュ画面(レトロテーマではドラクエ風)
21
22
  - **多言語対応**: 英語・日本語サポート
@@ -259,6 +260,95 @@ floq config turso --disable
259
260
  - TUIヘッダーに接続状態を表示(Tursoはクラウドアイコン、ローカルはローカルアイコン)
260
261
  - CLIコマンド実行時、Turso有効時は`🔄 Turso sync: hostname`を表示
261
262
 
263
+ ## Googleカレンダー連携
264
+
265
+ FloqはGoogleカレンダーの予定をTUIに表示できます。2つの方法があります:
266
+
267
+ ### 方法1: iCal URL(シンプル、認証不要)
268
+
269
+ GoogleカレンダーのシークレットiCal URLを使用して、OAuth設定なしで読み取り専用アクセスができます。
270
+
271
+ ```bash
272
+ # GoogleカレンダーからiCal URLを取得:
273
+ # 設定 > (カレンダー名) > カレンダーの統合 > "iCal形式の非公開URL"
274
+
275
+ floq calendar add "https://calendar.google.com/calendar/ical/..." -n "マイカレンダー"
276
+ floq calendar show
277
+ ```
278
+
279
+ ### 方法2: Google OAuth(フルAPIアクセス)
280
+
281
+ OAuthを使用すると、より信頼性が高く、すべてのカレンダーにアクセスできます。
282
+
283
+ #### セットアップ
284
+
285
+ 1. [Google Cloud Console](https://console.cloud.google.com/)にアクセス
286
+ 2. プロジェクトを作成(または既存のものを選択)
287
+ 3. **Google Calendar API を有効化**:
288
+ - **APIとサービス > ライブラリ**に移動
289
+ - 「Google Calendar API」を検索
290
+ - **有効にする**をクリック
291
+ 4. **OAuth 同意画面を設定**:
292
+ - **APIとサービス > OAuth 同意画面**に移動
293
+ - **外部**を選択して作成
294
+ - アプリ名と必須フィールドを入力
295
+ - **テストユーザー**に自分のメールアドレスを追加
296
+ 5. **OAuth 認証情報を作成**:
297
+ - **APIとサービス > 認証情報**に移動
298
+ - **認証情報を作成 > OAuth クライアント ID**をクリック
299
+ - アプリケーションの種類: **テレビと制限付き入力デバイス**(「デスクトップアプリ」ではない)
300
+ - クライアント ID とクライアントシークレットをコピー
301
+
302
+ #### 設定
303
+
304
+ ```bash
305
+ # OAuth認証情報を設定(または環境変数を使用)
306
+ floq calendar config --client-id "your-client-id.apps.googleusercontent.com" --client-secret "your-secret"
307
+
308
+ # または環境変数を使用
309
+ export GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
310
+ export GOOGLE_CLIENT_SECRET="your-secret"
311
+
312
+ # Googleでログイン
313
+ floq calendar login
314
+ # → ブラウザが開いて認証画面へ
315
+ # → 表示されたコードを入力
316
+
317
+ # カレンダーを選択
318
+ floq calendar select
319
+ # → カレンダー一覧が表示される
320
+ # → 番号を入力して選択
321
+
322
+ # 設定と今日の予定を表示
323
+ floq calendar show
324
+
325
+ # カレンダーキャッシュを更新
326
+ floq calendar sync
327
+
328
+ # ログアウト
329
+ floq calendar logout
330
+ ```
331
+
332
+ ### カレンダーコマンド
333
+
334
+ ```bash
335
+ # iCalモード
336
+ floq calendar add <url> [-n name] # iCal URLを追加
337
+ floq calendar remove # カレンダーを削除
338
+
339
+ # OAuthモード
340
+ floq calendar config --client-id <id> --client-secret <secret>
341
+ floq calendar login # Google OAuthログイン
342
+ floq calendar logout # OAuthトークンをクリア
343
+ floq calendar select # カレンダー選択(対話式)
344
+
345
+ # 共通コマンド
346
+ floq calendar show # 設定と今日の予定を表示
347
+ floq calendar sync # キャッシュを更新
348
+ floq calendar enable # 表示を有効化
349
+ floq calendar disable # 表示を無効化
350
+ ```
351
+
262
352
  ## テーマ
263
353
 
264
354
  26種類のテーマが利用可能。`floq config theme` でインタラクティブに選択(j/kで移動)。
package/README.md CHANGED
@@ -16,6 +16,7 @@ A terminal-based GTD (Getting Things Done) task manager with MS-DOS style themes
16
16
  - **Task Search**: Quick search across all tasks with `/`
17
17
  - **Comments**: Add notes and comments to tasks
18
18
  - **Cloud Sync**: Optional sync with [Turso](https://turso.tech/) using embedded replicas
19
+ - **Google Calendar**: Display today's events via iCal URL or OAuth integration
19
20
  - **Themes**: Multiple themes including MS-DOS nostalgic styles and Dragon Quest RPG style
20
21
  - **Splash Screen**: Configurable startup splash with Dragon Quest style for retro themes
21
22
  - **i18n**: English and Japanese support
@@ -259,6 +260,95 @@ floq config turso --disable
259
260
  - TUI header shows connection status (cloud icon for Turso, local icon for local mode)
260
261
  - CLI commands display `🔄 Turso sync: hostname` when Turso is enabled
261
262
 
263
+ ## Google Calendar Integration
264
+
265
+ Floq can display your Google Calendar events in the TUI. Two methods are available:
266
+
267
+ ### Option 1: iCal URL (Simple, No Authentication)
268
+
269
+ Use Google Calendar's secret iCal URL for read-only access without OAuth setup.
270
+
271
+ ```bash
272
+ # Get your iCal URL from Google Calendar:
273
+ # Settings > (Your Calendar) > Integrate calendar > "Secret address in iCal format"
274
+
275
+ floq calendar add "https://calendar.google.com/calendar/ical/..." -n "My Calendar"
276
+ floq calendar show
277
+ ```
278
+
279
+ ### Option 2: Google OAuth (Full API Access)
280
+
281
+ Use OAuth for better reliability and access to all your calendars.
282
+
283
+ #### Setup
284
+
285
+ 1. Go to [Google Cloud Console](https://console.cloud.google.com/)
286
+ 2. Create a project (or select an existing one)
287
+ 3. **Enable Google Calendar API**:
288
+ - Go to **APIs & Services > Library**
289
+ - Search for "Google Calendar API"
290
+ - Click **Enable**
291
+ 4. **Configure OAuth consent screen**:
292
+ - Go to **APIs & Services > OAuth consent screen**
293
+ - Select **External** and click Create
294
+ - Fill in app name and required fields
295
+ - Add your email to **Test users**
296
+ 5. **Create OAuth credentials**:
297
+ - Go to **APIs & Services > Credentials**
298
+ - Click **Create Credentials > OAuth client ID**
299
+ - Application type: **TV and Limited Input devices** (not "Desktop app")
300
+ - Copy the Client ID and Client Secret
301
+
302
+ #### Configuration
303
+
304
+ ```bash
305
+ # Set OAuth credentials (or use environment variables)
306
+ floq calendar config --client-id "your-client-id.apps.googleusercontent.com" --client-secret "your-secret"
307
+
308
+ # Or use environment variables
309
+ export GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
310
+ export GOOGLE_CLIENT_SECRET="your-secret"
311
+
312
+ # Login with Google
313
+ floq calendar login
314
+ # → Opens browser for authentication
315
+ # → Enter the displayed code when prompted
316
+
317
+ # Select a calendar
318
+ floq calendar select
319
+ # → Shows list of your calendars
320
+ # → Enter the number to select
321
+
322
+ # View configuration and today's events
323
+ floq calendar show
324
+
325
+ # Refresh calendar cache
326
+ floq calendar sync
327
+
328
+ # Logout
329
+ floq calendar logout
330
+ ```
331
+
332
+ ### Calendar Commands
333
+
334
+ ```bash
335
+ # iCal mode
336
+ floq calendar add <url> [-n name] # Add iCal URL
337
+ floq calendar remove # Remove calendar
338
+
339
+ # OAuth mode
340
+ floq calendar config --client-id <id> --client-secret <secret>
341
+ floq calendar login # Google OAuth login
342
+ floq calendar logout # Clear OAuth tokens
343
+ floq calendar select # Select calendar (interactive)
344
+
345
+ # Common commands
346
+ floq calendar show # Show config and today's events
347
+ floq calendar sync # Refresh cache
348
+ floq calendar enable # Enable display
349
+ floq calendar disable # Disable display
350
+ ```
351
+
262
352
  ## Themes
263
353
 
264
354
  26 themes available. Use `floq config theme` for interactive selection (j/k to navigate).
@@ -0,0 +1,16 @@
1
+ import type { CalendarEvent } from './index.js';
2
+ export interface GoogleCalendar {
3
+ id: string;
4
+ summary: string;
5
+ primary?: boolean;
6
+ backgroundColor?: string;
7
+ foregroundColor?: string;
8
+ }
9
+ /**
10
+ * List all calendars the user has access to
11
+ */
12
+ export declare function listCalendars(accessToken: string): Promise<GoogleCalendar[]>;
13
+ /**
14
+ * List events from a specific calendar
15
+ */
16
+ export declare function listEvents(accessToken: string, calendarId: string, timeMin: Date, timeMax: Date): Promise<CalendarEvent[]>;
@@ -0,0 +1,65 @@
1
+ const GOOGLE_CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3';
2
+ /**
3
+ * List all calendars the user has access to
4
+ */
5
+ export async function listCalendars(accessToken) {
6
+ const response = await fetch(`${GOOGLE_CALENDAR_API_BASE}/users/me/calendarList`, {
7
+ headers: {
8
+ Authorization: `Bearer ${accessToken}`,
9
+ },
10
+ });
11
+ if (!response.ok) {
12
+ const error = await response.text();
13
+ throw new Error(`Failed to list calendars: ${error}`);
14
+ }
15
+ const data = await response.json();
16
+ return data.items.map(item => ({
17
+ id: item.id,
18
+ summary: item.summary,
19
+ primary: item.primary,
20
+ backgroundColor: item.backgroundColor,
21
+ foregroundColor: item.foregroundColor,
22
+ }));
23
+ }
24
+ /**
25
+ * List events from a specific calendar
26
+ */
27
+ export async function listEvents(accessToken, calendarId, timeMin, timeMax) {
28
+ const params = new URLSearchParams({
29
+ timeMin: timeMin.toISOString(),
30
+ timeMax: timeMax.toISOString(),
31
+ singleEvents: 'true', // Expand recurring events
32
+ orderBy: 'startTime',
33
+ maxResults: '100',
34
+ });
35
+ const encodedCalendarId = encodeURIComponent(calendarId);
36
+ const response = await fetch(`${GOOGLE_CALENDAR_API_BASE}/calendars/${encodedCalendarId}/events?${params}`, {
37
+ headers: {
38
+ Authorization: `Bearer ${accessToken}`,
39
+ },
40
+ });
41
+ if (!response.ok) {
42
+ const error = await response.text();
43
+ throw new Error(`Failed to list events: ${error}`);
44
+ }
45
+ const data = await response.json();
46
+ return data.items
47
+ .filter(item => item.status !== 'cancelled')
48
+ .map(item => {
49
+ const isAllDay = !item.start.dateTime;
50
+ const start = isAllDay
51
+ ? new Date(item.start.date + 'T00:00:00')
52
+ : new Date(item.start.dateTime);
53
+ const end = isAllDay
54
+ ? new Date(item.end.date + 'T00:00:00')
55
+ : new Date(item.end.dateTime);
56
+ return {
57
+ id: item.id,
58
+ title: item.summary || 'Untitled',
59
+ start,
60
+ end,
61
+ allDay: isAllDay,
62
+ location: item.location,
63
+ };
64
+ });
65
+ }
@@ -0,0 +1,40 @@
1
+ export interface CalendarEvent {
2
+ id: string;
3
+ title: string;
4
+ start: Date;
5
+ end: Date;
6
+ allDay: boolean;
7
+ location?: string;
8
+ }
9
+ /**
10
+ * Fetch calendar events from the configured source (iCal URL or OAuth)
11
+ */
12
+ export declare function fetchCalendarEvents(url?: string): Promise<CalendarEvent[]>;
13
+ /**
14
+ * Get cached events or fetch if cache is stale
15
+ */
16
+ export declare function getCalendarEvents(): Promise<CalendarEvent[]>;
17
+ /**
18
+ * Get today's events from cache (synchronous)
19
+ */
20
+ export declare function getTodayEvents(): CalendarEvent[];
21
+ /**
22
+ * Get upcoming events (next event for each hour slot)
23
+ */
24
+ export declare function getUpcomingEvents(limit?: number): CalendarEvent[];
25
+ /**
26
+ * Clear the events cache
27
+ */
28
+ export declare function clearCalendarCache(): void;
29
+ /**
30
+ * Format time for display (HH:MM format)
31
+ */
32
+ export declare function formatEventTime(date: Date): string;
33
+ /**
34
+ * Check if an event is currently happening
35
+ */
36
+ export declare function isEventOngoing(event: CalendarEvent): boolean;
37
+ /**
38
+ * Get time until event starts (in minutes)
39
+ */
40
+ export declare function getMinutesUntilEvent(event: CalendarEvent): number;
@@ -0,0 +1,253 @@
1
+ import ICAL from 'ical.js';
2
+ import { getCalendarConfig, isCalendarEnabled, getCalendarType, getCalendarOAuthConfig } from '../config.js';
3
+ import { getValidAccessToken } from './oauth.js';
4
+ import { listEvents as listGoogleEvents } from './google-api.js';
5
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
6
+ let eventsCache = null;
7
+ /**
8
+ * Parse iCal data and extract events
9
+ */
10
+ function parseICalData(icalData) {
11
+ const events = [];
12
+ try {
13
+ const jcalData = ICAL.parse(icalData);
14
+ const vcalendar = new ICAL.Component(jcalData);
15
+ const vevents = vcalendar.getAllSubcomponents('vevent');
16
+ for (const vevent of vevents) {
17
+ const event = new ICAL.Event(vevent);
18
+ // Handle recurring events - get occurrences for today and next 7 days
19
+ const now = new Date();
20
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
21
+ const endOfWeek = new Date(startOfToday);
22
+ endOfWeek.setDate(endOfWeek.getDate() + 7);
23
+ if (event.isRecurring()) {
24
+ try {
25
+ const iter = event.iterator();
26
+ let next = iter.next();
27
+ let count = 0;
28
+ const maxOccurrences = 100; // Safety limit
29
+ while (next && count < maxOccurrences) {
30
+ const occurrenceStart = next.toJSDate();
31
+ const occurrenceEnd = new Date(occurrenceStart.getTime() + event.duration.toSeconds() * 1000);
32
+ // Only include occurrences within our time window
33
+ if (occurrenceStart >= startOfToday && occurrenceStart < endOfWeek) {
34
+ events.push({
35
+ id: `${event.uid}-${occurrenceStart.getTime()}`,
36
+ title: event.summary || 'Untitled',
37
+ start: occurrenceStart,
38
+ end: occurrenceEnd,
39
+ allDay: event.startDate.isDate,
40
+ location: event.location || undefined,
41
+ });
42
+ }
43
+ // Stop if we're past our window
44
+ if (occurrenceStart >= endOfWeek)
45
+ break;
46
+ next = iter.next();
47
+ count++;
48
+ }
49
+ }
50
+ catch {
51
+ // If recurring expansion fails, fall back to single event
52
+ const startDate = event.startDate.toJSDate();
53
+ const endDate = event.endDate?.toJSDate() || startDate;
54
+ events.push({
55
+ id: event.uid,
56
+ title: event.summary || 'Untitled',
57
+ start: startDate,
58
+ end: endDate,
59
+ allDay: event.startDate.isDate,
60
+ location: event.location || undefined,
61
+ });
62
+ }
63
+ }
64
+ else {
65
+ // Non-recurring event
66
+ const startDate = event.startDate.toJSDate();
67
+ const endDate = event.endDate?.toJSDate() || startDate;
68
+ events.push({
69
+ id: event.uid,
70
+ title: event.summary || 'Untitled',
71
+ start: startDate,
72
+ end: endDate,
73
+ allDay: event.startDate.isDate,
74
+ location: event.location || undefined,
75
+ });
76
+ }
77
+ }
78
+ }
79
+ catch (error) {
80
+ // Silently fail on parse errors
81
+ console.error('Failed to parse iCal data:', error);
82
+ }
83
+ return events;
84
+ }
85
+ /**
86
+ * Normalize iCal URL (convert webcal:// to https://)
87
+ */
88
+ function normalizeUrl(url) {
89
+ if (url.startsWith('webcal://')) {
90
+ return url.replace('webcal://', 'https://');
91
+ }
92
+ return url;
93
+ }
94
+ /**
95
+ * Fetch calendar events via OAuth (Google Calendar API)
96
+ */
97
+ async function fetchEventsViaOAuth() {
98
+ const oauthConfig = getCalendarOAuthConfig();
99
+ if (!oauthConfig) {
100
+ return [];
101
+ }
102
+ const accessToken = await getValidAccessToken();
103
+ if (!accessToken) {
104
+ return [];
105
+ }
106
+ const now = new Date();
107
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
108
+ const endOfWeek = new Date(startOfToday);
109
+ endOfWeek.setDate(endOfWeek.getDate() + 7);
110
+ try {
111
+ return await listGoogleEvents(accessToken, oauthConfig.calendarId, startOfToday, endOfWeek);
112
+ }
113
+ catch (error) {
114
+ console.error('Failed to fetch Google Calendar events:', error);
115
+ return [];
116
+ }
117
+ }
118
+ /**
119
+ * Fetch calendar events via iCal URL
120
+ */
121
+ async function fetchEventsViaIcal(url) {
122
+ const normalizedUrl = normalizeUrl(url);
123
+ const response = await fetch(normalizedUrl, {
124
+ headers: {
125
+ 'User-Agent': 'Floq GTD CLI',
126
+ },
127
+ });
128
+ if (!response.ok) {
129
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
130
+ }
131
+ const icalData = await response.text();
132
+ return parseICalData(icalData);
133
+ }
134
+ /**
135
+ * Fetch calendar events from the configured source (iCal URL or OAuth)
136
+ */
137
+ export async function fetchCalendarEvents(url) {
138
+ try {
139
+ let events;
140
+ const calendarType = getCalendarType();
141
+ if (url) {
142
+ // Explicit URL provided, use iCal mode
143
+ events = await fetchEventsViaIcal(url);
144
+ }
145
+ else if (calendarType === 'oauth') {
146
+ // OAuth mode
147
+ events = await fetchEventsViaOAuth();
148
+ }
149
+ else {
150
+ // iCal mode
151
+ const targetUrl = getCalendarConfig()?.url;
152
+ if (!targetUrl) {
153
+ return [];
154
+ }
155
+ events = await fetchEventsViaIcal(targetUrl);
156
+ }
157
+ // Update cache
158
+ eventsCache = {
159
+ events,
160
+ timestamp: Date.now(),
161
+ };
162
+ return events;
163
+ }
164
+ catch (error) {
165
+ // Silently fail on errors
166
+ console.error('Failed to fetch calendar:', error);
167
+ return eventsCache?.events || [];
168
+ }
169
+ }
170
+ /**
171
+ * Get cached events or fetch if cache is stale
172
+ */
173
+ export async function getCalendarEvents() {
174
+ if (!isCalendarEnabled()) {
175
+ return [];
176
+ }
177
+ // Check if cache is valid
178
+ if (eventsCache && Date.now() - eventsCache.timestamp < CACHE_TTL_MS) {
179
+ return eventsCache.events;
180
+ }
181
+ // Fetch fresh data
182
+ return fetchCalendarEvents();
183
+ }
184
+ /**
185
+ * Get today's events from cache (synchronous)
186
+ */
187
+ export function getTodayEvents() {
188
+ if (!eventsCache) {
189
+ return [];
190
+ }
191
+ const now = new Date();
192
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
193
+ const endOfToday = new Date(startOfToday);
194
+ endOfToday.setDate(endOfToday.getDate() + 1);
195
+ return eventsCache.events
196
+ .filter(event => {
197
+ // Event starts today or spans today
198
+ return (event.start >= startOfToday && event.start < endOfToday) ||
199
+ (event.start < startOfToday && event.end >= startOfToday);
200
+ })
201
+ .sort((a, b) => {
202
+ // All-day events first, then by start time
203
+ if (a.allDay && !b.allDay)
204
+ return -1;
205
+ if (!a.allDay && b.allDay)
206
+ return 1;
207
+ return a.start.getTime() - b.start.getTime();
208
+ });
209
+ }
210
+ /**
211
+ * Get upcoming events (next event for each hour slot)
212
+ */
213
+ export function getUpcomingEvents(limit = 5) {
214
+ if (!eventsCache) {
215
+ return [];
216
+ }
217
+ const now = new Date();
218
+ return eventsCache.events
219
+ .filter(event => {
220
+ // Future events or ongoing events
221
+ return event.end > now;
222
+ })
223
+ .sort((a, b) => a.start.getTime() - b.start.getTime())
224
+ .slice(0, limit);
225
+ }
226
+ /**
227
+ * Clear the events cache
228
+ */
229
+ export function clearCalendarCache() {
230
+ eventsCache = null;
231
+ }
232
+ /**
233
+ * Format time for display (HH:MM format)
234
+ */
235
+ export function formatEventTime(date) {
236
+ const hours = String(date.getHours()).padStart(2, '0');
237
+ const minutes = String(date.getMinutes()).padStart(2, '0');
238
+ return `${hours}:${minutes}`;
239
+ }
240
+ /**
241
+ * Check if an event is currently happening
242
+ */
243
+ export function isEventOngoing(event) {
244
+ const now = new Date();
245
+ return event.start <= now && event.end > now;
246
+ }
247
+ /**
248
+ * Get time until event starts (in minutes)
249
+ */
250
+ export function getMinutesUntilEvent(event) {
251
+ const now = new Date();
252
+ return Math.floor((event.start.getTime() - now.getTime()) / (1000 * 60));
253
+ }
@@ -0,0 +1,32 @@
1
+ import { type CalendarOAuthTokens } from '../config.js';
2
+ export interface DeviceCodeResponse {
3
+ deviceCode: string;
4
+ userCode: string;
5
+ verificationUrl: string;
6
+ expiresIn: number;
7
+ interval: number;
8
+ }
9
+ /**
10
+ * Start the OAuth Device Code flow
11
+ */
12
+ export declare function startOAuthFlow(): Promise<DeviceCodeResponse>;
13
+ /**
14
+ * Poll for tokens after user has authorized
15
+ */
16
+ export declare function pollForTokens(deviceCode: string, interval?: number): Promise<CalendarOAuthTokens>;
17
+ /**
18
+ * Refresh the access token using the refresh token
19
+ */
20
+ export declare function refreshAccessToken(refreshToken: string): Promise<CalendarOAuthTokens>;
21
+ /**
22
+ * Check if the token is expired (with 5-minute buffer)
23
+ */
24
+ export declare function isTokenExpired(tokens: CalendarOAuthTokens): boolean;
25
+ /**
26
+ * Get a valid access token, refreshing if necessary
27
+ */
28
+ export declare function getValidAccessToken(): Promise<string | null>;
29
+ /**
30
+ * Clear OAuth tokens (logout)
31
+ */
32
+ export declare function clearOAuthTokens(): void;