@goodandready/dsh-task-tracker 0.0.0-stage → 0.2.14
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/CHANGELOG.md +191 -0
- package/README.md +68 -2
- package/README.ru.md +100 -0
- package/README.zh.md +69 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +2002 -0
- package/lib/index.js +136 -0
- package/lib/routes.js +454 -0
- package/lib/storage.js +472 -0
- package/lib/updater.js +376 -0
- package/package.json +58 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// dsh-task-tracker — автономный встроенный todo-инбокс для Ди (Ди + DeepSeek Harness).
|
|
2
|
+
//
|
|
3
|
+
// Хранилище задач: локальный tasks.json (YAGNI, standard node:fs, атомарная запись).
|
|
4
|
+
// Три тула для агента:
|
|
5
|
+
// todo_add — создать задачу
|
|
6
|
+
// todo_list — список задач (с опциональным фильтром по статусу)
|
|
7
|
+
// todo_done — отметить задачу выполненной (status=completed)
|
|
8
|
+
//
|
|
9
|
+
// REST API для веб-интерфейса регистрируется через ctx.webServer.register.
|
|
10
|
+
|
|
11
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
12
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
13
|
+
import { TaskStore } from './storage.js';
|
|
14
|
+
import { registerRoutes } from './routes.js';
|
|
15
|
+
import { registerPluginUpdater } from './updater.js';
|
|
16
|
+
|
|
17
|
+
export const name = 'dsh-task-tracker';
|
|
18
|
+
export const inject = ['tools', 'webServer'];
|
|
19
|
+
|
|
20
|
+
function unwrapValue(val) {
|
|
21
|
+
if (val == null) return '';
|
|
22
|
+
if (typeof val === 'string') return val.trim();
|
|
23
|
+
if (typeof val === 'object') {
|
|
24
|
+
if (typeof val.value === 'string') return val.value.trim();
|
|
25
|
+
if (typeof val.valueOf === 'function') {
|
|
26
|
+
const v = val.valueOf();
|
|
27
|
+
if (typeof v === 'string') return v.trim();
|
|
28
|
+
}
|
|
29
|
+
if (typeof val.toString === 'function') {
|
|
30
|
+
const s = String(val);
|
|
31
|
+
if (s && s !== '[object Object]') return s.trim();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const Config = Schema.object({
|
|
38
|
+
/** Путь к файлу tasks.json. Пусто = значение по умолчанию (~/.dsh/task-tracker/tasks.json). */
|
|
39
|
+
filePath: Schema.string().default('').volatile(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export function apply(ctx, config = {}) {
|
|
43
|
+
const resolvedPath = unwrapValue(config?.filePath);
|
|
44
|
+
let store = new TaskStore({ filePath: resolvedPath });
|
|
45
|
+
|
|
46
|
+
const render = (_a, v) => [{ type: 'text', text: String(v ?? 'ok') }];
|
|
47
|
+
|
|
48
|
+
const register = (name_, params, desc, exec) =>
|
|
49
|
+
ctx.tools.register(defineTool({
|
|
50
|
+
name: name_,
|
|
51
|
+
description: desc,
|
|
52
|
+
parameters: params,
|
|
53
|
+
output: { schema: { type: 'string' }, render },
|
|
54
|
+
async execute(args, execCtx) {
|
|
55
|
+
return await exec(args, execCtx);
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
const setup = () => {
|
|
60
|
+
const disposers = [];
|
|
61
|
+
|
|
62
|
+
if (ctx.webServer && typeof ctx.webServer.register === 'function') {
|
|
63
|
+
const unregRoutes = registerRoutes(ctx, store);
|
|
64
|
+
if (typeof unregRoutes === 'function') disposers.push(unregRoutes);
|
|
65
|
+
const unregUpdater = registerPluginUpdater(ctx, {
|
|
66
|
+
endpoint: '/dsh-task-tracker/api/updater',
|
|
67
|
+
packageName: '@goodandready/dsh-task-tracker',
|
|
68
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
69
|
+
});
|
|
70
|
+
if (typeof unregUpdater === 'function') disposers.push(unregUpdater);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (ctx.tools && typeof ctx.tools.register === 'function') {
|
|
74
|
+
disposers.push(
|
|
75
|
+
register('todo_add',
|
|
76
|
+
{
|
|
77
|
+
title: { type: 'string', required: true, description: 'Заголовок задачи.' },
|
|
78
|
+
description: { type: 'string', description: 'Подробности (несколько строк).' },
|
|
79
|
+
priority: { type: 'integer', description: 'Приоритет (0 default).' },
|
|
80
|
+
folder: { type: 'string', description: 'Папка / область (по умолчанию Inbox).' },
|
|
81
|
+
},
|
|
82
|
+
'Создать задачу в todo-инбоксе трекера. Использовать для инцидентов, кронов, изучения и идей.',
|
|
83
|
+
async (args) => {
|
|
84
|
+
const t = store.create({
|
|
85
|
+
title: args.title,
|
|
86
|
+
description: args.description,
|
|
87
|
+
priority: args.priority,
|
|
88
|
+
folder: args.folder,
|
|
89
|
+
});
|
|
90
|
+
return `✅ Задача создана: ${t.id} — ${t.title}${t.folder ? ` [${t.folder}]` : ''}`;
|
|
91
|
+
}),
|
|
92
|
+
register('todo_list',
|
|
93
|
+
{
|
|
94
|
+
status: { type: 'string', description: 'Фильтр по статусу (например pending, completed). Пусто = все.' },
|
|
95
|
+
folder: { type: 'string', description: 'Фильтр по папке/области (например Inbox). Пусто = все.' },
|
|
96
|
+
limit: { type: 'integer', description: 'Максимум строк (по умолчанию 30).' },
|
|
97
|
+
},
|
|
98
|
+
'Список задач из todo-инбокса (task-tracker).',
|
|
99
|
+
async (args) => {
|
|
100
|
+
const items = store.list({ status: args.status, folder: args.folder, limit: args.limit ?? 30 });
|
|
101
|
+
if (!items || items.length === 0) return '(todo пусто)';
|
|
102
|
+
return items.map((t) =>
|
|
103
|
+
`${t.status === 'completed' ? '✅' : '⬜'} [${t.id}] ${t.title}${t.status !== 'completed' ? ` ${t.status}` : ''}`,
|
|
104
|
+
).join('\n');
|
|
105
|
+
}),
|
|
106
|
+
register('todo_done',
|
|
107
|
+
{ id: { type: 'string', required: true, description: 'id задачи (из todo_list).' } },
|
|
108
|
+
'Отметить задачу в todo-инбоксе выполненной (status=completed).',
|
|
109
|
+
async (args) => {
|
|
110
|
+
const updated = store.update(args.id, { status: 'completed' });
|
|
111
|
+
if (!updated) {
|
|
112
|
+
return `❌ Задача ${args.id} не найдена.`;
|
|
113
|
+
}
|
|
114
|
+
return `✅ Задача ${args.id} отмечена выполненной.`;
|
|
115
|
+
})
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return () => {
|
|
120
|
+
for (const dispose of disposers) {
|
|
121
|
+
if (typeof dispose === 'function') {
|
|
122
|
+
try {
|
|
123
|
+
dispose();
|
|
124
|
+
} catch (_) {
|
|
125
|
+
/* bestEffort */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (typeof ctx.effect === 'function') {
|
|
133
|
+
return ctx.effect(setup);
|
|
134
|
+
}
|
|
135
|
+
return setup();
|
|
136
|
+
}
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
export function sanitizeErrorMessage(err) {
|
|
2
|
+
if (!err) return 'Internal server error';
|
|
3
|
+
const rawMsg = typeof err === 'string' ? err : String(err.message || '');
|
|
4
|
+
if (!rawMsg) return 'Internal server error';
|
|
5
|
+
return rawMsg
|
|
6
|
+
.replace(/(?:[a-zA-Z]:\\|[a-zA-Z]:\/|\/)[^\s:;"']+/g, '<path>')
|
|
7
|
+
.trim() || 'Internal server error';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// HTTP API маршруты для dsh-task-tracker
|
|
11
|
+
//
|
|
12
|
+
// Браузерный клиент TasksScreen общается с сервером через эти маршруты.
|
|
13
|
+
// Запросы обслуживаются из локального TaskStore (tasks.json).
|
|
14
|
+
|
|
15
|
+
export const MAX_BODY_BYTES = 256 * 1024;
|
|
16
|
+
|
|
17
|
+
export function isLoopbackAddress(value) {
|
|
18
|
+
const address = String(value || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
19
|
+
return (
|
|
20
|
+
address === '127.0.0.1' ||
|
|
21
|
+
address === '::1' ||
|
|
22
|
+
address === 'localhost' ||
|
|
23
|
+
address.startsWith('127.') ||
|
|
24
|
+
address.startsWith('::ffff:127.')
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isPrivateLanAddress(value) {
|
|
29
|
+
const address = String(value || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
30
|
+
if (!address) return false;
|
|
31
|
+
const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address;
|
|
32
|
+
return (
|
|
33
|
+
ipv4.startsWith('192.168.') ||
|
|
34
|
+
ipv4.startsWith('10.') ||
|
|
35
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ipv4)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getConnection(ctx) {
|
|
40
|
+
try {
|
|
41
|
+
const fromCtx = ctx?.reflect?.get
|
|
42
|
+
? (ctx.reflect.get('connection') || ctx?.connection)
|
|
43
|
+
: (ctx?.get ? (ctx.get('connection') || ctx?.connection) : Reflect.get(ctx || {}, 'connection'));
|
|
44
|
+
if (fromCtx) return fromCtx;
|
|
45
|
+
if (ctx?.root) {
|
|
46
|
+
return getConnection(ctx.root);
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function checkRequestAuthorization(req, ctx = null) {
|
|
55
|
+
const remote = req.socket?.remoteAddress || req.connection?.remoteAddress;
|
|
56
|
+
const isLoopback = isLoopbackAddress(remote);
|
|
57
|
+
const isLan = isPrivateLanAddress(remote);
|
|
58
|
+
|
|
59
|
+
// #72: Never skip address check. Non-loopback, non-LAN callers (e.g. public IPs) are rejected unconditionally.
|
|
60
|
+
if (!isLoopback && !isLan) {
|
|
61
|
+
return { status: 403, error: 'Forbidden: remote address not allowed' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// #73: Core DSH connection auth gate. Remote callers must be authenticated by host connection service.
|
|
65
|
+
const conn = getConnection(ctx) || req?.connectionService;
|
|
66
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
67
|
+
let rejection;
|
|
68
|
+
try {
|
|
69
|
+
rejection = conn.requestRejection(req);
|
|
70
|
+
} catch {
|
|
71
|
+
rejection = 403;
|
|
72
|
+
}
|
|
73
|
+
if (rejection !== undefined) {
|
|
74
|
+
return {
|
|
75
|
+
status: rejection,
|
|
76
|
+
error: rejection === 401 ? 'Authentication required' : 'Forbidden',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const hasConnectionAuth = Boolean(conn && typeof conn.requestRejection === 'function');
|
|
82
|
+
|
|
83
|
+
// #72 & #73: Non-loopback callers without connection authentication cannot prove session legitimacy
|
|
84
|
+
if (!isLoopback && !hasConnectionAuth) {
|
|
85
|
+
return {
|
|
86
|
+
status: 401,
|
|
87
|
+
error: 'Authentication required: remote access requires authentication',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const headers = req.headers || {};
|
|
92
|
+
const site = headers['sec-fetch-site'];
|
|
93
|
+
if (site === 'cross-site') {
|
|
94
|
+
return { status: 403, error: 'Forbidden: cross-origin request rejected' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const method = String(req.method || 'GET').toUpperCase();
|
|
98
|
+
const isMutating = method === 'POST' || method === 'PATCH' || method === 'DELETE' || method === 'PUT';
|
|
99
|
+
|
|
100
|
+
// #61: Sec-Fetch-Site: none must not bypass mutating route authorization for remote callers
|
|
101
|
+
if (isMutating && site === 'none' && !isLoopback) {
|
|
102
|
+
return { status: 403, error: 'Forbidden: invalid fetch metadata' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const origin = headers.origin;
|
|
106
|
+
const host = headers.host || '';
|
|
107
|
+
|
|
108
|
+
if (origin !== undefined) {
|
|
109
|
+
if (origin === 'null') {
|
|
110
|
+
return { status: 403, error: 'Forbidden: null origin rejected' };
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const parsed = new URL(origin);
|
|
114
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
115
|
+
return { status: 403, error: 'Forbidden: invalid origin scheme' };
|
|
116
|
+
}
|
|
117
|
+
if (!host || parsed.host.toLowerCase() !== host.toLowerCase()) {
|
|
118
|
+
if (!isLoopback || !isLoopbackAddress(parsed.hostname)) {
|
|
119
|
+
return { status: 403, error: 'Forbidden: origin mismatch' };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
return { status: 403, error: 'Forbidden: malformed origin' };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!isLoopback && isMutating && !origin) {
|
|
128
|
+
const referer = headers.referer;
|
|
129
|
+
if (!referer) {
|
|
130
|
+
return { status: 403, error: 'Forbidden: missing origin or referer' };
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const parsedRef = new URL(referer);
|
|
134
|
+
if (!host || parsedRef.host.toLowerCase() !== host.toLowerCase()) {
|
|
135
|
+
return { status: 403, error: 'Forbidden: referer mismatch' };
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
return { status: 403, error: 'Forbidden: malformed referer' };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function isAllowedRequest(req, ctx = null) {
|
|
146
|
+
return checkRequestAuthorization(req, ctx) === null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function safeDecode(value) {
|
|
150
|
+
try {
|
|
151
|
+
return decodeURIComponent(value);
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function writeJson(res, statusCode, data) {
|
|
158
|
+
try {
|
|
159
|
+
res.writeHead(statusCode, {
|
|
160
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
161
|
+
'Cache-Control': 'no-store',
|
|
162
|
+
});
|
|
163
|
+
res.end(JSON.stringify(data));
|
|
164
|
+
} catch {
|
|
165
|
+
/* socket closed */
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function parseJsonBody(req, limit = MAX_BODY_BYTES) {
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
const chunks = [];
|
|
172
|
+
let bytes = 0;
|
|
173
|
+
let settled = false;
|
|
174
|
+
|
|
175
|
+
const fail = (err) => {
|
|
176
|
+
if (settled) return;
|
|
177
|
+
settled = true;
|
|
178
|
+
reject(err);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
req.on('data', (chunk) => {
|
|
182
|
+
if (settled) return;
|
|
183
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
184
|
+
bytes += buf.length;
|
|
185
|
+
if (bytes > limit) {
|
|
186
|
+
fail(new Error('Request body exceeds limit'));
|
|
187
|
+
req.destroy();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
chunks.push(buf);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
req.on('end', () => {
|
|
194
|
+
if (settled) return;
|
|
195
|
+
settled = true;
|
|
196
|
+
if (chunks.length === 0) return resolve({});
|
|
197
|
+
try {
|
|
198
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
199
|
+
resolve(text.trim() ? JSON.parse(text) : {});
|
|
200
|
+
} catch (err) {
|
|
201
|
+
reject(new Error('Invalid JSON'));
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
req.on('error', fail);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Обработчик маршрута /dsh-task-tracker/api/tasks[...]
|
|
211
|
+
*/
|
|
212
|
+
export async function handleTasksApi(req, res, store, ctx = null) {
|
|
213
|
+
const authErr = checkRequestAuthorization(req, ctx);
|
|
214
|
+
if (authErr) {
|
|
215
|
+
writeJson(res, authErr.status, { error: authErr.error });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
220
|
+
const pathname = url.pathname;
|
|
221
|
+
// Route prefix: /dsh-task-tracker/api/tasks
|
|
222
|
+
// Subpath after prefix:
|
|
223
|
+
const subpath = pathname.replace(/^\/dsh-task-tracker\/api\/tasks/, '').replace(/^\//, '');
|
|
224
|
+
|
|
225
|
+
if (subpath === 'reset' && req.method === 'POST') {
|
|
226
|
+
const remote = req.socket?.remoteAddress || req.connection?.remoteAddress;
|
|
227
|
+
if (!isLoopbackAddress(remote)) {
|
|
228
|
+
writeJson(res, 403, { error: 'Forbidden: storage reset is only permitted from loopback' });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
store.resetStorage();
|
|
232
|
+
writeJson(res, 200, { message: 'Storage reset successfully', quarantine: store.quarantinePath });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (store.corrupted) {
|
|
237
|
+
writeJson(res, 503, {
|
|
238
|
+
error: 'Storage corrupted and quarantined',
|
|
239
|
+
quarantine: store.quarantinePath,
|
|
240
|
+
message: 'Storage file was invalid and has been safely quarantined. Call POST /api/tasks/reset to reinitialize.',
|
|
241
|
+
});
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
if (req.method === 'GET') {
|
|
247
|
+
if (subpath === 'stats') {
|
|
248
|
+
const stats = store.stats();
|
|
249
|
+
writeJson(res, 200, stats);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (subpath === 'folders') {
|
|
254
|
+
const withDetails = url.searchParams.get('details') === '1' || url.searchParams.get('format') === 'details';
|
|
255
|
+
if (withDetails) {
|
|
256
|
+
const details = store.getFolders();
|
|
257
|
+
const folders = store.listFolders();
|
|
258
|
+
writeJson(res, 200, { folders, details });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const folders = store.listFolders();
|
|
262
|
+
writeJson(res, 200, folders);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (subpath && subpath !== '') {
|
|
267
|
+
const taskId = safeDecode(subpath);
|
|
268
|
+
if (taskId === null) {
|
|
269
|
+
writeJson(res, 400, { error: 'Invalid URL encoding' });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const task = store.get(taskId);
|
|
273
|
+
if (!task) {
|
|
274
|
+
writeJson(res, 404, { error: 'Task not found' });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
writeJson(res, 200, task);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const status = url.searchParams.get('status') || undefined;
|
|
282
|
+
const folder = url.searchParams.get('folder') || undefined;
|
|
283
|
+
const query = url.searchParams.get('query') || undefined;
|
|
284
|
+
|
|
285
|
+
let limit = undefined;
|
|
286
|
+
if (url.searchParams.has('limit')) {
|
|
287
|
+
const rawLimit = url.searchParams.get('limit');
|
|
288
|
+
const parsed = Number(rawLimit);
|
|
289
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
290
|
+
writeJson(res, 400, { error: 'Invalid limit parameter: must be a non-negative integer' });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
limit = parsed;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let offset = undefined;
|
|
297
|
+
if (url.searchParams.has('offset')) {
|
|
298
|
+
const rawOffset = url.searchParams.get('offset');
|
|
299
|
+
const parsed = Number(rawOffset);
|
|
300
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
301
|
+
writeJson(res, 400, { error: 'Invalid offset parameter: must be a non-negative integer' });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
offset = parsed;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const tasks = store.list({ status, folder, query, limit, offset });
|
|
308
|
+
writeJson(res, 200, tasks);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (req.method === 'POST') {
|
|
313
|
+
if (subpath === 'folders') {
|
|
314
|
+
const body = await parseJsonBody(req);
|
|
315
|
+
if (!body.name || !String(body.name).trim()) {
|
|
316
|
+
writeJson(res, 400, { error: 'Folder name is required' });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const folders = store.addFolder(body.name, body.icon);
|
|
320
|
+
const details = store.getFolders();
|
|
321
|
+
writeJson(res, 201, { folders, details });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (subpath && subpath !== '') {
|
|
326
|
+
writeJson(res, 404, { error: 'Not found' });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const body = await parseJsonBody(req);
|
|
331
|
+
if (!body.title || !String(body.title).trim()) {
|
|
332
|
+
writeJson(res, 400, { error: 'Title is required' });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const task = store.create({
|
|
337
|
+
title: body.title,
|
|
338
|
+
description: body.description,
|
|
339
|
+
priority: body.priority,
|
|
340
|
+
status: body.status,
|
|
341
|
+
folder: body.folder,
|
|
342
|
+
author: body.author || 'User',
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
writeJson(res, 201, task);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (req.method === 'PATCH') {
|
|
350
|
+
if (!subpath) {
|
|
351
|
+
writeJson(res, 400, { error: 'ID required' });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (subpath.startsWith('folders/')) {
|
|
356
|
+
const folderName = safeDecode(subpath.slice('folders/'.length));
|
|
357
|
+
if (folderName === null) {
|
|
358
|
+
writeJson(res, 400, { error: 'Invalid URL encoding' });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const body = await parseJsonBody(req);
|
|
362
|
+
const updated = store.updateFolder(folderName, body);
|
|
363
|
+
if (!updated) {
|
|
364
|
+
writeJson(res, 404, { error: 'Folder not found' });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
writeJson(res, 200, { folder: updated, folders: store.listFolders(), details: store.getFolders() });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const taskId = safeDecode(subpath);
|
|
372
|
+
if (taskId === null) {
|
|
373
|
+
writeJson(res, 400, { error: 'Invalid URL encoding' });
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const body = await parseJsonBody(req);
|
|
378
|
+
const updated = store.update(taskId, body);
|
|
379
|
+
if (!updated) {
|
|
380
|
+
writeJson(res, 404, { error: 'Task not found' });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
writeJson(res, 200, updated);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (req.method === 'DELETE') {
|
|
389
|
+
if (!subpath) {
|
|
390
|
+
writeJson(res, 400, { error: 'ID required' });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (subpath.startsWith('folders/')) {
|
|
395
|
+
const folderName = safeDecode(subpath.slice('folders/'.length));
|
|
396
|
+
if (folderName === null) {
|
|
397
|
+
writeJson(res, 400, { error: 'Invalid URL encoding' });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const ok = store.removeFolder(folderName);
|
|
401
|
+
writeJson(res, 200, { ok, folders: store.listFolders(), details: store.getFolders() });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const taskId = safeDecode(subpath);
|
|
406
|
+
if (taskId === null) {
|
|
407
|
+
writeJson(res, 400, { error: 'Invalid URL encoding' });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const deleted = store.delete(taskId);
|
|
412
|
+
if (!deleted) {
|
|
413
|
+
writeJson(res, 404, { error: 'Task not found' });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
writeJson(res, 200, { ok: true });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
writeJson(res, 405, { error: 'Method not allowed' });
|
|
422
|
+
} catch (err) {
|
|
423
|
+
if (err && (err.message === 'Title is required' || err.message === 'Title cannot be empty' || err.message === 'Folder name is required')) {
|
|
424
|
+
writeJson(res, 400, { error: err.message });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const safeError = sanitizeErrorMessage(err);
|
|
428
|
+
writeJson(res, 500, { error: safeError });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function registerRoutes(ctx, store) {
|
|
433
|
+
if (!ctx.webServer || typeof ctx.webServer.register !== 'function') {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const handler = (req, res) => handleTasksApi(req, res, store, ctx);
|
|
438
|
+
|
|
439
|
+
if (typeof ctx.effect === 'function') {
|
|
440
|
+
return ctx.effect(() => {
|
|
441
|
+
return ctx.webServer.register({
|
|
442
|
+
kind: 'prefix',
|
|
443
|
+
path: '/dsh-task-tracker/api/tasks',
|
|
444
|
+
handler,
|
|
445
|
+
});
|
|
446
|
+
}, 'dsh-task-tracker: /api/tasks');
|
|
447
|
+
} else {
|
|
448
|
+
ctx.webServer.register({
|
|
449
|
+
kind: 'prefix',
|
|
450
|
+
path: '/dsh-task-tracker/api/tasks',
|
|
451
|
+
handler,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|