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.
- package/README.md +271 -0
- package/dist/aiClient.js +40 -0
- package/dist/completions.js +637 -0
- package/dist/config.js +39 -0
- package/dist/index.js +2074 -0
- package/dist/nostrRuntime.js +888 -0
- package/dist/onboarding.js +93 -0
- package/dist/render.js +207 -0
- package/dist/shared/agentDispatcher.js +595 -0
- package/dist/shared/agentIdempotency.js +50 -0
- package/dist/shared/agentRuntime.js +7 -0
- package/dist/shared/agentSecurity.js +161 -0
- package/dist/shared/boardUtils.js +441 -0
- package/dist/shared/dateUtils.js +123 -0
- package/dist/shared/nostr.js +70 -0
- package/dist/shared/settingsTypes.js +23 -0
- package/dist/shared/taskTypes.js +12 -0
- package/dist/shared/taskUtils.js +261 -0
- package/dist/taskCache.js +59 -0
- package/package.json +44 -0
|
@@ -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
|
+
}
|