@cyberismo/backend 0.0.3

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,767 @@
1
+ /**
2
+ Cyberismo
3
+ Copyright © Cyberismo Ltd and contributors 2024
4
+
5
+ This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License version 3 as published by the Free Software Foundation.
6
+
7
+ This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
8
+
9
+ You should have received a copy of the GNU Affero General Public
10
+ License along with this program. If not, see <https://www.gnu.org/licenses/>.
11
+ */
12
+
13
+ import { Hono } from 'hono';
14
+ import Processor from '@asciidoctor/core';
15
+ import {
16
+ Card,
17
+ CardLocation,
18
+ MetadataContent,
19
+ ProjectFetchCardDetails,
20
+ } from '@cyberismo/data-handler/interfaces/project-interfaces';
21
+ import { CommandManager, evaluateMacros } from '@cyberismo/data-handler';
22
+ import { ContentfulStatusCode } from 'hono/utils/http-status';
23
+
24
+ const router = new Hono();
25
+
26
+ /**
27
+ * @swagger
28
+ * /api/cards:
29
+ * get:
30
+ * summary: Returns a list of all cards and their children in the defined project.
31
+ * description: List of cards does not include the content of the cards, only basic metadata. Use the /api/cards/{key} endpoint to get the content of a specific card.
32
+ * responses:
33
+ * 200:
34
+ * description: Object containing the project cards. See definitions.ts/Card for the structure.
35
+ * 400:
36
+ * description: Error in reading project details.
37
+ * 500:
38
+ * description: project_path not set.
39
+ */
40
+ router.get('/', async (c) => {
41
+ const commands = c.get('commands');
42
+
43
+ const projectResponse = await commands.showCmd.showProject();
44
+
45
+ const workflowsResponse = await commands.showCmd.showWorkflowsWithDetails();
46
+ if (!workflowsResponse) {
47
+ return c.text(`No workflows found from path ${c.get('projectPath')}`, 500);
48
+ }
49
+
50
+ const cardTypesResponse = await commands.showCmd.showCardTypesWithDetails();
51
+ if (!cardTypesResponse) {
52
+ return c.text(`No card types found from path ${c.get('projectPath')}`, 500);
53
+ }
54
+
55
+ const cardsResponse = await commands.showCmd.showProjectCards();
56
+
57
+ if (!cardsResponse) {
58
+ return c.text(`No cards found from path ${c.get('projectPath')}`, 500);
59
+ }
60
+
61
+ const response = {
62
+ name: (projectResponse! as any).name,
63
+ cards: cardsResponse,
64
+ workflows: workflowsResponse,
65
+ cardTypes: cardTypesResponse,
66
+ };
67
+ return c.json(response);
68
+ });
69
+
70
+ async function getCardDetails(
71
+ commands: CommandManager,
72
+ key: string,
73
+ ): Promise<any> {
74
+ const fetchCardDetails: ProjectFetchCardDetails = {
75
+ attachments: true,
76
+ children: false,
77
+ content: true,
78
+ contentType: 'adoc',
79
+ metadata: false,
80
+ parent: false,
81
+ location: CardLocation.projectOnly,
82
+ };
83
+
84
+ let cardDetailsResponse: Card | undefined;
85
+ try {
86
+ cardDetailsResponse = await commands.showCmd.showCardDetails(
87
+ fetchCardDetails,
88
+ key,
89
+ );
90
+ } catch {
91
+ return { status: 400, message: `Card ${key} not found from project` };
92
+ }
93
+
94
+ if (!cardDetailsResponse) {
95
+ return { status: 400, message: `Card ${key} not found from project` };
96
+ }
97
+
98
+ let asciidocContent = '';
99
+ try {
100
+ asciidocContent = await evaluateMacros(cardDetailsResponse.content || '', {
101
+ mode: 'inject',
102
+ projectPath: commands.project.basePath || '',
103
+ cardKey: key,
104
+ });
105
+ } catch (error) {
106
+ asciidocContent = `Macro error: ${error instanceof Error ? error.message : 'Unknown error'}\n\n${asciidocContent}`;
107
+ }
108
+
109
+ const htmlContent = Processor()
110
+ .convert(asciidocContent, {
111
+ safe: 'safe',
112
+ attributes: {
113
+ imagesdir: `/api/cards/${key}/a`,
114
+ icons: 'font',
115
+ },
116
+ })
117
+ .toString();
118
+
119
+ // always parse for now
120
+ await commands.calculateCmd.generate();
121
+
122
+ const card = await commands.calculateCmd.runQuery('card', {
123
+ cardKey: key,
124
+ });
125
+
126
+ if (card.length !== 1) {
127
+ throw new Error('Query failed. Check card-query syntax');
128
+ }
129
+
130
+ return {
131
+ status: 200,
132
+ data: {
133
+ ...card[0],
134
+ rawContent: cardDetailsResponse.content || '',
135
+ parsedContent: htmlContent,
136
+ attachments: cardDetailsResponse.attachments,
137
+ },
138
+ };
139
+ }
140
+
141
+ /**
142
+ * @swagger
143
+ * /api/cards/{key}:
144
+ * get:
145
+ * summary: Returns the full content of a specific card including calculations.
146
+ * description: The key parameter is the unique identifier ("cardKey") of the card. The response includes the content as asciidoc(editable) and parsed html, which also has macros already injected
147
+ * parameters:
148
+ * - name: key
149
+ * in: path
150
+ * required: true
151
+ * description: Card key (string)
152
+ * responses:
153
+ * 200:
154
+ * description: Object containing card details. See lib/api/types.ts/CardResponse for the structure.
155
+ * 400:
156
+ * description: No search key or card not found with given key
157
+ * 500:
158
+ * description: project_path not set.
159
+ */
160
+ router.get('/:key', async (c) => {
161
+ const key = c.req.param('key');
162
+ if (!key) {
163
+ return c.text('No search key', 400);
164
+ }
165
+
166
+ const result = await getCardDetails(c.get('commands'), key);
167
+ if (result.status === 200) {
168
+ return c.json(result.data);
169
+ } else {
170
+ return c.text(
171
+ result.message || 'Unknown error',
172
+ result.status as ContentfulStatusCode,
173
+ );
174
+ }
175
+ });
176
+
177
+ /**
178
+ * @swagger
179
+ * /api/cards/{key}:
180
+ * patch:
181
+ * summary: Make changes to a card
182
+ * description: The key parameter is the unique identifier ("cardKey") of the card.
183
+ * parameters:
184
+ * - name: key
185
+ * in: path
186
+ * required: true
187
+ * description: Card key (string)
188
+ * - name: content
189
+ * in: body
190
+ * required: false
191
+ * description: New asciidoc content for the card. Must be a string.
192
+ * - name: metadata
193
+ * in: body
194
+ * type: object
195
+ * required: false
196
+ * description: New metadata for the card. Must be an object with key-value pairs.
197
+ * responses:
198
+ * 200:
199
+ * description: Object containing card details, same as GET. See definitions.ts/CardDetails for the structure.
200
+ * 207:
201
+ * description: Partial success. some updates failed, some succeeded. Returns card object with successful updates.
202
+ * 400:
203
+ * description: Error. Card not found, all updates failed etc. Error message in response body.
204
+ * 500:
205
+ * description: project_path not set.
206
+ */
207
+ router.patch('/:key', async (c) => {
208
+ const commands = c.get('commands');
209
+ const key = c.req.param('key');
210
+ if (!key) {
211
+ return c.text('No search key', 400);
212
+ }
213
+
214
+ const body = await c.req.json();
215
+ let successes = 0;
216
+ const errors = [];
217
+
218
+ if (body.state) {
219
+ try {
220
+ await commands.transitionCmd.cardTransition(key, body.state);
221
+ successes++;
222
+ } catch (error) {
223
+ if (error instanceof Error) errors.push(error.message);
224
+ }
225
+ }
226
+
227
+ if (body.content != null) {
228
+ try {
229
+ await commands.editCmd.editCardContent(key, body.content);
230
+ successes++;
231
+ } catch (error) {
232
+ if (error instanceof Error) errors.push(error.message);
233
+ }
234
+ }
235
+
236
+ if (body.metadata) {
237
+ for (const [metadataKey, metadataValue] of Object.entries(body.metadata)) {
238
+ const value = metadataValue as MetadataContent;
239
+
240
+ try {
241
+ await commands.editCmd.editCardMetadata(key, metadataKey, value);
242
+ successes++;
243
+ } catch (error) {
244
+ if (error instanceof Error) errors.push(error.message);
245
+ }
246
+ }
247
+ }
248
+
249
+ if (body.parent) {
250
+ try {
251
+ await commands.moveCmd.moveCard(key, body.parent);
252
+ successes++;
253
+ } catch (error) {
254
+ if (error instanceof Error) errors.push(error.message);
255
+ }
256
+ }
257
+ if (body.index != null) {
258
+ try {
259
+ await commands.moveCmd.rankByIndex(key, body.index);
260
+ successes++;
261
+ } catch (error) {
262
+ if (error instanceof Error) errors.push(error.message);
263
+ }
264
+ }
265
+
266
+ if (errors.length > 0) {
267
+ return c.text(errors.join('\n'), 400);
268
+ }
269
+
270
+ const result = await getCardDetails(commands, key);
271
+ if (result.status === 200) {
272
+ return c.json(result.data);
273
+ } else {
274
+ return c.text(
275
+ result.message || 'Unknown error',
276
+ result.status as ContentfulStatusCode,
277
+ );
278
+ }
279
+ });
280
+
281
+ /**
282
+ * @swagger
283
+ * /api/cards/{key}:
284
+ * delete:
285
+ * summary: Delete a card
286
+ * description: The key parameter is the unique identifier ("cardKey") of the card.
287
+ * parameters:
288
+ * - name: key
289
+ * in: path
290
+ * required: true
291
+ * description: Card key (string)
292
+ *
293
+ * responses:
294
+ * 204:
295
+ * description: Card deleted successfully.
296
+ * 400:
297
+ * description: Error. Card not found. Error message in response body.
298
+ * 500:
299
+ * description: project_path not set.
300
+ */
301
+ router.delete('/:key', async (c) => {
302
+ const commands = c.get('commands');
303
+ const key = c.req.param('key');
304
+ if (!key) {
305
+ return c.text('No search key', 400);
306
+ }
307
+
308
+ try {
309
+ await commands.removeCmd.remove('card', key);
310
+ return new Response(null, { status: 204 });
311
+ } catch (error) {
312
+ return c.text(
313
+ error instanceof Error ? error.message : 'Unknown error',
314
+ 400,
315
+ );
316
+ }
317
+ });
318
+
319
+ /**
320
+ * @swagger
321
+ * /api/cards/{key}:
322
+ * post:
323
+ * summary: Create a new card
324
+ * description: Creates a new card using the specified template. If key is 'root', creates at root level.
325
+ * parameters:
326
+ * - name: key
327
+ * in: path
328
+ * required: true
329
+ * description: Card key (string)
330
+ * - name: template
331
+ * in: body
332
+ * required: true
333
+ * description: Template to use for creating the card
334
+ * responses:
335
+ * 200:
336
+ * description: Card created successfully
337
+ * 400:
338
+ * description: Error creating card or missing template
339
+ * 500:
340
+ * description: project_path not set
341
+ */
342
+ router.post('/:key', async (c) => {
343
+ const key = c.req.param('key');
344
+ if (!key) {
345
+ return c.text('No search key', 400);
346
+ }
347
+
348
+ const body = await c.req.json();
349
+ if (!body.template) {
350
+ return c.text('template is required', 400);
351
+ }
352
+
353
+ try {
354
+ const result = await c
355
+ .get('commands')
356
+ .createCmd.createCard(body.template, key === 'root' ? undefined : key);
357
+
358
+ if (result.length === 0) {
359
+ return c.json({ error: 'No cards created' }, 400);
360
+ }
361
+ return c.json(result);
362
+ } catch (error) {
363
+ if (error instanceof Error) {
364
+ return c.text(error.message, 400);
365
+ }
366
+ return c.text('Unknown error occurred', 500);
367
+ }
368
+ });
369
+
370
+ /**
371
+ * @swagger
372
+ * /api/cards/{key}/attachments:
373
+ * post:
374
+ * summary: Upload attachments to a card
375
+ * parameters:
376
+ * - name: key
377
+ * in: path
378
+ * required: true
379
+ * schema:
380
+ * type: string
381
+ * requestBody:
382
+ * content:
383
+ * multipart/form-data:
384
+ * schema:
385
+ * type: object
386
+ * properties:
387
+ * file:
388
+ * type: array
389
+ * items:
390
+ * type: string
391
+ * format: binary
392
+ * responses:
393
+ * 200:
394
+ * description: Attachments uploaded successfully
395
+ * 400:
396
+ * description: Invalid request
397
+ * 500:
398
+ * description: Server error
399
+ */
400
+ router.post('/:key/attachments', async (c) => {
401
+ const commands = c.get('commands');
402
+ const key = c.req.param('key');
403
+
404
+ try {
405
+ const formData = await c.req.formData();
406
+ const files = formData.getAll('files');
407
+ if (!files || files.length === 0) {
408
+ return c.json({ error: 'No files uploaded' }, 400);
409
+ }
410
+
411
+ const succeeded = [];
412
+ for (const file of files) {
413
+ if (file instanceof File) {
414
+ const buffer = await file.arrayBuffer();
415
+ await commands.createCmd.createAttachment(
416
+ key,
417
+ file.name,
418
+ Buffer.from(buffer),
419
+ );
420
+ succeeded.push(file.name);
421
+ }
422
+ }
423
+
424
+ return c.json({
425
+ message: 'Attachments uploaded successfully',
426
+ files: succeeded,
427
+ });
428
+ } catch (error) {
429
+ return c.json(
430
+ {
431
+ error:
432
+ error instanceof Error
433
+ ? error.message
434
+ : 'Failed to upload attachments',
435
+ },
436
+ 500,
437
+ );
438
+ }
439
+ });
440
+
441
+ /**
442
+ * @swagger
443
+ * /api/cards/{key}/attachments/{filename}:
444
+ * delete:
445
+ * summary: Remove an attachment from a card
446
+ * parameters:
447
+ * - name: key
448
+ * in: path
449
+ * required: true
450
+ * schema:
451
+ * type: string
452
+ * - name: filename
453
+ * in: path
454
+ * required: true
455
+ * schema:
456
+ * type: string
457
+ * responses:
458
+ * 200:
459
+ * description: Attachment removed successfully
460
+ * 400:
461
+ * description: Invalid request
462
+ * 500:
463
+ * description: Server error
464
+ */
465
+ router.delete('/:key/attachments/:filename', async (c) => {
466
+ const commands = c.get('commands');
467
+ const { key, filename } = c.req.param();
468
+
469
+ try {
470
+ await commands.removeCmd.remove('attachment', key, filename);
471
+ return c.json({ message: 'Attachment removed successfully' });
472
+ } catch (error) {
473
+ return c.json(
474
+ {
475
+ error:
476
+ error instanceof Error
477
+ ? error.message
478
+ : 'Failed to remove attachment',
479
+ },
480
+ 500,
481
+ );
482
+ }
483
+ });
484
+
485
+ /**
486
+ * @swagger
487
+ * /api/cards/{key}/attachments/{filename}/open:
488
+ * post:
489
+ * summary: Open an attachment using the system's default application
490
+ * parameters:
491
+ * - name: key
492
+ * in: path
493
+ * required: true
494
+ * schema:
495
+ * type: string
496
+ * - name: filename
497
+ * in: path
498
+ * required: true
499
+ * schema:
500
+ * type: string
501
+ * responses:
502
+ * 200:
503
+ * description: Attachment opened successfully
504
+ * 400:
505
+ * description: Invalid request
506
+ * 500:
507
+ * description: Server error
508
+ */
509
+ router.post('/:key/attachments/:filename/open', async (c) => {
510
+ const commands = c.get('commands');
511
+ const { key, filename } = c.req.param();
512
+
513
+ try {
514
+ await commands.showCmd.openAttachment(key, filename);
515
+ return c.json({ message: 'Attachment opened successfully' });
516
+ } catch (error) {
517
+ return c.json(
518
+ {
519
+ error:
520
+ error instanceof Error ? error.message : 'Failed to open attachment',
521
+ },
522
+ 500,
523
+ );
524
+ }
525
+ });
526
+
527
+ /**
528
+ * @swagger
529
+ * /api/cards/{key}/parse:
530
+ * post:
531
+ * summary: Parse card content
532
+ * parameters:
533
+ * - name: key
534
+ * in: path
535
+ * required: true
536
+ * schema:
537
+ * type: string
538
+ * requestBody:
539
+ * content:
540
+ * application/json:
541
+ * schema:
542
+ * type: object
543
+ * properties:
544
+ * content:
545
+ * type: string
546
+ * responses:
547
+ * 200:
548
+ * description: Content parsed successfully
549
+ * 400:
550
+ * description: Invalid request
551
+ * 500:
552
+ * description: Server error
553
+ */
554
+ router.post('/:key/parse', async (c) => {
555
+ const commands = c.get('commands');
556
+ const key = c.req.param('key');
557
+ const { content } = await c.req.json();
558
+
559
+ if (content == null) {
560
+ return c.json({ error: 'Content is required' }, 400);
561
+ }
562
+
563
+ try {
564
+ let asciidocContent = '';
565
+ try {
566
+ asciidocContent = await evaluateMacros(content, {
567
+ mode: 'inject',
568
+ projectPath: commands.project.basePath || '',
569
+ cardKey: key,
570
+ });
571
+ } catch (error) {
572
+ asciidocContent = `Macro error: ${error instanceof Error ? error.message : 'Unknown error'}\n\n${content}`;
573
+ }
574
+
575
+ const processor = Processor();
576
+ const parsedContent = processor
577
+ .convert(asciidocContent, {
578
+ safe: 'safe',
579
+ attributes: {
580
+ imagesdir: `/api/cards/${key}/a`,
581
+ icons: 'font',
582
+ },
583
+ })
584
+ .toString();
585
+
586
+ return c.json({ parsedContent });
587
+ } catch (error) {
588
+ return c.json(
589
+ {
590
+ error:
591
+ error instanceof Error ? error.message : 'Failed to parse content',
592
+ },
593
+ 500,
594
+ );
595
+ }
596
+ });
597
+
598
+ /**
599
+ * @swagger
600
+ * /api/cards/{key}/links:
601
+ * post:
602
+ * summary: Create a link between cards
603
+ * parameters:
604
+ * - name: key
605
+ * in: path
606
+ * required: true
607
+ * schema:
608
+ * type: string
609
+ * requestBody:
610
+ * content:
611
+ * application/json:
612
+ * schema:
613
+ * type: object
614
+ * properties:
615
+ * toCard:
616
+ * type: string
617
+ * linkType:
618
+ * type: string
619
+ * description:
620
+ * type: string
621
+ * responses:
622
+ * 200:
623
+ * description: Link created successfully
624
+ * 400:
625
+ * description: Invalid request
626
+ * 500:
627
+ * description: Server error
628
+ */
629
+ router.post('/:key/links', async (c) => {
630
+ const commands = c.get('commands');
631
+ const key = c.req.param('key');
632
+ const { toCard, linkType, description } = await c.req.json();
633
+
634
+ if (!toCard || !linkType) {
635
+ return c.json({ error: 'toCard and linkType are required' }, 400);
636
+ }
637
+
638
+ try {
639
+ await commands.createCmd.createLink(key, toCard, linkType, description);
640
+ return c.json({ message: 'Link created successfully' });
641
+ } catch (error) {
642
+ return c.json(
643
+ {
644
+ error: error instanceof Error ? error.message : 'Failed to create link',
645
+ },
646
+ 500,
647
+ );
648
+ }
649
+ });
650
+
651
+ /**
652
+ * @swagger
653
+ * /api/cards/{key}/links:
654
+ * delete:
655
+ * summary: Remove a link between cards
656
+ * parameters:
657
+ * - name: key
658
+ * in: path
659
+ * required: true
660
+ * schema:
661
+ * type: string
662
+ * requestBody:
663
+ * content:
664
+ * application/json:
665
+ * schema:
666
+ * type: object
667
+ * properties:
668
+ * toCard:
669
+ * type: string
670
+ * linkType:
671
+ * type: string
672
+ * description:
673
+ * type: string
674
+ * responses:
675
+ * 200:
676
+ * description: Link removed successfully
677
+ * 400:
678
+ * description: Invalid request
679
+ * 500:
680
+ * description: Server error
681
+ */
682
+ router.delete('/:key/links', async (c) => {
683
+ const commands = c.get('commands');
684
+ const key = c.req.param('key');
685
+ const { toCard, linkType, description } = await c.req.json();
686
+
687
+ if (!toCard || !linkType) {
688
+ return c.json({ error: 'toCard and linkType are required' }, 400);
689
+ }
690
+
691
+ try {
692
+ await commands.removeCmd.remove('link', key, toCard, linkType, description);
693
+ return c.json({ message: 'Link removed successfully' });
694
+ } catch (error) {
695
+ return c.json(
696
+ {
697
+ error: error instanceof Error ? error.message : 'Failed to remove link',
698
+ },
699
+ 500,
700
+ );
701
+ }
702
+ });
703
+
704
+ /**
705
+ * @swagger
706
+ * /api/cards/{key}/a/{attachment}:
707
+ * get:
708
+ * summary: Returns an attachment file for a specific card.
709
+ * parameters:
710
+ * - name: key
711
+ * in: path
712
+ * required: true
713
+ * description: Card key (string)
714
+ * - name: attachment
715
+ * in: path
716
+ * required: true
717
+ * description: file name of the attachment
718
+ * responses:
719
+ * 200:
720
+ * description: Attachment object as a file buffer, content-type set to the mime type of the file
721
+ * 400:
722
+ * description: No search key or card not found with given key
723
+ * 404:
724
+ * description: Attachment file not found
725
+ * 500:
726
+ * description: project_path not set.
727
+ */
728
+ router.get('/:key/a/:attachment', async (c) => {
729
+ const commands = c.get('commands');
730
+ const { key, attachment } = c.req.param();
731
+ const filename = decodeURI(attachment);
732
+
733
+ if (!filename || !key) {
734
+ return c.text('Missing cardKey or filename', 400);
735
+ }
736
+
737
+ try {
738
+ const attachmentResponse = await commands.showCmd.showAttachment(
739
+ key,
740
+ filename,
741
+ );
742
+
743
+ if (!attachmentResponse) {
744
+ return c.text(
745
+ `No attachment found from card ${key} and filename ${filename}`,
746
+ 404,
747
+ );
748
+ }
749
+
750
+ const payload = attachmentResponse as any;
751
+
752
+ return new Response(payload.fileBuffer, {
753
+ headers: {
754
+ 'Content-Type': payload.mimeType,
755
+ 'Content-Disposition': `attachment; filename="${filename}"`,
756
+ 'Cache-Control': 'no-store',
757
+ },
758
+ });
759
+ } catch {
760
+ return c.text(
761
+ `No attachment found from card ${key} and filename ${filename}`,
762
+ 404,
763
+ );
764
+ }
765
+ });
766
+
767
+ export default router;