@juppytt/fws 0.1.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/.claude/settings.local.json +72 -0
- package/README.md +126 -0
- package/bin/fws-cli.sh +4 -0
- package/bin/fws.ts +421 -0
- package/docs/cli-reference.md +211 -0
- package/docs/gws-support.md +276 -0
- package/package.json +28 -0
- package/src/config/rewrite-cache.ts +73 -0
- package/src/index.ts +3 -0
- package/src/proxy/mitm.ts +285 -0
- package/src/server/app.ts +26 -0
- package/src/server/middleware.ts +38 -0
- package/src/server/routes/calendar.ts +483 -0
- package/src/server/routes/control.ts +151 -0
- package/src/server/routes/drive.ts +342 -0
- package/src/server/routes/gmail.ts +758 -0
- package/src/server/routes/people.ts +239 -0
- package/src/server/routes/sheets.ts +242 -0
- package/src/server/routes/tasks.ts +191 -0
- package/src/store/index.ts +24 -0
- package/src/store/seed.ts +313 -0
- package/src/store/types.ts +225 -0
- package/src/util/id.ts +9 -0
- package/test/calendar.test.ts +227 -0
- package/test/drive.test.ts +153 -0
- package/test/gmail.test.ts +215 -0
- package/test/gws-validation.test.ts +883 -0
- package/test/helpers/harness.ts +109 -0
- package/test/snapshot.test.ts +80 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { getStore } from '../../store/index.js';
|
|
3
|
+
import { generateId, generateEtag } from '../../util/id.js';
|
|
4
|
+
import { DEFAULT_USER_EMAIL } from '../../store/seed.js';
|
|
5
|
+
|
|
6
|
+
export function calendarRoutes(): Router {
|
|
7
|
+
const r = Router();
|
|
8
|
+
const PREFIX = '/calendar/v3';
|
|
9
|
+
|
|
10
|
+
function resolveCalendarId(raw: string): string {
|
|
11
|
+
if (raw === 'primary') {
|
|
12
|
+
const store = getStore();
|
|
13
|
+
const primary = Object.values(store.calendar.calendarList).find(c => c.primary);
|
|
14
|
+
return primary?.id || Object.keys(store.calendar.calendars)[0] || raw;
|
|
15
|
+
}
|
|
16
|
+
return raw;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// LIST calendarList
|
|
20
|
+
r.get(`${PREFIX}/users/me/calendarList`, (_req, res) => {
|
|
21
|
+
const items = Object.values(getStore().calendar.calendarList);
|
|
22
|
+
res.json({ kind: 'calendar#calendarList', etag: generateEtag(), items });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// GET calendarList entry
|
|
26
|
+
r.get(`${PREFIX}/users/me/calendarList/:calendarId`, (req, res) => {
|
|
27
|
+
const id = resolveCalendarId(req.params.calendarId);
|
|
28
|
+
const entry = getStore().calendar.calendarList[id];
|
|
29
|
+
if (!entry) {
|
|
30
|
+
return res.status(404).json({
|
|
31
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
res.json(entry);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// INSERT calendarList entry
|
|
38
|
+
r.post(`${PREFIX}/users/me/calendarList`, (req, res) => {
|
|
39
|
+
const store = getStore();
|
|
40
|
+
const id = req.body.id;
|
|
41
|
+
if (!id || !store.calendar.calendars[id]) {
|
|
42
|
+
return res.status(404).json({
|
|
43
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const cal = store.calendar.calendars[id];
|
|
47
|
+
const etag = generateEtag();
|
|
48
|
+
store.calendar.calendarList[id] = {
|
|
49
|
+
kind: 'calendar#calendarListEntry',
|
|
50
|
+
id,
|
|
51
|
+
summary: cal.summary,
|
|
52
|
+
description: cal.description,
|
|
53
|
+
timeZone: cal.timeZone,
|
|
54
|
+
accessRole: req.body.accessRole || 'reader',
|
|
55
|
+
defaultReminders: req.body.defaultReminders || [],
|
|
56
|
+
selected: req.body.selected ?? true,
|
|
57
|
+
etag,
|
|
58
|
+
};
|
|
59
|
+
res.json(store.calendar.calendarList[id]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// PATCH calendarList entry
|
|
63
|
+
r.patch(`${PREFIX}/users/me/calendarList/:calendarId`, (req, res) => {
|
|
64
|
+
const id = resolveCalendarId(req.params.calendarId);
|
|
65
|
+
const store = getStore();
|
|
66
|
+
const entry = store.calendar.calendarList[id];
|
|
67
|
+
if (!entry) {
|
|
68
|
+
return res.status(404).json({
|
|
69
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
Object.assign(entry, req.body, { id, kind: entry.kind });
|
|
73
|
+
entry.etag = generateEtag();
|
|
74
|
+
res.json(entry);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// UPDATE calendarList entry (PUT)
|
|
78
|
+
r.put(`${PREFIX}/users/me/calendarList/:calendarId`, (req, res) => {
|
|
79
|
+
const id = resolveCalendarId(req.params.calendarId);
|
|
80
|
+
const store = getStore();
|
|
81
|
+
if (!store.calendar.calendarList[id]) {
|
|
82
|
+
return res.status(404).json({
|
|
83
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
store.calendar.calendarList[id] = {
|
|
87
|
+
kind: 'calendar#calendarListEntry',
|
|
88
|
+
...req.body,
|
|
89
|
+
id,
|
|
90
|
+
etag: generateEtag(),
|
|
91
|
+
};
|
|
92
|
+
res.json(store.calendar.calendarList[id]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// DELETE calendarList entry
|
|
96
|
+
r.delete(`${PREFIX}/users/me/calendarList/:calendarId`, (req, res) => {
|
|
97
|
+
const id = resolveCalendarId(req.params.calendarId);
|
|
98
|
+
const store = getStore();
|
|
99
|
+
if (!store.calendar.calendarList[id]) {
|
|
100
|
+
return res.status(404).json({
|
|
101
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
delete store.calendar.calendarList[id];
|
|
105
|
+
res.status(204).send();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// CREATE calendar
|
|
109
|
+
r.post(`${PREFIX}/calendars`, (req, res) => {
|
|
110
|
+
const store = getStore();
|
|
111
|
+
const id = generateId();
|
|
112
|
+
const etag = generateEtag();
|
|
113
|
+
const cal = {
|
|
114
|
+
kind: 'calendar#calendar' as const,
|
|
115
|
+
id,
|
|
116
|
+
summary: req.body.summary || 'Untitled',
|
|
117
|
+
description: req.body.description,
|
|
118
|
+
timeZone: req.body.timeZone || 'UTC',
|
|
119
|
+
etag,
|
|
120
|
+
};
|
|
121
|
+
store.calendar.calendars[id] = cal;
|
|
122
|
+
store.calendar.events[id] = {};
|
|
123
|
+
store.calendar.calendarList[id] = {
|
|
124
|
+
kind: 'calendar#calendarListEntry',
|
|
125
|
+
id,
|
|
126
|
+
summary: cal.summary,
|
|
127
|
+
description: cal.description,
|
|
128
|
+
timeZone: cal.timeZone,
|
|
129
|
+
accessRole: 'owner',
|
|
130
|
+
defaultReminders: [],
|
|
131
|
+
selected: true,
|
|
132
|
+
etag,
|
|
133
|
+
};
|
|
134
|
+
res.json(cal);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// GET calendar
|
|
138
|
+
r.get(`${PREFIX}/calendars/:calendarId`, (req, res) => {
|
|
139
|
+
const id = resolveCalendarId(req.params.calendarId);
|
|
140
|
+
const cal = getStore().calendar.calendars[id];
|
|
141
|
+
if (!cal) {
|
|
142
|
+
return res.status(404).json({
|
|
143
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
res.json(cal);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// PATCH calendar
|
|
150
|
+
r.patch(`${PREFIX}/calendars/:calendarId`, (req, res) => {
|
|
151
|
+
const id = resolveCalendarId(req.params.calendarId);
|
|
152
|
+
const store = getStore();
|
|
153
|
+
const cal = store.calendar.calendars[id];
|
|
154
|
+
if (!cal) {
|
|
155
|
+
return res.status(404).json({
|
|
156
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
Object.assign(cal, req.body, { id, kind: cal.kind });
|
|
160
|
+
cal.etag = generateEtag();
|
|
161
|
+
// Also update calendarList entry
|
|
162
|
+
const listEntry = store.calendar.calendarList[id];
|
|
163
|
+
if (listEntry) {
|
|
164
|
+
if (req.body.summary) listEntry.summary = req.body.summary;
|
|
165
|
+
if (req.body.description) listEntry.description = req.body.description;
|
|
166
|
+
listEntry.etag = cal.etag;
|
|
167
|
+
}
|
|
168
|
+
res.json(cal);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// UPDATE calendar (PUT)
|
|
172
|
+
r.put(`${PREFIX}/calendars/:calendarId`, (req, res) => {
|
|
173
|
+
const id = resolveCalendarId(req.params.calendarId);
|
|
174
|
+
const store = getStore();
|
|
175
|
+
if (!store.calendar.calendars[id]) {
|
|
176
|
+
return res.status(404).json({
|
|
177
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const etag = generateEtag();
|
|
181
|
+
store.calendar.calendars[id] = {
|
|
182
|
+
kind: 'calendar#calendar',
|
|
183
|
+
...req.body,
|
|
184
|
+
id,
|
|
185
|
+
etag,
|
|
186
|
+
};
|
|
187
|
+
const listEntry = store.calendar.calendarList[id];
|
|
188
|
+
if (listEntry) {
|
|
189
|
+
listEntry.summary = req.body.summary || listEntry.summary;
|
|
190
|
+
listEntry.description = req.body.description;
|
|
191
|
+
listEntry.timeZone = req.body.timeZone || listEntry.timeZone;
|
|
192
|
+
listEntry.etag = etag;
|
|
193
|
+
}
|
|
194
|
+
res.json(store.calendar.calendars[id]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// CLEAR calendar (delete all events)
|
|
198
|
+
r.post(`${PREFIX}/calendars/:calendarId/clear`, (req, res) => {
|
|
199
|
+
const id = resolveCalendarId(req.params.calendarId);
|
|
200
|
+
const store = getStore();
|
|
201
|
+
if (!store.calendar.events[id]) {
|
|
202
|
+
return res.status(404).json({
|
|
203
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
store.calendar.events[id] = {};
|
|
207
|
+
res.status(204).send();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// DELETE calendar
|
|
211
|
+
r.delete(`${PREFIX}/calendars/:calendarId`, (req, res) => {
|
|
212
|
+
const id = resolveCalendarId(req.params.calendarId);
|
|
213
|
+
const store = getStore();
|
|
214
|
+
if (!store.calendar.calendars[id]) {
|
|
215
|
+
return res.status(404).json({
|
|
216
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
delete store.calendar.calendars[id];
|
|
220
|
+
delete store.calendar.events[id];
|
|
221
|
+
delete store.calendar.calendarList[id];
|
|
222
|
+
res.status(204).send();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// LIST events
|
|
226
|
+
r.get(`${PREFIX}/calendars/:calendarId/events`, (req, res) => {
|
|
227
|
+
const id = resolveCalendarId(req.params.calendarId);
|
|
228
|
+
const store = getStore();
|
|
229
|
+
const eventsMap = store.calendar.events[id];
|
|
230
|
+
if (!eventsMap) {
|
|
231
|
+
return res.status(404).json({
|
|
232
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let events = Object.values(eventsMap);
|
|
237
|
+
|
|
238
|
+
// Filter cancelled unless showDeleted
|
|
239
|
+
if (req.query.showDeleted !== 'true') {
|
|
240
|
+
events = events.filter(e => e.status !== 'cancelled');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Time range filtering
|
|
244
|
+
const timeMin = req.query.timeMin as string | undefined;
|
|
245
|
+
const timeMax = req.query.timeMax as string | undefined;
|
|
246
|
+
if (timeMin) {
|
|
247
|
+
const min = new Date(timeMin).getTime();
|
|
248
|
+
events = events.filter(e => {
|
|
249
|
+
const end = new Date(e.end.dateTime || e.end.date || '').getTime();
|
|
250
|
+
return end > min;
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (timeMax) {
|
|
254
|
+
const max = new Date(timeMax).getTime();
|
|
255
|
+
events = events.filter(e => {
|
|
256
|
+
const start = new Date(e.start.dateTime || e.start.date || '').getTime();
|
|
257
|
+
return start < max;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Search by q
|
|
262
|
+
const q = req.query.q as string | undefined;
|
|
263
|
+
if (q) {
|
|
264
|
+
const lower = q.toLowerCase();
|
|
265
|
+
events = events.filter(e =>
|
|
266
|
+
(e.summary || '').toLowerCase().includes(lower) ||
|
|
267
|
+
(e.description || '').toLowerCase().includes(lower)
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Sort by start time
|
|
272
|
+
events.sort((a, b) => {
|
|
273
|
+
const aTime = new Date(a.start.dateTime || a.start.date || '').getTime();
|
|
274
|
+
const bTime = new Date(b.start.dateTime || b.start.date || '').getTime();
|
|
275
|
+
return aTime - bTime;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const maxResults = parseInt(req.query.maxResults as string) || 250;
|
|
279
|
+
events = events.slice(0, maxResults);
|
|
280
|
+
|
|
281
|
+
res.json({
|
|
282
|
+
kind: 'calendar#events',
|
|
283
|
+
summary: store.calendar.calendars[id]?.summary || '',
|
|
284
|
+
updated: new Date().toISOString(),
|
|
285
|
+
items: events,
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// CREATE event
|
|
290
|
+
r.post(`${PREFIX}/calendars/:calendarId/events`, (req, res) => {
|
|
291
|
+
const calId = resolveCalendarId(req.params.calendarId);
|
|
292
|
+
const store = getStore();
|
|
293
|
+
if (!store.calendar.events[calId]) {
|
|
294
|
+
return res.status(404).json({
|
|
295
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const eventId = generateId();
|
|
300
|
+
const now = new Date().toISOString();
|
|
301
|
+
const userEmail = store.gmail.profile.emailAddress;
|
|
302
|
+
|
|
303
|
+
const event = {
|
|
304
|
+
kind: 'calendar#event' as const,
|
|
305
|
+
id: eventId,
|
|
306
|
+
status: (req.body.status || 'confirmed') as 'confirmed',
|
|
307
|
+
summary: req.body.summary,
|
|
308
|
+
description: req.body.description,
|
|
309
|
+
location: req.body.location,
|
|
310
|
+
start: req.body.start || { dateTime: now },
|
|
311
|
+
end: req.body.end || { dateTime: now },
|
|
312
|
+
created: now,
|
|
313
|
+
updated: now,
|
|
314
|
+
creator: { email: userEmail },
|
|
315
|
+
organizer: { email: userEmail, self: true },
|
|
316
|
+
attendees: req.body.attendees,
|
|
317
|
+
etag: generateEtag(),
|
|
318
|
+
htmlLink: `https://calendar.google.com/event?eid=${eventId}`,
|
|
319
|
+
iCalUID: `${eventId}@example.com`,
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
store.calendar.events[calId][eventId] = event;
|
|
323
|
+
res.json(event);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// GET event
|
|
327
|
+
r.get(`${PREFIX}/calendars/:calendarId/events/:eventId`, (req, res) => {
|
|
328
|
+
const calId = resolveCalendarId(req.params.calendarId);
|
|
329
|
+
const event = getStore().calendar.events[calId]?.[req.params.eventId];
|
|
330
|
+
if (!event) {
|
|
331
|
+
return res.status(404).json({
|
|
332
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
res.json(event);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// PATCH event
|
|
339
|
+
r.patch(`${PREFIX}/calendars/:calendarId/events/:eventId`, (req, res) => {
|
|
340
|
+
const calId = resolveCalendarId(req.params.calendarId);
|
|
341
|
+
const store = getStore();
|
|
342
|
+
const event = store.calendar.events[calId]?.[req.params.eventId];
|
|
343
|
+
if (!event) {
|
|
344
|
+
return res.status(404).json({
|
|
345
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
Object.assign(event, req.body, { id: event.id, kind: event.kind });
|
|
349
|
+
event.updated = new Date().toISOString();
|
|
350
|
+
event.etag = generateEtag();
|
|
351
|
+
res.json(event);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// PUT event (full replace)
|
|
355
|
+
r.put(`${PREFIX}/calendars/:calendarId/events/:eventId`, (req, res) => {
|
|
356
|
+
const calId = resolveCalendarId(req.params.calendarId);
|
|
357
|
+
const store = getStore();
|
|
358
|
+
if (!store.calendar.events[calId]?.[req.params.eventId]) {
|
|
359
|
+
return res.status(404).json({
|
|
360
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
const userEmail = store.gmail.profile.emailAddress;
|
|
364
|
+
const event = {
|
|
365
|
+
kind: 'calendar#event' as const,
|
|
366
|
+
...req.body,
|
|
367
|
+
id: req.params.eventId,
|
|
368
|
+
updated: new Date().toISOString(),
|
|
369
|
+
creator: req.body.creator || { email: userEmail },
|
|
370
|
+
organizer: req.body.organizer || { email: userEmail, self: true },
|
|
371
|
+
etag: generateEtag(),
|
|
372
|
+
htmlLink: `https://calendar.google.com/event?eid=${req.params.eventId}`,
|
|
373
|
+
iCalUID: req.body.iCalUID || `${req.params.eventId}@example.com`,
|
|
374
|
+
};
|
|
375
|
+
store.calendar.events[calId][req.params.eventId] = event;
|
|
376
|
+
res.json(event);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// DELETE event
|
|
380
|
+
r.delete(`${PREFIX}/calendars/:calendarId/events/:eventId`, (req, res) => {
|
|
381
|
+
const calId = resolveCalendarId(req.params.calendarId);
|
|
382
|
+
const store = getStore();
|
|
383
|
+
if (!store.calendar.events[calId]?.[req.params.eventId]) {
|
|
384
|
+
return res.status(404).json({
|
|
385
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
delete store.calendar.events[calId][req.params.eventId];
|
|
389
|
+
res.status(204).send();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// IMPORT event (like insert but preserves iCalUID)
|
|
393
|
+
r.post(`${PREFIX}/calendars/:calendarId/events/import`, (req, res) => {
|
|
394
|
+
const calId = resolveCalendarId(req.params.calendarId);
|
|
395
|
+
const store = getStore();
|
|
396
|
+
if (!store.calendar.events[calId]) {
|
|
397
|
+
return res.status(404).json({
|
|
398
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
const eventId = generateId();
|
|
402
|
+
const now = new Date().toISOString();
|
|
403
|
+
const userEmail = store.gmail.profile.emailAddress;
|
|
404
|
+
const event = {
|
|
405
|
+
kind: 'calendar#event' as const,
|
|
406
|
+
...req.body,
|
|
407
|
+
id: eventId,
|
|
408
|
+
status: req.body.status || 'confirmed',
|
|
409
|
+
created: now,
|
|
410
|
+
updated: now,
|
|
411
|
+
creator: req.body.creator || { email: userEmail },
|
|
412
|
+
organizer: req.body.organizer || { email: userEmail, self: true },
|
|
413
|
+
etag: generateEtag(),
|
|
414
|
+
htmlLink: `https://calendar.google.com/event?eid=${eventId}`,
|
|
415
|
+
iCalUID: req.body.iCalUID || `${eventId}@example.com`,
|
|
416
|
+
};
|
|
417
|
+
store.calendar.events[calId][eventId] = event;
|
|
418
|
+
res.json(event);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// MOVE event to another calendar
|
|
422
|
+
r.post(`${PREFIX}/calendars/:calendarId/events/:eventId/move`, (req, res) => {
|
|
423
|
+
const srcCalId = resolveCalendarId(req.params.calendarId);
|
|
424
|
+
const destCalId = resolveCalendarId(req.query.destination as string);
|
|
425
|
+
const store = getStore();
|
|
426
|
+
const event = store.calendar.events[srcCalId]?.[req.params.eventId];
|
|
427
|
+
if (!event) {
|
|
428
|
+
return res.status(404).json({
|
|
429
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
if (!store.calendar.events[destCalId]) {
|
|
433
|
+
return res.status(404).json({
|
|
434
|
+
error: { code: 404, message: 'Destination calendar not found', status: 'NOT_FOUND' },
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
// Move: delete from source, add to destination
|
|
438
|
+
delete store.calendar.events[srcCalId][req.params.eventId];
|
|
439
|
+
store.calendar.events[destCalId][req.params.eventId] = event;
|
|
440
|
+
event.updated = new Date().toISOString();
|
|
441
|
+
event.etag = generateEtag();
|
|
442
|
+
res.json(event);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// QUICK ADD event (from text)
|
|
446
|
+
r.post(`${PREFIX}/calendars/:calendarId/events/quickAdd`, (req, res) => {
|
|
447
|
+
const calId = resolveCalendarId(req.params.calendarId);
|
|
448
|
+
const store = getStore();
|
|
449
|
+
if (!store.calendar.events[calId]) {
|
|
450
|
+
return res.status(404).json({
|
|
451
|
+
error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' },
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
const text = req.query.text as string || '';
|
|
455
|
+
const eventId = generateId();
|
|
456
|
+
const now = new Date().toISOString();
|
|
457
|
+
const userEmail = store.gmail.profile.emailAddress;
|
|
458
|
+
|
|
459
|
+
// Simple parsing: use the text as summary, default to 1h from now
|
|
460
|
+
const start = new Date();
|
|
461
|
+
const end = new Date(start.getTime() + 3600000);
|
|
462
|
+
|
|
463
|
+
const event = {
|
|
464
|
+
kind: 'calendar#event' as const,
|
|
465
|
+
id: eventId,
|
|
466
|
+
status: 'confirmed' as const,
|
|
467
|
+
summary: text,
|
|
468
|
+
start: { dateTime: start.toISOString() },
|
|
469
|
+
end: { dateTime: end.toISOString() },
|
|
470
|
+
created: now,
|
|
471
|
+
updated: now,
|
|
472
|
+
creator: { email: userEmail },
|
|
473
|
+
organizer: { email: userEmail, self: true },
|
|
474
|
+
etag: generateEtag(),
|
|
475
|
+
htmlLink: `https://calendar.google.com/event?eid=${eventId}`,
|
|
476
|
+
iCalUID: `${eventId}@example.com`,
|
|
477
|
+
};
|
|
478
|
+
store.calendar.events[calId][eventId] = event;
|
|
479
|
+
res.json(event);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
return r;
|
|
483
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { getStore, resetStore, loadStore, serializeStore } from '../../store/index.js';
|
|
3
|
+
import { generateId, generateEtag } from '../../util/id.js';
|
|
4
|
+
|
|
5
|
+
export function controlRoutes(): Router {
|
|
6
|
+
const r = Router();
|
|
7
|
+
|
|
8
|
+
r.get('/__fws/status', (_req, res) => {
|
|
9
|
+
res.json({ status: 'ok' });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
r.post('/__fws/snapshot/save', (_req, res) => {
|
|
13
|
+
const json = serializeStore();
|
|
14
|
+
res.type('application/json').send(json);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
r.post('/__fws/snapshot/load', (req, res) => {
|
|
18
|
+
const data = req.body;
|
|
19
|
+
loadStore(data);
|
|
20
|
+
res.json({ status: 'loaded' });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
r.post('/__fws/reset', (_req, res) => {
|
|
24
|
+
resetStore();
|
|
25
|
+
res.json({ status: 'reset' });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Setup convenience endpoints
|
|
29
|
+
r.post('/__fws/setup/gmail/message', (req, res) => {
|
|
30
|
+
const store = getStore();
|
|
31
|
+
const { from, to, subject, body, labels, date } = req.body;
|
|
32
|
+
const id = generateId();
|
|
33
|
+
const threadId = generateId();
|
|
34
|
+
const now = date || new Date().toISOString();
|
|
35
|
+
const internalDate = String(new Date(now).getTime());
|
|
36
|
+
const snippet = (body || '').slice(0, 100);
|
|
37
|
+
const msgLabels = labels || ['INBOX', 'UNREAD'];
|
|
38
|
+
const userEmail = store.gmail.profile.emailAddress;
|
|
39
|
+
|
|
40
|
+
store.gmail.messages[id] = {
|
|
41
|
+
id,
|
|
42
|
+
threadId,
|
|
43
|
+
labelIds: msgLabels,
|
|
44
|
+
snippet,
|
|
45
|
+
historyId: String(store.gmail.nextHistoryId++),
|
|
46
|
+
internalDate,
|
|
47
|
+
sizeEstimate: (body || '').length,
|
|
48
|
+
payload: {
|
|
49
|
+
partId: '',
|
|
50
|
+
mimeType: 'text/plain',
|
|
51
|
+
filename: '',
|
|
52
|
+
headers: [
|
|
53
|
+
{ name: 'From', value: from || 'unknown@example.com' },
|
|
54
|
+
{ name: 'To', value: to || userEmail },
|
|
55
|
+
{ name: 'Subject', value: subject || '(no subject)' },
|
|
56
|
+
{ name: 'Date', value: now },
|
|
57
|
+
],
|
|
58
|
+
body: {
|
|
59
|
+
size: (body || '').length,
|
|
60
|
+
data: Buffer.from(body || '').toString('base64url'),
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
store.gmail.profile.messagesTotal++;
|
|
65
|
+
store.gmail.profile.threadsTotal++;
|
|
66
|
+
|
|
67
|
+
res.json({ id, threadId });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
r.post('/__fws/setup/calendar/event', (req, res) => {
|
|
71
|
+
const store = getStore();
|
|
72
|
+
const { summary, description, location, start, duration, calendar, attendees } = req.body;
|
|
73
|
+
const calendarId = calendar || Object.keys(store.calendar.calendars)[0];
|
|
74
|
+
if (!store.calendar.events[calendarId]) {
|
|
75
|
+
store.calendar.events[calendarId] = {};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const id = generateId();
|
|
79
|
+
const now = new Date().toISOString();
|
|
80
|
+
const startDate = new Date(start);
|
|
81
|
+
const durationMs = parseDuration(duration || '1h');
|
|
82
|
+
const endDate = new Date(startDate.getTime() + durationMs);
|
|
83
|
+
const userEmail = store.gmail.profile.emailAddress;
|
|
84
|
+
|
|
85
|
+
const event = {
|
|
86
|
+
kind: 'calendar#event' as const,
|
|
87
|
+
id,
|
|
88
|
+
status: 'confirmed' as const,
|
|
89
|
+
summary: summary || '(no title)',
|
|
90
|
+
description,
|
|
91
|
+
location,
|
|
92
|
+
start: { dateTime: startDate.toISOString() },
|
|
93
|
+
end: { dateTime: endDate.toISOString() },
|
|
94
|
+
created: now,
|
|
95
|
+
updated: now,
|
|
96
|
+
creator: { email: userEmail },
|
|
97
|
+
organizer: { email: userEmail, self: true },
|
|
98
|
+
attendees: attendees
|
|
99
|
+
? attendees.map((email: string) => ({ email, responseStatus: 'needsAction' }))
|
|
100
|
+
: undefined,
|
|
101
|
+
etag: generateEtag(),
|
|
102
|
+
htmlLink: `https://calendar.google.com/event?eid=${id}`,
|
|
103
|
+
iCalUID: `${id}@example.com`,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
store.calendar.events[calendarId][id] = event;
|
|
107
|
+
res.json({ id, calendarId });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
r.post('/__fws/setup/drive/file', (req, res) => {
|
|
111
|
+
const store = getStore();
|
|
112
|
+
const { name, mimeType, parent, size, description } = req.body;
|
|
113
|
+
const id = generateId();
|
|
114
|
+
const now = new Date().toISOString();
|
|
115
|
+
const userEmail = store.gmail.profile.emailAddress;
|
|
116
|
+
|
|
117
|
+
store.drive.files[id] = {
|
|
118
|
+
kind: 'drive#file',
|
|
119
|
+
id,
|
|
120
|
+
name: name || 'Untitled',
|
|
121
|
+
mimeType: mimeType || 'application/octet-stream',
|
|
122
|
+
parents: parent ? [parent] : ['root'],
|
|
123
|
+
createdTime: now,
|
|
124
|
+
modifiedTime: now,
|
|
125
|
+
size: size ? String(size) : undefined,
|
|
126
|
+
trashed: false,
|
|
127
|
+
starred: false,
|
|
128
|
+
owners: [{ emailAddress: userEmail, displayName: 'Test User' }],
|
|
129
|
+
description,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
res.json({ id });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return r;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseDuration(d: string): number {
|
|
139
|
+
const match = d.match(/^(\d+)(ms|s|m|h|d)$/);
|
|
140
|
+
if (!match) return 3600000; // default 1h
|
|
141
|
+
const val = parseInt(match[1]);
|
|
142
|
+
const unit = match[2];
|
|
143
|
+
switch (unit) {
|
|
144
|
+
case 'ms': return val;
|
|
145
|
+
case 's': return val * 1000;
|
|
146
|
+
case 'm': return val * 60000;
|
|
147
|
+
case 'h': return val * 3600000;
|
|
148
|
+
case 'd': return val * 86400000;
|
|
149
|
+
default: return 3600000;
|
|
150
|
+
}
|
|
151
|
+
}
|