floq 1.8.0 → 1.9.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
@@ -274,7 +274,7 @@ floq config turso --disable
274
274
 
275
275
  ## Googleカレンダー連携
276
276
 
277
- FloqはGoogleカレンダーの予定をTUIに表示できます。2つの方法があります:
277
+ FloqはGoogleカレンダーの予定をTUIに表示できます。複数のカレンダーを登録でき(iCalとOAuthの混在も可能)、有効なカレンダーすべての予定がマージして表示されます。2つの方法があります:
278
278
 
279
279
  ### 方法1: iCal URL(シンプル、認証不要)
280
280
 
@@ -284,7 +284,9 @@ GoogleカレンダーのシークレットiCal URLを使用して、OAuth設定
284
284
  # GoogleカレンダーからiCal URLを取得:
285
285
  # 設定 > (カレンダー名) > カレンダーの統合 > "iCal形式の非公開URL"
286
286
 
287
- floq calendar add "https://calendar.google.com/calendar/ical/..." -n "マイカレンダー"
287
+ floq calendar add "https://calendar.google.com/calendar/ical/..." -n "仕事"
288
+ floq calendar add "https://calendar.google.com/calendar/ical/..." -n "プライベート"
289
+ floq calendar list
288
290
  floq calendar show
289
291
  ```
290
292
 
@@ -326,10 +328,10 @@ floq calendar login
326
328
  # → ブラウザが開いて認証画面へ
327
329
  # → 表示されたコードを入力
328
330
 
329
- # カレンダーを選択
331
+ # 登録するカレンダーを選択(複数選択可)
330
332
  floq calendar select
331
333
  # → カレンダー一覧が表示される
332
- # → 番号を入力して選択
334
+ # → 番号をカンマ区切りで入力(例: 1,3)
333
335
 
334
336
  # 設定と今日の予定を表示
335
337
  floq calendar show
@@ -345,20 +347,33 @@ floq calendar logout
345
347
 
346
348
  ```bash
347
349
  # iCalモード
348
- floq calendar add <url> [-n name] # iCal URLを追加
349
- floq calendar remove # カレンダーを削除
350
+ floq calendar add <url> [-n name] # iCalカレンダーを追加(複数登録可)
350
351
 
351
352
  # OAuthモード
352
353
  floq calendar config --client-id <id> --client-secret <secret>
353
354
  floq calendar login # Google OAuthログイン
354
355
  floq calendar logout # OAuthトークンをクリア
355
- floq calendar select # カレンダー選択(対話式)
356
+ floq calendar select # Googleカレンダーを登録(対話式・複数可)
356
357
 
357
358
  # 共通コマンド
359
+ floq calendar list # 登録済みカレンダー一覧
360
+ floq calendar remove <id|番号|名前> # カレンダーを削除
361
+ floq calendar remove --all # カレンダー設定をすべて削除
358
362
  floq calendar show # 設定と今日の予定を表示
359
363
  floq calendar sync # キャッシュを更新
360
- floq calendar enable # 表示を有効化
361
- floq calendar disable # 表示を無効化
364
+ floq calendar enable [id] # 表示を有効化(全体または個別)
365
+ floq calendar disable [id] # 表示を無効化(全体または個別)
366
+ ```
367
+
368
+ ### スケジュールコマンド
369
+
370
+ 登録したすべてのカレンダーの予定をまとめて確認できます:
371
+
372
+ ```bash
373
+ floq schedule # 今日の予定
374
+ floq schedule tomorrow # 明日の予定
375
+ floq schedule week # 今後7日間
376
+ floq schedule --days 3 # 今後N日間
362
377
  ```
363
378
 
364
379
  ## テーマ
package/README.md CHANGED
@@ -274,7 +274,7 @@ floq config turso --disable
274
274
 
275
275
  ## Google Calendar Integration
276
276
 
277
- Floq can display your Google Calendar events in the TUI. Two methods are available:
277
+ Floq can display your Google Calendar events in the TUI. Multiple calendars can be registered (iCal and OAuth sources can be mixed), and events from all enabled calendars are merged. Two methods are available:
278
278
 
279
279
  ### Option 1: iCal URL (Simple, No Authentication)
280
280
 
@@ -284,7 +284,9 @@ Use Google Calendar's secret iCal URL for read-only access without OAuth setup.
284
284
  # Get your iCal URL from Google Calendar:
285
285
  # Settings > (Your Calendar) > Integrate calendar > "Secret address in iCal format"
286
286
 
287
- floq calendar add "https://calendar.google.com/calendar/ical/..." -n "My Calendar"
287
+ floq calendar add "https://calendar.google.com/calendar/ical/..." -n "Work"
288
+ floq calendar add "https://calendar.google.com/calendar/ical/..." -n "Personal"
289
+ floq calendar list
288
290
  floq calendar show
289
291
  ```
290
292
 
@@ -326,10 +328,10 @@ floq calendar login
326
328
  # → Opens browser for authentication
327
329
  # → Enter the displayed code when prompted
328
330
 
329
- # Select a calendar
331
+ # Select calendars to register (multiple selections supported)
330
332
  floq calendar select
331
333
  # → Shows list of your calendars
332
- # → Enter the number to select
334
+ # → Enter numbers, comma-separated (e.g. 1,3)
333
335
 
334
336
  # View configuration and today's events
335
337
  floq calendar show
@@ -345,20 +347,33 @@ floq calendar logout
345
347
 
346
348
  ```bash
347
349
  # iCal mode
348
- floq calendar add <url> [-n name] # Add iCal URL
349
- floq calendar remove # Remove calendar
350
+ floq calendar add <url> [-n name] # Add an iCal calendar (repeatable)
350
351
 
351
352
  # OAuth mode
352
353
  floq calendar config --client-id <id> --client-secret <secret>
353
354
  floq calendar login # Google OAuth login
354
355
  floq calendar logout # Clear OAuth tokens
355
- floq calendar select # Select calendar (interactive)
356
+ floq calendar select # Register Google calendars (interactive, multiple)
356
357
 
357
358
  # Common commands
359
+ floq calendar list # List registered calendars
360
+ floq calendar remove <id|num|name> # Remove a calendar
361
+ floq calendar remove --all # Remove all calendar configuration
358
362
  floq calendar show # Show config and today's events
359
363
  floq calendar sync # Refresh cache
360
- floq calendar enable # Enable display
361
- floq calendar disable # Disable display
364
+ floq calendar enable [id] # Enable display (all, or one calendar)
365
+ floq calendar disable [id] # Disable display (all, or one calendar)
366
+ ```
367
+
368
+ ### Schedule Command
369
+
370
+ View merged events from all registered calendars:
371
+
372
+ ```bash
373
+ floq schedule # Today's events
374
+ floq schedule tomorrow # Tomorrow's events
375
+ floq schedule week # Next 7 days
376
+ floq schedule --days 3 # Next N days
362
377
  ```
363
378
 
364
379
  ## Themes
@@ -5,9 +5,12 @@ export interface CalendarEvent {
5
5
  end: Date;
6
6
  allDay: boolean;
7
7
  location?: string;
8
+ calendarName?: string;
9
+ sourceId?: string;
8
10
  }
9
11
  /**
10
- * Fetch calendar events from the configured source (iCal URL or OAuth)
12
+ * Fetch and merge calendar events from all enabled calendar sources.
13
+ * When a URL is passed explicitly, fetch only that iCal URL (validation use; cache is not updated).
11
14
  */
12
15
  export declare function fetchCalendarEvents(url?: string): Promise<CalendarEvent[]>;
13
16
  /**
@@ -1,5 +1,5 @@
1
1
  import ICAL from 'ical.js';
2
- import { getCalendarConfig, isCalendarEnabled, getCalendarType, getCalendarOAuthConfig } from '../config.js';
2
+ import { getCalendarConfig, isCalendarEnabled, isCalendarSourceUsable } from '../config.js';
3
3
  import { getValidAccessToken } from './oauth.js';
4
4
  import { listEvents as listGoogleEvents } from './google-api.js';
5
5
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
@@ -90,29 +90,6 @@ function normalizeUrl(url) {
90
90
  }
91
91
  return url;
92
92
  }
93
- /**
94
- * Fetch calendar events via OAuth (Google Calendar API)
95
- */
96
- async function fetchEventsViaOAuth() {
97
- const oauthConfig = getCalendarOAuthConfig();
98
- if (!oauthConfig) {
99
- return [];
100
- }
101
- const accessToken = await getValidAccessToken();
102
- if (!accessToken) {
103
- return [];
104
- }
105
- const now = new Date();
106
- const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
107
- const endOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 2, 0); // Last day of next month
108
- try {
109
- return await listGoogleEvents(accessToken, oauthConfig.calendarId, startOfMonth, endOfNextMonth);
110
- }
111
- catch (error) {
112
- console.error('Failed to fetch Google Calendar events:', error);
113
- return [];
114
- }
115
- }
116
93
  /**
117
94
  * Fetch calendar events via iCal URL
118
95
  */
@@ -130,28 +107,59 @@ async function fetchEventsViaIcal(url) {
130
107
  return parseICalData(icalData);
131
108
  }
132
109
  /**
133
- * Fetch calendar events from the configured source (iCal URL or OAuth)
110
+ * Fetch events for a single calendar source
111
+ */
112
+ async function fetchEventsForSource(source, accessToken) {
113
+ let events;
114
+ if (source.type === 'oauth') {
115
+ if (!accessToken || !source.calendarId) {
116
+ return [];
117
+ }
118
+ const now = new Date();
119
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
120
+ const endOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 2, 0); // Last day of next month
121
+ events = await listGoogleEvents(accessToken, source.calendarId, startOfMonth, endOfNextMonth);
122
+ }
123
+ else {
124
+ if (!source.url) {
125
+ return [];
126
+ }
127
+ events = await fetchEventsViaIcal(source.url);
128
+ }
129
+ return events.map(event => ({
130
+ ...event,
131
+ calendarName: source.name,
132
+ sourceId: source.id,
133
+ }));
134
+ }
135
+ /**
136
+ * Fetch and merge calendar events from all enabled calendar sources.
137
+ * When a URL is passed explicitly, fetch only that iCal URL (validation use; cache is not updated).
134
138
  */
135
139
  export async function fetchCalendarEvents(url) {
140
+ if (url) {
141
+ return fetchEventsViaIcal(url);
142
+ }
136
143
  try {
137
- let events;
138
- const calendarType = getCalendarType();
139
- if (url) {
140
- // Explicit URL provided, use iCal mode
141
- events = await fetchEventsViaIcal(url);
142
- }
143
- else if (calendarType === 'oauth') {
144
- // OAuth mode
145
- events = await fetchEventsViaOAuth();
144
+ const config = getCalendarConfig();
145
+ const sources = (config?.calendars || []).filter(s => s.enabled !== false && isCalendarSourceUsable(s, config));
146
+ if (sources.length === 0) {
147
+ return [];
146
148
  }
147
- else {
148
- // iCal mode
149
- const targetUrl = getCalendarConfig()?.url;
150
- if (!targetUrl) {
151
- return [];
149
+ // Resolve the access token once if any oauth source is present
150
+ const needsOAuth = sources.some(s => s.type === 'oauth');
151
+ const accessToken = needsOAuth ? await getValidAccessToken() : null;
152
+ const results = await Promise.allSettled(sources.map(source => fetchEventsForSource(source, accessToken)));
153
+ const events = [];
154
+ results.forEach((result, index) => {
155
+ if (result.status === 'fulfilled') {
156
+ events.push(...result.value);
152
157
  }
153
- events = await fetchEventsViaIcal(targetUrl);
154
- }
158
+ else {
159
+ console.error(`Failed to fetch calendar "${sources[index].name}":`, result.reason);
160
+ }
161
+ });
162
+ events.sort((a, b) => a.start.getTime() - b.start.getTime());
155
163
  // Update cache
156
164
  eventsCache = {
157
165
  events,
@@ -1,4 +1,4 @@
1
- import { getGoogleOAuthClient, getCalendarOAuthConfig, setCalendarOAuthConfig, } from '../config.js';
1
+ import { getGoogleOAuthClient, getCalendarOAuthTokens, setCalendarOAuthTokens, } from '../config.js';
2
2
  const GOOGLE_OAUTH_DEVICE_CODE_URL = 'https://oauth2.googleapis.com/device/code';
3
3
  const GOOGLE_OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
4
4
  const CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar.readonly';
@@ -125,19 +125,15 @@ export function isTokenExpired(tokens) {
125
125
  * Get a valid access token, refreshing if necessary
126
126
  */
127
127
  export async function getValidAccessToken() {
128
- const oauthConfig = getCalendarOAuthConfig();
129
- if (!oauthConfig) {
128
+ let tokens = getCalendarOAuthTokens();
129
+ if (!tokens) {
130
130
  return null;
131
131
  }
132
- let tokens = oauthConfig.tokens;
133
132
  if (isTokenExpired(tokens)) {
134
133
  try {
135
134
  tokens = await refreshAccessToken(tokens.refreshToken);
136
135
  // Update stored tokens
137
- setCalendarOAuthConfig({
138
- ...oauthConfig,
139
- tokens,
140
- });
136
+ setCalendarOAuthTokens(tokens);
141
137
  }
142
138
  catch (error) {
143
139
  console.error('Failed to refresh access token:', error);
@@ -150,5 +146,5 @@ export async function getValidAccessToken() {
150
146
  * Clear OAuth tokens (logout)
151
147
  */
152
148
  export function clearOAuthTokens() {
153
- setCalendarOAuthConfig(undefined);
149
+ setCalendarOAuthTokens(undefined);
154
150
  }
package/dist/cli.js CHANGED
@@ -12,7 +12,8 @@ import { addComment, listComments } from './commands/comment.js';
12
12
  import { listContexts, addContextCommand, removeContextCommand } from './commands/context.js';
13
13
  import { showInsights } from './commands/insights.js';
14
14
  import { runSetupWizard } from './commands/setup.js';
15
- import { addCalendar, removeCalendar, showCalendar, syncCalendar, enableCalendar, disableCalendar, configOAuthClient, loginCalendar, logoutCalendar, selectCalendar } from './commands/calendar.js';
15
+ import { addCalendar, removeCalendar, showCalendar, syncCalendar, enableCalendar, disableCalendar, configOAuthClient, loginCalendar, logoutCalendar, selectCalendar, listCalendarSourcesCommand } from './commands/calendar.js';
16
+ import { showSchedule } from './commands/schedule.js';
16
17
  import { VERSION } from './version.js';
17
18
  const program = new Command();
18
19
  program
@@ -316,22 +317,39 @@ program
316
317
  .action(async () => {
317
318
  await runSetupWizard();
318
319
  });
320
+ // Schedule command
321
+ program
322
+ .command('schedule [period]')
323
+ .description('Show schedule from registered calendars (today, tomorrow, week)')
324
+ .option('-d, --days <n>', 'Number of days to show')
325
+ .action(async (period, options) => {
326
+ await showSchedule(period, options);
327
+ });
319
328
  // Calendar commands
320
329
  const calendarCmd = program
321
330
  .command('calendar')
322
331
  .description('Google Calendar (iCal) integration');
323
332
  calendarCmd
324
333
  .command('add <url>')
325
- .description('Add or update calendar URL')
334
+ .description('Add a calendar by iCal URL (multiple calendars supported)')
326
335
  .option('-n, --name <name>', 'Display name for the calendar')
327
336
  .action(async (url, options) => {
328
337
  await addCalendar(url, options);
329
338
  });
330
339
  calendarCmd
331
- .command('remove')
332
- .description('Remove calendar configuration')
340
+ .command('list')
341
+ .alias('ls')
342
+ .description('List registered calendars')
333
343
  .action(async () => {
334
- await removeCalendar();
344
+ await listCalendarSourcesCommand();
345
+ });
346
+ calendarCmd
347
+ .command('remove [id]')
348
+ .alias('rm')
349
+ .description('Remove a calendar by id/number/name')
350
+ .option('--all', 'Remove all calendar configuration')
351
+ .action(async (id, options) => {
352
+ await removeCalendar(id, options);
335
353
  });
336
354
  calendarCmd
337
355
  .command('show')
@@ -346,16 +364,16 @@ calendarCmd
346
364
  await syncCalendar();
347
365
  });
348
366
  calendarCmd
349
- .command('enable')
350
- .description('Enable calendar display')
351
- .action(async () => {
352
- await enableCalendar();
367
+ .command('enable [id]')
368
+ .description('Enable calendar display (all, or a specific calendar by id)')
369
+ .action(async (id) => {
370
+ await enableCalendar(id);
353
371
  });
354
372
  calendarCmd
355
- .command('disable')
356
- .description('Disable calendar display')
357
- .action(async () => {
358
- await disableCalendar();
373
+ .command('disable [id]')
374
+ .description('Disable calendar display (all, or a specific calendar by id)')
375
+ .action(async (id) => {
376
+ await disableCalendar(id);
359
377
  });
360
378
  // OAuth commands
361
379
  calendarCmd
@@ -390,7 +408,7 @@ calendarCmd
390
408
  });
391
409
  calendarCmd
392
410
  .command('select')
393
- .description('Select a calendar to display')
411
+ .description('Select Google calendars to register (multiple supported)')
394
412
  .action(async () => {
395
413
  await selectCalendar();
396
414
  });
@@ -1,13 +1,19 @@
1
1
  /**
2
- * Add or update calendar URL (iCal mode)
2
+ * Add a new iCal calendar (multiple calendars can be registered)
3
3
  */
4
4
  export declare function addCalendar(url: string, options: {
5
5
  name?: string;
6
6
  }): Promise<void>;
7
7
  /**
8
- * Remove calendar configuration
8
+ * List registered calendars
9
9
  */
10
- export declare function removeCalendar(): Promise<void>;
10
+ export declare function listCalendarSourcesCommand(): Promise<void>;
11
+ /**
12
+ * Remove a calendar (by id/index/name), or all calendar configuration with --all
13
+ */
14
+ export declare function removeCalendar(idOrIndex?: string, options?: {
15
+ all?: boolean;
16
+ }): Promise<void>;
11
17
  /**
12
18
  * Show calendar configuration and today's events
13
19
  */
@@ -16,14 +22,8 @@ export declare function showCalendar(): Promise<void>;
16
22
  * Sync/refresh calendar cache
17
23
  */
18
24
  export declare function syncCalendar(): Promise<void>;
19
- /**
20
- * Enable calendar display
21
- */
22
- export declare function enableCalendar(): Promise<void>;
23
- /**
24
- * Disable calendar display
25
- */
26
- export declare function disableCalendar(): Promise<void>;
25
+ export declare function enableCalendar(idOrIndex?: string): Promise<void>;
26
+ export declare function disableCalendar(idOrIndex?: string): Promise<void>;
27
27
  /**
28
28
  * Configure OAuth client credentials
29
29
  */
@@ -37,6 +37,6 @@ export declare function loginCalendar(): Promise<void>;
37
37
  */
38
38
  export declare function logoutCalendar(): Promise<void>;
39
39
  /**
40
- * Select a calendar from the user's calendars
40
+ * Select Google calendars to register (multiple selections supported)
41
41
  */
42
42
  export declare function selectCalendar(): Promise<void>;
@@ -1,11 +1,38 @@
1
- import { getCalendarConfig, setCalendarConfig, setCalendarEnabled, getGoogleOAuthClient, setGoogleOAuthClient, setCalendarOAuthConfig, getCalendarOAuthConfig, getCalendarType, } from '../config.js';
1
+ import { getCalendarConfig, setCalendarConfig, getCalendarSources, addCalendarSource, removeCalendarSource, updateCalendarSource, setCalendarEnabled, getGoogleOAuthClient, setGoogleOAuthClient, getCalendarOAuthTokens, setCalendarOAuthTokens, } from '../config.js';
2
2
  import { fetchCalendarEvents, getTodayEvents, clearCalendarCache, formatEventTime } from '../calendar/index.js';
3
3
  import { getLocale } from '../config.js';
4
- import { startOAuthFlow, pollForTokens, clearOAuthTokens } from '../calendar/oauth.js';
4
+ import { startOAuthFlow, pollForTokens, clearOAuthTokens, getValidAccessToken } from '../calendar/oauth.js';
5
5
  import { listCalendars } from '../calendar/google-api.js';
6
6
  import * as readline from 'readline';
7
7
  /**
8
- * Add or update calendar URL (iCal mode)
8
+ * Resolve a calendar source by id, 1-based index, or name
9
+ */
10
+ function resolveSource(idOrIndex) {
11
+ const sources = getCalendarSources();
12
+ // Exact id match
13
+ const byId = sources.find(s => s.id === idOrIndex);
14
+ if (byId)
15
+ return byId;
16
+ // 1-based index
17
+ const index = parseInt(idOrIndex, 10);
18
+ if (!isNaN(index) && index >= 1 && index <= sources.length) {
19
+ return sources[index - 1];
20
+ }
21
+ // Name match
22
+ return sources.find(s => s.name === idOrIndex);
23
+ }
24
+ function printSourceList(sources) {
25
+ sources.forEach((source, index) => {
26
+ const status = source.enabled !== false ? 'enabled' : 'disabled';
27
+ const detail = source.type === 'ical'
28
+ ? (source.url && source.url.length > 50 ? source.url.substring(0, 50) + '...' : source.url)
29
+ : source.calendarId;
30
+ console.log(` ${index + 1}. [${source.id}] ${source.name} (${source.type}, ${status})`);
31
+ console.log(` ${detail}`);
32
+ });
33
+ }
34
+ /**
35
+ * Add a new iCal calendar (multiple calendars can be registered)
9
36
  */
10
37
  export async function addCalendar(url, options) {
11
38
  // Validate URL
@@ -13,50 +40,112 @@ export async function addCalendar(url, options) {
13
40
  console.error('Error: Invalid URL. Must start with https://, webcal://, or http://');
14
41
  process.exit(1);
15
42
  }
16
- setCalendarConfig({
17
- url,
18
- name: options.name,
43
+ const existing = getCalendarSources().find(s => s.url === url);
44
+ if (existing) {
45
+ console.error(`Error: This URL is already registered as "${existing.name}" [${existing.id}].`);
46
+ process.exit(1);
47
+ }
48
+ // Default name from URL hostname when not provided
49
+ let name = options.name;
50
+ if (!name) {
51
+ try {
52
+ name = new URL(url.replace('webcal://', 'https://')).hostname;
53
+ }
54
+ catch {
55
+ name = `Calendar ${getCalendarSources().length + 1}`;
56
+ }
57
+ }
58
+ const source = addCalendarSource({
59
+ name,
19
60
  type: 'ical',
61
+ url,
20
62
  enabled: true,
21
63
  });
22
64
  console.log('Calendar added successfully!');
65
+ console.log(` ID: ${source.id}`);
66
+ console.log(` Name: ${source.name}`);
23
67
  console.log(` URL: ${url}`);
24
- if (options.name) {
25
- console.log(` Name: ${options.name}`);
26
- }
27
68
  // Try to fetch events to validate the URL
28
69
  console.log('\nFetching events...');
29
70
  try {
30
71
  const events = await fetchCalendarEvents(url);
31
72
  console.log(`Found ${events.length} events.`);
32
73
  }
33
- catch (error) {
74
+ catch {
34
75
  console.log('Warning: Could not fetch events. Please check the URL.');
35
76
  }
77
+ if (getCalendarConfig()?.enabled === false) {
78
+ console.log('Note: calendar display is currently disabled. Run "floq calendar enable" to show events.');
79
+ }
36
80
  }
37
81
  /**
38
- * Remove calendar configuration
82
+ * List registered calendars
39
83
  */
40
- export async function removeCalendar() {
41
- const config = getCalendarConfig();
42
- if (!config) {
43
- console.log('No calendar configured.');
84
+ export async function listCalendarSourcesCommand() {
85
+ const sources = getCalendarSources();
86
+ if (sources.length === 0) {
87
+ console.log('No calendars registered.');
88
+ console.log('Use "floq calendar add <url>" or "floq calendar select" to add one.');
89
+ return;
90
+ }
91
+ console.log(`Registered calendars (${sources.length})`);
92
+ console.log('-'.repeat(40));
93
+ printSourceList(sources);
94
+ }
95
+ /**
96
+ * Remove a calendar (by id/index/name), or all calendar configuration with --all
97
+ */
98
+ export async function removeCalendar(idOrIndex, options = {}) {
99
+ const sources = getCalendarSources();
100
+ if (options.all) {
101
+ if (sources.length === 0 && !getCalendarConfig()) {
102
+ console.log('No calendar configured.');
103
+ return;
104
+ }
105
+ setCalendarConfig(undefined);
106
+ clearCalendarCache();
107
+ console.log('All calendar configuration removed.');
44
108
  return;
45
109
  }
46
- setCalendarConfig(undefined);
110
+ if (sources.length === 0) {
111
+ console.log('No calendars registered.');
112
+ return;
113
+ }
114
+ let target;
115
+ if (idOrIndex) {
116
+ target = resolveSource(idOrIndex);
117
+ if (!target) {
118
+ console.error(`Error: Calendar "${idOrIndex}" not found.`);
119
+ console.log('');
120
+ printSourceList(sources);
121
+ process.exit(1);
122
+ }
123
+ }
124
+ else if (sources.length === 1) {
125
+ target = sources[0];
126
+ }
127
+ else {
128
+ console.log('Multiple calendars are registered. Specify which one to remove:');
129
+ console.log('');
130
+ printSourceList(sources);
131
+ console.log('');
132
+ console.log('Usage: floq calendar remove <id|number|name>');
133
+ console.log(' floq calendar remove --all (remove everything)');
134
+ return;
135
+ }
136
+ removeCalendarSource(target.id);
47
137
  clearCalendarCache();
48
- console.log('Calendar removed.');
138
+ console.log(`Calendar "${target.name}" [${target.id}] removed.`);
49
139
  }
50
140
  /**
51
141
  * Show calendar configuration and today's events
52
142
  */
53
143
  export async function showCalendar() {
54
- const config = getCalendarConfig();
144
+ const sources = getCalendarSources();
55
145
  const locale = getLocale();
56
- const calendarType = getCalendarType();
57
146
  console.log('Calendar Configuration');
58
147
  console.log('-'.repeat(40));
59
- if (!config || (!config.url && !config.oauth)) {
148
+ if (sources.length === 0) {
60
149
  console.log('No calendar configured.');
61
150
  console.log('');
62
151
  console.log('Option 1: iCal URL (no authentication required)');
@@ -68,18 +157,9 @@ export async function showCalendar() {
68
157
  console.log(' floq calendar select');
69
158
  return;
70
159
  }
71
- console.log(`Type: ${calendarType || 'unknown'}`);
72
- if (calendarType === 'oauth' && config.oauth) {
73
- console.log(`Calendar: ${config.oauth.calendarName}`);
74
- console.log(`Calendar ID: ${config.oauth.calendarId}`);
75
- }
76
- else if (config.url) {
77
- console.log(`URL: ${config.url}`);
78
- if (config.name) {
79
- console.log(`Name: ${config.name}`);
80
- }
81
- }
82
- console.log(`Status: ${config.enabled !== false ? 'enabled' : 'disabled'}`);
160
+ printSourceList(sources);
161
+ console.log('');
162
+ console.log(`Display: ${getCalendarConfig()?.enabled !== false ? 'enabled' : 'disabled'}`);
83
163
  console.log('');
84
164
  console.log("Today's Events");
85
165
  console.log('-'.repeat(40));
@@ -90,11 +170,13 @@ export async function showCalendar() {
90
170
  console.log(locale === 'ja' ? '今日の予定はありません' : 'No events today');
91
171
  return;
92
172
  }
173
+ const showCalendarName = sources.length > 1;
93
174
  for (const event of todayEvents) {
94
175
  const timeStr = event.allDay
95
176
  ? (locale === 'ja' ? '終日' : 'All day')
96
177
  : `${formatEventTime(event.start)} - ${formatEventTime(event.end)}`;
97
- console.log(` ${timeStr} ${event.title}`);
178
+ const calLabel = showCalendarName && event.calendarName ? ` [${event.calendarName}]` : '';
179
+ console.log(` ${timeStr} ${event.title}${calLabel}`);
98
180
  if (event.location) {
99
181
  console.log(` 📍 ${event.location}`);
100
182
  }
@@ -104,16 +186,16 @@ export async function showCalendar() {
104
186
  * Sync/refresh calendar cache
105
187
  */
106
188
  export async function syncCalendar() {
107
- const config = getCalendarConfig();
108
- if (!config) {
189
+ const sources = getCalendarSources();
190
+ if (sources.length === 0) {
109
191
  console.log('No calendar configured.');
110
192
  return;
111
193
  }
112
- console.log('Syncing calendar...');
194
+ console.log('Syncing calendars...');
113
195
  clearCalendarCache();
114
196
  try {
115
197
  const events = await fetchCalendarEvents();
116
- console.log(`Synced ${events.length} events.`);
198
+ console.log(`Synced ${events.length} events from ${sources.filter(s => s.enabled !== false).length} calendar(s).`);
117
199
  const todayEvents = getTodayEvents();
118
200
  console.log(`${todayEvents.length} events today.`);
119
201
  }
@@ -123,29 +205,43 @@ export async function syncCalendar() {
123
205
  }
124
206
  }
125
207
  /**
126
- * Enable calendar display
208
+ * Toggle calendar display (overall, or a specific calendar by id)
127
209
  */
128
- export async function enableCalendar() {
129
- const config = getCalendarConfig();
130
- if (!config) {
210
+ async function setCalendarDisplay(enabled, idOrIndex) {
211
+ const sources = getCalendarSources();
212
+ const label = enabled ? 'enabled' : 'disabled';
213
+ if (sources.length === 0) {
131
214
  console.log('No calendar configured.');
132
- console.log('Use "floq calendar add <url>" to add a calendar first.');
215
+ if (enabled) {
216
+ console.log('Use "floq calendar add <url>" to add a calendar first.');
217
+ }
133
218
  return;
134
219
  }
135
- setCalendarEnabled(true);
136
- console.log('Calendar display enabled.');
137
- }
138
- /**
139
- * Disable calendar display
140
- */
141
- export async function disableCalendar() {
142
- const config = getCalendarConfig();
143
- if (!config) {
144
- console.log('No calendar configured.');
220
+ if (idOrIndex) {
221
+ const target = resolveSource(idOrIndex);
222
+ if (!target) {
223
+ console.error(`Error: Calendar "${idOrIndex}" not found.`);
224
+ process.exit(1);
225
+ }
226
+ updateCalendarSource(target.id, { enabled });
227
+ // Enabling a specific calendar implies the user wants events shown —
228
+ // also lift a global disable so the change is actually visible
229
+ if (enabled && getCalendarConfig()?.enabled === false) {
230
+ setCalendarEnabled(true);
231
+ console.log('Calendar display was disabled — re-enabled.');
232
+ }
233
+ clearCalendarCache();
234
+ console.log(`Calendar "${target.name}" ${label}.`);
145
235
  return;
146
236
  }
147
- setCalendarEnabled(false);
148
- console.log('Calendar display disabled.');
237
+ setCalendarEnabled(enabled);
238
+ console.log(`Calendar display ${label}.`);
239
+ }
240
+ export async function enableCalendar(idOrIndex) {
241
+ await setCalendarDisplay(true, idOrIndex);
242
+ }
243
+ export async function disableCalendar(idOrIndex) {
244
+ await setCalendarDisplay(false, idOrIndex);
149
245
  }
150
246
  /**
151
247
  * Configure OAuth client credentials
@@ -156,7 +252,7 @@ export async function configOAuthClient(clientId, clientSecret) {
156
252
  console.log('');
157
253
  console.log('Next steps:');
158
254
  console.log(' 1. Run "floq calendar login" to authenticate');
159
- console.log(' 2. Run "floq calendar select" to choose a calendar');
255
+ console.log(' 2. Run "floq calendar select" to choose calendars');
160
256
  }
161
257
  /**
162
258
  * Start OAuth login flow
@@ -206,21 +302,11 @@ export async function loginCalendar() {
206
302
  console.log('');
207
303
  console.log('Waiting for authorization...');
208
304
  const tokens = await pollForTokens(deviceCode, interval);
209
- // Save tokens temporarily (calendar selection happens next)
210
- const config = getCalendarConfig() || {};
211
- setCalendarConfig({
212
- ...config,
213
- type: 'oauth',
214
- oauth: {
215
- tokens,
216
- calendarId: '',
217
- calendarName: '',
218
- },
219
- });
305
+ setCalendarOAuthTokens(tokens);
220
306
  console.log('');
221
307
  console.log('Login successful!');
222
308
  console.log('');
223
- console.log('Now run "floq calendar select" to choose a calendar.');
309
+ console.log('Now run "floq calendar select" to choose calendars.');
224
310
  }
225
311
  catch (error) {
226
312
  console.error('Login failed:', error instanceof Error ? error.message : error);
@@ -231,24 +317,27 @@ export async function loginCalendar() {
231
317
  * Logout from OAuth (clear tokens)
232
318
  */
233
319
  export async function logoutCalendar() {
234
- const oauthConfig = getCalendarOAuthConfig();
235
- if (!oauthConfig) {
320
+ const tokens = getCalendarOAuthTokens();
321
+ if (!tokens) {
236
322
  console.log('Not logged in.');
237
323
  return;
238
324
  }
239
325
  clearOAuthTokens();
240
326
  console.log('Logged out successfully.');
327
+ const oauthSources = getCalendarSources().filter(s => s.type === 'oauth');
328
+ if (oauthSources.length > 0) {
329
+ console.log(`Note: ${oauthSources.length} Google calendar(s) remain registered but need login to fetch events.`);
330
+ }
241
331
  }
242
332
  /**
243
- * Select a calendar from the user's calendars
333
+ * Select Google calendars to register (multiple selections supported)
244
334
  */
245
335
  export async function selectCalendar() {
246
- const oauthConfig = getCalendarOAuthConfig();
247
- if (!oauthConfig) {
336
+ const tokens = getCalendarOAuthTokens();
337
+ if (!tokens) {
248
338
  console.log('Not logged in. Run "floq calendar login" first.');
249
339
  return;
250
340
  }
251
- const { getValidAccessToken } = await import('../calendar/oauth.js');
252
341
  const accessToken = await getValidAccessToken();
253
342
  if (!accessToken) {
254
343
  console.log('Failed to get access token. Try logging in again.');
@@ -262,38 +351,58 @@ export async function selectCalendar() {
262
351
  console.log('No calendars found.');
263
352
  return;
264
353
  }
354
+ const registeredIds = new Set(getCalendarSources()
355
+ .filter(s => s.type === 'oauth')
356
+ .map(s => s.calendarId));
265
357
  console.log('Available calendars:');
266
358
  console.log('');
267
359
  calendars.forEach((cal, index) => {
268
360
  const primary = cal.primary ? ' (primary)' : '';
269
- console.log(` ${index + 1}. ${cal.summary}${primary}`);
361
+ const registered = registeredIds.has(cal.id) ? ' [registered]' : '';
362
+ console.log(` ${index + 1}. ${cal.summary}${primary}${registered}`);
270
363
  });
271
364
  console.log('');
272
- // Interactive selection
365
+ // Interactive selection (comma-separated numbers for multiple calendars)
273
366
  const rl = readline.createInterface({
274
367
  input: process.stdin,
275
368
  output: process.stdout,
276
369
  });
277
370
  const answer = await new Promise((resolve) => {
278
- rl.question('Select calendar (number): ', resolve);
371
+ rl.question('Select calendars (numbers, comma-separated): ', resolve);
279
372
  });
280
373
  rl.close();
281
- const selection = parseInt(answer, 10);
282
- if (isNaN(selection) || selection < 1 || selection > calendars.length) {
374
+ const selections = answer
375
+ .split(',')
376
+ .map(s => parseInt(s.trim(), 10))
377
+ .filter(n => !isNaN(n) && n >= 1 && n <= calendars.length);
378
+ if (selections.length === 0) {
283
379
  console.log('Invalid selection.');
284
380
  return;
285
381
  }
286
- const selectedCalendar = calendars[selection - 1];
287
- // Update config with selected calendar
288
- setCalendarOAuthConfig({
289
- tokens: oauthConfig.tokens,
290
- calendarId: selectedCalendar.id,
291
- calendarName: selectedCalendar.summary,
292
- });
293
382
  console.log('');
294
- console.log(`Calendar "${selectedCalendar.summary}" selected!`);
383
+ for (const selection of [...new Set(selections)]) {
384
+ const selectedCalendar = calendars[selection - 1];
385
+ if (registeredIds.has(selectedCalendar.id)) {
386
+ console.log(`Calendar "${selectedCalendar.summary}" is already registered. Skipped.`);
387
+ continue;
388
+ }
389
+ const source = addCalendarSource({
390
+ name: selectedCalendar.summary,
391
+ type: 'oauth',
392
+ calendarId: selectedCalendar.id,
393
+ enabled: true,
394
+ });
395
+ registeredIds.add(selectedCalendar.id);
396
+ console.log(`Calendar "${selectedCalendar.summary}" added! [${source.id}]`);
397
+ }
398
+ clearCalendarCache();
295
399
  console.log('');
296
- console.log('Calendar integration is now active. Run "floq calendar show" to see events.');
400
+ if (getCalendarConfig()?.enabled === false) {
401
+ console.log('Note: calendar display is currently disabled. Run "floq calendar enable" to show events.');
402
+ }
403
+ else {
404
+ console.log('Calendar integration is now active. Run "floq schedule" to see events.');
405
+ }
297
406
  }
298
407
  catch (error) {
299
408
  console.error('Failed to list calendars:', error instanceof Error ? error.message : error);
@@ -35,19 +35,11 @@ export async function showConfig() {
35
35
  }
36
36
  // Calendar configuration
37
37
  const calendarConfig = getCalendarConfig();
38
- console.log(`Calendar: ${isCalendarEnabled() ? 'enabled' : calendarConfig ? 'disabled' : 'not configured'}`);
39
- if (calendarConfig) {
40
- const calendarType = calendarConfig.type || (calendarConfig.url ? 'ical' : 'unknown');
41
- console.log(` Type: ${calendarType}`);
42
- if (calendarConfig.name) {
43
- console.log(` Name: ${calendarConfig.name}`);
44
- }
45
- if (calendarConfig.url) {
46
- console.log(` URL: ${calendarConfig.url.substring(0, 50)}...`);
47
- }
48
- if (calendarConfig.oauth) {
49
- console.log(` Calendar: ${calendarConfig.oauth.calendarName}`);
50
- }
38
+ const calendarSources = calendarConfig?.calendars || [];
39
+ console.log(`Calendar: ${isCalendarEnabled() ? 'enabled' : calendarSources.length > 0 ? 'disabled' : 'not configured'}`);
40
+ for (const source of calendarSources) {
41
+ const status = source.enabled !== false ? '' : ' (disabled)';
42
+ console.log(` - ${source.name} [${source.type}]${status}`);
51
43
  }
52
44
  }
53
45
  export async function setLanguage(locale) {
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Show schedule from all registered calendars
3
+ */
4
+ export declare function showSchedule(period?: string, options?: {
5
+ days?: string;
6
+ }): Promise<void>;
@@ -0,0 +1,109 @@
1
+ import { getCalendarSources, getLocale } from '../config.js';
2
+ import { fetchCalendarEvents, getEventsForDate, formatEventTime, isEventOngoing } from '../calendar/index.js';
3
+ const WEEKDAYS_EN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
4
+ const WEEKDAYS_JA = ['日', '月', '火', '水', '木', '金', '土'];
5
+ function resolveRange(period, daysOption) {
6
+ const today = new Date();
7
+ const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
8
+ if (daysOption) {
9
+ const days = parseInt(daysOption, 10);
10
+ if (isNaN(days) || days < 1) {
11
+ console.error('Error: --days must be a positive integer');
12
+ process.exit(1);
13
+ }
14
+ return { start: startOfToday, days };
15
+ }
16
+ switch (period) {
17
+ case undefined:
18
+ case 'today':
19
+ return { start: startOfToday, days: 1 };
20
+ case 'tomorrow': {
21
+ const tomorrow = new Date(startOfToday);
22
+ tomorrow.setDate(tomorrow.getDate() + 1);
23
+ return { start: tomorrow, days: 1 };
24
+ }
25
+ case 'week':
26
+ return { start: startOfToday, days: 7 };
27
+ default:
28
+ console.error(`Error: Unknown period "${period}". Use: today, tomorrow, week`);
29
+ process.exit(1);
30
+ }
31
+ }
32
+ function formatDateHeader(date, locale) {
33
+ const today = new Date();
34
+ const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
35
+ const diff = Math.round((date.getTime() - startOfToday.getTime()) / (1000 * 60 * 60 * 24));
36
+ const weekdays = locale === 'ja' ? WEEKDAYS_JA : WEEKDAYS_EN;
37
+ const dateStr = locale === 'ja'
38
+ ? `${date.getMonth() + 1}/${date.getDate()}(${weekdays[date.getDay()]})`
39
+ : `${weekdays[date.getDay()]}, ${date.toLocaleString('en', { month: 'short' })} ${date.getDate()}`;
40
+ if (diff === 0)
41
+ return `${dateStr} ${locale === 'ja' ? '今日' : '(Today)'}`;
42
+ if (diff === 1)
43
+ return `${dateStr} ${locale === 'ja' ? '明日' : '(Tomorrow)'}`;
44
+ return dateStr;
45
+ }
46
+ /**
47
+ * Show schedule from all registered calendars
48
+ */
49
+ export async function showSchedule(period, options = {}) {
50
+ const sources = getCalendarSources();
51
+ const locale = getLocale();
52
+ if (sources.length === 0) {
53
+ console.log(locale === 'ja' ? 'カレンダーが設定されていません。' : 'No calendar configured.');
54
+ console.log('');
55
+ console.log(' floq calendar add <url> [-n name] (iCal URL)');
56
+ console.log(' floq calendar login && floq calendar select (Google OAuth)');
57
+ return;
58
+ }
59
+ const range = resolveRange(period, options.days);
60
+ await fetchCalendarEvents();
61
+ const showCalendarName = sources.filter(s => s.enabled !== false).length > 1;
62
+ const now = new Date();
63
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
64
+ const baseOffset = Math.round((range.start.getTime() - startOfToday.getTime()) / (1000 * 60 * 60 * 24));
65
+ // Events are only fetched through the end of next month — warn if the range extends past it
66
+ const fetchWindowEnd = new Date(now.getFullYear(), now.getMonth() + 2, 0, 23, 59, 59);
67
+ const rangeEnd = new Date(range.start);
68
+ rangeEnd.setDate(rangeEnd.getDate() + range.days - 1);
69
+ if (rangeEnd > fetchWindowEnd) {
70
+ console.log(locale === 'ja'
71
+ ? `注意: 来月末より先の予定は表示されません`
72
+ : 'Note: events beyond the end of next month are not shown');
73
+ console.log('');
74
+ }
75
+ let totalShown = 0;
76
+ for (let i = 0; i < range.days; i++) {
77
+ const dayStart = new Date(range.start);
78
+ dayStart.setDate(dayStart.getDate() + i);
79
+ const dayEvents = getEventsForDate(baseOffset + i);
80
+ // For multi-day views, skip empty days
81
+ if (dayEvents.length === 0 && range.days > 1) {
82
+ continue;
83
+ }
84
+ if (totalShown > 0 || i > 0) {
85
+ console.log('');
86
+ }
87
+ console.log(formatDateHeader(dayStart, locale));
88
+ console.log('-'.repeat(40));
89
+ if (dayEvents.length === 0) {
90
+ console.log(locale === 'ja' ? ' 予定はありません' : ' No events');
91
+ continue;
92
+ }
93
+ for (const event of dayEvents) {
94
+ const timeStr = event.allDay
95
+ ? (locale === 'ja' ? '終日 ' : 'All day ')
96
+ : `${formatEventTime(event.start)}-${formatEventTime(event.end)}`;
97
+ const ongoing = isEventOngoing(event) ? ' ◀' : '';
98
+ const calLabel = showCalendarName && event.calendarName ? ` [${event.calendarName}]` : '';
99
+ console.log(` ${timeStr} ${event.title}${calLabel}${ongoing}`);
100
+ if (event.location) {
101
+ console.log(` 📍 ${event.location}`);
102
+ }
103
+ totalShown++;
104
+ }
105
+ }
106
+ if (range.days > 1 && totalShown === 0) {
107
+ console.log(locale === 'ja' ? '期間内に予定はありません' : 'No events in this period');
108
+ }
109
+ }
package/dist/config.d.ts CHANGED
@@ -21,12 +21,22 @@ export interface CalendarOAuthConfig {
21
21
  calendarId: string;
22
22
  calendarName: string;
23
23
  }
24
+ export interface CalendarSource {
25
+ id: string;
26
+ name: string;
27
+ type: 'ical' | 'oauth';
28
+ url?: string;
29
+ calendarId?: string;
30
+ enabled?: boolean;
31
+ }
24
32
  export interface CalendarConfig {
33
+ enabled?: boolean;
34
+ calendars?: CalendarSource[];
35
+ googleOAuth?: GoogleOAuthClient;
36
+ oauthTokens?: CalendarOAuthTokens;
25
37
  url?: string;
26
38
  name?: string;
27
- enabled?: boolean;
28
39
  type?: 'ical' | 'oauth';
29
- googleOAuth?: GoogleOAuthClient;
30
40
  oauth?: CalendarOAuthConfig;
31
41
  }
32
42
  export type DateFormat = 'auto' | 'ddd, MMM D' | 'MM/DD(ddd)' | 'YYYY-MM-DD' | 'MM-DD' | 'DD/MM' | 'none';
@@ -74,12 +84,16 @@ export declare function getDateFormat(): DateFormat;
74
84
  export declare function setDateFormat(format: DateFormat): void;
75
85
  export declare function getCalendarConfig(): CalendarConfig | undefined;
76
86
  export declare function setCalendarConfig(config: CalendarConfig | undefined): void;
87
+ export declare function getCalendarSources(): CalendarSource[];
88
+ export declare function addCalendarSource(source: Omit<CalendarSource, 'id'>): CalendarSource;
89
+ export declare function removeCalendarSource(id: string): boolean;
90
+ export declare function updateCalendarSource(id: string, updates: Partial<Omit<CalendarSource, 'id'>>): boolean;
91
+ export declare function isCalendarSourceUsable(source: CalendarSource, config?: CalendarConfig): boolean;
77
92
  export declare function isCalendarEnabled(): boolean;
78
93
  export declare function setCalendarEnabled(enabled: boolean): void;
79
94
  export declare function getGoogleOAuthClient(): GoogleOAuthClient | undefined;
80
95
  export declare function setGoogleOAuthClient(client: GoogleOAuthClient): void;
81
- export declare function getCalendarOAuthConfig(): CalendarOAuthConfig | undefined;
82
- export declare function setCalendarOAuthConfig(oauth: CalendarOAuthConfig | undefined): void;
83
- export declare function getCalendarType(): 'ical' | 'oauth' | undefined;
96
+ export declare function getCalendarOAuthTokens(): CalendarOAuthTokens | undefined;
97
+ export declare function setCalendarOAuthTokens(tokens: CalendarOAuthTokens | undefined): void;
84
98
  export declare function getInsightsWeeks(): number;
85
99
  export declare function setInsightsWeeks(weeks: number): void;
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
2
2
  import { dirname, join, isAbsolute } from 'path';
3
+ import { randomUUID } from 'crypto';
3
4
  import { CONFIG_FILE, DATA_DIR } from './paths.js';
4
5
  // Migrate legacy DB file names (including related metadata files)
5
6
  function migrateDbFiles() {
@@ -183,25 +184,109 @@ export function getDateFormat() {
183
184
  export function setDateFormat(format) {
184
185
  saveConfig({ dateFormat: format });
185
186
  }
187
+ function generateCalendarId() {
188
+ return randomUUID().slice(0, 8);
189
+ }
190
+ // Migrate legacy single-calendar config to the multi-calendar format
191
+ function migrateCalendarConfig(calendar) {
192
+ const hasLegacyFields = calendar.url !== undefined ||
193
+ calendar.name !== undefined ||
194
+ calendar.type !== undefined ||
195
+ calendar.oauth !== undefined;
196
+ if (!hasLegacyFields)
197
+ return null;
198
+ const calendars = [...(calendar.calendars || [])];
199
+ let oauthTokens = calendar.oauthTokens;
200
+ if (calendar.url && !calendars.some(c => c.url === calendar.url)) {
201
+ calendars.push({
202
+ id: generateCalendarId(),
203
+ name: calendar.name || 'Calendar',
204
+ type: 'ical',
205
+ url: calendar.url,
206
+ });
207
+ }
208
+ if (calendar.oauth) {
209
+ oauthTokens = oauthTokens || calendar.oauth.tokens;
210
+ if (calendar.oauth.calendarId && !calendars.some(c => c.calendarId === calendar.oauth?.calendarId)) {
211
+ calendars.push({
212
+ id: generateCalendarId(),
213
+ name: calendar.oauth.calendarName || 'Google Calendar',
214
+ type: 'oauth',
215
+ calendarId: calendar.oauth.calendarId,
216
+ });
217
+ }
218
+ }
219
+ return {
220
+ enabled: calendar.enabled,
221
+ calendars,
222
+ googleOAuth: calendar.googleOAuth,
223
+ oauthTokens,
224
+ };
225
+ }
186
226
  export function getCalendarConfig() {
187
- return loadConfig().calendar;
227
+ const calendar = loadConfig().calendar;
228
+ if (!calendar)
229
+ return undefined;
230
+ const migrated = migrateCalendarConfig(calendar);
231
+ if (migrated) {
232
+ saveConfig({ calendar: migrated });
233
+ return migrated;
234
+ }
235
+ return calendar;
188
236
  }
189
237
  export function setCalendarConfig(config) {
190
238
  saveConfig({ calendar: config });
191
239
  }
240
+ export function getCalendarSources() {
241
+ return getCalendarConfig()?.calendars || [];
242
+ }
243
+ export function addCalendarSource(source) {
244
+ const calendar = getCalendarConfig() || {};
245
+ const newSource = { id: generateCalendarId(), ...source };
246
+ setCalendarConfig({
247
+ ...calendar,
248
+ calendars: [...(calendar.calendars || []), newSource],
249
+ enabled: calendar.enabled === false ? calendar.enabled : true,
250
+ });
251
+ return newSource;
252
+ }
253
+ export function removeCalendarSource(id) {
254
+ const calendar = getCalendarConfig();
255
+ if (!calendar)
256
+ return false;
257
+ const calendars = calendar.calendars || [];
258
+ const newCalendars = calendars.filter(c => c.id !== id);
259
+ if (newCalendars.length === calendars.length)
260
+ return false;
261
+ setCalendarConfig({ ...calendar, calendars: newCalendars });
262
+ return true;
263
+ }
264
+ export function updateCalendarSource(id, updates) {
265
+ const calendar = getCalendarConfig();
266
+ if (!calendar)
267
+ return false;
268
+ const calendars = calendar.calendars || [];
269
+ const index = calendars.findIndex(c => c.id === id);
270
+ if (index === -1)
271
+ return false;
272
+ const newCalendars = [...calendars];
273
+ newCalendars[index] = { ...newCalendars[index], ...updates };
274
+ setCalendarConfig({ ...calendar, calendars: newCalendars });
275
+ return true;
276
+ }
277
+ // True when a calendar source has everything it needs to fetch events
278
+ export function isCalendarSourceUsable(source, config) {
279
+ const calendar = config ?? getCalendarConfig();
280
+ if (source.type === 'ical') {
281
+ return !!source.url;
282
+ }
283
+ return !!source.calendarId && !!calendar?.oauthTokens;
284
+ }
192
285
  export function isCalendarEnabled() {
193
286
  const calendar = getCalendarConfig();
194
287
  if (!calendar || calendar.enabled === false)
195
288
  return false;
196
- // OAuth mode
197
- if (calendar.type === 'oauth' && calendar.oauth) {
198
- return true;
199
- }
200
- // iCal mode
201
- if (calendar.url && calendar.url !== '') {
202
- return true;
203
- }
204
- return false;
289
+ return (calendar.calendars || []).some(c => c.enabled !== false && isCalendarSourceUsable(c, calendar));
205
290
  }
206
291
  export function setCalendarEnabled(enabled) {
207
292
  const calendar = getCalendarConfig();
@@ -224,26 +309,19 @@ export function setGoogleOAuthClient(client) {
224
309
  const calendar = getCalendarConfig() || {};
225
310
  setCalendarConfig({ ...calendar, googleOAuth: client });
226
311
  }
227
- export function getCalendarOAuthConfig() {
228
- return getCalendarConfig()?.oauth;
312
+ export function getCalendarOAuthTokens() {
313
+ return getCalendarConfig()?.oauthTokens;
229
314
  }
230
- export function setCalendarOAuthConfig(oauth) {
315
+ export function setCalendarOAuthTokens(tokens) {
231
316
  const calendar = getCalendarConfig() || {};
232
- if (oauth) {
233
- setCalendarConfig({ ...calendar, type: 'oauth', oauth, enabled: true });
317
+ if (tokens) {
318
+ setCalendarConfig({ ...calendar, oauthTokens: tokens });
234
319
  }
235
320
  else {
236
- // Clear OAuth config
237
- const { oauth: _removed, ...rest } = calendar;
321
+ const { oauthTokens: _removed, ...rest } = calendar;
238
322
  setCalendarConfig(rest);
239
323
  }
240
324
  }
241
- export function getCalendarType() {
242
- const calendar = getCalendarConfig();
243
- if (!calendar)
244
- return undefined;
245
- return calendar.type || (calendar.url ? 'ical' : undefined);
246
- }
247
325
  const DEFAULT_INSIGHTS_WEEKS = 2;
248
326
  export function getInsightsWeeks() {
249
327
  const weeks = loadConfig().insightsWeeks;
@@ -3,7 +3,7 @@ import { useState, useEffect, useMemo } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import { t } from '../../i18n/index.js';
5
5
  import { useTheme } from '../theme/index.js';
6
- import { isCalendarEnabled, getCalendarConfig, getCalendarType } from '../../config.js';
6
+ import { isCalendarEnabled, getCalendarSources } from '../../config.js';
7
7
  import { getCalendarEvents, getEventsForDate, formatEventTime, isEventOngoing, } from '../../calendar/index.js';
8
8
  const WEEKDAYS_EN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
9
9
  const WEEKDAYS_JA = ['日', '月', '火', '水', '木', '金', '土'];
@@ -19,8 +19,7 @@ export function CalendarModal({ onClose }) {
19
19
  const [viewMonth, setViewMonth] = useState(new Date());
20
20
  const [mode, setMode] = useState('calendar');
21
21
  const calendarEnabled = isCalendarEnabled();
22
- const config = getCalendarConfig();
23
- const calendarType = getCalendarType();
22
+ const calendarSources = getCalendarSources().filter(s => s.enabled !== false);
24
23
  const isJapanese = i18n.tui.calendar?.yesterday === '昨日';
25
24
  const weekdays = isJapanese ? WEEKDAYS_JA : WEEKDAYS_EN;
26
25
  // Load all events on mount
@@ -211,10 +210,12 @@ export function CalendarModal({ onClose }) {
211
210
  }
212
211
  });
213
212
  const formatTitle = (title) => theme.style.headerUppercase ? title.toUpperCase() : title;
214
- // Get calendar name for header
215
- const calendarName = calendarType === 'oauth' && config?.oauth
216
- ? config.oauth.calendarName
217
- : config?.name || 'Calendar';
213
+ // Get calendar name for header (single calendar shows its name, multiple show a count)
214
+ const calendarName = calendarSources.length === 1
215
+ ? calendarSources[0].name
216
+ : calendarSources.length > 1
217
+ ? `Calendar (${calendarSources.length})`
218
+ : 'Calendar';
218
219
  // Format month header
219
220
  const monthNames = isJapanese
220
221
  ? ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "floq",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Floq - Getting Things Done Task Manager with MS-DOS style themes",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",