@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.
@@ -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
+ }