floq 1.7.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 +24 -9
- package/README.md +24 -9
- package/dist/calendar/index.d.ts +4 -1
- package/dist/calendar/index.js +49 -41
- package/dist/calendar/oauth.js +5 -9
- package/dist/cli.js +43 -15
- package/dist/commands/calendar.d.ts +12 -12
- package/dist/commands/calendar.js +196 -87
- package/dist/commands/config.js +5 -13
- package/dist/commands/project.d.ts +6 -0
- package/dist/commands/project.js +95 -0
- package/dist/commands/schedule.d.ts +6 -0
- package/dist/commands/schedule.js +109 -0
- package/dist/config.d.ts +19 -5
- package/dist/config.js +101 -23
- package/dist/db/projectDelete.d.ts +20 -0
- package/dist/db/projectDelete.js +49 -0
- package/dist/i18n/en.d.ts +16 -0
- package/dist/i18n/en.js +11 -0
- package/dist/i18n/ja.js +11 -0
- package/dist/ui/App.js +78 -2
- package/dist/ui/components/CalendarModal.js +8 -7
- package/dist/ui/components/GtdDQ.js +77 -2
- package/dist/ui/components/GtdMario.js +76 -2
- package/dist/ui/history/commands/DeleteProjectCommand.d.ts +31 -0
- package/dist/ui/history/commands/DeleteProjectCommand.js +133 -0
- package/dist/ui/history/commands/index.d.ts +1 -0
- package/dist/ui/history/commands/index.js +1 -0
- package/dist/ui/history/commands/registry.js +2 -0
- package/dist/ui/history/index.d.ts +1 -1
- package/dist/ui/history/index.js +1 -1
- package/package.json +5 -6
|
@@ -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
|
|
82
|
-
export declare function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
228
|
-
return getCalendarConfig()?.
|
|
312
|
+
export function getCalendarOAuthTokens() {
|
|
313
|
+
return getCalendarConfig()?.oauthTokens;
|
|
229
314
|
}
|
|
230
|
-
export function
|
|
315
|
+
export function setCalendarOAuthTokens(tokens) {
|
|
231
316
|
const calendar = getCalendarConfig() || {};
|
|
232
|
-
if (
|
|
233
|
-
setCalendarConfig({ ...calendar,
|
|
317
|
+
if (tokens) {
|
|
318
|
+
setCalendarConfig({ ...calendar, oauthTokens: tokens });
|
|
234
319
|
}
|
|
235
320
|
else {
|
|
236
|
-
|
|
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;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Task, Comment } from './schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* How to handle child tasks when deleting a project.
|
|
4
|
+
* - `cascade`: delete the project together with all its child tasks (and their comments)
|
|
5
|
+
* - `keep`: delete only the project, moving its child tasks back to Inbox (unlinked)
|
|
6
|
+
*/
|
|
7
|
+
export type ProjectDeleteMode = 'cascade' | 'keep';
|
|
8
|
+
export interface ProjectDeleteResult {
|
|
9
|
+
/** Child tasks that were deleted (only populated for `cascade`). */
|
|
10
|
+
deletedTasks: Task[];
|
|
11
|
+
/** Child tasks moved to Inbox, in their original state (only populated for `keep`). */
|
|
12
|
+
movedTasks: Task[];
|
|
13
|
+
/** Comments that were deleted (project's own comments, plus child comments in `cascade`). */
|
|
14
|
+
deletedComments: Comment[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Delete a project and handle its child tasks according to `mode`.
|
|
18
|
+
* Returns the prior state so the operation can be undone.
|
|
19
|
+
*/
|
|
20
|
+
export declare function deleteProject(project: Task, mode: ProjectDeleteMode): Promise<ProjectDeleteResult>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
2
|
+
import { getDb, schema } from './index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Delete a project and handle its child tasks according to `mode`.
|
|
5
|
+
* Returns the prior state so the operation can be undone.
|
|
6
|
+
*/
|
|
7
|
+
export async function deleteProject(project, mode) {
|
|
8
|
+
const db = getDb();
|
|
9
|
+
const children = await db
|
|
10
|
+
.select()
|
|
11
|
+
.from(schema.tasks)
|
|
12
|
+
.where(eq(schema.tasks.parentId, project.id));
|
|
13
|
+
const deletedTasks = [];
|
|
14
|
+
const movedTasks = [];
|
|
15
|
+
let deletedComments = [];
|
|
16
|
+
// Always remove the project's own comments.
|
|
17
|
+
const projectComments = await db
|
|
18
|
+
.select()
|
|
19
|
+
.from(schema.comments)
|
|
20
|
+
.where(eq(schema.comments.taskId, project.id));
|
|
21
|
+
deletedComments = deletedComments.concat(projectComments);
|
|
22
|
+
await db.delete(schema.comments).where(eq(schema.comments.taskId, project.id));
|
|
23
|
+
if (mode === 'cascade') {
|
|
24
|
+
for (const child of children) {
|
|
25
|
+
const childComments = await db
|
|
26
|
+
.select()
|
|
27
|
+
.from(schema.comments)
|
|
28
|
+
.where(eq(schema.comments.taskId, child.id));
|
|
29
|
+
deletedComments = deletedComments.concat(childComments);
|
|
30
|
+
await db.delete(schema.comments).where(eq(schema.comments.taskId, child.id));
|
|
31
|
+
await db.delete(schema.tasks).where(eq(schema.tasks.id, child.id));
|
|
32
|
+
deletedTasks.push(child);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
// keep: unlink children and move them back to Inbox.
|
|
37
|
+
const now = new Date();
|
|
38
|
+
for (const child of children) {
|
|
39
|
+
movedTasks.push(child);
|
|
40
|
+
await db
|
|
41
|
+
.update(schema.tasks)
|
|
42
|
+
.set({ parentId: null, status: 'inbox', updatedAt: now })
|
|
43
|
+
.where(eq(schema.tasks.id, child.id));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Finally remove the project itself.
|
|
47
|
+
await db.delete(schema.tasks).where(eq(schema.tasks.id, project.id));
|
|
48
|
+
return { deletedTasks, movedTasks, deletedComments };
|
|
49
|
+
}
|
package/dist/i18n/en.d.ts
CHANGED
|
@@ -51,6 +51,12 @@ export declare const en: {
|
|
|
51
51
|
notFound: string;
|
|
52
52
|
multipleMatch: string;
|
|
53
53
|
completed: string;
|
|
54
|
+
deleted: string;
|
|
55
|
+
deletedWithTasks: string;
|
|
56
|
+
tasksMovedToInbox: string;
|
|
57
|
+
deletePrompt: string;
|
|
58
|
+
deleteChildrenPrompt: string;
|
|
59
|
+
deleteCancelled: string;
|
|
54
60
|
noProjects: string;
|
|
55
61
|
description: string;
|
|
56
62
|
statusLabel: string;
|
|
@@ -247,6 +253,11 @@ export declare const en: {
|
|
|
247
253
|
deleteConfirm: string;
|
|
248
254
|
deleted: string;
|
|
249
255
|
deleteCancelled: string;
|
|
256
|
+
deleteProjectConfirm: string;
|
|
257
|
+
deleteProjectChildren: string;
|
|
258
|
+
deletedProject: string;
|
|
259
|
+
deletedProjectWithTasks: string;
|
|
260
|
+
projectTasksToInbox: string;
|
|
250
261
|
undone: string;
|
|
251
262
|
redone: string;
|
|
252
263
|
nothingToUndo: string;
|
|
@@ -675,6 +686,11 @@ export type TuiTranslations = {
|
|
|
675
686
|
deleteConfirm: string;
|
|
676
687
|
deleted: string;
|
|
677
688
|
deleteCancelled: string;
|
|
689
|
+
deleteProjectConfirm?: string;
|
|
690
|
+
deleteProjectChildren?: string;
|
|
691
|
+
deletedProject?: string;
|
|
692
|
+
deletedProjectWithTasks?: string;
|
|
693
|
+
projectTasksToInbox?: string;
|
|
678
694
|
undone: string;
|
|
679
695
|
redone: string;
|
|
680
696
|
nothingToUndo: string;
|
package/dist/i18n/en.js
CHANGED
|
@@ -55,6 +55,12 @@ export const en = {
|
|
|
55
55
|
notFound: 'Project not found: {id}',
|
|
56
56
|
multipleMatch: 'Multiple projects match "{id}". Please be more specific.',
|
|
57
57
|
completed: 'Completed project: "{name}"',
|
|
58
|
+
deleted: 'Deleted project: "{name}"',
|
|
59
|
+
deletedWithTasks: 'Deleted project "{name}" and {count} task(s)',
|
|
60
|
+
tasksMovedToInbox: 'Moved {count} task(s) to Inbox',
|
|
61
|
+
deletePrompt: 'Delete project "{name}"?',
|
|
62
|
+
deleteChildrenPrompt: 'Project "{name}" has {count} task(s). a=delete all k=keep tasks (→Inbox) n=cancel',
|
|
63
|
+
deleteCancelled: 'Cancelled.',
|
|
58
64
|
noProjects: 'No projects',
|
|
59
65
|
description: 'Description: {description}',
|
|
60
66
|
statusLabel: 'Status: {status}',
|
|
@@ -260,6 +266,11 @@ export const en = {
|
|
|
260
266
|
deleteConfirm: 'Delete "{title}"? (y/n)',
|
|
261
267
|
deleted: 'Deleted: "{title}"',
|
|
262
268
|
deleteCancelled: 'Delete cancelled',
|
|
269
|
+
deleteProjectConfirm: 'Delete project "{title}"? (y/n)',
|
|
270
|
+
deleteProjectChildren: 'Project "{title}" has {count} task(s). t=delete all i=keep tasks (→Inbox) n=cancel',
|
|
271
|
+
deletedProject: 'Deleted project: "{title}"',
|
|
272
|
+
deletedProjectWithTasks: 'Deleted project "{title}" and {count} task(s)',
|
|
273
|
+
projectTasksToInbox: 'Deleted project "{title}", moved {count} task(s) to Inbox',
|
|
263
274
|
// Undo/Redo
|
|
264
275
|
undone: 'Undone: {action}',
|
|
265
276
|
redone: 'Redone: {action}',
|
package/dist/i18n/ja.js
CHANGED
|
@@ -55,6 +55,12 @@ export const ja = {
|
|
|
55
55
|
notFound: 'プロジェクトが見つかりません: {id}',
|
|
56
56
|
multipleMatch: '「{id}」に一致するプロジェクトが複数あります。より具体的に指定してください。',
|
|
57
57
|
completed: 'プロジェクトを完了しました: 「{name}」',
|
|
58
|
+
deleted: 'プロジェクトを削除しました: 「{name}」',
|
|
59
|
+
deletedWithTasks: 'プロジェクト「{name}」とタスク{count}件を削除しました',
|
|
60
|
+
tasksMovedToInbox: 'タスク{count}件をInboxへ移動しました',
|
|
61
|
+
deletePrompt: 'プロジェクト「{name}」を削除しますか?',
|
|
62
|
+
deleteChildrenPrompt: 'プロジェクト「{name}」にはタスクが{count}件あります。 a=すべて削除 k=タスクを残す(→Inbox) n=キャンセル',
|
|
63
|
+
deleteCancelled: 'キャンセルしました。',
|
|
58
64
|
noProjects: 'プロジェクトなし',
|
|
59
65
|
description: '説明: {description}',
|
|
60
66
|
statusLabel: 'ステータス: {status}',
|
|
@@ -260,6 +266,11 @@ export const ja = {
|
|
|
260
266
|
deleteConfirm: '「{title}」を削除しますか? (y/n)',
|
|
261
267
|
deleted: '削除しました: 「{title}」',
|
|
262
268
|
deleteCancelled: '削除をキャンセルしました',
|
|
269
|
+
deleteProjectConfirm: 'プロジェクト「{title}」を削除しますか? (y/n)',
|
|
270
|
+
deleteProjectChildren: 'プロジェクト「{title}」にはタスクが{count}件あります。 t=すべて削除 i=タスクを残す(→Inbox) n=キャンセル',
|
|
271
|
+
deletedProject: 'プロジェクトを削除しました: 「{title}」',
|
|
272
|
+
deletedProjectWithTasks: 'プロジェクト「{title}」とタスク{count}件を削除しました',
|
|
273
|
+
projectTasksToInbox: 'プロジェクト「{title}」を削除し、タスク{count}件をInboxへ移動しました',
|
|
263
274
|
// Undo/Redo
|
|
264
275
|
undone: '元に戻しました: {action}',
|
|
265
276
|
redone: 'やり直しました: {action}',
|
package/dist/ui/App.js
CHANGED
|
@@ -30,7 +30,7 @@ import { KanbanMario } from './components/KanbanMario.js';
|
|
|
30
30
|
import { GtdDQ } from './components/GtdDQ.js';
|
|
31
31
|
import { GtdMario } from './components/GtdMario.js';
|
|
32
32
|
import { VERSION } from '../version.js';
|
|
33
|
-
import { HistoryProvider, useHistory, CreateTaskCommand, DeleteTaskCommand, MoveTaskCommand, LinkTaskCommand, ConvertToProjectCommand, CreateCommentCommand, DeleteCommentCommand, SetContextCommand, SetFocusCommand, SetEffortCommand, } from './history/index.js';
|
|
33
|
+
import { HistoryProvider, useHistory, CreateTaskCommand, DeleteTaskCommand, DeleteProjectCommand, MoveTaskCommand, LinkTaskCommand, ConvertToProjectCommand, CreateCommentCommand, DeleteCommentCommand, SetContextCommand, SetFocusCommand, SetEffortCommand, } from './history/index.js';
|
|
34
34
|
const TABS = ['inbox', 'next', 'waiting', 'someday', 'projects', 'done'];
|
|
35
35
|
export function App() {
|
|
36
36
|
const [themeName, setThemeNameState] = useState(getThemeName);
|
|
@@ -102,6 +102,8 @@ function AppContent({ onOpenSettings }) {
|
|
|
102
102
|
const [selectedCommentIndex, setSelectedCommentIndex] = useState(0);
|
|
103
103
|
const [taskToWaiting, setTaskToWaiting] = useState(null);
|
|
104
104
|
const [taskToDelete, setTaskToDelete] = useState(null);
|
|
105
|
+
const [projectToDelete, setProjectToDelete] = useState(null);
|
|
106
|
+
const [projectChildCount, setProjectChildCount] = useState(0);
|
|
105
107
|
const [projectProgress, setProjectProgress] = useState({});
|
|
106
108
|
// Search state
|
|
107
109
|
const [searchQuery, setSearchQuery] = useState('');
|
|
@@ -517,6 +519,19 @@ function AppContent({ onOpenSettings }) {
|
|
|
517
519
|
setMessage(fmt(i18n.tui.deleted || 'Deleted: "{title}"', { title: task.title }));
|
|
518
520
|
await loadTasks();
|
|
519
521
|
}, [i18n.tui.deleted, loadTasks, history]);
|
|
522
|
+
const deleteProjectAction = useCallback(async (project, mode, childCount) => {
|
|
523
|
+
const message = mode === 'cascade'
|
|
524
|
+
? (childCount > 0
|
|
525
|
+
? fmt(i18n.tui.deletedProjectWithTasks || 'Deleted project "{title}" and {count} task(s)', { title: project.title, count: childCount })
|
|
526
|
+
: fmt(i18n.tui.deletedProject || 'Deleted project: "{title}"', { title: project.title }))
|
|
527
|
+
: (childCount > 0
|
|
528
|
+
? fmt(i18n.tui.projectTasksToInbox || 'Deleted project "{title}", moved {count} task(s) to Inbox', { title: project.title, count: childCount })
|
|
529
|
+
: fmt(i18n.tui.deletedProject || 'Deleted project: "{title}"', { title: project.title }));
|
|
530
|
+
const command = new DeleteProjectCommand({ project, mode, description: message });
|
|
531
|
+
await history.execute(command);
|
|
532
|
+
setMessage(message);
|
|
533
|
+
await loadTasks();
|
|
534
|
+
}, [i18n.tui.deletedProject, i18n.tui.deletedProjectWithTasks, i18n.tui.projectTasksToInbox, loadTasks, history]);
|
|
520
535
|
const getTabLabel = (tab) => {
|
|
521
536
|
switch (tab) {
|
|
522
537
|
case 'inbox':
|
|
@@ -576,6 +591,51 @@ function AppContent({ onOpenSettings }) {
|
|
|
576
591
|
// Ignore other keys in confirm mode
|
|
577
592
|
return;
|
|
578
593
|
}
|
|
594
|
+
// Handle confirm-delete-project mode
|
|
595
|
+
if (mode === 'confirm-delete-project' && projectToDelete) {
|
|
596
|
+
const cancel = () => {
|
|
597
|
+
setMessage(i18n.tui.deleteCancelled || 'Delete cancelled');
|
|
598
|
+
setProjectToDelete(null);
|
|
599
|
+
setMode('normal');
|
|
600
|
+
};
|
|
601
|
+
const runDelete = (deleteMode) => {
|
|
602
|
+
const project = projectToDelete;
|
|
603
|
+
const count = projectChildCount;
|
|
604
|
+
deleteProjectAction(project, deleteMode, count).then(() => {
|
|
605
|
+
if (selectedTaskIndex >= currentTasks.length - 1) {
|
|
606
|
+
setSelectedTaskIndex(Math.max(0, selectedTaskIndex - 1));
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
setProjectToDelete(null);
|
|
610
|
+
setMode('normal');
|
|
611
|
+
};
|
|
612
|
+
if (projectChildCount > 0) {
|
|
613
|
+
if (input === 't' || input === 'T') {
|
|
614
|
+
runDelete('cascade');
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (input === 'i' || input === 'I') {
|
|
618
|
+
runDelete('keep');
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (input === 'n' || input === 'N' || key.escape) {
|
|
622
|
+
cancel();
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
if (input === 'y' || input === 'Y') {
|
|
628
|
+
runDelete('cascade');
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (input === 'n' || input === 'N' || key.escape) {
|
|
632
|
+
cancel();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
// Ignore other keys in confirm mode
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
579
639
|
// Handle add mode
|
|
580
640
|
if (mode === 'add' || mode === 'add-to-project' || mode === 'add-comment' || mode === 'move-to-waiting') {
|
|
581
641
|
if (key.escape) {
|
|
@@ -1077,6 +1137,20 @@ function AppContent({ onOpenSettings }) {
|
|
|
1077
1137
|
});
|
|
1078
1138
|
return;
|
|
1079
1139
|
}
|
|
1140
|
+
// Delete project (D key on projects tab - with confirmation)
|
|
1141
|
+
if (input === 'D' && currentTasks.length > 0 && currentTab === 'projects') {
|
|
1142
|
+
const project = currentTasks[selectedTaskIndex];
|
|
1143
|
+
const db = getDb();
|
|
1144
|
+
db.select()
|
|
1145
|
+
.from(schema.tasks)
|
|
1146
|
+
.where(eq(schema.tasks.parentId, project.id))
|
|
1147
|
+
.then((children) => {
|
|
1148
|
+
setProjectChildCount(children.length);
|
|
1149
|
+
setProjectToDelete(project);
|
|
1150
|
+
setMode('confirm-delete-project');
|
|
1151
|
+
});
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1080
1154
|
// Delete task (D key - with confirmation)
|
|
1081
1155
|
if (input === 'D' && currentTasks.length > 0 && currentTab !== 'projects') {
|
|
1082
1156
|
const task = currentTasks[selectedTaskIndex];
|
|
@@ -1224,7 +1298,9 @@ function AppContent({ onOpenSettings }) {
|
|
|
1224
1298
|
const isActive = (ctx === 'clear' && !currentContext) ||
|
|
1225
1299
|
(ctx !== 'clear' && ctx !== 'new' && currentContext === ctx);
|
|
1226
1300
|
return (_jsxs(Text, { color: index === contextSelectIndex ? theme.colors.textSelected : theme.colors.text, bold: index === contextSelectIndex, children: [index === contextSelectIndex ? theme.style.selectedPrefix : theme.style.unselectedPrefix, label, isActive && ' *'] }, ctx));
|
|
1227
|
-
}) }), _jsx(Text, { color: theme.colors.textMuted, children: i18n.tui.context?.setContextHelp || 'j/k: select, Enter: confirm, Esc: cancel' })] })), mode === 'set-effort' && (_jsxs(Box, { flexDirection: "column", borderStyle: theme.borders.modal, borderColor: theme.colors.borderActive, paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: theme.colors.accent, children: i18n.tui.effort?.set || 'Set effort' }), _jsx(Text, { color: theme.colors.textMuted, children: i18n.tui.effort?.setHelp || 'j/k: select, Enter: confirm, Esc: cancel' }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: EFFORT_OPTIONS.map((option, index) => (_jsxs(Text, { color: index === effortSelectIndex ? theme.colors.textSelected : theme.colors.text, bold: index === effortSelectIndex, children: [index === effortSelectIndex ? theme.style.selectedPrefix : theme.style.unselectedPrefix, option.label] }, option.label))) })] })), mode === 'add-context' && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.colors.secondary, bold: true, children: i18n.tui.context?.newContext || 'New context: ' }), _jsx(TextInput, { value: inputValue, onChange: setInputValue, onSubmit: handleInputSubmit, placeholder: i18n.tui.context?.newContextPlaceholder || 'Enter context name...' }), _jsxs(Text, { color: theme.colors.textMuted, children: [" ", i18n.tui.inputHelp] })] })), mode === 'search' && (_jsx(SearchBar, { value: searchQuery, onChange: handleSearchChange, onSubmit: handleInputSubmit })), mode === 'confirm-delete' && taskToDelete && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.accent, bold: true, children: fmt(i18n.tui.deleteConfirm || 'Delete "{title}"? (y/n)', { title: taskToDelete.title }) }) })),
|
|
1301
|
+
}) }), _jsx(Text, { color: theme.colors.textMuted, children: i18n.tui.context?.setContextHelp || 'j/k: select, Enter: confirm, Esc: cancel' })] })), mode === 'set-effort' && (_jsxs(Box, { flexDirection: "column", borderStyle: theme.borders.modal, borderColor: theme.colors.borderActive, paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: theme.colors.accent, children: i18n.tui.effort?.set || 'Set effort' }), _jsx(Text, { color: theme.colors.textMuted, children: i18n.tui.effort?.setHelp || 'j/k: select, Enter: confirm, Esc: cancel' }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: EFFORT_OPTIONS.map((option, index) => (_jsxs(Text, { color: index === effortSelectIndex ? theme.colors.textSelected : theme.colors.text, bold: index === effortSelectIndex, children: [index === effortSelectIndex ? theme.style.selectedPrefix : theme.style.unselectedPrefix, option.label] }, option.label))) })] })), mode === 'add-context' && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.colors.secondary, bold: true, children: i18n.tui.context?.newContext || 'New context: ' }), _jsx(TextInput, { value: inputValue, onChange: setInputValue, onSubmit: handleInputSubmit, placeholder: i18n.tui.context?.newContextPlaceholder || 'Enter context name...' }), _jsxs(Text, { color: theme.colors.textMuted, children: [" ", i18n.tui.inputHelp] })] })), mode === 'search' && (_jsx(SearchBar, { value: searchQuery, onChange: handleSearchChange, onSubmit: handleInputSubmit })), mode === 'confirm-delete' && taskToDelete && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.accent, bold: true, children: fmt(i18n.tui.deleteConfirm || 'Delete "{title}"? (y/n)', { title: taskToDelete.title }) }) })), mode === 'confirm-delete-project' && projectToDelete && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.accent, bold: true, children: projectChildCount > 0
|
|
1302
|
+
? fmt(i18n.tui.deleteProjectChildren || 'Project "{title}" has {count} task(s). t=delete all i=keep tasks (→Inbox) n=cancel', { title: projectToDelete.title, count: projectChildCount })
|
|
1303
|
+
: fmt(i18n.tui.deleteProjectConfirm || 'Delete project "{title}"? (y/n)', { title: projectToDelete.title }) }) })), message && mode === 'normal' && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.textHighlight, children: message }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [(mode === 'task-detail' || mode === 'add-comment') ? (theme.style.showFunctionKeys ? (_jsx(FunctionKeyBar, { keys: [
|
|
1228
1304
|
{ key: 'i', label: i18n.tui.keyBar.comment },
|
|
1229
1305
|
{ key: 'd', label: i18n.tui.keyBar.delete },
|
|
1230
1306
|
{ key: 'P', label: i18n.tui.keyBar.project },
|
|
@@ -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,
|
|
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
|
|
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 =
|
|
216
|
-
?
|
|
217
|
-
:
|
|
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月']
|