@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,239 @@
|
|
|
1
|
+
import { Router, type Request, type Response } from 'express';
|
|
2
|
+
import { getStore } from '../../store/index.js';
|
|
3
|
+
import { generateId, generateEtag } from '../../util/id.js';
|
|
4
|
+
|
|
5
|
+
export function peopleRoutes(): Router {
|
|
6
|
+
const r = Router();
|
|
7
|
+
const PREFIX = '/v1';
|
|
8
|
+
|
|
9
|
+
// Google API uses :action suffix (e.g., people/123:deleteContact)
|
|
10
|
+
// Express can't parse these naturally, so we use wildcard routes
|
|
11
|
+
|
|
12
|
+
// === People / Contacts ===
|
|
13
|
+
|
|
14
|
+
// CREATE contact
|
|
15
|
+
r.post(`${PREFIX}/people:createContact`, (req, res) => {
|
|
16
|
+
const store = getStore();
|
|
17
|
+
const id = `c${generateId(8)}`;
|
|
18
|
+
const resourceName = `people/${id}`;
|
|
19
|
+
const person = { resourceName, etag: generateEtag(), ...req.body };
|
|
20
|
+
store.people.contacts[resourceName] = person;
|
|
21
|
+
res.json(person);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// BATCH CREATE contacts
|
|
25
|
+
r.post(`${PREFIX}/people:batchCreateContacts`, (req, res) => {
|
|
26
|
+
const store = getStore();
|
|
27
|
+
const contacts = req.body.contacts || [];
|
|
28
|
+
const createdPeople = contacts.map((c: any) => {
|
|
29
|
+
const id = `c${generateId(8)}`;
|
|
30
|
+
const resourceName = `people/${id}`;
|
|
31
|
+
const person = { resourceName, etag: generateEtag(), ...c.contactPerson };
|
|
32
|
+
store.people.contacts[resourceName] = person;
|
|
33
|
+
return { httpStatusCode: 200, person };
|
|
34
|
+
});
|
|
35
|
+
res.json({ createdPeople });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// BATCH UPDATE contacts
|
|
39
|
+
r.post(`${PREFIX}/people:batchUpdateContacts`, (req, res) => {
|
|
40
|
+
const store = getStore();
|
|
41
|
+
const contacts = req.body.contacts || {};
|
|
42
|
+
const updateResult: Record<string, any> = {};
|
|
43
|
+
for (const [resourceName, body] of Object.entries(contacts) as [string, any][]) {
|
|
44
|
+
const person = store.people.contacts[resourceName];
|
|
45
|
+
if (person) {
|
|
46
|
+
Object.assign(person, body, { resourceName });
|
|
47
|
+
person.etag = generateEtag();
|
|
48
|
+
updateResult[resourceName] = { httpStatusCode: 200, person };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
res.json({ updateResult });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// BATCH DELETE contacts
|
|
55
|
+
r.post(`${PREFIX}/people:batchDeleteContacts`, (req, res) => {
|
|
56
|
+
const store = getStore();
|
|
57
|
+
const resourceNames: string[] = req.body.resourceNames || [];
|
|
58
|
+
for (const rn of resourceNames) {
|
|
59
|
+
delete store.people.contacts[rn];
|
|
60
|
+
}
|
|
61
|
+
res.json({});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// BATCH GET people
|
|
65
|
+
r.get(`${PREFIX}/people:batchGet`, (req, res) => {
|
|
66
|
+
const resourceNames = (Array.isArray(req.query.resourceNames) ? req.query.resourceNames : [req.query.resourceNames]) as string[];
|
|
67
|
+
const store = getStore();
|
|
68
|
+
const responses = resourceNames.filter(Boolean).map(rn => {
|
|
69
|
+
const person = store.people.contacts[rn];
|
|
70
|
+
return person ? { httpStatusCode: 200, person } : { httpStatusCode: 404 };
|
|
71
|
+
});
|
|
72
|
+
res.json({ responses });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// SEARCH contacts
|
|
76
|
+
r.get(`${PREFIX}/people:searchContacts`, (req, res) => {
|
|
77
|
+
const query = (req.query.query as string || '').toLowerCase();
|
|
78
|
+
const store = getStore();
|
|
79
|
+
const results = Object.values(store.people.contacts).filter(p => {
|
|
80
|
+
const name = p.names?.[0]?.displayName?.toLowerCase() || '';
|
|
81
|
+
const email = p.emailAddresses?.[0]?.value?.toLowerCase() || '';
|
|
82
|
+
return name.includes(query) || email.includes(query);
|
|
83
|
+
});
|
|
84
|
+
res.json({ results: results.map(person => ({ person })) });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// LIST directory people
|
|
88
|
+
r.get(`${PREFIX}/people:listDirectoryPeople`, (_req, res) => {
|
|
89
|
+
res.json({ people: Object.values(getStore().people.contacts) });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// SEARCH directory people
|
|
93
|
+
r.get(`${PREFIX}/people:searchDirectoryPeople`, (req, res) => {
|
|
94
|
+
const query = (req.query.query as string || '').toLowerCase();
|
|
95
|
+
const people = Object.values(getStore().people.contacts).filter(p => {
|
|
96
|
+
const name = p.names?.[0]?.displayName?.toLowerCase() || '';
|
|
97
|
+
const email = p.emailAddresses?.[0]?.value?.toLowerCase() || '';
|
|
98
|
+
return name.includes(query) || email.includes(query);
|
|
99
|
+
});
|
|
100
|
+
res.json({ people });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// LIST connections
|
|
104
|
+
r.get(`${PREFIX}/people/me/connections`, (_req, res) => {
|
|
105
|
+
const connections = Object.values(getStore().people.contacts);
|
|
106
|
+
res.json({ connections, totalPeople: connections.length, totalItems: connections.length });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// GET person (must be after :action routes)
|
|
110
|
+
r.get(`${PREFIX}/people/:peopleId`, (req, res) => {
|
|
111
|
+
const resourceName = `people/${req.params.peopleId}`;
|
|
112
|
+
const person = getStore().people.contacts[resourceName];
|
|
113
|
+
if (!person) {
|
|
114
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
115
|
+
}
|
|
116
|
+
res.json(person);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// UPDATE contact — matches people/123:updateContact
|
|
120
|
+
r.patch(`${PREFIX}/people/:rest`, (req, res) => {
|
|
121
|
+
const peopleId = req.params.rest.replace(/:.*$/, '');
|
|
122
|
+
const resourceName = `people/${peopleId}`;
|
|
123
|
+
const store = getStore();
|
|
124
|
+
const person = store.people.contacts[resourceName];
|
|
125
|
+
if (!person) {
|
|
126
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
127
|
+
}
|
|
128
|
+
Object.assign(person, req.body, { resourceName });
|
|
129
|
+
person.etag = generateEtag();
|
|
130
|
+
res.json(person);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// DELETE contact — matches people/123:deleteContact
|
|
134
|
+
r.delete(`${PREFIX}/people/:rest`, (req, res) => {
|
|
135
|
+
const peopleId = req.params.rest.replace(/:.*$/, '');
|
|
136
|
+
const resourceName = `people/${peopleId}`;
|
|
137
|
+
const store = getStore();
|
|
138
|
+
if (!store.people.contacts[resourceName]) {
|
|
139
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
140
|
+
}
|
|
141
|
+
delete store.people.contacts[resourceName];
|
|
142
|
+
res.status(204).send();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// === Other Contacts ===
|
|
146
|
+
|
|
147
|
+
r.get(`${PREFIX}/otherContacts`, (_req, res) => {
|
|
148
|
+
res.json({ otherContacts: [], totalSize: 0 });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
r.get(`${PREFIX}/otherContacts:search`, (_req, res) => {
|
|
152
|
+
res.json({ results: [] });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
r.post(`${PREFIX}/otherContacts/:rest`, (_req, res) => {
|
|
156
|
+
res.json({ resourceName: `people/copied`, etag: generateEtag() });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// === Contact Groups ===
|
|
160
|
+
|
|
161
|
+
r.get(`${PREFIX}/contactGroups:batchGet`, (req, res) => {
|
|
162
|
+
const resourceNames = (Array.isArray(req.query.resourceNames) ? req.query.resourceNames : [req.query.resourceNames]) as string[];
|
|
163
|
+
const store = getStore();
|
|
164
|
+
const responses = resourceNames.filter(Boolean).map(rn => store.people.contactGroups[rn]).filter(Boolean);
|
|
165
|
+
res.json({ responses });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
r.get(`${PREFIX}/contactGroups`, (_req, res) => {
|
|
169
|
+
const groups = Object.values(getStore().people.contactGroups);
|
|
170
|
+
res.json({ contactGroups: groups, totalItems: groups.length });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
r.post(`${PREFIX}/contactGroups`, (req, res) => {
|
|
174
|
+
const store = getStore();
|
|
175
|
+
const id = generateId(8);
|
|
176
|
+
const rn = `contactGroups/${id}`;
|
|
177
|
+
const group = {
|
|
178
|
+
resourceName: rn,
|
|
179
|
+
etag: generateEtag(),
|
|
180
|
+
name: req.body.contactGroup?.name || 'Untitled',
|
|
181
|
+
groupType: 'USER_CONTACT_GROUP' as const,
|
|
182
|
+
memberCount: 0,
|
|
183
|
+
};
|
|
184
|
+
store.people.contactGroups[rn] = group;
|
|
185
|
+
res.json(group);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
r.get(`${PREFIX}/contactGroups/:id`, (req, res) => {
|
|
189
|
+
const rn = `contactGroups/${req.params.id}`;
|
|
190
|
+
const group = getStore().people.contactGroups[rn];
|
|
191
|
+
if (!group) {
|
|
192
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
193
|
+
}
|
|
194
|
+
res.json(group);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
r.put(`${PREFIX}/contactGroups/:id`, (req, res) => {
|
|
198
|
+
const rn = `contactGroups/${req.params.id}`;
|
|
199
|
+
const store = getStore();
|
|
200
|
+
const group = store.people.contactGroups[rn];
|
|
201
|
+
if (!group) {
|
|
202
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
203
|
+
}
|
|
204
|
+
if (req.body.contactGroup?.name) group.name = req.body.contactGroup.name;
|
|
205
|
+
group.etag = generateEtag();
|
|
206
|
+
res.json(group);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
r.delete(`${PREFIX}/contactGroups/:id`, (req, res) => {
|
|
210
|
+
const rn = `contactGroups/${req.params.id}`;
|
|
211
|
+
const store = getStore();
|
|
212
|
+
if (!store.people.contactGroups[rn]) {
|
|
213
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
214
|
+
}
|
|
215
|
+
delete store.people.contactGroups[rn];
|
|
216
|
+
res.status(204).send();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// MODIFY members
|
|
220
|
+
r.post(`${PREFIX}/contactGroups/:id/members:modify`, (req, res) => {
|
|
221
|
+
const rn = `contactGroups/${req.params.id}`;
|
|
222
|
+
const store = getStore();
|
|
223
|
+
const group = store.people.contactGroups[rn];
|
|
224
|
+
if (!group) {
|
|
225
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
226
|
+
}
|
|
227
|
+
const toAdd: string[] = req.body.resourceNamesToAdd || [];
|
|
228
|
+
const toRemove: string[] = req.body.resourceNamesToRemove || [];
|
|
229
|
+
if (!group.memberResourceNames) group.memberResourceNames = [];
|
|
230
|
+
group.memberResourceNames = group.memberResourceNames.filter(m => !toRemove.includes(m));
|
|
231
|
+
for (const m of toAdd) {
|
|
232
|
+
if (!group.memberResourceNames.includes(m)) group.memberResourceNames.push(m);
|
|
233
|
+
}
|
|
234
|
+
group.memberCount = group.memberResourceNames.length;
|
|
235
|
+
res.json({ notFoundResourceNames: [] });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return r;
|
|
239
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { getStore } from '../../store/index.js';
|
|
3
|
+
import { generateId } from '../../util/id.js';
|
|
4
|
+
|
|
5
|
+
export function sheetsRoutes(): Router {
|
|
6
|
+
const r = Router();
|
|
7
|
+
const PREFIX = '/v4/spreadsheets';
|
|
8
|
+
|
|
9
|
+
// In-memory cell values: key = "spreadsheetId:sheetTitle" -> string[][]
|
|
10
|
+
const cellValues: Record<string, string[][]> = {};
|
|
11
|
+
|
|
12
|
+
function getValues(spreadsheetId: string, sheetTitle: string): string[][] {
|
|
13
|
+
const key = `${spreadsheetId}::${sheetTitle}`; // double colon to avoid key collision
|
|
14
|
+
if (!cellValues[key]) cellValues[key] = [];
|
|
15
|
+
return cellValues[key];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function setCell(grid: string[][], row: number, col: number, value: string): void {
|
|
19
|
+
while (grid.length <= row) grid.push([]);
|
|
20
|
+
while (grid[row].length <= col) grid[row].push('');
|
|
21
|
+
grid[row][col] = value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseRange(range: string): { sheet: string; startRow: number; startCol: number; endRow: number; endCol: number } {
|
|
25
|
+
let sheet = 'Sheet1';
|
|
26
|
+
let cellRange = range;
|
|
27
|
+
if (range.includes('!')) {
|
|
28
|
+
const parts = range.split('!');
|
|
29
|
+
sheet = parts[0].replace(/^'|'$/g, '');
|
|
30
|
+
cellRange = parts[1];
|
|
31
|
+
}
|
|
32
|
+
const colToNum = (c: string) => {
|
|
33
|
+
let n = 0;
|
|
34
|
+
for (const ch of c.toUpperCase()) n = n * 26 + ch.charCodeAt(0) - 64;
|
|
35
|
+
return n - 1;
|
|
36
|
+
};
|
|
37
|
+
const parseCell = (cell: string) => {
|
|
38
|
+
const m = cell.match(/^([A-Za-z]+)(\d+)$/);
|
|
39
|
+
if (!m) return { row: 0, col: 0 };
|
|
40
|
+
return { row: parseInt(m[2]) - 1, col: colToNum(m[1]) };
|
|
41
|
+
};
|
|
42
|
+
const parts = cellRange.split(':');
|
|
43
|
+
const start = parseCell(parts[0]);
|
|
44
|
+
const end = parts[1] ? parseCell(parts[1]) : start;
|
|
45
|
+
return { sheet, startRow: start.row, startCol: start.col, endRow: end.row, endCol: end.col };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Helper to extract spreadsheetId from params that may include :action suffix
|
|
49
|
+
function ssId(raw: string): string {
|
|
50
|
+
return raw.replace(/:.*$/, '');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// CREATE spreadsheet
|
|
54
|
+
r.post(PREFIX, (req, res) => {
|
|
55
|
+
const store = getStore();
|
|
56
|
+
const id = generateId();
|
|
57
|
+
const ss = {
|
|
58
|
+
spreadsheetId: id,
|
|
59
|
+
properties: {
|
|
60
|
+
title: req.body.properties?.title || 'Untitled',
|
|
61
|
+
locale: req.body.properties?.locale || 'en_US',
|
|
62
|
+
timeZone: req.body.properties?.timeZone || 'UTC',
|
|
63
|
+
},
|
|
64
|
+
sheets: req.body.sheets || [{
|
|
65
|
+
properties: { sheetId: 0, title: 'Sheet1', index: 0, sheetType: 'GRID', gridProperties: { rowCount: 1000, columnCount: 26 } },
|
|
66
|
+
}],
|
|
67
|
+
spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${id}/edit`,
|
|
68
|
+
};
|
|
69
|
+
store.sheets.spreadsheets[id] = ss;
|
|
70
|
+
res.json(ss);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// GET spreadsheet
|
|
74
|
+
r.get(`${PREFIX}/:spreadsheetId`, (req, res) => {
|
|
75
|
+
const ss = getStore().sheets.spreadsheets[req.params.spreadsheetId];
|
|
76
|
+
if (!ss) {
|
|
77
|
+
return res.status(404).json({ error: { code: 404, message: 'Spreadsheet not found.', status: 'NOT_FOUND' } });
|
|
78
|
+
}
|
|
79
|
+
res.json(ss);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// BATCH UPDATE — matches :batchUpdate suffix via wildcard
|
|
83
|
+
r.post(`${PREFIX}/:rest`, (req, res, next) => {
|
|
84
|
+
if (!req.params.rest.includes(':batchUpdate')) return next();
|
|
85
|
+
const id = ssId(req.params.rest);
|
|
86
|
+
const ss = getStore().sheets.spreadsheets[id];
|
|
87
|
+
if (!ss) {
|
|
88
|
+
return res.status(404).json({ error: { code: 404, message: 'Spreadsheet not found.', status: 'NOT_FOUND' } });
|
|
89
|
+
}
|
|
90
|
+
const replies = (req.body.requests || []).map(() => ({}));
|
|
91
|
+
res.json({ spreadsheetId: ss.spreadsheetId, replies });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// GET values
|
|
95
|
+
r.get(`${PREFIX}/:spreadsheetId/values/:range`, (req, res) => {
|
|
96
|
+
const ss = getStore().sheets.spreadsheets[req.params.spreadsheetId];
|
|
97
|
+
if (!ss) {
|
|
98
|
+
return res.status(404).json({ error: { code: 404, message: 'Spreadsheet not found.', status: 'NOT_FOUND' } });
|
|
99
|
+
}
|
|
100
|
+
const parsed = parseRange(req.params.range);
|
|
101
|
+
const grid = getValues(req.params.spreadsheetId, parsed.sheet);
|
|
102
|
+
const values: string[][] = [];
|
|
103
|
+
for (let r = parsed.startRow; r <= parsed.endRow; r++) {
|
|
104
|
+
const row: string[] = [];
|
|
105
|
+
for (let c = parsed.startCol; c <= parsed.endCol; c++) {
|
|
106
|
+
row.push(grid[r]?.[c] || '');
|
|
107
|
+
}
|
|
108
|
+
values.push(row);
|
|
109
|
+
}
|
|
110
|
+
res.json({ range: req.params.range, majorDimension: 'ROWS', values });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// UPDATE values (PUT)
|
|
114
|
+
r.put(`${PREFIX}/:spreadsheetId/values/:range`, (req, res) => {
|
|
115
|
+
const ss = getStore().sheets.spreadsheets[req.params.spreadsheetId];
|
|
116
|
+
if (!ss) {
|
|
117
|
+
return res.status(404).json({ error: { code: 404, message: 'Spreadsheet not found.', status: 'NOT_FOUND' } });
|
|
118
|
+
}
|
|
119
|
+
const parsed = parseRange(req.params.range);
|
|
120
|
+
const grid = getValues(req.params.spreadsheetId, parsed.sheet);
|
|
121
|
+
const values: string[][] = req.body.values || [];
|
|
122
|
+
let updatedCells = 0;
|
|
123
|
+
for (let ri = 0; ri < values.length; ri++) {
|
|
124
|
+
for (let ci = 0; ci < values[ri].length; ci++) {
|
|
125
|
+
setCell(grid, parsed.startRow + ri, parsed.startCol + ci, values[ri][ci]);
|
|
126
|
+
updatedCells++;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
res.json({
|
|
130
|
+
spreadsheetId: req.params.spreadsheetId,
|
|
131
|
+
updatedRange: req.params.range,
|
|
132
|
+
updatedRows: values.length,
|
|
133
|
+
updatedColumns: values[0]?.length || 0,
|
|
134
|
+
updatedCells,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// APPEND, CLEAR, BATCH GET, BATCH UPDATE, BATCH CLEAR — match via wildcard
|
|
139
|
+
// These use :action suffix on the range or spreadsheetId
|
|
140
|
+
|
|
141
|
+
// Handle /spreadsheets/:id/values/:range:append
|
|
142
|
+
r.post(`${PREFIX}/:spreadsheetId/values/:rest`, (req, res) => {
|
|
143
|
+
const ss = getStore().sheets.spreadsheets[req.params.spreadsheetId];
|
|
144
|
+
if (!ss) {
|
|
145
|
+
return res.status(404).json({ error: { code: 404, message: 'Spreadsheet not found.', status: 'NOT_FOUND' } });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const rest = req.params.rest;
|
|
149
|
+
|
|
150
|
+
if (rest.includes(':append')) {
|
|
151
|
+
const range = rest.replace(/:append$/, '');
|
|
152
|
+
const parsed = parseRange(range);
|
|
153
|
+
const grid = getValues(req.params.spreadsheetId, parsed.sheet);
|
|
154
|
+
const values: string[][] = req.body.values || [];
|
|
155
|
+
const startRow = grid.length;
|
|
156
|
+
let updatedCells = 0;
|
|
157
|
+
for (let ri = 0; ri < values.length; ri++) {
|
|
158
|
+
for (let ci = 0; ci < values[ri].length; ci++) {
|
|
159
|
+
setCell(grid, startRow + ri, parsed.startCol + ci, values[ri][ci]);
|
|
160
|
+
updatedCells++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return res.json({
|
|
164
|
+
spreadsheetId: req.params.spreadsheetId,
|
|
165
|
+
updates: { updatedRange: range, updatedRows: values.length, updatedColumns: values[0]?.length || 0, updatedCells },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (rest.includes(':clear')) {
|
|
170
|
+
const range = rest.replace(/:clear$/, '');
|
|
171
|
+
const parsed = parseRange(range);
|
|
172
|
+
const grid = getValues(req.params.spreadsheetId, parsed.sheet);
|
|
173
|
+
for (let r = parsed.startRow; r <= parsed.endRow && r < grid.length; r++) {
|
|
174
|
+
for (let c = parsed.startCol; c <= parsed.endCol && grid[r] && c < grid[r].length; c++) {
|
|
175
|
+
grid[r][c] = '';
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return res.json({ spreadsheetId: req.params.spreadsheetId, clearedRange: range });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (rest === ':batchUpdate' || rest === ':batchClear') {
|
|
182
|
+
return res.json({ spreadsheetId: req.params.spreadsheetId, responses: [] });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
res.status(404).json({ error: { code: 404, message: 'Not found', status: 'NOT_FOUND' } });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// BATCH GET values — /spreadsheets/:id/values:batchGet
|
|
189
|
+
r.get(`${PREFIX}/:rest`, (req, res, next) => {
|
|
190
|
+
if (!req.params.rest.includes('/values:batchGet') && !req.params.rest.includes('/values:batchGet')) {
|
|
191
|
+
// Check if it matches the pattern spreadsheetId/values:batchGet
|
|
192
|
+
const match = req.path.match(/\/v4\/spreadsheets\/([^/]+)\/values:batchGet/);
|
|
193
|
+
if (!match) return next();
|
|
194
|
+
}
|
|
195
|
+
const match = req.path.match(/\/v4\/spreadsheets\/([^/]+)\/values/);
|
|
196
|
+
if (!match) return next();
|
|
197
|
+
const spreadsheetId = match[1];
|
|
198
|
+
const ss = getStore().sheets.spreadsheets[spreadsheetId];
|
|
199
|
+
if (!ss) {
|
|
200
|
+
return res.status(404).json({ error: { code: 404, message: 'Spreadsheet not found.', status: 'NOT_FOUND' } });
|
|
201
|
+
}
|
|
202
|
+
const ranges = (Array.isArray(req.query.ranges) ? req.query.ranges : [req.query.ranges]) as string[];
|
|
203
|
+
const valueRanges = ranges.filter(Boolean).map(range => {
|
|
204
|
+
const parsed = parseRange(range);
|
|
205
|
+
const grid = getValues(spreadsheetId, parsed.sheet);
|
|
206
|
+
const values: string[][] = [];
|
|
207
|
+
for (let r = parsed.startRow; r <= parsed.endRow; r++) {
|
|
208
|
+
const row: string[] = [];
|
|
209
|
+
for (let c = parsed.startCol; c <= parsed.endCol; c++) {
|
|
210
|
+
row.push(grid[r]?.[c] || '');
|
|
211
|
+
}
|
|
212
|
+
values.push(row);
|
|
213
|
+
}
|
|
214
|
+
return { range, majorDimension: 'ROWS', values };
|
|
215
|
+
});
|
|
216
|
+
res.json({ spreadsheetId, valueRanges });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// COPY sheet — /spreadsheets/:id/sheets/:sheetId:copyTo
|
|
220
|
+
r.post(`${PREFIX}/:spreadsheetId/sheets/:rest`, (req, res) => {
|
|
221
|
+
const ss = getStore().sheets.spreadsheets[req.params.spreadsheetId];
|
|
222
|
+
if (!ss) {
|
|
223
|
+
return res.status(404).json({ error: { code: 404, message: 'Spreadsheet not found.', status: 'NOT_FOUND' } });
|
|
224
|
+
}
|
|
225
|
+
const sheetId = parseInt(req.params.rest.replace(/:.*$/, ''));
|
|
226
|
+
const destId = req.body.destinationSpreadsheetId;
|
|
227
|
+
const destSs = getStore().sheets.spreadsheets[destId];
|
|
228
|
+
if (!destSs) {
|
|
229
|
+
return res.status(404).json({ error: { code: 404, message: 'Destination not found.', status: 'NOT_FOUND' } });
|
|
230
|
+
}
|
|
231
|
+
const sourceSheet = ss.sheets.find(s => s.properties.sheetId === sheetId);
|
|
232
|
+
if (!sourceSheet) {
|
|
233
|
+
return res.status(404).json({ error: { code: 404, message: 'Sheet not found.', status: 'NOT_FOUND' } });
|
|
234
|
+
}
|
|
235
|
+
const newSheetId = destSs.sheets.length;
|
|
236
|
+
const copy = { ...sourceSheet, properties: { ...sourceSheet.properties, sheetId: newSheetId, index: destSs.sheets.length } };
|
|
237
|
+
destSs.sheets.push(copy);
|
|
238
|
+
res.json(copy.properties);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return r;
|
|
242
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { getStore } from '../../store/index.js';
|
|
3
|
+
import { generateId } from '../../util/id.js';
|
|
4
|
+
|
|
5
|
+
export function tasksRoutes(): Router {
|
|
6
|
+
const r = Router();
|
|
7
|
+
const PREFIX = '/tasks/v1';
|
|
8
|
+
|
|
9
|
+
// === Task Lists ===
|
|
10
|
+
|
|
11
|
+
r.get(`${PREFIX}/users/@me/lists`, (_req, res) => {
|
|
12
|
+
const items = Object.values(getStore().tasks.taskLists);
|
|
13
|
+
res.json({ kind: 'tasks#taskLists', items });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
r.get(`${PREFIX}/users/@me/lists/:tasklist`, (req, res) => {
|
|
17
|
+
const tl = getStore().tasks.taskLists[req.params.tasklist];
|
|
18
|
+
if (!tl) {
|
|
19
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
20
|
+
}
|
|
21
|
+
res.json(tl);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
r.post(`${PREFIX}/users/@me/lists`, (req, res) => {
|
|
25
|
+
const store = getStore();
|
|
26
|
+
const id = generateId();
|
|
27
|
+
const tl = {
|
|
28
|
+
kind: 'tasks#taskList' as const,
|
|
29
|
+
id,
|
|
30
|
+
title: req.body.title || 'Untitled',
|
|
31
|
+
updated: new Date().toISOString(),
|
|
32
|
+
selfLink: '',
|
|
33
|
+
};
|
|
34
|
+
store.tasks.taskLists[id] = tl;
|
|
35
|
+
store.tasks.tasks[id] = {};
|
|
36
|
+
res.json(tl);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
r.patch(`${PREFIX}/users/@me/lists/:tasklist`, (req, res) => {
|
|
40
|
+
const store = getStore();
|
|
41
|
+
const tl = store.tasks.taskLists[req.params.tasklist];
|
|
42
|
+
if (!tl) {
|
|
43
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
44
|
+
}
|
|
45
|
+
Object.assign(tl, req.body, { id: tl.id, kind: tl.kind });
|
|
46
|
+
tl.updated = new Date().toISOString();
|
|
47
|
+
res.json(tl);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
r.put(`${PREFIX}/users/@me/lists/:tasklist`, (req, res) => {
|
|
51
|
+
const store = getStore();
|
|
52
|
+
if (!store.tasks.taskLists[req.params.tasklist]) {
|
|
53
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
54
|
+
}
|
|
55
|
+
store.tasks.taskLists[req.params.tasklist] = {
|
|
56
|
+
kind: 'tasks#taskList',
|
|
57
|
+
...req.body,
|
|
58
|
+
id: req.params.tasklist,
|
|
59
|
+
updated: new Date().toISOString(),
|
|
60
|
+
};
|
|
61
|
+
res.json(store.tasks.taskLists[req.params.tasklist]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
r.delete(`${PREFIX}/users/@me/lists/:tasklist`, (req, res) => {
|
|
65
|
+
const store = getStore();
|
|
66
|
+
if (!store.tasks.taskLists[req.params.tasklist]) {
|
|
67
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
68
|
+
}
|
|
69
|
+
delete store.tasks.taskLists[req.params.tasklist];
|
|
70
|
+
delete store.tasks.tasks[req.params.tasklist];
|
|
71
|
+
res.status(204).send();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// === Tasks ===
|
|
75
|
+
|
|
76
|
+
r.get(`${PREFIX}/lists/:tasklist/tasks`, (req, res) => {
|
|
77
|
+
const store = getStore();
|
|
78
|
+
const tasksMap = store.tasks.tasks[req.params.tasklist];
|
|
79
|
+
if (!tasksMap) {
|
|
80
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
81
|
+
}
|
|
82
|
+
let items = Object.values(tasksMap);
|
|
83
|
+
// Filter completed
|
|
84
|
+
if (req.query.showCompleted === 'false') {
|
|
85
|
+
items = items.filter(t => t.status !== 'completed');
|
|
86
|
+
}
|
|
87
|
+
items.sort((a, b) => a.position.localeCompare(b.position));
|
|
88
|
+
res.json({ kind: 'tasks#tasks', items });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
r.get(`${PREFIX}/lists/:tasklist/tasks/:task`, (req, res) => {
|
|
92
|
+
const task = getStore().tasks.tasks[req.params.tasklist]?.[req.params.task];
|
|
93
|
+
if (!task) {
|
|
94
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
95
|
+
}
|
|
96
|
+
res.json(task);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
r.post(`${PREFIX}/lists/:tasklist/tasks`, (req, res) => {
|
|
100
|
+
const store = getStore();
|
|
101
|
+
if (!store.tasks.tasks[req.params.tasklist]) {
|
|
102
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
103
|
+
}
|
|
104
|
+
const id = generateId();
|
|
105
|
+
const existing = Object.values(store.tasks.tasks[req.params.tasklist]);
|
|
106
|
+
const maxPos = existing.length > 0
|
|
107
|
+
? Math.max(...existing.map(t => parseInt(t.position) || 0))
|
|
108
|
+
: 0;
|
|
109
|
+
const task = {
|
|
110
|
+
kind: 'tasks#task' as const,
|
|
111
|
+
id,
|
|
112
|
+
title: req.body.title || '',
|
|
113
|
+
updated: new Date().toISOString(),
|
|
114
|
+
selfLink: '',
|
|
115
|
+
status: (req.body.status || 'needsAction') as 'needsAction',
|
|
116
|
+
due: req.body.due,
|
|
117
|
+
notes: req.body.notes,
|
|
118
|
+
parent: req.body.parent,
|
|
119
|
+
position: String(maxPos + 1).padStart(20, '0'),
|
|
120
|
+
};
|
|
121
|
+
store.tasks.tasks[req.params.tasklist][id] = task;
|
|
122
|
+
res.json(task);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
r.patch(`${PREFIX}/lists/:tasklist/tasks/:task`, (req, res) => {
|
|
126
|
+
const store = getStore();
|
|
127
|
+
const task = store.tasks.tasks[req.params.tasklist]?.[req.params.task];
|
|
128
|
+
if (!task) {
|
|
129
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
130
|
+
}
|
|
131
|
+
Object.assign(task, req.body, { id: task.id, kind: task.kind });
|
|
132
|
+
task.updated = new Date().toISOString();
|
|
133
|
+
if (req.body.status === 'completed' && !task.completed) {
|
|
134
|
+
task.completed = new Date().toISOString();
|
|
135
|
+
}
|
|
136
|
+
res.json(task);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
r.put(`${PREFIX}/lists/:tasklist/tasks/:task`, (req, res) => {
|
|
140
|
+
const store = getStore();
|
|
141
|
+
if (!store.tasks.tasks[req.params.tasklist]?.[req.params.task]) {
|
|
142
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
143
|
+
}
|
|
144
|
+
const task = {
|
|
145
|
+
kind: 'tasks#task' as const,
|
|
146
|
+
...req.body,
|
|
147
|
+
id: req.params.task,
|
|
148
|
+
updated: new Date().toISOString(),
|
|
149
|
+
};
|
|
150
|
+
store.tasks.tasks[req.params.tasklist][req.params.task] = task;
|
|
151
|
+
res.json(task);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
r.delete(`${PREFIX}/lists/:tasklist/tasks/:task`, (req, res) => {
|
|
155
|
+
const store = getStore();
|
|
156
|
+
if (!store.tasks.tasks[req.params.tasklist]?.[req.params.task]) {
|
|
157
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
158
|
+
}
|
|
159
|
+
delete store.tasks.tasks[req.params.tasklist][req.params.task];
|
|
160
|
+
res.status(204).send();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// MOVE task
|
|
164
|
+
r.post(`${PREFIX}/lists/:tasklist/tasks/:task/move`, (req, res) => {
|
|
165
|
+
const store = getStore();
|
|
166
|
+
const task = store.tasks.tasks[req.params.tasklist]?.[req.params.task];
|
|
167
|
+
if (!task) {
|
|
168
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
169
|
+
}
|
|
170
|
+
if (req.query.parent) task.parent = req.query.parent as string;
|
|
171
|
+
task.updated = new Date().toISOString();
|
|
172
|
+
res.json(task);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// CLEAR completed tasks
|
|
176
|
+
r.post(`${PREFIX}/lists/:tasklist/clear`, (req, res) => {
|
|
177
|
+
const store = getStore();
|
|
178
|
+
const tasksMap = store.tasks.tasks[req.params.tasklist];
|
|
179
|
+
if (!tasksMap) {
|
|
180
|
+
return res.status(404).json({ error: { code: 404, message: 'Not Found', status: 'NOT_FOUND' } });
|
|
181
|
+
}
|
|
182
|
+
for (const [id, task] of Object.entries(tasksMap)) {
|
|
183
|
+
if (task.status === 'completed') {
|
|
184
|
+
delete tasksMap[id];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
res.status(204).send();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return r;
|
|
191
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { FwsStore } from './types.js';
|
|
2
|
+
import { createSeedStore } from './seed.js';
|
|
3
|
+
|
|
4
|
+
let store: FwsStore = createSeedStore();
|
|
5
|
+
|
|
6
|
+
export function getStore(): FwsStore {
|
|
7
|
+
return store;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function resetStore(): void {
|
|
11
|
+
store = createSeedStore();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function loadStore(data: FwsStore): void {
|
|
15
|
+
store = data;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function serializeStore(): string {
|
|
19
|
+
return JSON.stringify(store, null, 2);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function deserializeStore(json: string): FwsStore {
|
|
23
|
+
return JSON.parse(json) as FwsStore;
|
|
24
|
+
}
|