@tenyokjs/devdevian-cli 0.5.1-alpha.1 → 0.6.1-alpha.1

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,611 @@
1
+ import { open, writeFile } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import { workspacePath } from "./workspace.mjs";
6
+ export const viewerZone = () =>
7
+ Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
8
+ export const isCatalog = (route) =>
9
+ ["projects", "events", "project", "event"].includes(route.view);
10
+ const stages = ["Idea", "Prototype", "Beta", "Live"].map((s) => [s, s]);
11
+ const imageHint =
12
+ "Ctrl+O selects an image inside this project. Blank keeps it; - removes it.";
13
+ export function catalogFields(form) {
14
+ const topic = { key: "topic", label: "Topic", options: form.topics };
15
+ if (form.kind === "catalogFilter")
16
+ return [
17
+ { key: "query", label: "Search", max: 200 },
18
+ { ...topic, options: [["", "Every topic"], ...form.topics] },
19
+ ...(form.entity === "project"
20
+ ? [
21
+ {
22
+ key: "stage",
23
+ label: "Stage",
24
+ options: [["", "Every stage"], ...stages],
25
+ },
26
+ {
27
+ key: "author",
28
+ label: "Owner",
29
+ options: [
30
+ ["", "Everyone"],
31
+ ["me", "My projects"],
32
+ ],
33
+ },
34
+ {
35
+ key: "order",
36
+ label: "Order",
37
+ options: [
38
+ ["newest", "Newest first"],
39
+ ["oldest", "Oldest first"],
40
+ ],
41
+ },
42
+ ]
43
+ : [
44
+ {
45
+ key: "status",
46
+ label: "When",
47
+ options: [
48
+ ["", "All events"],
49
+ ["upcoming", "Upcoming"],
50
+ ["past", "Past"],
51
+ ],
52
+ },
53
+ {
54
+ key: "date",
55
+ label: "Calendar date (optional)",
56
+ max: 10,
57
+ hint: "YYYY-MM-DD in the timezone below; blank clears the date filter.",
58
+ },
59
+ { key: "timezone", label: "Viewer timezone", min: 1, max: 100 },
60
+ {
61
+ key: "mine",
62
+ label: "Participation",
63
+ options: [
64
+ ["", "Everyone"],
65
+ ["attending", "Attending"],
66
+ ["mine", "Hosting or attending"],
67
+ ],
68
+ },
69
+ {
70
+ key: "order",
71
+ label: "Order",
72
+ options: [
73
+ ["earliest", "Earliest first"],
74
+ ["latest", "Latest first"],
75
+ ],
76
+ },
77
+ ]),
78
+ ];
79
+ if (form.kind === "calendar")
80
+ return [
81
+ {
82
+ key: "path",
83
+ label: "Calendar filename",
84
+ min: 1,
85
+ max: 500,
86
+ hint: "A new .ics file inside this project. Existing files are never overwritten.",
87
+ },
88
+ ];
89
+ const links = {
90
+ key: "links",
91
+ label: "Additional links (optional)",
92
+ multiline: true,
93
+ max: 15000,
94
+ hint: "One per line: Label | https://example.com",
95
+ };
96
+ if (form.entity === "event")
97
+ return [
98
+ { key: "title", label: "Event title", min: 5, max: 160 },
99
+ topic,
100
+ {
101
+ key: "local_start",
102
+ label: "Organizer's local start",
103
+ min: 16,
104
+ max: 16,
105
+ hint: "YYYY-MM-DDTHH:mm (24-hour clock); the server validates DST and future dates.",
106
+ },
107
+ {
108
+ key: "timezone",
109
+ label: "Organizer timezone",
110
+ min: 1,
111
+ max: 100,
112
+ hint: "IANA timezone, for example Europe/Kyiv or Asia/Shanghai.",
113
+ },
114
+ {
115
+ key: "duration",
116
+ label: "Duration in minutes",
117
+ min: 2,
118
+ max: 3,
119
+ hint: "15–720 minutes",
120
+ },
121
+ { key: "url", label: "Meeting URL", min: 8, max: 1000 },
122
+ {
123
+ key: "description",
124
+ label: "Description",
125
+ min: 20,
126
+ max: 5000,
127
+ multiline: true,
128
+ },
129
+ {
130
+ key: "banner",
131
+ label: "Banner image (optional)",
132
+ max: 1500,
133
+ hint: imageHint,
134
+ },
135
+ links,
136
+ ];
137
+ return [
138
+ { key: "name", label: "Project name", min: 2, max: 80 },
139
+ { key: "tagline", label: "Tagline (optional)", max: 1000 },
140
+ topic,
141
+ { key: "stage", label: "Stage", options: stages },
142
+ { key: "stack", label: "Tools & technologies", max: 150 },
143
+ {
144
+ key: "description",
145
+ label: "Description",
146
+ min: 20,
147
+ max: 5000,
148
+ multiline: true,
149
+ },
150
+ {
151
+ key: "help",
152
+ label: "What do you need help with?",
153
+ min: 10,
154
+ max: 1500,
155
+ multiline: true,
156
+ },
157
+ { key: "url", label: "Project URL (optional)", max: 1000 },
158
+ { key: "repo", label: "Source URL (optional)", max: 1000 },
159
+ { key: "problem", label: "Problem (optional)", multiline: true, max: 1000 },
160
+ { key: "audience", label: "Audience (optional)", max: 1000 },
161
+ {
162
+ key: "team",
163
+ label: "Team (optional)",
164
+ multiline: true,
165
+ max: 5000,
166
+ hint: "One per line: @username | Role. Existing members can use #user_id | Role.",
167
+ },
168
+ links,
169
+ { key: "logo", label: "Logo image (optional)", max: 1500, hint: imageHint },
170
+ {
171
+ key: "screenshots",
172
+ label: "Screenshot images (optional)",
173
+ multiline: true,
174
+ max: 15000,
175
+ hint: "Ctrl+O appends a file. One local path per line; up to ten. Blank keeps existing screenshots; - removes them.",
176
+ },
177
+ ];
178
+ }
179
+ export function initialCatalog(entity, row = {}) {
180
+ const values =
181
+ entity === "project"
182
+ ? Object.fromEntries(
183
+ [
184
+ "name",
185
+ "description",
186
+ "help",
187
+ "stack",
188
+ "url",
189
+ "repo",
190
+ "tagline",
191
+ "problem",
192
+ "audience",
193
+ ].map((key) => [key, row[key] || ""]),
194
+ )
195
+ : {
196
+ title: row.title || "",
197
+ description: row.description || "",
198
+ url: row.url || "",
199
+ timezone: row.timezone || viewerZone(),
200
+ duration: row.start_at
201
+ ? String(
202
+ Math.round(
203
+ (new Date(row.end_at) - new Date(row.start_at)) / 60000,
204
+ ),
205
+ )
206
+ : "60",
207
+ local_start: row.start_at
208
+ ? localTime(row.start_at, row.timezone)
209
+ : "",
210
+ banner: "",
211
+ };
212
+ return {
213
+ ...values,
214
+ topic: row.topic || "web",
215
+ ...(entity === "project"
216
+ ? {
217
+ stage: row.stage || "Idea",
218
+ team: (row.team || [])
219
+ .map((m) => `#${m.user_id} | ${m.role}`)
220
+ .join("\n"),
221
+ logo: "",
222
+ screenshots: "",
223
+ }
224
+ : {}),
225
+ links: (row.links || []).map((l) => `${l.label} | ${l.url}`).join("\n"),
226
+ };
227
+ }
228
+ export function localTime(value, timezone = viewerZone()) {
229
+ const parts = Object.fromEntries(
230
+ new Intl.DateTimeFormat("en-CA", {
231
+ timeZone: timezone,
232
+ year: "numeric",
233
+ month: "2-digit",
234
+ day: "2-digit",
235
+ hour: "2-digit",
236
+ minute: "2-digit",
237
+ hourCycle: "h23",
238
+ })
239
+ .formatToParts(new Date(value))
240
+ .map((p) => [p.type, p.value]),
241
+ );
242
+ return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}`;
243
+ }
244
+ export function catalogQuery(route) {
245
+ const q = new URLSearchParams({ page: String(route.page || 1), limit: "12" });
246
+ for (const key of [
247
+ "topic",
248
+ "stage",
249
+ "author",
250
+ "order",
251
+ "status",
252
+ "date",
253
+ "timezone",
254
+ ])
255
+ if (route[key]) q.set(key, route[key]);
256
+ if (route.query) q.set("q", route.query);
257
+ if (route.mine) q.set(route.mine === "attending" ? "attending" : "mine", "1");
258
+ return q.toString();
259
+ }
260
+ export async function loadCatalog(api, route, signal) {
261
+ const plural = route.view === "projects" || route.view === "events";
262
+ if (plural)
263
+ return {
264
+ ...(await api(`/${route.view}?${catalogQuery(route)}`, { signal })),
265
+ title: route.view === "projects" ? "Projects" : "Events",
266
+ subtitle: "F filters · N creates · Enter opens · [ ] pages",
267
+ };
268
+ const row = await api(`/${route.view}s/${route.id}`, { signal });
269
+ return {
270
+ [route.view]: row,
271
+ title: row.name || row.title,
272
+ subtitle:
273
+ route.view === "project"
274
+ ? `${row.stage} · by ${row.founder_name}`
275
+ : `${row.cancelled_at ? "Cancelled · " : ""}Hosted by ${row.organizer_name} · ${row.attendee_count} attending`,
276
+ };
277
+ }
278
+ export function catalogDocument(route, data) {
279
+ const p = data.project,
280
+ e = data.event;
281
+ if (p)
282
+ return [
283
+ p.tagline,
284
+ p.description,
285
+ "## Request for feedback",
286
+ p.help,
287
+ p.problem && "## Problem\n" + p.problem,
288
+ p.audience && "## Audience\n" + p.audience,
289
+ p.stack && "## Technologies\n" + p.stack,
290
+ p.url && "Project: " + p.url,
291
+ p.repo && "Source: " + p.repo,
292
+ p.team?.length &&
293
+ "## Team\n" +
294
+ p.team
295
+ .map(
296
+ (m) =>
297
+ `${m.name || "Member"} (@${m.username || m.user_id}) · ${m.role}`,
298
+ )
299
+ .join("\n"),
300
+ p.logo && "Logo: uploaded image",
301
+ p.screenshots?.length &&
302
+ `${p.screenshots.length} screenshot(s) · view in browser`,
303
+ ...(p.links || []).map((l) => `${l.label}: ${l.url}`),
304
+ ]
305
+ .filter(Boolean)
306
+ .join("\n\n");
307
+ if (e)
308
+ return [
309
+ `Your time: ${localTime(e.start_at)} – ${localTime(e.end_at)} (${viewerZone()})`,
310
+ `Organizer time: ${localTime(e.start_at, e.timezone)} – ${localTime(e.end_at, e.timezone)} (${e.timezone})`,
311
+ e.cancelled_at
312
+ ? "This event is cancelled."
313
+ : e.viewer_attending
314
+ ? "You're attending."
315
+ : "Join to access the meeting link.",
316
+ e.description,
317
+ e.url && "Meeting: " + e.url,
318
+ ...(e.links || []).map((l) => `${l.label}: ${l.url}`),
319
+ e.banner && "Banner: uploaded image",
320
+ ]
321
+ .filter(Boolean)
322
+ .join("\n\n");
323
+ return "";
324
+ }
325
+ export function catalogActions(route, data, user) {
326
+ const actions = [],
327
+ row = data?.project || data?.event;
328
+ if (!row) return actions;
329
+ const add = (id, label) => actions.push({ id: "catalog-" + id, label });
330
+ if (row.user_id === user?.id) add("edit", "Edit your " + route.view);
331
+ if (route.view === "project") {
332
+ add("conversation", "Open project conversation");
333
+ if (row.url) add("website", "Open project website");
334
+ if (row.repo) add("source", "Open source repository");
335
+ } else {
336
+ if (
337
+ row.viewer_attending ||
338
+ (!row.cancelled_at && new Date(row.end_at) > new Date())
339
+ )
340
+ add("attend", row.viewer_attending ? "Leave event…" : "Attend event…");
341
+ add("calendar", "Export calendar .ics…");
342
+ if (row.url) add("meeting", "Open meeting link");
343
+ if (row.user_id === user?.id)
344
+ add(
345
+ "lifecycle",
346
+ row.cancelled_at ? "Restore your event…" : "Cancel your event…",
347
+ );
348
+ }
349
+ add("browser", "Open in browser");
350
+ return actions;
351
+ }
352
+ export async function readImage(root, path) {
353
+ const file = await workspacePath(root, path),
354
+ handle = await open(
355
+ file,
356
+ constants.O_RDONLY |
357
+ (constants.O_NOFOLLOW || 0) |
358
+ (constants.O_NONBLOCK || 0),
359
+ );
360
+ try {
361
+ const info = await handle.stat();
362
+ if (!info.isFile() || !info.size || info.size > 4 * 1024 * 1024)
363
+ throw Error("Choose an image up to 4 MiB.");
364
+ const buffer = Buffer.alloc(4 * 1024 * 1024 + 1);
365
+ let size = 0;
366
+ while (size < buffer.length) {
367
+ const r = await handle.read(buffer, size, buffer.length - size, null);
368
+ if (!r.bytesRead) break;
369
+ size += r.bytesRead;
370
+ }
371
+ if (size > 4 * 1024 * 1024) throw Error("Image exceeds 4 MiB.");
372
+ const bytes = buffer.subarray(0, size);
373
+ const mime = bytes
374
+ .subarray(0, 8)
375
+ .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
376
+ ? "image/png"
377
+ : bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255
378
+ ? "image/jpeg"
379
+ : /^GIF8[79]a/.test(bytes.subarray(0, 6).toString())
380
+ ? "image/gif"
381
+ : bytes.subarray(0, 4).toString() === "RIFF" &&
382
+ bytes.subarray(8, 12).toString() === "WEBP"
383
+ ? "image/webp"
384
+ : null;
385
+ if (!mime)
386
+ throw Error(
387
+ "Choose a PNG, JPEG, WebP or GIF image. SVG is not accepted.",
388
+ );
389
+ return {
390
+ path,
391
+ name: basename(file),
392
+ bytes,
393
+ size,
394
+ mime,
395
+ hash: createHash("sha256").update(bytes).digest("hex"),
396
+ };
397
+ } finally {
398
+ await handle.close();
399
+ }
400
+ }
401
+ const lines = (text) =>
402
+ String(text || "")
403
+ .split("\n")
404
+ .map((s) => s.trim())
405
+ .filter(Boolean);
406
+ function pairs(value) {
407
+ return lines(value).map((line) => {
408
+ const at = line.indexOf("|");
409
+ if (at < 1 || !line.slice(at + 1).trim())
410
+ throw Error("Use one Label | Value per line.");
411
+ return [line.slice(0, at).trim(), line.slice(at + 1).trim()];
412
+ });
413
+ }
414
+ export function webURL(value, optional = false) {
415
+ if (!value && optional) return "";
416
+ let url;
417
+ try {
418
+ url = new URL(value);
419
+ } catch {
420
+ throw Error("Use a complete http:// or https:// URL.");
421
+ }
422
+ if (
423
+ !["http:", "https:"].includes(url.protocol) ||
424
+ url.username ||
425
+ url.password ||
426
+ value.length > 1000
427
+ )
428
+ throw Error("Use an http(s) URL without embedded credentials.");
429
+ return value;
430
+ }
431
+ export function validateFilter(values) {
432
+ if (
433
+ values.date &&
434
+ (!/^\d{4}-\d{2}-\d{2}$/.test(values.date) ||
435
+ new Date(values.date + "T00:00Z").toISOString().slice(0, 10) !==
436
+ values.date)
437
+ )
438
+ throw Error("Use a valid YYYY-MM-DD date.");
439
+ if (values.timezone)
440
+ new Intl.DateTimeFormat("en", { timeZone: values.timezone });
441
+ return { ...values };
442
+ }
443
+ export async function prepareCatalog(api, root, form) {
444
+ const v = form.values,
445
+ entity = form.entity;
446
+ const details = {
447
+ links: pairs(v.links).map(([label, url]) => {
448
+ if (label.length > 80) throw Error("Link labels allow 80 characters.");
449
+ return { label, url: webURL(url) };
450
+ }),
451
+ };
452
+ if (details.links.length > 100)
453
+ throw Error("Use up to 100 additional links.");
454
+ let payload;
455
+ if (entity === "project") {
456
+ payload = Object.fromEntries(
457
+ [
458
+ "name",
459
+ "description",
460
+ "help",
461
+ "topic",
462
+ "stage",
463
+ "stack",
464
+ "url",
465
+ "repo",
466
+ ].map((k) => [k, v[k] || ""]),
467
+ );
468
+ webURL(v.url, true);
469
+ webURL(v.repo, true);
470
+ Object.assign(details, {
471
+ tagline: v.tagline,
472
+ problem: v.problem,
473
+ audience: v.audience,
474
+ });
475
+ const team = pairs(v.team);
476
+ if (team.length > 30) throw Error("Use up to 30 team members.");
477
+ details.team = [];
478
+ for (const [handle, role] of team) {
479
+ if (role.length > 80) throw Error("Team roles allow 80 characters.");
480
+ let person;
481
+ if (/^#[1-9]\d*$/.test(handle)) {
482
+ const id = Number(handle.slice(1));
483
+ person =
484
+ form.existing?.team?.find((m) => m.user_id === id) ||
485
+ (await api("/users/" + id));
486
+ } else {
487
+ const username = handle.replace(/^@/, "");
488
+ if (!/^[a-zA-Z0-9_]{3,30}$/.test(username))
489
+ throw Error("Choose a valid @username or existing #user_id.");
490
+ for (let page = 1; page <= 5 && !person; page++) {
491
+ const r = await api(
492
+ `/people?q=${encodeURIComponent(username)}&limit=30&page=${page}`,
493
+ );
494
+ person = r.items.find(
495
+ (p) => p.username?.toLowerCase() === username.toLowerCase(),
496
+ );
497
+ if (page >= r.pagination.pages) break;
498
+ }
499
+ }
500
+ if (!person) throw Error("Team member not found: " + handle);
501
+ if (
502
+ details.team.some(
503
+ (m) => m.user_id === person.id || m.user_id === person.user_id,
504
+ )
505
+ )
506
+ throw Error("Choose distinct team members.");
507
+ details.team.push({ user_id: person.user_id || person.id, role });
508
+ }
509
+ } else {
510
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(v.local_start))
511
+ throw Error("Use YYYY-MM-DDTHH:mm for the organizer's local start.");
512
+ new Intl.DateTimeFormat("en", { timeZone: v.timezone });
513
+ const duration = Number(v.duration);
514
+ if (!Number.isInteger(duration) || duration < 15 || duration > 720)
515
+ throw Error("Use a duration of 15–720 minutes.");
516
+ payload = {
517
+ ...Object.fromEntries(
518
+ ["title", "description", "topic", "local_start", "timezone"].map(
519
+ (k) => [k, v[k]],
520
+ ),
521
+ ),
522
+ duration,
523
+ url: webURL(v.url),
524
+ };
525
+ }
526
+ if (form.entityId) payload.revision = form.revision;
527
+ const files = [];
528
+ for (const key of entity === "project"
529
+ ? ["logo", "screenshots"]
530
+ : ["banner"]) {
531
+ const value = (v[key] || "").trim();
532
+ if (!value) continue;
533
+ if (value === "-") {
534
+ details[key] = key === "screenshots" ? [] : null;
535
+ continue;
536
+ }
537
+ const paths = key === "screenshots" ? lines(value) : [value];
538
+ if (paths.length > (key === "screenshots" ? 10 : 1))
539
+ throw Error("Choose up to ten screenshots and one logo/banner.");
540
+ for (const path of paths)
541
+ files.push({ ...(await readImage(root, path)), key });
542
+ }
543
+ if (files.reduce((n, file) => n + file.size, 0) > 20 * 1024 * 1024)
544
+ throw Error("Choose at most 20 MiB of images for one publication.");
545
+ return { entity, id: form.entityId, payload: { ...payload, details }, files };
546
+ }
547
+ // Durable progress stores only IDs/hashes. Publication is never automatically replayed.
548
+ export async function publishCatalog(
549
+ api,
550
+ plan,
551
+ progress = {},
552
+ save = async () => {},
553
+ ) {
554
+ if (progress.completedId) return { id: progress.completedId };
555
+ if (progress.uncertain)
556
+ throw Error(
557
+ "The previous publication outcome is unknown. Check My projects / Hosting or attending before creating another copy. Keep this draft for reference.",
558
+ );
559
+ const next = { ...progress, uploads: { ...progress.uploads } },
560
+ details = { ...plan.payload.details };
561
+ for (const file of plan.files) {
562
+ let id = next.uploads[file.hash];
563
+ if (!id) {
564
+ const uploaded = await api("/uploads", {
565
+ method: "POST",
566
+ raw: file.bytes,
567
+ filename: file.name,
568
+ });
569
+ id = uploaded.id;
570
+ next.uploads[file.hash] = id;
571
+ await save({ ...next, uploads: { ...next.uploads } });
572
+ }
573
+ if (file.key === "screenshots") {
574
+ details.screenshots ||= [];
575
+ details.screenshots.push(id);
576
+ } else details[file.key] = id;
577
+ }
578
+ await save({ ...next, uncertain: true });
579
+ let result;
580
+ try {
581
+ result = await api(`/${plan.entity}s${plan.id ? "/" + plan.id : ""}`, {
582
+ method: plan.id ? "PATCH" : "POST",
583
+ body: { ...plan.payload, details },
584
+ });
585
+ } catch (error) {
586
+ if (error.status >= 400 && error.status < 500)
587
+ await save({ ...next, uncertain: false });
588
+ throw error;
589
+ }
590
+ await save({ ...next, completedId: result.id, uncertain: false });
591
+ return result;
592
+ }
593
+ export async function saveCalendar(api, root, id, path) {
594
+ if (!/\.ics$/i.test(path) || path.split(/[\\/]/).at(-1).startsWith("."))
595
+ throw Error("Choose a new .ics filename.");
596
+ const directory = await workspacePath(root, dirname(path));
597
+ const text = await api(`/events/${id}/calendar`, { responseType: "text" });
598
+ if (!text.startsWith("BEGIN:VCALENDAR") || !text.includes("END:VCALENDAR"))
599
+ throw Error("The server returned an invalid calendar.");
600
+ const file = join(directory, basename(path));
601
+ try {
602
+ await writeFile(file, text, { flag: "wx", mode: 0o600 });
603
+ } catch (error) {
604
+ if (error.code === "EEXIST")
605
+ throw Error(
606
+ "This filename already exists. Choose a different .ics filename.",
607
+ );
608
+ throw error;
609
+ }
610
+ return file;
611
+ }
package/lib/client.mjs CHANGED
@@ -90,7 +90,10 @@ export async function removeConfig() {
90
90
  }
91
91
  export function client(origin, token = null) {
92
92
  origin = originOf(origin);
93
- return async (path, { method = "GET", body, signal } = {}) => {
93
+ return async (
94
+ path,
95
+ { method = "GET", body, signal, raw, filename, responseType = "json" } = {},
96
+ ) => {
94
97
  if (!path.startsWith("/") || path.startsWith("//"))
95
98
  throw Error("Invalid API path.");
96
99
  let response;
@@ -99,11 +102,18 @@ export function client(origin, token = null) {
99
102
  method,
100
103
  redirect: "error",
101
104
  headers: {
102
- Accept: "application/json",
105
+ Accept:
106
+ responseType === "text" ? "text/calendar" : "application/json",
107
+ ...(raw
108
+ ? {
109
+ "Content-Type": "application/octet-stream",
110
+ "X-Filename": encodeURIComponent(filename || "image"),
111
+ }
112
+ : {}),
103
113
  ...(body ? { "Content-Type": "application/json" } : {}),
104
114
  ...(token ? { Authorization: "Bearer " + token } : {}),
105
115
  },
106
- body: body ? JSON.stringify(body) : undefined,
116
+ body: raw || (body ? JSON.stringify(body) : undefined),
107
117
  signal: signal
108
118
  ? AbortSignal.any([signal, AbortSignal.timeout(20000)])
109
119
  : AbortSignal.timeout(20000),
@@ -128,6 +138,7 @@ export function client(origin, token = null) {
128
138
  chunks.push(chunk);
129
139
  }
130
140
  const text = Buffer.concat(chunks).toString("utf8");
141
+ if (responseType === "text" && response.ok) return text;
131
142
  let data;
132
143
  try {
133
144
  data = JSON.parse(text);
@@ -1,6 +1,7 @@
1
+ import { catalogActions } from "./catalog.mjs";
1
2
  // Visibility is a convenience; the API remains the permission authority.
2
3
  export function contextActions({ route, data, selected, user, live }) {
3
- const actions = [],
4
+ const actions = catalogActions(route, data, user),
4
5
  post = data?.post;
5
6
  const add = (id, label) => actions.push({ id, label });
6
7
  const feedback =
package/lib/drafts.mjs CHANGED
@@ -56,9 +56,14 @@ export class DraftStore {
56
56
  record.id !== id ||
57
57
  Date.now() - record.savedAt > TTL ||
58
58
  record.savedAt > Date.now() + 60000 ||
59
- !["publication", "reply", "line", "edit", "community"].includes(
60
- record.form?.kind,
61
- ) ||
59
+ ![
60
+ "publication",
61
+ "reply",
62
+ "line",
63
+ "edit",
64
+ "community",
65
+ "catalog",
66
+ ].includes(record.form?.kind) ||
62
67
  !record.form?.values ||
63
68
  typeof record.form.values !== "object" ||
64
69
  Array.isArray(record.form.values)
@@ -82,7 +87,9 @@ export class DraftStore {
82
87
  }
83
88
  async save(form) {
84
89
  if (
85
- ["open", "report"].includes(form.kind) ||
90
+ ["open", "report", "catalogFilter", "calendar", "appearance"].includes(
91
+ form.kind,
92
+ ) ||
86
93
  !["title", "body", "name", "description", "file", "review_focus"].some(
87
94
  (key) => String(form.values[key] || "").trim(),
88
95
  )