trelly 0.1.0 → 0.2.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/.claude-plugin/plugin.json +15 -0
- package/.codex-plugin/plugin.json +25 -0
- package/.cursor-plugin/mcp.json +11 -0
- package/.cursor-plugin/plugin.json +15 -0
- package/.mcp.json +8 -0
- package/PLUGIN.md +85 -0
- package/PRIVACY.md +43 -0
- package/README.md +58 -9
- package/assets/logo.svg +6 -0
- package/assets/terminal.gif +0 -0
- package/bin/install-cursor-plugin-local.sh +15 -0
- package/bin/run-ts +5 -0
- package/bin/trelly +8 -1
- package/bin/trelly-mcp +8 -1
- package/package.json +18 -4
- package/skills/README.md +115 -0
- package/skills/trelly/SKILL.md +105 -0
- package/skills/trelly-mcp/SKILL.md +115 -0
- package/src/api/client.ts +18 -3
- package/src/cli/commands/boards.ts +6 -6
- package/src/cli/commands/cards.ts +19 -1
- package/src/cli/ui/app.tsx +263 -70
- package/src/index.test.ts +9 -0
- package/src/util/attachment.ts +29 -0
package/src/cli/ui/app.tsx
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
2
|
-
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
5
|
import type { TrelloClient } from "../../api/client.ts";
|
|
6
|
+
import { attachmentForm } from "../../util/attachment.ts";
|
|
7
|
+
import { openInBrowser } from "../../util/runtime.ts";
|
|
4
8
|
import {
|
|
5
9
|
type CardChip,
|
|
6
10
|
customFieldChips,
|
|
@@ -51,13 +55,19 @@ type BoardData = {
|
|
|
51
55
|
};
|
|
52
56
|
|
|
53
57
|
type CardExtras = {
|
|
54
|
-
attachments: Array<{ id: string; name: string }>;
|
|
58
|
+
attachments: Array<{ id: string; name: string; url: string }>;
|
|
55
59
|
checklists: Array<{
|
|
56
60
|
id: string;
|
|
57
61
|
name: string;
|
|
58
62
|
items: Array<{ id: string; name: string; complete: boolean }>;
|
|
59
63
|
}>;
|
|
60
|
-
comments: Array<{
|
|
64
|
+
comments: Array<{
|
|
65
|
+
id: string;
|
|
66
|
+
author: string;
|
|
67
|
+
username?: string;
|
|
68
|
+
date: string;
|
|
69
|
+
text: string;
|
|
70
|
+
}>;
|
|
61
71
|
error?: string;
|
|
62
72
|
};
|
|
63
73
|
|
|
@@ -215,6 +225,8 @@ function Column({
|
|
|
215
225
|
);
|
|
216
226
|
}
|
|
217
227
|
|
|
228
|
+
type DetailMode = "view" | "comment" | "reply" | "attach";
|
|
229
|
+
|
|
218
230
|
function CardDetail({
|
|
219
231
|
card,
|
|
220
232
|
listName,
|
|
@@ -222,6 +234,9 @@ function CardDetail({
|
|
|
222
234
|
defs,
|
|
223
235
|
membersById,
|
|
224
236
|
extras,
|
|
237
|
+
client,
|
|
238
|
+
onClose,
|
|
239
|
+
onChanged,
|
|
225
240
|
}: {
|
|
226
241
|
card: UiCard;
|
|
227
242
|
listName: string;
|
|
@@ -229,6 +244,9 @@ function CardDetail({
|
|
|
229
244
|
defs: UiCustomFieldDef[];
|
|
230
245
|
membersById: Map<string, string>;
|
|
231
246
|
extras?: CardExtras;
|
|
247
|
+
client: TrelloClient;
|
|
248
|
+
onClose: () => void;
|
|
249
|
+
onChanged: () => void;
|
|
232
250
|
}) {
|
|
233
251
|
const status = dueStatus(card.due, card.dueComplete);
|
|
234
252
|
const checkItems = card.badges?.checkItems ?? 0;
|
|
@@ -238,6 +256,131 @@ function CardDetail({
|
|
|
238
256
|
const memberNames = (card.idMembers ?? [])
|
|
239
257
|
.map((id) => membersById.get(id))
|
|
240
258
|
.filter((name): name is string => Boolean(name));
|
|
259
|
+
|
|
260
|
+
const [focus, setFocus] = useState(0);
|
|
261
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
262
|
+
const [mode, setMode] = useState<DetailMode>("view");
|
|
263
|
+
const [buffer, setBuffer] = useState("");
|
|
264
|
+
const [busy, setBusy] = useState(false);
|
|
265
|
+
const [notice, setNotice] = useState<{ text: string; error?: boolean } | null>(null);
|
|
266
|
+
// refs mirror state so several key events in one tick see current values
|
|
267
|
+
const bufferRef = useRef("");
|
|
268
|
+
const busyRef = useRef(false);
|
|
269
|
+
const setComposerBuffer = useCallback((value: string) => {
|
|
270
|
+
bufferRef.current = value;
|
|
271
|
+
setBuffer(value);
|
|
272
|
+
}, []);
|
|
273
|
+
|
|
274
|
+
const attachments = extras?.attachments ?? [];
|
|
275
|
+
const comments = extras?.comments ?? [];
|
|
276
|
+
const itemCount = attachments.length + comments.length;
|
|
277
|
+
const safeFocus = itemCount === 0 ? -1 : Math.min(focus, itemCount - 1);
|
|
278
|
+
const focusedAttachment =
|
|
279
|
+
safeFocus >= 0 && safeFocus < attachments.length
|
|
280
|
+
? attachments[safeFocus]
|
|
281
|
+
: undefined;
|
|
282
|
+
const focusedComment =
|
|
283
|
+
safeFocus >= attachments.length
|
|
284
|
+
? comments[safeFocus - attachments.length]
|
|
285
|
+
: undefined;
|
|
286
|
+
|
|
287
|
+
const submit = useCallback(
|
|
288
|
+
async (raw: string) => {
|
|
289
|
+
const text = raw.trim();
|
|
290
|
+
if (!text || busyRef.current) return;
|
|
291
|
+
busyRef.current = true;
|
|
292
|
+
setBusy(true);
|
|
293
|
+
setNotice(null);
|
|
294
|
+
try {
|
|
295
|
+
if (mode === "attach") {
|
|
296
|
+
if (/^https?:\/\//i.test(text)) {
|
|
297
|
+
await client.cardAddAttachment(card.id, { url: text });
|
|
298
|
+
} else {
|
|
299
|
+
const path = text.startsWith("~/") ? join(homedir(), text.slice(2)) : text;
|
|
300
|
+
await client.cardUploadAttachment(card.id, attachmentForm(path));
|
|
301
|
+
}
|
|
302
|
+
setNotice({ text: "✓ attachment added" });
|
|
303
|
+
} else {
|
|
304
|
+
await client.cardComment(card.id, text);
|
|
305
|
+
setNotice({ text: "✓ comment added" });
|
|
306
|
+
}
|
|
307
|
+
setMode("view");
|
|
308
|
+
setComposerBuffer("");
|
|
309
|
+
onChanged();
|
|
310
|
+
} catch (err) {
|
|
311
|
+
setNotice({
|
|
312
|
+
text: err instanceof Error ? err.message : String(err),
|
|
313
|
+
error: true,
|
|
314
|
+
});
|
|
315
|
+
} finally {
|
|
316
|
+
busyRef.current = false;
|
|
317
|
+
setBusy(false);
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
[mode, client, card.id, onChanged, setComposerBuffer],
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
useInput((input, key) => {
|
|
324
|
+
if (busyRef.current) return;
|
|
325
|
+
if (mode !== "view") {
|
|
326
|
+
if (key.escape) {
|
|
327
|
+
setMode("view");
|
|
328
|
+
setComposerBuffer("");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (key.backspace || key.delete) {
|
|
332
|
+
setComposerBuffer(bufferRef.current.slice(0, -1));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
// paste can deliver text and the newline in one event; append before submit
|
|
336
|
+
const typed = input && !key.ctrl && !key.meta ? input.replace(/[\r\n]/g, "") : "";
|
|
337
|
+
if (typed) setComposerBuffer(bufferRef.current + typed);
|
|
338
|
+
if (key.return) void submit(bufferRef.current);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (key.escape || input === "q") {
|
|
342
|
+
onClose();
|
|
343
|
+
} else if (key.upArrow || input === "k") {
|
|
344
|
+
if (safeFocus > 0) setFocus(safeFocus - 1);
|
|
345
|
+
} else if (key.downArrow || input === "j") {
|
|
346
|
+
if (safeFocus >= 0 && safeFocus < itemCount - 1) setFocus(safeFocus + 1);
|
|
347
|
+
} else if (key.return && focusedAttachment) {
|
|
348
|
+
openInBrowser(focusedAttachment.url).then(
|
|
349
|
+
() => setNotice({ text: `✓ opened ${focusedAttachment.name}` }),
|
|
350
|
+
(err) =>
|
|
351
|
+
setNotice({
|
|
352
|
+
text: err instanceof Error ? err.message : String(err),
|
|
353
|
+
error: true,
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
} else if (key.return && focusedComment) {
|
|
357
|
+
setExpanded((prev) => {
|
|
358
|
+
const next = new Set(prev);
|
|
359
|
+
if (next.has(focusedComment.id)) next.delete(focusedComment.id);
|
|
360
|
+
else next.add(focusedComment.id);
|
|
361
|
+
return next;
|
|
362
|
+
});
|
|
363
|
+
} else if (input === "c") {
|
|
364
|
+
setMode("comment");
|
|
365
|
+
setComposerBuffer("");
|
|
366
|
+
setNotice(null);
|
|
367
|
+
} else if (input === "r" && focusedComment) {
|
|
368
|
+
setMode("reply");
|
|
369
|
+
setComposerBuffer(focusedComment.username ? `@${focusedComment.username} ` : "");
|
|
370
|
+
setNotice(null);
|
|
371
|
+
} else if (input === "a") {
|
|
372
|
+
setMode("attach");
|
|
373
|
+
setComposerBuffer("");
|
|
374
|
+
setNotice(null);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const commentFocus = safeFocus - attachments.length;
|
|
379
|
+
const maxComments = 5;
|
|
380
|
+
const commentStart = commentFocus >= maxComments ? commentFocus - maxComments + 1 : 0;
|
|
381
|
+
const visibleComments = comments.slice(commentStart, commentStart + maxComments);
|
|
382
|
+
const commentsBelow = comments.length - (commentStart + visibleComments.length);
|
|
383
|
+
|
|
241
384
|
return (
|
|
242
385
|
<Box flexDirection="column" paddingX={1} width={Math.min(width, 82)}>
|
|
243
386
|
<Text bold wrap="truncate">
|
|
@@ -315,34 +458,77 @@ function CardDetail({
|
|
|
315
458
|
) : null}
|
|
316
459
|
</Box>
|
|
317
460
|
))}
|
|
318
|
-
{
|
|
461
|
+
{attachments.length > 0 ? (
|
|
319
462
|
<Box flexDirection="column" marginTop={1}>
|
|
320
|
-
<Text bold>📎 {
|
|
321
|
-
{
|
|
322
|
-
<Text key={attachment.id}
|
|
323
|
-
{
|
|
463
|
+
<Text bold>📎 {attachments.length} attachments</Text>
|
|
464
|
+
{attachments.map((attachment, i) => (
|
|
465
|
+
<Text key={attachment.id} wrap="truncate">
|
|
466
|
+
{safeFocus === i ? <Text color={TRELLO_BLUE}>❯ </Text> : " "}
|
|
467
|
+
<Text bold={safeFocus === i} dimColor={safeFocus !== i}>
|
|
468
|
+
{attachment.name}
|
|
469
|
+
</Text>
|
|
324
470
|
</Text>
|
|
325
471
|
))}
|
|
326
|
-
{extras.attachments.length > 4 ? (
|
|
327
|
-
<Text dimColor>… {extras.attachments.length - 4} more</Text>
|
|
328
|
-
) : null}
|
|
329
472
|
</Box>
|
|
330
473
|
) : null}
|
|
331
|
-
{
|
|
474
|
+
{comments.length > 0 ? (
|
|
332
475
|
<Box flexDirection="column" marginTop={1}>
|
|
333
476
|
<Text bold>💬 comments</Text>
|
|
334
|
-
{
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
<
|
|
340
|
-
|
|
341
|
-
|
|
477
|
+
{commentStart > 0 ? <Text dimColor> ↑ {commentStart} more</Text> : null}
|
|
478
|
+
{visibleComments.map((comment, i) => {
|
|
479
|
+
const focused = commentFocus === commentStart + i;
|
|
480
|
+
const isExpanded = expanded.has(comment.id);
|
|
481
|
+
return (
|
|
482
|
+
<Box key={comment.id} flexDirection="column">
|
|
483
|
+
<Text wrap="truncate">
|
|
484
|
+
{focused ? <Text color={TRELLO_BLUE}>❯ </Text> : " "}
|
|
485
|
+
<Text dimColor bold={focused}>
|
|
486
|
+
{comment.author} · {comment.date.slice(0, 16).replace("T", " ")}
|
|
487
|
+
</Text>
|
|
488
|
+
</Text>
|
|
489
|
+
<Box paddingLeft={2}>
|
|
490
|
+
<Text wrap={isExpanded ? "wrap" : "truncate-end"}>
|
|
491
|
+
{isExpanded ? comment.text : truncateLines(comment.text, 2)}
|
|
492
|
+
</Text>
|
|
493
|
+
</Box>
|
|
494
|
+
</Box>
|
|
495
|
+
);
|
|
496
|
+
})}
|
|
497
|
+
{commentsBelow > 0 ? <Text dimColor> ↓ {commentsBelow} more</Text> : null}
|
|
342
498
|
</Box>
|
|
343
499
|
) : null}
|
|
344
500
|
</>
|
|
345
501
|
)}
|
|
502
|
+
{mode !== "view" ? (
|
|
503
|
+
<Box flexDirection="column" marginTop={1}>
|
|
504
|
+
<Text bold>
|
|
505
|
+
{mode === "attach"
|
|
506
|
+
? "attach (file path or URL)"
|
|
507
|
+
: mode === "reply"
|
|
508
|
+
? "reply"
|
|
509
|
+
: "comment"}
|
|
510
|
+
</Text>
|
|
511
|
+
<Text>
|
|
512
|
+
{"> "}
|
|
513
|
+
{buffer}
|
|
514
|
+
<Text inverse> </Text>
|
|
515
|
+
</Text>
|
|
516
|
+
</Box>
|
|
517
|
+
) : null}
|
|
518
|
+
{busy ? (
|
|
519
|
+
<Text dimColor>sending…</Text>
|
|
520
|
+
) : notice ? (
|
|
521
|
+
<Text color={notice.error ? "#eb5a46" : undefined} dimColor={!notice.error}>
|
|
522
|
+
{notice.text}
|
|
523
|
+
</Text>
|
|
524
|
+
) : null}
|
|
525
|
+
<Box marginTop={1}>
|
|
526
|
+
<Text dimColor>
|
|
527
|
+
{mode !== "view"
|
|
528
|
+
? "⏎ send · esc cancel"
|
|
529
|
+
: "↑↓ move · ⏎ open/expand · c comment · r reply · a attach · esc back"}
|
|
530
|
+
</Text>
|
|
531
|
+
</Box>
|
|
346
532
|
</Box>
|
|
347
533
|
);
|
|
348
534
|
}
|
|
@@ -415,12 +601,12 @@ function BoardView({
|
|
|
415
601
|
}, [load]);
|
|
416
602
|
|
|
417
603
|
const loadExtras = useCallback(
|
|
418
|
-
async (card: UiCard) => {
|
|
419
|
-
if (extras.has(card.id)) return;
|
|
604
|
+
async (card: UiCard, force = false) => {
|
|
605
|
+
if (!force && extras.has(card.id)) return;
|
|
420
606
|
try {
|
|
421
607
|
const [attachments, checklists, actions] = await Promise.all([
|
|
422
608
|
client.cardAttachments(card.id) as Promise<
|
|
423
|
-
Array<{ id: string; name: string }>
|
|
609
|
+
Array<{ id: string; name: string; url: string }>
|
|
424
610
|
>,
|
|
425
611
|
client.get(`/cards/${card.id}/checklists`) as Promise<
|
|
426
612
|
Array<{
|
|
@@ -429,7 +615,7 @@ function BoardView({
|
|
|
429
615
|
checkItems?: Array<{ id: string; name: string; state?: string }>;
|
|
430
616
|
}>
|
|
431
617
|
>,
|
|
432
|
-
client.cardActions(card.id, { filter: "commentCard", limit:
|
|
618
|
+
client.cardActions(card.id, { filter: "commentCard", limit: 20 }) as Promise<
|
|
433
619
|
Array<{
|
|
434
620
|
id: string;
|
|
435
621
|
date: string;
|
|
@@ -440,7 +626,11 @@ function BoardView({
|
|
|
440
626
|
]);
|
|
441
627
|
setExtras((prev) =>
|
|
442
628
|
new Map(prev).set(card.id, {
|
|
443
|
-
attachments: attachments.map((a) => ({
|
|
629
|
+
attachments: attachments.map((a) => ({
|
|
630
|
+
id: a.id,
|
|
631
|
+
name: a.name,
|
|
632
|
+
url: a.url,
|
|
633
|
+
})),
|
|
444
634
|
checklists: checklists.map((cl) => ({
|
|
445
635
|
id: cl.id,
|
|
446
636
|
name: cl.name,
|
|
@@ -456,6 +646,7 @@ function BoardView({
|
|
|
456
646
|
action.memberCreator?.fullName ??
|
|
457
647
|
action.memberCreator?.username ??
|
|
458
648
|
"unknown",
|
|
649
|
+
username: action.memberCreator?.username,
|
|
459
650
|
date: action.date,
|
|
460
651
|
text: action.data?.text ?? "",
|
|
461
652
|
})),
|
|
@@ -475,44 +666,43 @@ function BoardView({
|
|
|
475
666
|
[client, extras],
|
|
476
667
|
);
|
|
477
668
|
|
|
478
|
-
useInput(
|
|
479
|
-
|
|
480
|
-
if (
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
if (safeRow
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
});
|
|
669
|
+
useInput(
|
|
670
|
+
(input, key) => {
|
|
671
|
+
if (input === "q") {
|
|
672
|
+
exit();
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (input === "r") {
|
|
676
|
+
void load();
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if ((key.escape || key.backspace) && onBack) {
|
|
680
|
+
onBack();
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (!data || data.lists.length === 0) return;
|
|
684
|
+
const lists = data.lists;
|
|
685
|
+
const safeCol = Math.min(col, lists.length - 1);
|
|
686
|
+
const cards = data.cardsByList.get(lists[safeCol].id) ?? [];
|
|
687
|
+
const safeRow = cards.length === 0 ? -1 : Math.min(row, cards.length - 1);
|
|
688
|
+
if (key.leftArrow || input === "h") {
|
|
689
|
+
setCol(Math.max(0, safeCol - 1));
|
|
690
|
+
setRow(0);
|
|
691
|
+
} else if (key.rightArrow || input === "l") {
|
|
692
|
+
setCol(Math.min(lists.length - 1, safeCol + 1));
|
|
693
|
+
setRow(0);
|
|
694
|
+
} else if (key.upArrow || input === "k") {
|
|
695
|
+
if (safeRow > 0) setRow(safeRow - 1);
|
|
696
|
+
} else if (key.downArrow || input === "j") {
|
|
697
|
+
if (safeRow >= 0 && safeRow < cards.length - 1) setRow(safeRow + 1);
|
|
698
|
+
} else if (key.return && safeRow >= 0) {
|
|
699
|
+
const focusedCard = cards[safeRow];
|
|
700
|
+
if (focusedCard) void loadExtras(focusedCard);
|
|
701
|
+
setDetail(true);
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
{ isActive: !detail },
|
|
705
|
+
);
|
|
516
706
|
|
|
517
707
|
const columns = stdout?.columns ?? 80;
|
|
518
708
|
const rows = stdout?.rows ?? 24;
|
|
@@ -568,6 +758,9 @@ function BoardView({
|
|
|
568
758
|
defs={data.customFields}
|
|
569
759
|
membersById={data.membersById}
|
|
570
760
|
extras={extras.get(focusedCard.id)}
|
|
761
|
+
client={client}
|
|
762
|
+
onClose={() => setDetail(false)}
|
|
763
|
+
onChanged={() => void loadExtras(focusedCard, true)}
|
|
571
764
|
/>
|
|
572
765
|
) : (
|
|
573
766
|
<Box>
|
|
@@ -588,13 +781,13 @@ function BoardView({
|
|
|
588
781
|
{colStart + visibleCols < lists.length ? <Text dimColor>›</Text> : null}
|
|
589
782
|
</Box>
|
|
590
783
|
)}
|
|
591
|
-
|
|
592
|
-
<
|
|
593
|
-
|
|
594
|
-
? "esc
|
|
595
|
-
|
|
596
|
-
</
|
|
597
|
-
|
|
784
|
+
{detail ? null : (
|
|
785
|
+
<Box marginTop={1}>
|
|
786
|
+
<Text dimColor>
|
|
787
|
+
{`←→ lists · ↑↓ cards · ⏎ detail · r refresh${onBack ? " · esc boards" : ""} · q quit`}
|
|
788
|
+
</Text>
|
|
789
|
+
</Box>
|
|
790
|
+
)}
|
|
598
791
|
</Box>
|
|
599
792
|
);
|
|
600
793
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { parseKvPairs } from "./cli/context.ts";
|
|
|
6
6
|
import { customFieldChips } from "./cli/ui/custom-fields.ts";
|
|
7
7
|
import { dueStatus, labelHex, listAccentHex } from "./cli/ui/palette.ts";
|
|
8
8
|
import { isBoard, isCard, isLabel, isList } from "./cli/ui/shapes.ts";
|
|
9
|
+
import { attachmentMime } from "./util/attachment.ts";
|
|
9
10
|
|
|
10
11
|
describe("parseKvPairs", () => {
|
|
11
12
|
it("parses key=value pairs", () => {
|
|
@@ -39,6 +40,14 @@ describe("authLoginUrl", () => {
|
|
|
39
40
|
});
|
|
40
41
|
});
|
|
41
42
|
|
|
43
|
+
describe("attachmentMime", () => {
|
|
44
|
+
it("maps common extensions and falls back to octet-stream", () => {
|
|
45
|
+
assert.equal(attachmentMime("shot.PNG"), "image/png");
|
|
46
|
+
assert.equal(attachmentMime("doc.pdf"), "application/pdf");
|
|
47
|
+
assert.equal(attachmentMime("archive.zip"), "application/octet-stream");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
42
51
|
describe("trello HTTP helpers", () => {
|
|
43
52
|
it("parses JSON responses", () => {
|
|
44
53
|
assert.deepEqual(parseTrelloResponse('{"id":"1"}'), { id: "1" });
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename, extname } from "node:path";
|
|
3
|
+
|
|
4
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
5
|
+
".png": "image/png",
|
|
6
|
+
".jpg": "image/jpeg",
|
|
7
|
+
".jpeg": "image/jpeg",
|
|
8
|
+
".gif": "image/gif",
|
|
9
|
+
".webp": "image/webp",
|
|
10
|
+
".svg": "image/svg+xml",
|
|
11
|
+
".pdf": "application/pdf",
|
|
12
|
+
".txt": "text/plain",
|
|
13
|
+
".md": "text/markdown",
|
|
14
|
+
".json": "application/json",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function attachmentMime(filePath: string): string {
|
|
18
|
+
return MIME_BY_EXT[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function attachmentForm(filePath: string, name?: string): FormData {
|
|
22
|
+
const form = new FormData();
|
|
23
|
+
form.append(
|
|
24
|
+
"file",
|
|
25
|
+
new Blob([readFileSync(filePath)], { type: attachmentMime(filePath) }),
|
|
26
|
+
name ?? basename(filePath),
|
|
27
|
+
);
|
|
28
|
+
return form;
|
|
29
|
+
}
|