agent-whiteboard 1.0.4 → 1.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/CHANGELOG.md +76 -0
- package/README.md +61 -5
- package/dist/client/assets/abnfDiagram-VCTEODGH-Dj8sd_5g.js +1 -0
- package/dist/client/assets/{arc-4691b008.js → arc-Aq92EeUK.js} +1 -1
- package/dist/client/assets/architectureDiagram-5GKGNRK7-B1QyVL0F.js +36 -0
- package/dist/client/assets/blockDiagram-I7D4REHJ-CmU2SSFe.js +129 -0
- package/dist/client/assets/{bootstrap.min-21652813.js → bootstrap.min-UtnB-TxT.js} +1 -2
- package/dist/client/assets/c4Diagram-7LVT6UL2-DWmhp77c.js +38 -0
- package/dist/client/assets/channel-C9ni1Via.js +1 -0
- package/dist/client/assets/{chunk-QZHKN3VN-0f554985.js → chunk-2Q5K7J3B-CQLo8Sbv.js} +1 -1
- package/dist/client/assets/{chunk-FMBD7UC4-23d48b24.js → chunk-5VM5RSS4-fJTWmTTt.js} +1 -1
- package/dist/client/assets/{chunk-ND2GUHAM-1104c326.js → chunk-F27PBJKO-DqzEf5gi.js} +1 -1
- package/dist/client/assets/chunk-IMKFNOWR-CMamLTz3.js +231 -0
- package/dist/client/assets/{chunk-4BX2VUAB-3c93511d.js → chunk-JWPE2WC7-tyYPyD43.js} +1 -1
- package/dist/client/assets/chunk-POPQ4Y6H-XdY4rO_V.js +1 -0
- package/dist/client/assets/chunk-SVP7TREG-COvqzqaO.js +88 -0
- package/dist/client/assets/chunk-TICWLB2K-BsJSFuPC.js +206 -0
- package/dist/client/assets/{chunk-55IACEB6-0243aefd.js → chunk-XXDRQBXY-HfVrYBJb.js} +1 -1
- package/dist/client/assets/classDiagram-ZZMXUADV-DktNEiGC.js +1 -0
- package/dist/client/assets/classDiagram-v2-VYDZK3BY-DktNEiGC.js +1 -0
- package/dist/client/assets/cose-bilkent-JH36ORCC-UXGdwSjS.js +1 -0
- package/dist/client/assets/cynefin-OW5HDTMX-PWlPYnpn.js +178 -0
- package/dist/client/assets/cynefinDiagram-5FMLGOSQ-CYE5H1wR.js +62 -0
- package/dist/client/assets/cytoscape.esm-C5JX3QLV.js +331 -0
- package/dist/client/assets/dagre-GXQ25YYZ-DaknBOKq.js +4 -0
- package/dist/client/assets/diagram-S7CK7UJ4-Dz7rZHxF.js +30 -0
- package/dist/client/assets/diagram-UQ7AKVKN-DzCi6tiS.js +41 -0
- package/dist/client/assets/{diagram-KO2AKTUF-81f42eeb.js → diagram-VSXAHHWV-TlUzhXgv.js} +3 -3
- package/dist/client/assets/diagram-VX7I27RA-Ds7jdzK7.js +24 -0
- package/dist/client/assets/diagram-Z3DM3KII-C--JD7cf.js +24 -0
- package/dist/client/assets/ebnfDiagram-PWID7BFC-Dw5OM-dL.js +1 -0
- package/dist/client/assets/embed-DcNeILcO.js +211 -0
- package/dist/client/assets/erDiagram-RLTQ6QDP-DXalBHjA.js +99 -0
- package/dist/client/assets/flowDiagram-HODETNUW-DcI3UqyE.js +168 -0
- package/dist/client/assets/ganttDiagram-EL5Y4UJY-DyrkIo6c.js +292 -0
- package/dist/client/assets/gitGraphDiagram-WWUBYQGX-fJKfGRcg.js +106 -0
- package/dist/client/assets/index-CIhkXT_z.js +19 -0
- package/dist/client/assets/index-DEiH2AE7.css +1 -0
- package/dist/client/assets/infoDiagram-27XIBGKW-Cijiqswb.js +2 -0
- package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-50b43d03.js → ishikawaDiagram-5VMMS53U-C1WqWU2l.js} +2 -2
- package/dist/client/assets/{journeyDiagram-JHISSGLW-dce41bdf.js → journeyDiagram-3NMN7TZE-7j05mbLR.js} +4 -4
- package/dist/client/assets/{kanban-definition-UN3LZRKU-92401a86.js → kanban-definition-UXKFOSKX-D5mGDfs_.js} +1 -1
- package/dist/client/assets/{katex-4c9ea27f.js → katex-BTI-AXqd.js} +3 -3
- package/dist/client/assets/{katex-0cba7652.js → katex-C5jXJg4s.js} +3 -3
- package/dist/client/assets/layout-KEin06-H.js +1 -0
- package/dist/client/assets/linear-D1EL1Azg.js +1 -0
- package/dist/client/assets/mermaid.core-CLvSDmrA.js +310 -0
- package/dist/client/assets/{mindmap-definition-RKZ34NQL-66fe4544.js → mindmap-definition-YA3MSWOX-BsCqKd_d.js} +12 -12
- package/dist/client/assets/{ordinal-db88ae6f.js → ordinal-BeghXfj9.js} +1 -1
- package/dist/client/assets/pegDiagram-XKGWAZYB-Du5lDU3W.js +1 -0
- package/dist/client/assets/pieDiagram-E7YTZNPT-hjv5Ao6B.js +39 -0
- package/dist/client/assets/{quadrantDiagram-W4KKPZXB-f64a7ed7.js → quadrantDiagram-AXDQQJYC-DNon8LWK.js} +4 -4
- package/dist/client/assets/railroadDiagram-O6MQD6OU-yNZz2zxo.js +1 -0
- package/dist/client/assets/requirementDiagram-BXWQKSXE-CZiiEUHW.js +84 -0
- package/dist/client/assets/sankeyDiagram-P5KCCOFB-D-u8ZQUV.js +40 -0
- package/dist/client/assets/sequenceDiagram-WJ2MYXX4-BxjTf3q9.js +162 -0
- package/dist/client/assets/sizeCapture-INFHLROL-Dt4Wg8C5.js +1 -0
- package/dist/client/assets/stateDiagram-D77RDMKH-B8BM6dln.js +1 -0
- package/dist/client/assets/stateDiagram-v2-MP3YSRHH-MJauX84T.js +1 -0
- package/dist/client/assets/step-BEQzeS8V.js +1 -0
- package/dist/client/assets/swimlanes-42K2YHIH-eGWrsWAA.js +1 -0
- package/dist/client/assets/swimlanesDiagram-VR7AAH4N-BYFu8tyL.js +8 -0
- package/dist/client/assets/{time-043b3464.js → time-BaoZouO5.js} +1 -1
- package/dist/client/assets/{timeline-definition-PNZ67QCA-b0c81243.js → timeline-definition-24CTP7MA-CAM-3Qj9.js} +10 -10
- package/dist/client/assets/vennDiagram-4TSXK5OY-CuFEwwD0.js +34 -0
- package/dist/client/assets/{wardleyDiagram-YWT4CUSO-8d4d7626.js → wardleyDiagram-VM6X3IG4-DI5yCVh0.js} +3 -3
- package/dist/client/assets/xychartDiagram-S5SC5T6Z-BI0Bn-OQ.js +7 -0
- package/dist/client/index.html +2 -3
- package/dist/server/app.js +28 -396
- package/dist/server/channel.js +5 -3
- package/dist/server/export-html.js +30 -60
- package/dist/server/export-zip.js +83 -0
- package/dist/server/import-zip.js +196 -0
- package/dist/server/index.js +2 -1
- package/dist/server/mcp.js +28 -13
- package/dist/server/port.js +18 -0
- package/dist/server/quiz-submission.js +116 -0
- package/dist/server/quiz.js +110 -0
- package/dist/server/render-core.js +8 -7
- package/dist/server/routes/export.js +95 -0
- package/dist/server/routes/import.js +24 -0
- package/dist/server/routes/quiz.js +12 -0
- package/dist/server/routes/render.js +184 -0
- package/dist/server/routes/slideshow.js +52 -0
- package/dist/server/routes/snapshots.js +119 -0
- package/dist/server/snapshot-reader.js +58 -20
- package/dist/server/snapshot-writer.js +18 -16
- package/dist/server/validate.js +14 -0
- package/dist/server/viewport-cache.js +97 -25
- package/package.json +13 -7
- package/dist/client/assets/architecture-7EHR7CIX-1221aa61.js +0 -1
- package/dist/client/assets/architectureDiagram-3BPJPVTR-1eef4d3c.js +0 -36
- package/dist/client/assets/blockDiagram-GPEHLZMM-313fb06c.js +0 -132
- package/dist/client/assets/c4Diagram-AAUBKEIU-92b023ad.js +0 -10
- package/dist/client/assets/channel-5ee4a8a7.js +0 -1
- package/dist/client/assets/chunk-2J33WTMH-fb021fdc.js +0 -1
- package/dist/client/assets/chunk-727SXJPM-8e0e0228.js +0 -206
- package/dist/client/assets/chunk-AQP2D5EJ-2ccc13c2.js +0 -231
- package/dist/client/assets/classDiagram-4FO5ZUOK-42ffecb6.js +0 -1
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-42ffecb6.js +0 -1
- package/dist/client/assets/cose-bilkent-S5V4N54A-45ec2e52.js +0 -1
- package/dist/client/assets/cytoscape.esm-1491e259.js +0 -331
- package/dist/client/assets/dagre-BM42HDAG-ed748885.js +0 -4
- package/dist/client/assets/diagram-2AECGRRQ-ccc0ddec.js +0 -43
- package/dist/client/assets/diagram-5GNKFQAL-342efdea.js +0 -10
- package/dist/client/assets/diagram-LMA3HP47-39e66739.js +0 -24
- package/dist/client/assets/diagram-OG6HWLK6-b3e61903.js +0 -24
- package/dist/client/assets/embed-2b4adf34.js +0 -201
- package/dist/client/assets/erDiagram-TEJ5UH35-281f5cc7.js +0 -85
- package/dist/client/assets/eventmodeling-FCH6USID-6508120e.js +0 -1
- package/dist/client/assets/flowDiagram-I6XJVG4X-c3a4269e.js +0 -162
- package/dist/client/assets/ganttDiagram-6RSMTGT7-013b07ec.js +0 -292
- package/dist/client/assets/gitGraph-WXDBUCRP-02827359.js +0 -1
- package/dist/client/assets/gitGraphDiagram-PVQCEYII-de184acb.js +0 -106
- package/dist/client/assets/graph-f3dfaf53.js +0 -1
- package/dist/client/assets/index-d674a6a4.css +0 -1
- package/dist/client/assets/index-ffc5af4b.js +0 -5
- package/dist/client/assets/info-J43DQDTF-81e32294.js +0 -1
- package/dist/client/assets/infoDiagram-5YYISTIA-196101ee.js +0 -2
- package/dist/client/assets/layout-3a4eff80.js +0 -1
- package/dist/client/assets/linear-fed00341.js +0 -1
- package/dist/client/assets/mermaid-parser.core-52f9cd92.js +0 -173
- package/dist/client/assets/mermaid.core-7dfc221a.js +0 -306
- package/dist/client/assets/packet-YPE3B663-278a272a.js +0 -1
- package/dist/client/assets/pie-LRSECV5Y-2e55d9ae.js +0 -1
- package/dist/client/assets/pieDiagram-4H26LBE5-65b1bcbd.js +0 -30
- package/dist/client/assets/radar-GUYGQ44K-608e459f.js +0 -1
- package/dist/client/assets/requirementDiagram-4Y6WPE33-90ebf889.js +0 -84
- package/dist/client/assets/sankeyDiagram-5OEKKPKP-2d1a83b3.js +0 -40
- package/dist/client/assets/sequenceDiagram-3UESZ5HK-aece3633.js +0 -162
- package/dist/client/assets/stateDiagram-AJRCARHV-9bd5b5f4.js +0 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-7c16c251.js +0 -1
- package/dist/client/assets/step-171ad54a.js +0 -1
- package/dist/client/assets/treeView-BLDUP644-e54b9ddb.js +0 -1
- package/dist/client/assets/treemap-LRROVOQU-6a81775b.js +0 -1
- package/dist/client/assets/vennDiagram-CIIHVFJN-8eaf0464.js +0 -34
- package/dist/client/assets/wardley-L42UT6IY-7abd921f.js +0 -1
- package/dist/client/assets/xychartDiagram-2RQKCTM6-450f333c.js +0 -7
- /package/dist/client/assets/{KaTeX_AMS-Regular-0cdd387c.woff2 → KaTeX_AMS-Regular-BQhdFMY1.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_AMS-Regular-30da91e8.woff → KaTeX_AMS-Regular-DMm9YOAa.woff} +0 -0
- /package/dist/client/assets/{KaTeX_AMS-Regular-68534840.ttf → KaTeX_AMS-Regular-DRggAlZN.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_Caligraphic-Bold-07d8e303.ttf → KaTeX_Caligraphic-Bold-ATXxdsX0.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_Caligraphic-Bold-1ae6bd74.woff → KaTeX_Caligraphic-Bold-BEiXGLvX.woff} +0 -0
- /package/dist/client/assets/{KaTeX_Caligraphic-Bold-de7701e4.woff2 → KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_Caligraphic-Regular-3398dd02.woff → KaTeX_Caligraphic-Regular-CTRA-rTL.woff} +0 -0
- /package/dist/client/assets/{KaTeX_Caligraphic-Regular-5d53e70a.woff2 → KaTeX_Caligraphic-Regular-Di6jR-x-.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_Caligraphic-Regular-ed0b7437.ttf → KaTeX_Caligraphic-Regular-wX97UBjC.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_Fraktur-Bold-9163df9c.ttf → KaTeX_Fraktur-Bold-BdnERNNW.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_Fraktur-Bold-9be7ceb8.woff → KaTeX_Fraktur-Bold-BsDP51OF.woff} +0 -0
- /package/dist/client/assets/{KaTeX_Fraktur-Bold-74444efd.woff2 → KaTeX_Fraktur-Bold-CL6g_b3V.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_Fraktur-Regular-1e6f9579.ttf → KaTeX_Fraktur-Regular-CB_wures.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_Fraktur-Regular-51814d27.woff2 → KaTeX_Fraktur-Regular-CTYiF6lA.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_Fraktur-Regular-5e28753b.woff → KaTeX_Fraktur-Regular-Dxdc4cR9.woff} +0 -0
- /package/dist/client/assets/{KaTeX_Main-Bold-0f60d1b8.woff2 → KaTeX_Main-Bold-Cx986IdX.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_Main-Bold-c76c5d69.woff → KaTeX_Main-Bold-Jm3AIy58.woff} +0 -0
- /package/dist/client/assets/{KaTeX_Main-Bold-138ac28d.ttf → KaTeX_Main-Bold-waoOVXN0.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_Main-BoldItalic-99cd42a3.woff2 → KaTeX_Main-BoldItalic-DxDJ3AOS.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_Main-BoldItalic-70ee1f64.ttf → KaTeX_Main-BoldItalic-DzxPMmG6.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_Main-BoldItalic-a6f7ec0d.woff → KaTeX_Main-BoldItalic-SpSLRI95.woff} +0 -0
- /package/dist/client/assets/{KaTeX_Main-Italic-0d85ae7c.ttf → KaTeX_Main-Italic-3WenGoN9.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_Main-Italic-f1d6ef86.woff → KaTeX_Main-Italic-BMLOBm91.woff} +0 -0
- /package/dist/client/assets/{KaTeX_Main-Italic-97479ca6.woff2 → KaTeX_Main-Italic-NWA7e6Wa.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_Main-Regular-c2342cd8.woff2 → KaTeX_Main-Regular-B22Nviop.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_Main-Regular-c6368d87.woff → KaTeX_Main-Regular-Dr94JaBh.woff} +0 -0
- /package/dist/client/assets/{KaTeX_Main-Regular-d0332f52.ttf → KaTeX_Main-Regular-ypZvNtVU.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_Math-BoldItalic-f9377ab0.ttf → KaTeX_Math-BoldItalic-B3XSjfu4.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_Math-BoldItalic-dc47344d.woff2 → KaTeX_Math-BoldItalic-CZnvNsCZ.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_Math-BoldItalic-850c0af5.woff → KaTeX_Math-BoldItalic-iY-2wyZ7.woff} +0 -0
- /package/dist/client/assets/{KaTeX_Math-Italic-8a8d2445.woff → KaTeX_Math-Italic-DA0__PXp.woff} +0 -0
- /package/dist/client/assets/{KaTeX_Math-Italic-08ce98e5.ttf → KaTeX_Math-Italic-flOr_0UB.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_Math-Italic-7af58c5e.woff2 → KaTeX_Math-Italic-t53AETM-.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_SansSerif-Bold-1ece03f7.ttf → KaTeX_SansSerif-Bold-CFMepnvq.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_SansSerif-Bold-e99ae511.woff2 → KaTeX_SansSerif-Bold-D1sUS0GD.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_SansSerif-Bold-ece03cfd.woff → KaTeX_SansSerif-Bold-DbIhKOiC.woff} +0 -0
- /package/dist/client/assets/{KaTeX_SansSerif-Italic-00b26ac8.woff2 → KaTeX_SansSerif-Italic-C3H0VqGB.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_SansSerif-Italic-91ee6750.woff → KaTeX_SansSerif-Italic-DN2j7dab.woff} +0 -0
- /package/dist/client/assets/{KaTeX_SansSerif-Italic-3931dd81.ttf → KaTeX_SansSerif-Italic-YYjJ1zSn.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_SansSerif-Regular-f36ea897.ttf → KaTeX_SansSerif-Regular-BNo7hRIc.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_SansSerif-Regular-11e4dc8a.woff → KaTeX_SansSerif-Regular-CS6fqUqJ.woff} +0 -0
- /package/dist/client/assets/{KaTeX_SansSerif-Regular-68e8c73e.woff2 → KaTeX_SansSerif-Regular-DDBCnlJ7.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_Script-Regular-1c67f068.ttf → KaTeX_Script-Regular-C5JkGWo-.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_Script-Regular-036d4e95.woff2 → KaTeX_Script-Regular-D3wIWfF6.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_Script-Regular-d96cdf2b.woff → KaTeX_Script-Regular-D5yQViql.woff} +0 -0
- /package/dist/client/assets/{KaTeX_Size1-Regular-c943cc98.woff → KaTeX_Size1-Regular-C195tn64.woff} +0 -0
- /package/dist/client/assets/{KaTeX_Size1-Regular-95b6d2f1.ttf → KaTeX_Size1-Regular-Dbsnue_I.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_Size1-Regular-6b47c401.woff2 → KaTeX_Size1-Regular-mCD8mA8B.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_Size2-Regular-a6b2099f.ttf → KaTeX_Size2-Regular-B7gKUWhC.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_Size2-Regular-d04c5421.woff2 → KaTeX_Size2-Regular-Dy4dx90m.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_Size2-Regular-2014c523.woff → KaTeX_Size2-Regular-oD1tc_U0.woff} +0 -0
- /package/dist/client/assets/{KaTeX_Size3-Regular-6ab6b62e.woff → KaTeX_Size3-Regular-CTq5MqoE.woff} +0 -0
- /package/dist/client/assets/{KaTeX_Size3-Regular-500e04d5.ttf → KaTeX_Size3-Regular-DgpXs0kz.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_Size4-Regular-99f9c675.woff → KaTeX_Size4-Regular-BF-4gkZK.woff} +0 -0
- /package/dist/client/assets/{KaTeX_Size4-Regular-c647367d.ttf → KaTeX_Size4-Regular-DWFBv043.ttf} +0 -0
- /package/dist/client/assets/{KaTeX_Size4-Regular-a4af7d41.woff2 → KaTeX_Size4-Regular-Dl5lxZxV.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_Typewriter-Regular-e14fed02.woff → KaTeX_Typewriter-Regular-C0xS9mPB.woff} +0 -0
- /package/dist/client/assets/{KaTeX_Typewriter-Regular-71d517d6.woff2 → KaTeX_Typewriter-Regular-CO6r4hn1.woff2} +0 -0
- /package/dist/client/assets/{KaTeX_Typewriter-Regular-f01f3e87.ttf → KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf} +0 -0
- /package/dist/client/assets/{colors-98f238a0.js → colors-Cc3OSVma.js} +0 -0
- /package/dist/client/assets/{defaultLocale-a8476f12.js → defaultLocale-CzDG__Ur.js} +0 -0
- /package/dist/client/assets/{index-a1656aec.js → index-Bi7T_FA4.js} +0 -0
- /package/dist/client/assets/{init-a5b10ee5.js → init-Dmth1JHB.js} +0 -0
- /package/dist/client/assets/{range-6bc2a9a0.js → range-OtVwhkKS.js} +0 -0
|
@@ -1,47 +1,12 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
2
|
import { createRequire } from "module";
|
|
3
3
|
import { createHash } from "crypto";
|
|
4
|
-
import {
|
|
4
|
+
import { JSDOM } from "jsdom";
|
|
5
5
|
import katex from "katex";
|
|
6
6
|
import * as vl from "vega-lite";
|
|
7
7
|
import * as vega from "vega";
|
|
8
8
|
import DOMPurify from "dompurify";
|
|
9
9
|
import { badgeType } from "./snapshot-reader.js";
|
|
10
|
-
function saveGlobals() {
|
|
11
|
-
const keys = [
|
|
12
|
-
"document", "window", "CSSStyleSheet", "SVGElement", "HTMLElement",
|
|
13
|
-
"Element", "DOMParser", "MutationObserver",
|
|
14
|
-
"requestAnimationFrame", "cancelAnimationFrame",
|
|
15
|
-
];
|
|
16
|
-
const saved = new Map();
|
|
17
|
-
for (const k of keys)
|
|
18
|
-
saved.set(k, global[k]);
|
|
19
|
-
return saved;
|
|
20
|
-
}
|
|
21
|
-
function setGlobals(win) {
|
|
22
|
-
const g = global;
|
|
23
|
-
const w = win;
|
|
24
|
-
g["document"] = win.document;
|
|
25
|
-
g["window"] = win;
|
|
26
|
-
g["CSSStyleSheet"] = w["CSSStyleSheet"];
|
|
27
|
-
g["SVGElement"] = w["SVGElement"];
|
|
28
|
-
g["HTMLElement"] = w["HTMLElement"];
|
|
29
|
-
g["Element"] = w["Element"];
|
|
30
|
-
g["DOMParser"] = w["DOMParser"];
|
|
31
|
-
g["MutationObserver"] = w["MutationObserver"];
|
|
32
|
-
g["requestAnimationFrame"] = (fn) => setTimeout(() => fn(Date.now()), 16);
|
|
33
|
-
g["cancelAnimationFrame"] = clearTimeout;
|
|
34
|
-
}
|
|
35
|
-
function restoreGlobals(saved) {
|
|
36
|
-
for (const [k, v] of saved) {
|
|
37
|
-
if (v === undefined) {
|
|
38
|
-
delete global[k];
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
global[k] = v;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
10
|
// ── Renderers ──────────────────────────────────────────────────────────────
|
|
46
11
|
function renderMermaidContainer(payload) {
|
|
47
12
|
return `<pre class="mermaid">${escapeHtml(payload)}</pre>`;
|
|
@@ -59,7 +24,13 @@ function renderSvgPayload(payload, purify) {
|
|
|
59
24
|
return purify.sanitize(payload, { USE_PROFILES: { svg: true } });
|
|
60
25
|
}
|
|
61
26
|
function renderHtmlPayload(payload, purify) {
|
|
62
|
-
|
|
27
|
+
// ADD_TAGS: the "html" profile's default allowlist excludes <style> (unlike
|
|
28
|
+
// the "svg" profile below, which already allows it) — explicit here since
|
|
29
|
+
// scopeEmbeddedStyles() (B20) depends on <style> surviving sanitization.
|
|
30
|
+
// FORCE_BODY: without it, a leading <style> is parsed as implicit <head>
|
|
31
|
+
// content and silently dropped from DOMPurify's body-only output — ADD_TAGS
|
|
32
|
+
// alone isn't enough.
|
|
33
|
+
return purify.sanitize(payload, { USE_PROFILES: { html: true }, ADD_TAGS: ["style"], FORCE_BODY: true });
|
|
63
34
|
}
|
|
64
35
|
/**
|
|
65
36
|
* Pure string-transform primitive: wraps a CSS string in `@scope (<selector-list>)
|
|
@@ -102,6 +73,9 @@ async function renderByType(type, payload, purify) {
|
|
|
102
73
|
return renderSvgPayload(payload, purify);
|
|
103
74
|
case "html":
|
|
104
75
|
return renderHtmlPayload(payload, purify);
|
|
76
|
+
case "quiz":
|
|
77
|
+
// Quizzes are interactive and carry no static representation (F50) — export notes it instead of failing.
|
|
78
|
+
return '<p class="export-note">This item is an interactive quiz and is not included in HTML exports.</p>';
|
|
105
79
|
default:
|
|
106
80
|
throw new Error(`unsupported render type: ${type}`);
|
|
107
81
|
}
|
|
@@ -276,6 +250,7 @@ const LAYOUT_CSS = `
|
|
|
276
250
|
.type-badge { background: #e8f4fd; color: #2980b9; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
|
277
251
|
.frame-section { margin-top: 16px; padding: 12px 16px; border: 1px solid #f0f0f0; border-radius: 4px; background: #fafafa; overflow-x: auto; }
|
|
278
252
|
.frame-heading { font-size: 12px; color: #777; margin: 0 0 10px; font-style: italic; }
|
|
253
|
+
.export-note { color: #555; background: #f4f5f6; padding: 10px 14px; border-radius: 4px; border-left: 3px solid #aaa; font-size: 13px; margin: 0; }
|
|
279
254
|
.export-error { color: #c0392b; background: #fdf0f0; padding: 10px 14px; border-radius: 4px; border-left: 3px solid #e74c3c; font-size: 13px; margin: 0; }
|
|
280
255
|
svg { max-width: 100%; height: auto; }
|
|
281
256
|
table { max-width: 100%; border-collapse: collapse; }
|
|
@@ -412,10 +387,24 @@ function itemsIncludeType(items, type) {
|
|
|
412
387
|
return items.some(({ record }) => record.frames.some((f) => f.type === type));
|
|
413
388
|
}
|
|
414
389
|
// ── Public entrypoint ──────────────────────────────────────────────────────
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
390
|
+
/**
|
|
391
|
+
* Each call builds its own jsdom `Window` and passes it explicitly to
|
|
392
|
+
* `DOMPurify(win)` — sanitization runs entirely against that instance, never
|
|
393
|
+
* against Node's global object. KaTeX's `renderToString()` and Vega's
|
|
394
|
+
* `View.toSVG()` (via `renderer: "none"`) need no DOM at all. Because
|
|
395
|
+
* nothing here reads or writes `global.document`/`window`, concurrent calls
|
|
396
|
+
* (reachable from both `POST /export-html` and the `export_html` MCP tool,
|
|
397
|
+
* see bug B14 in `01`) can never stomp on each other's state — no
|
|
398
|
+
* serialization queue is needed.
|
|
399
|
+
*
|
|
400
|
+
* jsdom, not happy-dom: DOMPurify 3.4.8+ silently fails to sanitize
|
|
401
|
+
* descendant nodes against a happy-dom `Window` (upstream incompatibility,
|
|
402
|
+
* capricorn86/happy-dom#1810) — <script> tags and on* attributes pass
|
|
403
|
+
* through unstripped. DOMPurify's own maintainers recommend jsdom for
|
|
404
|
+
* server-side use; confirmed this sanitizes correctly against jsdom.
|
|
405
|
+
*/
|
|
406
|
+
export async function generateExportHtml(items, mode) {
|
|
407
|
+
const win = new JSDOM("").window;
|
|
419
408
|
const purify = DOMPurify(win);
|
|
420
409
|
const rendered = [];
|
|
421
410
|
try {
|
|
@@ -431,7 +420,6 @@ async function generateExportHtmlInner(items, mode) {
|
|
|
431
420
|
}
|
|
432
421
|
}
|
|
433
422
|
finally {
|
|
434
|
-
restoreGlobals(savedGlobals);
|
|
435
423
|
win.close();
|
|
436
424
|
}
|
|
437
425
|
const hasKatex = itemsIncludeType(items, "katex");
|
|
@@ -441,21 +429,3 @@ async function generateExportHtmlInner(items, mode) {
|
|
|
441
429
|
const downloadFilename = buildDownloadFilename(uniqueWorkspaces);
|
|
442
430
|
return { html, downloadFilename };
|
|
443
431
|
}
|
|
444
|
-
// generateExportHtmlInner() patches global DOM state (document, window, ...)
|
|
445
|
-
// for the duration of a call so happy-dom-backed rendering works, then
|
|
446
|
-
// restores it in a `finally`. That save/set/restore isn't reentrant: two
|
|
447
|
-
// overlapping calls (POST /export-html and the export_html MCP tool can run
|
|
448
|
-
// concurrently) each save/restore against whatever the *other* call happened
|
|
449
|
-
// to have in place at that moment, which can leave global DOM state pointing
|
|
450
|
-
// at an already-closed Window once both settle (B14). A simple queue forces
|
|
451
|
-
// calls to run one at a time, in the order they were made, so only one
|
|
452
|
-
// call's globals are ever active at once.
|
|
453
|
-
let exportQueue = Promise.resolve();
|
|
454
|
-
export function generateExportHtml(items, mode) {
|
|
455
|
-
const result = exportQueue.then(() => generateExportHtmlInner(items, mode));
|
|
456
|
-
// Chain the queue off a version that never rejects, so one failed export
|
|
457
|
-
// doesn't permanently wedge every export queued after it; the caller of
|
|
458
|
-
// generateExportHtml() still sees the original rejection via `result`.
|
|
459
|
-
exportQueue = result.then(() => undefined, () => undefined);
|
|
460
|
-
return result;
|
|
461
|
-
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import archiver from "archiver";
|
|
3
|
+
import { findSnapshotFileByIdInWorkspace } from "./snapshot-reader.js";
|
|
4
|
+
/**
|
|
5
|
+
* Reads own package.json version via createRequire (works whether this
|
|
6
|
+
* module runs as server/export-zip.ts under tsx, one level below repo root,
|
|
7
|
+
* or as the compiled dist/server/export-zip.js, two levels below).
|
|
8
|
+
*/
|
|
9
|
+
function getAppVersion() {
|
|
10
|
+
const req = createRequire(import.meta.url);
|
|
11
|
+
for (const rel of ["../package.json", "../../package.json"]) {
|
|
12
|
+
try {
|
|
13
|
+
const pkg = req(rel);
|
|
14
|
+
if (typeof pkg.version === "string")
|
|
15
|
+
return pkg.version;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// try next candidate
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return "unknown";
|
|
22
|
+
}
|
|
23
|
+
function buildZipDownloadFilename(workspace) {
|
|
24
|
+
const now = new Date();
|
|
25
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
26
|
+
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
|
27
|
+
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
28
|
+
const sanitized = workspace.replace(/[^a-zA-Z0-9_.-]/g, "-").slice(0, 24);
|
|
29
|
+
return `${sanitized}-${ts}.zip`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Builds a downloadable zip (F36) of the given snapshots' raw JSON files
|
|
33
|
+
* plus a generated manifest.json — no rendering/sanitization pass, unlike
|
|
34
|
+
* generateExportHtml(). Every item must share the same workspace (the
|
|
35
|
+
* manifest format is single-workspace); a mixed-workspace list is rejected
|
|
36
|
+
* rather than silently exporting a subset. Ids that don't resolve to a file
|
|
37
|
+
* on disk are silently skipped (matching /export-html's per-item leniency);
|
|
38
|
+
* an all-miss request is rejected. Viewport-cache data is never included —
|
|
39
|
+
* this only ever touches the snapshot JSON files themselves.
|
|
40
|
+
*/
|
|
41
|
+
export async function generateExportZip(items, root) {
|
|
42
|
+
if (items.length === 0) {
|
|
43
|
+
return { ok: false, error: "items must be a non-empty array" };
|
|
44
|
+
}
|
|
45
|
+
const workspace = items[0].workspace;
|
|
46
|
+
if (items.some((item) => item.workspace !== workspace)) {
|
|
47
|
+
return { ok: false, error: "all items must belong to the same workspace" };
|
|
48
|
+
}
|
|
49
|
+
const files = [];
|
|
50
|
+
for (const item of items) {
|
|
51
|
+
const found = await findSnapshotFileByIdInWorkspace(workspace, item.id, root);
|
|
52
|
+
if (found !== null)
|
|
53
|
+
files.push(found);
|
|
54
|
+
}
|
|
55
|
+
if (files.length === 0) {
|
|
56
|
+
return { ok: false, error: "no valid items to export" };
|
|
57
|
+
}
|
|
58
|
+
const manifest = {
|
|
59
|
+
formatVersion: 1,
|
|
60
|
+
workspace,
|
|
61
|
+
exportedAt: new Date().toISOString(),
|
|
62
|
+
appVersion: getAppVersion(),
|
|
63
|
+
snapshots: files.map((f) => f.filename),
|
|
64
|
+
};
|
|
65
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
66
|
+
const chunks = [];
|
|
67
|
+
archive.on("data", (chunk) => chunks.push(chunk));
|
|
68
|
+
const done = new Promise((resolve, reject) => {
|
|
69
|
+
archive.on("end", resolve);
|
|
70
|
+
archive.on("error", reject);
|
|
71
|
+
});
|
|
72
|
+
for (const f of files) {
|
|
73
|
+
archive.append(f.raw, { name: f.filename });
|
|
74
|
+
}
|
|
75
|
+
archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });
|
|
76
|
+
await archive.finalize();
|
|
77
|
+
await done;
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
zip: Buffer.concat(chunks),
|
|
81
|
+
downloadFilename: buildZipDownloadFilename(workspace),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import extract from "extract-zip";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { isValidSnapshotFilename, validateWorkspaceInput } from "./validate.js";
|
|
7
|
+
import { findSnapshotFileByIdInWorkspace } from "./snapshot-reader.js";
|
|
8
|
+
const DEFAULT_LIMITS = {
|
|
9
|
+
maxEntryUncompressedBytes: 20 * 1024 * 1024, // 20MB — generous for a snapshot JSON file
|
|
10
|
+
maxTotalUncompressedBytes: 200 * 1024 * 1024, // 200MB total across the whole archive
|
|
11
|
+
maxEntries: 2000,
|
|
12
|
+
};
|
|
13
|
+
const manifestSchema = z.object({
|
|
14
|
+
formatVersion: z.number(),
|
|
15
|
+
workspace: z.string().min(1),
|
|
16
|
+
exportedAt: z.string(),
|
|
17
|
+
appVersion: z.string(),
|
|
18
|
+
snapshots: z.array(z.string()),
|
|
19
|
+
});
|
|
20
|
+
function parseIdTimestamp(raw) {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
return {
|
|
24
|
+
id: typeof parsed.id === "string" ? parsed.id : undefined,
|
|
25
|
+
timestamp: typeof parsed.timestamp === "string" ? parsed.timestamp : undefined,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* F40 — picks the filename of the newest (by ISO-8601 timestamp string
|
|
34
|
+
* comparison, same convention as snapshot-reader.ts's own sort) entry among
|
|
35
|
+
* everything actually added/updated by this import, so the caller can load
|
|
36
|
+
* it and switch the client's active workspace as a side effect.
|
|
37
|
+
*/
|
|
38
|
+
function pickNewestFilename(entries) {
|
|
39
|
+
const withTimestamp = entries.filter((e) => e.timestamp !== undefined);
|
|
40
|
+
if (withTimestamp.length === 0)
|
|
41
|
+
return undefined;
|
|
42
|
+
return withTimestamp.reduce((newest, e) => (e.timestamp > newest.timestamp ? e : newest)).filename;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* F39/NF55 — imports an uploaded workspace-export zip (F36's format).
|
|
46
|
+
* Extracts to a fresh temp directory first (never directly into the live
|
|
47
|
+
* snapshots tree) via `extract-zip`, which rejects any entry whose resolved
|
|
48
|
+
* path would escape that directory (zip-slip) as part of its own
|
|
49
|
+
* extraction — confirmed empirically, not just by reading its docs. Only
|
|
50
|
+
* `manifest.json` and the files it explicitly lists are ever trusted; a
|
|
51
|
+
* listed filename is re-validated with `isValidSnapshotFilename()` before
|
|
52
|
+
* being joined into any path, since the manifest itself is just JSON text
|
|
53
|
+
* an attacker could otherwise use to point outside the temp/destination
|
|
54
|
+
* directory even though the zip *entries* themselves are already safe.
|
|
55
|
+
*/
|
|
56
|
+
export async function importZip(zipBuffer, targetWorkspaceInput, mode, root, limits = {}) {
|
|
57
|
+
const { maxEntryUncompressedBytes, maxTotalUncompressedBytes, maxEntries } = { ...DEFAULT_LIMITS, ...limits };
|
|
58
|
+
const validated = validateWorkspaceInput(targetWorkspaceInput);
|
|
59
|
+
if (!validated.ok) {
|
|
60
|
+
return { ok: false, error: validated.error };
|
|
61
|
+
}
|
|
62
|
+
const targetWorkspace = validated.workspace;
|
|
63
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "agent-whiteboard-import-"));
|
|
64
|
+
const zipPath = join(tempRoot, "upload.zip");
|
|
65
|
+
const extractDir = join(tempRoot, "extracted");
|
|
66
|
+
try {
|
|
67
|
+
await writeFile(zipPath, zipBuffer);
|
|
68
|
+
await mkdir(extractDir, { recursive: true });
|
|
69
|
+
let totalUncompressed = 0;
|
|
70
|
+
let entryCount = 0;
|
|
71
|
+
try {
|
|
72
|
+
await extract(zipPath, {
|
|
73
|
+
dir: extractDir,
|
|
74
|
+
onEntry: (entry) => {
|
|
75
|
+
entryCount++;
|
|
76
|
+
if (entryCount > maxEntries) {
|
|
77
|
+
throw new Error("zip contains too many entries");
|
|
78
|
+
}
|
|
79
|
+
if (entry.uncompressedSize > maxEntryUncompressedBytes) {
|
|
80
|
+
throw new Error("zip contains an entry that is too large");
|
|
81
|
+
}
|
|
82
|
+
totalUncompressed += entry.uncompressedSize;
|
|
83
|
+
if (totalUncompressed > maxTotalUncompressedBytes) {
|
|
84
|
+
throw new Error("zip's total uncompressed size is too large");
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
return { ok: false, error: err instanceof Error ? err.message : "failed to extract zip" };
|
|
91
|
+
}
|
|
92
|
+
let manifestRaw;
|
|
93
|
+
try {
|
|
94
|
+
manifestRaw = await readFile(join(extractDir, "manifest.json"), "utf-8");
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { ok: false, error: "manifest.json is missing from the zip" };
|
|
98
|
+
}
|
|
99
|
+
let manifestJson;
|
|
100
|
+
try {
|
|
101
|
+
manifestJson = JSON.parse(manifestRaw);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return { ok: false, error: "manifest.json is not valid JSON" };
|
|
105
|
+
}
|
|
106
|
+
const manifestResult = manifestSchema.safeParse(manifestJson);
|
|
107
|
+
if (!manifestResult.success) {
|
|
108
|
+
return { ok: false, error: "manifest.json is malformed" };
|
|
109
|
+
}
|
|
110
|
+
const manifest = manifestResult.data;
|
|
111
|
+
const destDir = join(root, targetWorkspace);
|
|
112
|
+
const destExists = await stat(destDir).then(() => true, () => false);
|
|
113
|
+
// F40 — every added/updated file's {filename, timestamp}, so the newest
|
|
114
|
+
// one (by timestamp) can be reported back for the client's post-import
|
|
115
|
+
// POST /snapshots/load. Skipped files are deliberately excluded — nothing
|
|
116
|
+
// about them changed.
|
|
117
|
+
const changed = [];
|
|
118
|
+
if (mode === "create") {
|
|
119
|
+
if (destExists) {
|
|
120
|
+
return { ok: false, error: `workspace "${targetWorkspace}" already exists` };
|
|
121
|
+
}
|
|
122
|
+
await mkdir(destDir, { recursive: true });
|
|
123
|
+
let added = 0;
|
|
124
|
+
for (const filename of manifest.snapshots) {
|
|
125
|
+
if (!isValidSnapshotFilename(filename))
|
|
126
|
+
continue;
|
|
127
|
+
const srcPath = join(extractDir, filename);
|
|
128
|
+
let raw;
|
|
129
|
+
try {
|
|
130
|
+
raw = await readFile(srcPath, "utf-8");
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
await writeFile(join(destDir, filename), raw, "utf-8");
|
|
136
|
+
added++;
|
|
137
|
+
changed.push({ filename, timestamp: parseIdTimestamp(raw).timestamp });
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, workspace: targetWorkspace, added, updated: 0, skipped: 0, newestFilename: pickNewestFilename(changed) };
|
|
140
|
+
}
|
|
141
|
+
// mode === "merge"
|
|
142
|
+
if (!destExists) {
|
|
143
|
+
return { ok: false, error: `workspace "${targetWorkspace}" does not exist` };
|
|
144
|
+
}
|
|
145
|
+
let added = 0;
|
|
146
|
+
let updated = 0;
|
|
147
|
+
let skipped = 0;
|
|
148
|
+
for (const filename of manifest.snapshots) {
|
|
149
|
+
if (!isValidSnapshotFilename(filename))
|
|
150
|
+
continue;
|
|
151
|
+
let raw;
|
|
152
|
+
try {
|
|
153
|
+
raw = await readFile(join(extractDir, filename), "utf-8");
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const incoming = parseIdTimestamp(raw);
|
|
159
|
+
if (incoming.id === undefined) {
|
|
160
|
+
// Legacy/id-less (pre-v0.11) — always imported as new, never matched.
|
|
161
|
+
await writeFile(join(destDir, filename), raw, "utf-8");
|
|
162
|
+
added++;
|
|
163
|
+
changed.push({ filename, timestamp: incoming.timestamp });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const existing = await findSnapshotFileByIdInWorkspace(targetWorkspace, incoming.id, root);
|
|
167
|
+
if (existing === null) {
|
|
168
|
+
await writeFile(join(destDir, filename), raw, "utf-8");
|
|
169
|
+
added++;
|
|
170
|
+
changed.push({ filename, timestamp: incoming.timestamp });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const existingTimestamp = parseIdTimestamp(existing.raw).timestamp;
|
|
174
|
+
if (existingTimestamp === incoming.timestamp) {
|
|
175
|
+
skipped++;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (existingTimestamp !== undefined && incoming.timestamp !== undefined && existingTimestamp > incoming.timestamp) {
|
|
179
|
+
// Destination already has the newer version — nothing to do.
|
|
180
|
+
skipped++;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
// Incoming is newer — overwrite (at most one file per id afterward).
|
|
184
|
+
if (existing.filename !== filename) {
|
|
185
|
+
await unlink(join(destDir, existing.filename)).catch(() => { });
|
|
186
|
+
}
|
|
187
|
+
await writeFile(join(destDir, filename), raw, "utf-8");
|
|
188
|
+
updated++;
|
|
189
|
+
changed.push({ filename, timestamp: incoming.timestamp });
|
|
190
|
+
}
|
|
191
|
+
return { ok: true, workspace: targetWorkspace, added, updated, skipped, newestFilename: pickNewestFilename(changed) };
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { pathToFileURL } from "node:url";
|
|
|
11
11
|
import { WebSocketServer } from "ws";
|
|
12
12
|
import { createApp } from "./app.js";
|
|
13
13
|
import { createMcpServer } from "./mcp.js";
|
|
14
|
+
import { parsePort } from "./port.js";
|
|
14
15
|
import { addClient } from "./ws.js";
|
|
15
16
|
// F27 (v1.0) — the server has no authentication on any endpoint (accepted
|
|
16
17
|
// trade-off, see docs/02_assumptions-and-risks.md), so single-user/local-only
|
|
@@ -26,7 +27,7 @@ function assertLoopbackHost(host) {
|
|
|
26
27
|
"Set ALLOW_NON_LOOPBACK=1 if you understand the risk and want to override this.");
|
|
27
28
|
}
|
|
28
29
|
export function startServer(options = {}) {
|
|
29
|
-
const PORT =
|
|
30
|
+
const PORT = parsePort(process.env.PORT, 3000, "PORT");
|
|
30
31
|
const HOST = process.env.HOST ?? "localhost";
|
|
31
32
|
assertLoopbackHost(HOST);
|
|
32
33
|
const app = createApp({ staticRoot: options.staticRoot });
|
package/dist/server/mcp.js
CHANGED
|
@@ -4,10 +4,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { clearCanvas, exportCanvas } from "./session.js";
|
|
6
6
|
import { broadcast } from "./ws.js";
|
|
7
|
-
import { nodeActionsSchema, nodeToFrameSchema, validateFrame } from "./validate.js";
|
|
7
|
+
import { nodeActionsSchema, nodeToFrameSchema, validateFrame, validateRenderPayload } from "./validate.js";
|
|
8
8
|
import { getSnapshotsRoot } from "./paths.js";
|
|
9
9
|
import { cancelSlideshow, startSlideshow } from "./slideshow.js";
|
|
10
10
|
import { waitForClick, waitForDone } from "./interaction.js";
|
|
11
|
+
import { waitForQuiz } from "./quiz-submission.js";
|
|
11
12
|
import { findSnapshotById, findSnapshotByIdInWorkspace, listSnapshots } from "./snapshot-reader.js";
|
|
12
13
|
import { generateExportHtml } from "./export-html.js";
|
|
13
14
|
import { appendFrameAndBroadcast, commitRenderResult, commitStepFramesResult, initStepFramesResult, seekAndBroadcast, stepAndBroadcast, validateWorkspaceInput, } from "./render-core.js";
|
|
@@ -25,18 +26,19 @@ export function createMcpServer() {
|
|
|
25
26
|
' • "html" — HTML fragment, styled with Bootstrap 5 (CSS-only build, always available — no need to link or import it). Use its component classes instead of hand-authored CSS: "card"/"card-body"/"card-title" for panels, "alert alert-info"/"alert-warning"/"alert-danger" for callouts, "badge bg-primary"/"bg-success" for tags, "table table-striped"/"table-bordered" for data tables. No Bootstrap JS is loaded — components that depend on it (dropdowns, modals, tooltips, popovers, collapses, carousels, offcanvas) render their static markup but are not interactive, so avoid them. Example: render({ type: "html", payload: "<div class="card"><div class="card-body"><h5 class="card-title">Hello</h5><span class="badge bg-success">Done</span></div></div>" })\n' +
|
|
26
27
|
' • "katex" — LaTeX string, rendered in display mode. Example: render({ type: "katex", payload: "E = mc^2" })\n' +
|
|
27
28
|
' • "vega-lite" — Vega-Lite JSON spec (must be valid JSON). Example: render({ type: "vega-lite", payload: "{"$schema":"...","mark":"bar",...}" })\n' +
|
|
29
|
+
' • "quiz" — JSON quiz: { title?, questions: [{ id, text, kind: "single"|"multiple", options: [{ id, text, correct?, weight? }], scoring?: "all_or_nothing"|"partial", points? }] }. Either every question marks its correct option(s) (inline: the browser scores it) or none does (deferred). "partial" scoring (multiple-choice only) needs a weight on each correct option, summing to 1. Valid in render() only — not in slideshow() or step-frames.\n' +
|
|
28
30
|
"render() is single-frame only. For a step-through sequence (multiple frames navigable via step()/seek()), use init_step_frames()/append_frame()/commit_step_frames() instead (see below).\n" +
|
|
29
31
|
'options: { "workspace": "my-course", "title": "My diagram" }. workspace is REQUIRED — snapshot routing fails without it. title is optional.\n' +
|
|
30
32
|
'options.workspace: workspace name for snapshot routing. Must be alphanumeric with dashes, underscores, dots, or spaces — no path separators. No env var fallback: always pass explicitly.\n' +
|
|
31
33
|
'Example: render({ type: "mermaid", payload: "graph TD; A --> B", options: { workspace: "course_2", title: "System flow" } })',
|
|
32
34
|
inputSchema: z.object({
|
|
33
35
|
type: z
|
|
34
|
-
.enum(["mermaid", "svg", "html", "katex", "vega-lite"])
|
|
36
|
+
.enum(["mermaid", "svg", "html", "katex", "vega-lite", "quiz"])
|
|
35
37
|
.describe("Content type."),
|
|
36
38
|
payload: z
|
|
37
39
|
.string()
|
|
38
40
|
.describe("The content source. For mermaid: must begin with a valid diagram keyword. " +
|
|
39
|
-
"For vega-lite: must be valid JSON. For svg/html/katex: any string."),
|
|
41
|
+
"For vega-lite and quiz: must be valid JSON. For svg/html/katex: any string."),
|
|
40
42
|
options: z
|
|
41
43
|
.object({
|
|
42
44
|
workspace: z.string().describe("REQUIRED. Workspace name for snapshot routing. " +
|
|
@@ -55,7 +57,7 @@ export function createMcpServer() {
|
|
|
55
57
|
};
|
|
56
58
|
}
|
|
57
59
|
const { workspace } = workspaceResult;
|
|
58
|
-
const validationError = await
|
|
60
|
+
const validationError = await validateRenderPayload(type, payload);
|
|
59
61
|
if (validationError) {
|
|
60
62
|
return {
|
|
61
63
|
content: [{ type: "text", text: JSON.stringify({ ok: false, error: validationError }) }],
|
|
@@ -76,9 +78,9 @@ export function createMcpServer() {
|
|
|
76
78
|
.enum(["next", "prev"])
|
|
77
79
|
.describe('"next" to advance, "prev" to rewind.'),
|
|
78
80
|
}),
|
|
79
|
-
}, ({ direction }) => {
|
|
81
|
+
}, async ({ direction }) => {
|
|
80
82
|
return {
|
|
81
|
-
content: [{ type: "text", text: JSON.stringify(stepAndBroadcast(direction)) }],
|
|
83
|
+
content: [{ type: "text", text: JSON.stringify(await stepAndBroadcast(direction)) }],
|
|
82
84
|
};
|
|
83
85
|
});
|
|
84
86
|
// seek(frame) — jump the step cursor to an arbitrary frame index.
|
|
@@ -90,9 +92,9 @@ export function createMcpServer() {
|
|
|
90
92
|
inputSchema: z.object({
|
|
91
93
|
frame: z.number().int().nonnegative().describe("Zero-based frame index to jump to."),
|
|
92
94
|
}),
|
|
93
|
-
}, ({ frame }) => {
|
|
95
|
+
}, async ({ frame }) => {
|
|
94
96
|
return {
|
|
95
|
-
content: [{ type: "text", text: JSON.stringify(seekAndBroadcast(frame)) }],
|
|
97
|
+
content: [{ type: "text", text: JSON.stringify(await seekAndBroadcast(frame)) }],
|
|
96
98
|
};
|
|
97
99
|
});
|
|
98
100
|
// clear() — reset the canvas.
|
|
@@ -224,6 +226,19 @@ export function createMcpServer() {
|
|
|
224
226
|
content: [{ type: "text", text: JSON.stringify({ ok: true }) }],
|
|
225
227
|
};
|
|
226
228
|
});
|
|
229
|
+
// wait_quiz() — block until the user submits the quiz currently on the canvas.
|
|
230
|
+
server.registerTool("wait_quiz", {
|
|
231
|
+
description: 'Block until the user clicks Submit on the quiz currently on the whiteboard (render({ type: "quiz", ... })). ' +
|
|
232
|
+
'Returns { "ok": true, "type": "submitted", "questions": <the quiz>, "answers": [{ "questionId": "...", "selected": ["optionId", ...] }] } ' +
|
|
233
|
+
"— for an inline quiz also a client-computed \"score\": { earned, possible }; for a deferred quiz (no answer key in the payload) you evaluate and score the answers yourself. " +
|
|
234
|
+
"A submission made before you call wait_quiz() is kept and returned immediately, as long as that quiz is still on screen. " +
|
|
235
|
+
'Times out after 10 minutes with { "ok": true, "type": "timeout" }; a second wait_quiz() supersedes a pending one ({ "type": "superseded" }). ' +
|
|
236
|
+
'Returns { "ok": false, "error": "..." } if no quiz is on the canvas. ' +
|
|
237
|
+
"Example flow: render({ type: \"quiz\", ... }) → wait_quiz() → evaluate answers",
|
|
238
|
+
inputSchema: z.object({}),
|
|
239
|
+
}, async () => ({
|
|
240
|
+
content: [{ type: "text", text: JSON.stringify(await waitForQuiz()) }],
|
|
241
|
+
}));
|
|
227
242
|
// init_step_frames(frame_type, workspace, title?) — begin an incremental step-frames build.
|
|
228
243
|
server.registerTool("init_step_frames", {
|
|
229
244
|
description: "Begin a step-frames sequence — a step-through diagram built one frame at a time and navigable afterwards via step()/seek(). This is the only way to create a multi-frame sequence (render() is single-frame only).\n" +
|
|
@@ -317,10 +332,10 @@ export function createMcpServer() {
|
|
|
317
332
|
id: z.string().optional().describe("Optional UUID returned by a previous render() or commit_step_frames() call. " +
|
|
318
333
|
"When provided, retrieves that specific snapshot's payload instead of the current canvas state."),
|
|
319
334
|
}),
|
|
320
|
-
}, ({ id }) => {
|
|
335
|
+
}, async ({ id }) => {
|
|
321
336
|
if (id !== undefined && id !== "") {
|
|
322
337
|
const root = getSnapshotsRoot();
|
|
323
|
-
const payload = findSnapshotById(id, root);
|
|
338
|
+
const payload = await findSnapshotById(id, root);
|
|
324
339
|
if (payload === null) {
|
|
325
340
|
return {
|
|
326
341
|
content: [{ type: "text", text: JSON.stringify({ ok: false, error: "graph not found" }) }],
|
|
@@ -349,7 +364,7 @@ export function createMcpServer() {
|
|
|
349
364
|
.describe("REQUIRED. Workspace name to list snapshots for. " +
|
|
350
365
|
"Alphanumeric, dashes, underscores, dots, spaces — no path separators or '..'."),
|
|
351
366
|
}),
|
|
352
|
-
}, ({ workspace }) => {
|
|
367
|
+
}, async ({ workspace }) => {
|
|
353
368
|
const workspaceResult = validateWorkspaceInput(workspace);
|
|
354
369
|
if (!workspaceResult.ok) {
|
|
355
370
|
return {
|
|
@@ -357,7 +372,7 @@ export function createMcpServer() {
|
|
|
357
372
|
};
|
|
358
373
|
}
|
|
359
374
|
const root = getSnapshotsRoot();
|
|
360
|
-
const snapshots = listSnapshots(workspaceResult.workspace, root);
|
|
375
|
+
const snapshots = await listSnapshots(workspaceResult.workspace, root);
|
|
361
376
|
return {
|
|
362
377
|
content: [{ type: "text", text: JSON.stringify({ ok: true, snapshots }) }],
|
|
363
378
|
};
|
|
@@ -402,7 +417,7 @@ export function createMcpServer() {
|
|
|
402
417
|
const root = getSnapshotsRoot();
|
|
403
418
|
const validItems = [];
|
|
404
419
|
for (const id of ids) {
|
|
405
|
-
const record = findSnapshotByIdInWorkspace(validatedWorkspace, id, root);
|
|
420
|
+
const record = await findSnapshotByIdInWorkspace(validatedWorkspace, id, root);
|
|
406
421
|
if (record !== null) {
|
|
407
422
|
validItems.push({ workspace: validatedWorkspace, id, record });
|
|
408
423
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared port-env-var validation (NF52) — used by index.ts's PORT and
|
|
3
|
+
* channel.ts/app.ts's CHANNEL_PORT, so an invalid value fails fast with a
|
|
4
|
+
* clear error instead of a low-level bind/listen/fetch failure.
|
|
5
|
+
*
|
|
6
|
+
* 0 is accepted (not just 1–65535): Node's listen()/serve() treat it as "let
|
|
7
|
+
* the OS assign a free port", which tests/unit/server/index.test.ts already
|
|
8
|
+
* relies on for ephemeral test binding.
|
|
9
|
+
*/
|
|
10
|
+
export function parsePort(value, fallback, envVarName) {
|
|
11
|
+
if (value === undefined)
|
|
12
|
+
return fallback;
|
|
13
|
+
const port = Number(value);
|
|
14
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
15
|
+
throw new Error(`Invalid ${envVarName} "${value}": must be an integer between 0 and 65535.`);
|
|
16
|
+
}
|
|
17
|
+
return port;
|
|
18
|
+
}
|