taskify-nostr 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,888 @@
1
+ import NDK, { NDKEvent, NDKPrivateKeySigner, NDKRelayStatus } from "@nostr-dev-kit/ndk";
2
+ import { sha256 } from "@noble/hashes/sha256";
3
+ import { bytesToHex } from "@noble/hashes/utils";
4
+ import { getPublicKey, nip19 } from "nostr-tools";
5
+ import { saveConfig, loadConfig } from "./config.js";
6
+ import { readCache, writeCache, isCacheFresh } from "./taskCache.js";
7
+ function nowISO() {
8
+ return new Date().toISOString();
9
+ }
10
+ // ---- Internal helpers (not exported) ----
11
+ function boardTagHash(boardId) {
12
+ return bytesToHex(sha256(new TextEncoder().encode(boardId)));
13
+ }
14
+ function deriveBoardKeys(boardId) {
15
+ const label = new TextEncoder().encode("taskify-board-nostr-key-v1");
16
+ const id = new TextEncoder().encode(boardId);
17
+ const material = new Uint8Array(label.length + id.length);
18
+ material.set(label, 0);
19
+ material.set(id, label.length);
20
+ const sk = sha256(material);
21
+ const skHex = bytesToHex(sk);
22
+ const pk = getPublicKey(sk);
23
+ return { sk, skHex, pk, signer: new NDKPrivateKeySigner(skHex) };
24
+ }
25
+ async function deriveAESKey(boardId) {
26
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(boardId));
27
+ return crypto.subtle.importKey("raw", digest, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
28
+ }
29
+ async function encryptContent(boardId, plaintext) {
30
+ const key = await deriveAESKey(boardId);
31
+ const iv = crypto.getRandomValues(new Uint8Array(12));
32
+ const pt = new TextEncoder().encode(plaintext);
33
+ const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, pt);
34
+ const result = new Uint8Array(12 + ct.byteLength);
35
+ result.set(iv, 0);
36
+ result.set(new Uint8Array(ct), 12);
37
+ return Buffer.from(result).toString("base64");
38
+ }
39
+ async function decryptContent(boardId, data) {
40
+ const key = await deriveAESKey(boardId);
41
+ const bytes = Buffer.from(data, "base64");
42
+ const iv = bytes.subarray(0, 12);
43
+ const ct = bytes.subarray(12);
44
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
45
+ return new TextDecoder().decode(pt);
46
+ }
47
+ function getUserPubkeyHex(config) {
48
+ if (!config.nsec)
49
+ return undefined;
50
+ try {
51
+ const decoded = nip19.decode(config.nsec);
52
+ if (decoded.type === "nsec") {
53
+ return getPublicKey(decoded.data);
54
+ }
55
+ }
56
+ catch {
57
+ // ignore
58
+ }
59
+ return undefined;
60
+ }
61
+ function validateEventCompat(event) {
62
+ if (event.kind !== 30301)
63
+ return false;
64
+ const hasD = event.tags.some((t) => t[0] === "d");
65
+ const hasB = event.tags.some((t) => t[0] === "b");
66
+ if (!hasD || !hasB)
67
+ return false;
68
+ if (!event.content)
69
+ return false;
70
+ return true;
71
+ }
72
+ function resolveBoardEntry(config, boardIdOrName) {
73
+ // Exact UUID match first
74
+ let entry = config.boards.find((b) => b.id === boardIdOrName);
75
+ if (entry)
76
+ return entry;
77
+ // Case-insensitive name match
78
+ const lower = boardIdOrName.toLowerCase();
79
+ entry = config.boards.find((b) => b.name.toLowerCase() === lower);
80
+ return entry ?? null;
81
+ }
82
+ // ---- Cache conversion helpers ----
83
+ function recordToCache(r) {
84
+ return {
85
+ id: r.id,
86
+ title: r.title,
87
+ boardId: r.boardId,
88
+ boardName: r.boardName,
89
+ status: r.completed ? "done" : "open",
90
+ updatedAt: r.createdAt,
91
+ note: r.note,
92
+ dueISO: r.dueISO,
93
+ dueDateEnabled: r.dueDateEnabled,
94
+ dueTimeEnabled: r.dueTimeEnabled,
95
+ priority: r.priority,
96
+ completed: r.completed,
97
+ completedAt: r.completedAt,
98
+ createdAt: r.createdAt,
99
+ createdBy: r.createdBy,
100
+ lastEditedBy: r.lastEditedBy,
101
+ column: r.column,
102
+ subtasks: r.subtasks,
103
+ recurrence: r.recurrence,
104
+ bounty: r.bounty,
105
+ reminders: r.reminders,
106
+ inboxItem: r.inboxItem,
107
+ assignees: r.assignees,
108
+ };
109
+ }
110
+ function cacheToRecord(t, boardName) {
111
+ return {
112
+ id: t.id,
113
+ boardId: t.boardId,
114
+ boardName: t.boardName ?? boardName,
115
+ title: t.title,
116
+ note: t.note,
117
+ dueISO: t.dueISO ?? "",
118
+ dueDateEnabled: t.dueDateEnabled,
119
+ dueTimeEnabled: t.dueTimeEnabled,
120
+ priority: t.priority,
121
+ completed: t.status === "done",
122
+ completedAt: t.completedAt,
123
+ createdAt: t.createdAt,
124
+ updatedAt: t.updatedAt
125
+ ? new Date(t.updatedAt * 1000).toISOString()
126
+ : undefined,
127
+ createdBy: t.createdBy,
128
+ lastEditedBy: t.lastEditedBy,
129
+ column: t.column,
130
+ subtasks: t.subtasks,
131
+ recurrence: t.recurrence,
132
+ bounty: t.bounty,
133
+ reminders: t.reminders,
134
+ inboxItem: t.inboxItem,
135
+ assignees: t.assignees,
136
+ };
137
+ }
138
+ // ---- Event parsing ----
139
+ async function parseDecryptedEvent(event, boardId, boardName) {
140
+ if (!validateEventCompat(event))
141
+ return null;
142
+ try {
143
+ const plaintext = await decryptContent(boardId, event.content);
144
+ const payload = JSON.parse(plaintext);
145
+ const dTag = event.tags.find((t) => t[0] === "d");
146
+ const taskId = dTag?.[1] ?? "";
147
+ if (!taskId)
148
+ return null;
149
+ const statusTag = event.tags.find((t) => t[0] === "status");
150
+ const statusVal = statusTag?.[1] ?? "open";
151
+ const completed = statusVal === "done";
152
+ const colTag = event.tags.find((t) => t[0] === "col");
153
+ const column = colTag?.[1] || undefined;
154
+ return {
155
+ id: taskId,
156
+ boardId,
157
+ boardName,
158
+ title: payload.title ?? "",
159
+ note: payload.note || undefined,
160
+ dueISO: payload.dueISO ?? "",
161
+ dueDateEnabled: payload.dueDateEnabled ?? undefined,
162
+ dueTimeEnabled: payload.dueTimeEnabled ?? undefined,
163
+ priority: payload.priority ?? undefined,
164
+ completed,
165
+ completedAt: payload.completedAt ?? undefined,
166
+ // payload.createdAt is ms; convert to seconds for render compat
167
+ createdAt: payload.createdAt
168
+ ? Math.floor(payload.createdAt / 1000)
169
+ : event.created_at,
170
+ updatedAt: event.created_at
171
+ ? new Date(event.created_at * 1000).toISOString()
172
+ : undefined,
173
+ createdBy: payload.createdBy,
174
+ lastEditedBy: payload.lastEditedBy,
175
+ recurrence: payload.recurrence ?? undefined,
176
+ subtasks: payload.subtasks ?? undefined,
177
+ bounty: payload.bounty ?? undefined,
178
+ column,
179
+ inboxItem: payload.inboxItem === true ? true : undefined,
180
+ assignees: Array.isArray(payload.assignees) && payload.assignees.length > 0
181
+ ? payload.assignees.map((a) => typeof a === "string" ? a : a.pubkey ?? "")
182
+ .filter(Boolean)
183
+ : undefined,
184
+ };
185
+ }
186
+ catch {
187
+ return null;
188
+ }
189
+ }
190
+ // ---- Runtime factory ----
191
+ export function createNostrRuntime(config) {
192
+ const ndk = new NDK({
193
+ explicitRelayUrls: config.relays,
194
+ });
195
+ let connected = false;
196
+ async function ensureConnected() {
197
+ if (!connected) {
198
+ await Promise.race([
199
+ ndk.connect(),
200
+ new Promise((resolve) => setTimeout(() => {
201
+ process.stderr.write("⚠ Relay connection slow — continuing with available relays\n");
202
+ resolve();
203
+ }, 8000)),
204
+ ]);
205
+ connected = true;
206
+ }
207
+ }
208
+ async function fetchBoardEvents(boardId, taskId) {
209
+ const bTag = boardTagHash(boardId);
210
+ const filter = {
211
+ kinds: [30301],
212
+ "#b": [bTag],
213
+ limit: taskId ? undefined : 500,
214
+ };
215
+ if (taskId)
216
+ filter["#d"] = [taskId];
217
+ let hardTimer;
218
+ return new Promise((resolve) => {
219
+ const collected = new Set();
220
+ let graceTimer = null;
221
+ let settled = false;
222
+ const HARD_TIMEOUT_MS = taskId ? 10_000 : 15_000;
223
+ const EOSE_GRACE_MS = 200;
224
+ const settle = () => {
225
+ if (settled)
226
+ return;
227
+ settled = true;
228
+ if (graceTimer)
229
+ clearTimeout(graceTimer);
230
+ if (hardTimer)
231
+ clearTimeout(hardTimer);
232
+ try {
233
+ sub.stop();
234
+ }
235
+ catch { /* ignore */ }
236
+ resolve(collected);
237
+ };
238
+ hardTimer = setTimeout(settle, HARD_TIMEOUT_MS);
239
+ const sub = ndk.subscribe(filter, { closeOnEose: false });
240
+ sub.on("event", (evt) => {
241
+ if (!settled)
242
+ collected.add(evt);
243
+ });
244
+ sub.on("eose", () => {
245
+ // First EOSE received — start grace window if not already started
246
+ if (!graceTimer && !settled) {
247
+ graceTimer = setTimeout(settle, EOSE_GRACE_MS);
248
+ }
249
+ });
250
+ });
251
+ }
252
+ // Resolves a full UUID from a short prefix — fetches all board events and scans "d" tags.
253
+ async function resolveTaskId(boardId, taskIdOrPrefix) {
254
+ // Full UUID — use directly
255
+ if (taskIdOrPrefix.length === 36)
256
+ return taskIdOrPrefix;
257
+ // Short prefix — scan board events
258
+ const allEvents = await fetchBoardEvents(boardId);
259
+ const prefix = taskIdOrPrefix.toLowerCase().slice(0, 8);
260
+ for (const event of allEvents) {
261
+ const dTag = event.tags.find((t) => t[0] === "d");
262
+ const dVal = (dTag?.[1] ?? "").toLowerCase();
263
+ if (dVal.startsWith(prefix))
264
+ return dTag[1];
265
+ }
266
+ return null;
267
+ }
268
+ async function publishTaskEvent(boardId, taskId, payload, status, colId = "") {
269
+ const { signer } = deriveBoardKeys(boardId);
270
+ const bTag = boardTagHash(boardId);
271
+ const encrypted = await encryptContent(boardId, JSON.stringify(payload));
272
+ const event = new NDKEvent(ndk);
273
+ event.kind = 30301;
274
+ event.content = encrypted;
275
+ event.tags = [
276
+ ["d", taskId],
277
+ ["b", bTag],
278
+ ["col", colId],
279
+ ["status", status],
280
+ ];
281
+ await event.sign(signer);
282
+ try {
283
+ await event.publish();
284
+ }
285
+ catch (err) {
286
+ throw new Error(`Publish failed — check relay connectivity (taskify relay status): ${String(err)}`);
287
+ }
288
+ return event;
289
+ }
290
+ return {
291
+ getDefaultBoardId() {
292
+ return config.boards[0]?.id ?? null;
293
+ },
294
+ async disconnect() {
295
+ try {
296
+ ndk.pool?.destroy?.();
297
+ }
298
+ catch {
299
+ // ignore teardown errors
300
+ }
301
+ },
302
+ async listTasks({ boardId, status, columnId, refresh, noCache }) {
303
+ const boards = [];
304
+ if (boardId) {
305
+ const entry = resolveBoardEntry(config, boardId);
306
+ if (!entry) {
307
+ throw new Error(`Board not found in config: "${boardId}". Use: taskify board join <id> --name <name>`);
308
+ }
309
+ boards.push(entry);
310
+ }
311
+ else {
312
+ boards.push(...config.boards);
313
+ }
314
+ if (boards.length === 0)
315
+ return [];
316
+ const cache = readCache();
317
+ const records = [];
318
+ for (const board of boards) {
319
+ // Compound board: aggregate tasks from children
320
+ if (board.kind === "compound") {
321
+ await ensureConnected();
322
+ const childIds = board.children ?? [];
323
+ const seen = new Set();
324
+ for (const childId of childIds) {
325
+ const childEntry = resolveBoardEntry(config, childId) ?? { id: childId, name: childId };
326
+ const childEvents = await fetchBoardEvents(childId);
327
+ for (const event of childEvents) {
328
+ const record = await parseDecryptedEvent(event, childId, childEntry.name ?? childId);
329
+ if (!record)
330
+ continue;
331
+ if (seen.has(record.id))
332
+ continue;
333
+ seen.add(record.id);
334
+ record.sourceBoardId = childId;
335
+ if (status === "open" && record.completed)
336
+ continue;
337
+ if (status === "done" && !record.completed)
338
+ continue;
339
+ if (columnId !== undefined && record.column !== columnId)
340
+ continue;
341
+ records.push(record);
342
+ }
343
+ }
344
+ continue;
345
+ }
346
+ const boardCache = cache.boards[board.id];
347
+ // Use cache if fresh and not forcing a refresh
348
+ if (!refresh && boardCache && isCacheFresh(boardCache)) {
349
+ for (const t of boardCache.tasks) {
350
+ const rec = cacheToRecord(t, board.name);
351
+ if (status === "open" && rec.completed)
352
+ continue;
353
+ if (status === "done" && !rec.completed)
354
+ continue;
355
+ if (columnId !== undefined && rec.column !== columnId)
356
+ continue;
357
+ records.push(rec);
358
+ }
359
+ continue;
360
+ }
361
+ // Fetch live from relay
362
+ await ensureConnected();
363
+ const events = await fetchBoardEvents(board.id);
364
+ const liveRecords = [];
365
+ for (const event of events) {
366
+ const record = await parseDecryptedEvent(event, board.id, board.name);
367
+ if (!record)
368
+ continue;
369
+ liveRecords.push(record);
370
+ }
371
+ // A3/A2: guard against caching an empty result when previous data exists
372
+ if (liveRecords.length === 0 && boardCache && !noCache) {
373
+ const cacheAgeMs = Date.now() - boardCache.fetchedAt;
374
+ const TEN_MIN_MS = 10 * 60 * 1000;
375
+ if (cacheAgeMs <= TEN_MIN_MS) {
376
+ // A3: cache ≤ 10 min old — silently fall back, skip relay result
377
+ for (const t of boardCache.tasks) {
378
+ const rec = cacheToRecord(t, board.name);
379
+ if (status === "open" && rec.completed)
380
+ continue;
381
+ if (status === "done" && !rec.completed)
382
+ continue;
383
+ if (columnId !== undefined && rec.column !== columnId)
384
+ continue;
385
+ records.push(rec);
386
+ }
387
+ continue;
388
+ }
389
+ else if (boardCache.tasks.length > 0) {
390
+ // A2: stale cache has tasks — warn and keep, don't overwrite with empty
391
+ process.stderr.write("⚠ Relay returned 0 tasks — keeping cached results\n");
392
+ for (const t of boardCache.tasks) {
393
+ const rec = cacheToRecord(t, board.name);
394
+ if (status === "open" && rec.completed)
395
+ continue;
396
+ if (status === "done" && !rec.completed)
397
+ continue;
398
+ if (columnId !== undefined && rec.column !== columnId)
399
+ continue;
400
+ records.push(rec);
401
+ }
402
+ continue;
403
+ }
404
+ }
405
+ // Write all records (any status) to cache
406
+ cache.boards[board.id] = {
407
+ tasks: liveRecords.map(recordToCache),
408
+ fetchedAt: Date.now(),
409
+ };
410
+ // Filter for the caller
411
+ for (const record of liveRecords) {
412
+ if (columnId !== undefined && record.column !== columnId)
413
+ continue;
414
+ if (status === "open" && record.completed)
415
+ continue;
416
+ if (status === "done" && !record.completed)
417
+ continue;
418
+ records.push(record);
419
+ }
420
+ }
421
+ writeCache(cache);
422
+ records.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
423
+ return records;
424
+ },
425
+ async syncBoard(boardId) {
426
+ await ensureConnected();
427
+ const bTag = boardTagHash(boardId);
428
+ const fetchPromise = ndk.fetchEvents({ kinds: [30300], "#b": [bTag], limit: 1 }, { closeOnEose: true });
429
+ const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(new Set()), 10000));
430
+ const events = await Promise.race([fetchPromise, timeoutPromise]);
431
+ // Load fresh config to ensure we have the latest board entry
432
+ const cfg = await loadConfig();
433
+ const entry = cfg.boards.find((b) => b.id === boardId);
434
+ if (!entry)
435
+ return {};
436
+ let kind;
437
+ let columns;
438
+ let children;
439
+ if (events.size > 0) {
440
+ const [event] = events;
441
+ const kTag = event.tags.find((t) => t[0] === "k");
442
+ if (kTag?.[1])
443
+ kind = kTag[1];
444
+ const colTags = event.tags.filter((t) => t[0] === "col" && t[1] && t[2]);
445
+ if (colTags.length > 0) {
446
+ columns = colTags.map((t) => ({ id: t[1], name: t[2] }));
447
+ }
448
+ // Check for "ch" tags
449
+ const chTags = event.tags.filter((t) => t[0] === "ch" && t[1]);
450
+ if (chTags.length > 0) {
451
+ children = chTags.map((t) => t[1]);
452
+ }
453
+ // Try to decrypt content for additional columns and kind (fallback)
454
+ try {
455
+ if (event.content) {
456
+ const plaintext = await decryptContent(boardId, event.content);
457
+ const parsed = JSON.parse(plaintext);
458
+ // Extract kind from content if not found in tags
459
+ if (!kind && parsed.kind) {
460
+ kind = String(parsed.kind);
461
+ }
462
+ // Extract columns from content
463
+ if (Array.isArray(parsed.columns)) {
464
+ const contentCols = parsed.columns
465
+ .filter((c) => c && typeof c === "object" && "id" in c && "name" in c)
466
+ .map((c) => ({ id: String(c.id), name: String(c.name) }));
467
+ // Merge with tag-discovered columns (deduplicate by id)
468
+ const merged = [...(columns ?? [])];
469
+ for (const cc of contentCols) {
470
+ if (!merged.find((m) => m.id === cc.id)) {
471
+ merged.push(cc);
472
+ }
473
+ }
474
+ if (merged.length > 0)
475
+ columns = merged;
476
+ }
477
+ // Extract children from content
478
+ if (Array.isArray(parsed.children)) {
479
+ const contentChildren = parsed.children.filter((c) => typeof c === "string");
480
+ const mergedChildren = [...(children ?? [])];
481
+ for (const cc of contentChildren) {
482
+ if (!mergedChildren.includes(cc))
483
+ mergedChildren.push(cc);
484
+ }
485
+ if (mergedChildren.length > 0)
486
+ children = mergedChildren;
487
+ }
488
+ }
489
+ }
490
+ catch { /* non-fatal — content may not be in expected format */ }
491
+ if (kind)
492
+ entry.kind = kind;
493
+ if (columns && columns.length > 0)
494
+ entry.columns = columns;
495
+ if (children && children.length > 0)
496
+ entry.children = children;
497
+ await saveConfig(cfg);
498
+ }
499
+ return { kind, columns, children };
500
+ },
501
+ async createTask(input) {
502
+ return this.createTaskFull(input);
503
+ },
504
+ async createTaskFull(input) {
505
+ await ensureConnected();
506
+ const entry = resolveBoardEntry(config, input.boardId);
507
+ if (!entry) {
508
+ throw new Error(`Board not found in config: "${input.boardId}". Use: taskify board join <id> --name <name>`);
509
+ }
510
+ const boardId = entry.id;
511
+ const taskId = crypto.randomUUID();
512
+ const userPubkey = getUserPubkeyHex(config);
513
+ if (!userPubkey) {
514
+ process.stderr.write("\x1b[33m(warning: no nsec configured — createdBy/lastEditedBy will be empty)\x1b[0m\n");
515
+ }
516
+ const now = Date.now();
517
+ const payload = {
518
+ title: input.title,
519
+ priority: input.priority ?? null,
520
+ note: input.note ?? "",
521
+ dueISO: input.dueISO ?? "",
522
+ completedAt: null,
523
+ completedBy: null,
524
+ recurrence: null,
525
+ hiddenUntilISO: null,
526
+ createdBy: userPubkey,
527
+ lastEditedBy: userPubkey,
528
+ createdAt: now,
529
+ streak: null,
530
+ longestStreak: null,
531
+ seriesId: null,
532
+ dueDateEnabled: input.dueISO ? true : null,
533
+ dueTimeEnabled: null,
534
+ dueTimeZone: null,
535
+ images: null,
536
+ documents: null,
537
+ bounty: null,
538
+ subtasks: input.subtasks ?? null,
539
+ assignees: input.assignees ? input.assignees.map((pk) => ({ pubkey: pk })) : null,
540
+ inboxItem: input.inboxItem === true ? true : null,
541
+ };
542
+ // Resolve column: explicit > week-board today > ""
543
+ let colId = "";
544
+ if (input.columnId !== undefined) {
545
+ colId = input.columnId;
546
+ }
547
+ else if (entry.kind === "week") {
548
+ colId = new Date().toISOString().slice(0, 10);
549
+ }
550
+ await publishTaskEvent(boardId, taskId, payload, "open", colId);
551
+ const result = {
552
+ id: taskId,
553
+ boardId,
554
+ boardName: entry.name,
555
+ title: input.title,
556
+ note: input.note || undefined,
557
+ dueISO: input.dueISO ?? "",
558
+ dueDateEnabled: input.dueISO ? true : undefined,
559
+ priority: input.priority,
560
+ completed: false,
561
+ createdAt: Math.floor(now / 1000),
562
+ createdBy: userPubkey,
563
+ lastEditedBy: userPubkey,
564
+ subtasks: input.subtasks,
565
+ column: colId || undefined,
566
+ inboxItem: input.inboxItem === true ? true : undefined,
567
+ assignees: input.assignees,
568
+ };
569
+ // Update cache with the new task
570
+ const cache = readCache();
571
+ const boardCache = cache.boards[boardId] ?? { tasks: [], fetchedAt: Date.now() };
572
+ boardCache.tasks = boardCache.tasks.filter((t) => t.id !== taskId);
573
+ boardCache.tasks.push(recordToCache(result));
574
+ cache.boards[boardId] = boardCache;
575
+ writeCache(cache);
576
+ return result;
577
+ },
578
+ async updateTask(taskId, boardId, patch) {
579
+ await ensureConnected();
580
+ const entry = resolveBoardEntry(config, boardId);
581
+ if (!entry)
582
+ return null;
583
+ const resolvedId = await resolveTaskId(entry.id, taskId);
584
+ if (!resolvedId)
585
+ return null;
586
+ taskId = resolvedId;
587
+ const events = await fetchBoardEvents(entry.id, taskId);
588
+ if (events.size === 0)
589
+ return null;
590
+ const [event] = events;
591
+ const existing = await parseDecryptedEvent(event, entry.id, entry.name);
592
+ if (!existing)
593
+ return null;
594
+ const plaintext = await decryptContent(entry.id, event.content);
595
+ const rawPayload = JSON.parse(plaintext);
596
+ const userPubkey = getUserPubkeyHex(config);
597
+ const merged = {
598
+ ...rawPayload,
599
+ ...(patch.title !== undefined ? { title: patch.title } : {}),
600
+ ...(patch.note !== undefined ? { note: patch.note ?? "" } : {}),
601
+ ...(patch.dueISO !== undefined ? { dueISO: patch.dueISO ?? "" } : {}),
602
+ ...(patch.priority !== undefined ? { priority: patch.priority ?? null } : {}),
603
+ ...(patch.inboxItem !== undefined ? { inboxItem: patch.inboxItem } : {}),
604
+ // Store assignees as {pubkey} objects in Nostr payload for PWA compat
605
+ ...(patch.assignees !== undefined ? { assignees: patch.assignees.map((pk) => ({ pubkey: pk })) } : {}),
606
+ lastEditedBy: userPubkey,
607
+ };
608
+ const statusTag = event.tags.find((t) => t[0] === "status");
609
+ const status = (statusTag?.[1] ?? "open");
610
+ const colTag = event.tags.find((t) => t[0] === "col");
611
+ const existingColId = colTag?.[1] ?? "";
612
+ const colId = patch.columnId !== undefined ? (patch.columnId ?? "") : existingColId;
613
+ await publishTaskEvent(entry.id, taskId, merged, status, colId);
614
+ // Build updated FullTaskRecord — keep assignees as string[] (extract pubkeys)
615
+ const updatedAssignees = patch.assignees !== undefined
616
+ ? patch.assignees
617
+ : existing.assignees;
618
+ const updated = {
619
+ ...existing,
620
+ title: merged.title ?? existing.title,
621
+ note: merged.note || undefined,
622
+ dueISO: merged.dueISO ?? existing.dueISO,
623
+ priority: merged.priority ?? undefined,
624
+ inboxItem: merged.inboxItem === true ? true : undefined,
625
+ assignees: updatedAssignees,
626
+ lastEditedBy: merged.lastEditedBy,
627
+ };
628
+ if (patch.columnId !== undefined)
629
+ updated.column = colId || undefined;
630
+ // Invalidate cache entry
631
+ const cache = readCache();
632
+ const bc = cache.boards[entry.id];
633
+ if (bc) {
634
+ bc.tasks = bc.tasks.filter((t) => t.id !== taskId);
635
+ bc.tasks.push(recordToCache(updated));
636
+ writeCache(cache);
637
+ }
638
+ return updated;
639
+ },
640
+ async setTaskStatus(taskId, status, boardId) {
641
+ await ensureConnected();
642
+ const entry = resolveBoardEntry(config, boardId);
643
+ if (!entry)
644
+ return null;
645
+ const resolvedId = await resolveTaskId(entry.id, taskId);
646
+ if (!resolvedId)
647
+ return null;
648
+ taskId = resolvedId;
649
+ const events = await fetchBoardEvents(entry.id, taskId);
650
+ if (events.size === 0)
651
+ return null;
652
+ const [event] = events;
653
+ const existing = await parseDecryptedEvent(event, entry.id, entry.name);
654
+ if (!existing)
655
+ return null;
656
+ const plaintext = await decryptContent(entry.id, event.content);
657
+ const rawPayload = JSON.parse(plaintext);
658
+ const userPubkey = getUserPubkeyHex(config);
659
+ const completed = status === "done";
660
+ const merged = {
661
+ ...rawPayload,
662
+ completedAt: completed ? nowISO() : null,
663
+ lastEditedBy: userPubkey,
664
+ };
665
+ const nostrStatus = completed ? "done" : "open";
666
+ const colTag = event.tags.find((t) => t[0] === "col");
667
+ const colId = colTag?.[1] ?? "";
668
+ await publishTaskEvent(entry.id, taskId, merged, nostrStatus, colId);
669
+ const updated = { ...existing, completed, completedAt: merged.completedAt ?? undefined };
670
+ // Update cache
671
+ const cache = readCache();
672
+ const bc = cache.boards[entry.id];
673
+ if (bc) {
674
+ bc.tasks = bc.tasks.filter((t) => t.id !== taskId);
675
+ bc.tasks.push(recordToCache(updated));
676
+ writeCache(cache);
677
+ }
678
+ return updated;
679
+ },
680
+ async deleteTask(taskId, boardId) {
681
+ await ensureConnected();
682
+ const entry = resolveBoardEntry(config, boardId);
683
+ if (!entry)
684
+ return null;
685
+ const resolvedId = await resolveTaskId(entry.id, taskId);
686
+ if (!resolvedId)
687
+ return null;
688
+ taskId = resolvedId;
689
+ const events = await fetchBoardEvents(entry.id, taskId);
690
+ if (events.size === 0)
691
+ return null;
692
+ const [event] = events;
693
+ const existing = await parseDecryptedEvent(event, entry.id, entry.name);
694
+ if (!existing)
695
+ return null;
696
+ const plaintext = await decryptContent(entry.id, event.content);
697
+ const rawPayload = JSON.parse(plaintext);
698
+ const colTag = event.tags.find((t) => t[0] === "col");
699
+ const colId = colTag?.[1] ?? "";
700
+ // Step 1: publish kind 30301 status=deleted (app-level soft delete)
701
+ await publishTaskEvent(entry.id, taskId, rawPayload, "deleted", colId);
702
+ // Step 2: publish NIP-09 kind 5 deletion request (matches PWA's publishTaskDeletionRequest)
703
+ const boardKeys = deriveBoardKeys(entry.id);
704
+ const aTag = `30301:${boardKeys.pk}:${taskId}`;
705
+ try {
706
+ const nip09Event = new NDKEvent(ndk);
707
+ nip09Event.kind = 5;
708
+ nip09Event.content = "Task deleted";
709
+ nip09Event.tags = [["a", aTag]];
710
+ nip09Event.created_at = Math.floor(Date.now() / 1000);
711
+ ndk.signer = boardKeys.signer;
712
+ await nip09Event.publish();
713
+ }
714
+ catch {
715
+ // Non-fatal: NIP-09 relay support varies; soft delete already published
716
+ }
717
+ // Remove from cache
718
+ const cache = readCache();
719
+ const bc = cache.boards[entry.id];
720
+ if (bc) {
721
+ bc.tasks = bc.tasks.filter((t) => t.id !== taskId);
722
+ writeCache(cache);
723
+ }
724
+ return existing;
725
+ },
726
+ async toggleSubtask(taskId, boardId, subtaskRef, completed) {
727
+ await ensureConnected();
728
+ const entry = resolveBoardEntry(config, boardId);
729
+ if (!entry)
730
+ return null;
731
+ const resolvedId = await resolveTaskId(entry.id, taskId);
732
+ if (!resolvedId)
733
+ return null;
734
+ taskId = resolvedId;
735
+ const events = await fetchBoardEvents(entry.id, taskId);
736
+ if (events.size === 0)
737
+ return null;
738
+ const [event] = events;
739
+ const existing = await parseDecryptedEvent(event, entry.id, entry.name);
740
+ if (!existing)
741
+ return null;
742
+ const plaintext = await decryptContent(entry.id, event.content);
743
+ const rawPayload = JSON.parse(plaintext);
744
+ const subtasks = rawPayload.subtasks ?? [];
745
+ // Resolve by 1-based index or title substring
746
+ const indexNum = parseInt(subtaskRef, 10);
747
+ let targetIdx = -1;
748
+ if (!isNaN(indexNum) && indexNum >= 1 && indexNum <= subtasks.length) {
749
+ targetIdx = indexNum - 1;
750
+ }
751
+ else {
752
+ const lower = subtaskRef.toLowerCase();
753
+ targetIdx = subtasks.findIndex((s) => s.title.toLowerCase().includes(lower));
754
+ }
755
+ if (targetIdx === -1) {
756
+ throw new Error(`Subtask not found: ${subtaskRef}`);
757
+ }
758
+ subtasks[targetIdx] = { ...subtasks[targetIdx], completed };
759
+ rawPayload.subtasks = subtasks;
760
+ const statusTag = event.tags.find((t) => t[0] === "status");
761
+ const status = (statusTag?.[1] ?? "open");
762
+ const colTag = event.tags.find((t) => t[0] === "col");
763
+ const colId = colTag?.[1] ?? "";
764
+ await publishTaskEvent(entry.id, taskId, rawPayload, status, colId);
765
+ return { ...existing, subtasks };
766
+ },
767
+ async getTask(taskId, boardId) {
768
+ await ensureConnected();
769
+ const boards = [];
770
+ if (boardId) {
771
+ const entry = resolveBoardEntry(config, boardId);
772
+ if (entry)
773
+ boards.push(entry);
774
+ }
775
+ else {
776
+ boards.push(...config.boards);
777
+ }
778
+ for (const board of boards) {
779
+ // Try exact UUID match via #d filter
780
+ const events = await fetchBoardEvents(board.id, taskId);
781
+ for (const event of events) {
782
+ const record = await parseDecryptedEvent(event, board.id, board.name);
783
+ if (record)
784
+ return record;
785
+ }
786
+ // UUID prefix match: fetch all and scan
787
+ if (taskId.length < 36) {
788
+ const allEvents = await fetchBoardEvents(board.id);
789
+ const prefix = taskId.toLowerCase().slice(0, 8);
790
+ for (const event of allEvents) {
791
+ const dTag = event.tags.find((t) => t[0] === "d");
792
+ const dVal = (dTag?.[1] ?? "").toLowerCase();
793
+ if (dVal.startsWith(prefix)) {
794
+ const record = await parseDecryptedEvent(event, board.id, board.name);
795
+ if (record)
796
+ return record;
797
+ }
798
+ }
799
+ }
800
+ }
801
+ return null;
802
+ },
803
+ async remindTask(taskId, presets) {
804
+ // Device-local only — NEVER publish to Nostr
805
+ const cfg = await loadConfig();
806
+ if (!cfg.taskReminders)
807
+ cfg.taskReminders = {};
808
+ cfg.taskReminders[taskId] = presets;
809
+ await saveConfig(cfg);
810
+ process.stderr.write("\x1b[2m Note: Reminders are device-local and will not sync to other devices\x1b[0m\n");
811
+ },
812
+ getLocalReminders(taskId) {
813
+ return config.taskReminders?.[taskId] ?? [];
814
+ },
815
+ async getAgentSecurityConfig() {
816
+ const cfg = await loadConfig();
817
+ return {
818
+ enabled: cfg.securityEnabled,
819
+ mode: cfg.securityMode,
820
+ trustedNpubs: cfg.trustedNpubs,
821
+ updatedISO: nowISO(),
822
+ };
823
+ },
824
+ async setAgentSecurityConfig(secCfg) {
825
+ const cfg = await loadConfig();
826
+ cfg.securityEnabled = secCfg.enabled;
827
+ cfg.securityMode = secCfg.mode;
828
+ cfg.trustedNpubs = secCfg.trustedNpubs;
829
+ await saveConfig(cfg);
830
+ return secCfg;
831
+ },
832
+ async getRelayStatus() {
833
+ await ensureConnected();
834
+ const results = [];
835
+ for (const [url, relay] of ndk.pool.relays) {
836
+ const connected = relay.status === NDKRelayStatus.CONNECTED;
837
+ results.push({ url, connected });
838
+ }
839
+ // If pool is empty (no relays in map), fall back to config list as disconnected
840
+ if (results.length === 0) {
841
+ for (const url of config.relays) {
842
+ results.push({ url, connected: false });
843
+ }
844
+ }
845
+ return results;
846
+ },
847
+ async createBoard(input) {
848
+ await ensureConnected();
849
+ const boardId = crypto.randomUUID();
850
+ const { signer } = deriveBoardKeys(boardId);
851
+ const bTag = boardTagHash(boardId);
852
+ const contentPayload = {
853
+ name: input.name,
854
+ kind: input.kind,
855
+ columns: input.columns ?? [],
856
+ version: 1,
857
+ };
858
+ const encrypted = await encryptContent(boardId, JSON.stringify(contentPayload));
859
+ const event = new NDKEvent(ndk);
860
+ event.kind = 30300;
861
+ event.content = encrypted;
862
+ event.tags = [
863
+ ["d", boardId],
864
+ ["b", bTag],
865
+ ["k", input.kind],
866
+ ...(input.columns ?? []).map((c) => ["col", c.id, c.name]),
867
+ ];
868
+ await event.sign(signer);
869
+ try {
870
+ await event.publish();
871
+ }
872
+ catch (err) {
873
+ throw new Error(`Board publish failed: ${String(err)}`);
874
+ }
875
+ // Auto-join: save to config
876
+ const cfg = await loadConfig();
877
+ const newEntry = {
878
+ id: boardId,
879
+ name: input.name,
880
+ kind: input.kind,
881
+ columns: input.columns ?? [],
882
+ };
883
+ cfg.boards.push(newEntry);
884
+ await saveConfig(cfg);
885
+ return { boardId };
886
+ },
887
+ };
888
+ }