@tequila99/githuman 0.8.0 → 0.9.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.
Files changed (178) hide show
  1. package/README.md +39 -3
  2. package/dist/server/app.js +1 -0
  3. package/dist/server/db/migrations.js +6 -0
  4. package/dist/server/errors/note-assets.js +3 -0
  5. package/dist/server/repositories/notes-catalog.repo.js +4 -3
  6. package/dist/server/repositories/notes.repo.js +69 -27
  7. package/dist/server/routes/agent.js +1 -0
  8. package/dist/server/routes/notes.js +48 -2
  9. package/dist/server/services/agent/context-builder.js +13 -0
  10. package/dist/server/services/notes-external-assets.service.js +177 -0
  11. package/dist/server/services/notes-identity.service.js +112 -27
  12. package/dist/server/services/notes-operations.service.js +3 -5
  13. package/dist/server/services/notes.service.js +29 -4
  14. package/dist/server/utils/note-asset-format.js +1 -1
  15. package/dist/server/utils/remote-asset-request.js +193 -0
  16. package/dist/server/utils/request-abort.js +20 -0
  17. package/dist/shared/agents/schemas.js +14 -0
  18. package/dist/shared/notes/asset-formats.js +19 -1
  19. package/dist/shared/notes/constants.js +5 -0
  20. package/dist/shared/notes/external-assets.js +153 -0
  21. package/dist/shared/notes/note-path.js +17 -0
  22. package/dist/shared/notes/reference-path.js +2 -1
  23. package/dist/shared/notes/references.js +1 -1
  24. package/dist/shared/notes/schemas.js +60 -2
  25. package/dist/shared/utils/web-url.js +15 -0
  26. package/dist/web/assets/AppPage-DdmqiV2R.js +1 -0
  27. package/dist/web/assets/{ClosePopup-D2_HzWXW.js → ClosePopup-lpH_1Ju2.js} +1 -1
  28. package/dist/web/assets/{CreateButton-pmDRGwna.js → CreateButton-Dnpwobd9.js} +2 -2
  29. package/dist/web/assets/{CreateReviewFabButton-CcXWnmSN.js → CreateReviewFabButton-KvKPEJzI.js} +1 -1
  30. package/dist/web/assets/IndexPage-DE-IkAhK.js +1 -0
  31. package/dist/web/assets/IndexPage-F8MMAAkn.css +1 -0
  32. package/dist/web/assets/{MainLayout-DOGoQ-1m.js → MainLayout-wWA8ckBg.js} +4 -4
  33. package/dist/web/assets/NoteVisualEditor-Bj13_oEl.js +1054 -0
  34. package/dist/web/assets/{NoteVisualEditor-CClIKW0X.css → NoteVisualEditor-IH3s3d3W.css} +1 -1
  35. package/dist/web/assets/NotesPage-FidBg83M.js +4 -0
  36. package/dist/web/assets/NotesPage-KaCEnfdz.css +1 -0
  37. package/dist/web/assets/PanelResize-C51WwwLD.css +1 -0
  38. package/dist/web/assets/PanelResize-DY0Nzmql.js +1 -0
  39. package/dist/web/assets/{PdfPreview-CdgeqqEv.js → PdfPreview-C7HomxEg.js} +1 -1
  40. package/dist/web/assets/{QFab-DyUFgj0W.js → QFab-C-YZWgmW.js} +1 -1
  41. package/dist/web/assets/{QForm-BiRkV9BY.js → QForm-DpEhYVCa.js} +1 -1
  42. package/dist/web/assets/QResizeObserver-DeRFR_WF.js +1 -0
  43. package/dist/web/assets/{QScrollArea-DfPW35JQ.js → QScrollArea-BSSZoaV5.js} +1 -1
  44. package/dist/web/assets/{QSelect-BpZ5KfWP.js → QSelect-2iOBkp1K.js} +1 -1
  45. package/dist/web/assets/QSpace-B5NneHAd.js +1 -0
  46. package/dist/web/assets/QVirtualScroll-CZJMBpmR.js +1 -0
  47. package/dist/web/assets/ReviewDetailPage-BI1iAhSf.js +1 -0
  48. package/dist/web/assets/{ReviewsPage-BaxUZzUZ.js → ReviewsPage-RyST0FlH.js} +1 -1
  49. package/dist/web/assets/StatusSwitcher-DAeNzNP7.js +1 -0
  50. package/dist/web/assets/{TerminalView-DMJTXE-G.js → TerminalView-BAzJgUTq.js} +2 -2
  51. package/dist/web/assets/{abnfDiagram-O67JEVCF-CiOkMSRz.js → abnfDiagram-O67JEVCF-BNZQ7duT.js} +1 -1
  52. package/dist/web/assets/agent-store-DP6Vrw6L.js +113 -0
  53. package/dist/web/assets/app-info-store-u6ZVFjv5.js +1 -0
  54. package/dist/web/assets/architecture-7GRP2DOG-BSdnjp9Z.js +1 -0
  55. package/dist/web/assets/{architectureDiagram-NJMV4G6O-BexHJffj.js → architectureDiagram-NJMV4G6O-Bkj0kfFn.js} +1 -1
  56. package/dist/web/assets/{blockDiagram-BEXU5L5S-CEyB_5a1.js → blockDiagram-BEXU5L5S-CXO3zN2c.js} +1 -1
  57. package/dist/web/assets/{c4Diagram-YGBWAQC7-BRNWA7xI.js → c4Diagram-YGBWAQC7-BHfMeZNf.js} +1 -1
  58. package/dist/web/assets/channel-DmoOM6AB.js +1 -0
  59. package/dist/web/assets/{chunk-3FUC2YCW-BcQR1JDF.js → chunk-3FUC2YCW-z1WbH7Gi.js} +1 -1
  60. package/dist/web/assets/{chunk-5DYCD2WN-D6aQ1hFt.js → chunk-5DYCD2WN-K9HPch97.js} +1 -1
  61. package/dist/web/assets/{chunk-742MDFTN-BRgrFtkf.js → chunk-742MDFTN-Bhjp9fC-.js} +1 -1
  62. package/dist/web/assets/{chunk-7INBJB4K-3RBjXeq6.js → chunk-7INBJB4K-Do0EmU14.js} +1 -1
  63. package/dist/web/assets/{chunk-7M6MHVWA-Malugc_N.js → chunk-7M6MHVWA-veeEjsOh.js} +1 -1
  64. package/dist/web/assets/{chunk-7PRAP22T-Bb3gaISA.js → chunk-7PRAP22T-CPqB5tCK.js} +1 -1
  65. package/dist/web/assets/{chunk-GTNCS2PH-Cps7XhUC.js → chunk-GTNCS2PH-Civ1V52P.js} +1 -1
  66. package/dist/web/assets/{chunk-GWA4HPMP-Bfp2Tg3_.js → chunk-GWA4HPMP-C7LzAlyZ.js} +1 -1
  67. package/dist/web/assets/{chunk-MBY4JIJT-IfXbX1Ur.js → chunk-MBY4JIJT-DfmJy0o_.js} +1 -1
  68. package/dist/web/assets/{chunk-NETBCI7D-r2fmahwk.js → chunk-NETBCI7D-BZIKmMvL.js} +1 -1
  69. package/dist/web/assets/{chunk-O7XYJQB3-sadsiWmA.js → chunk-O7XYJQB3-DcHeu6b7.js} +1 -1
  70. package/dist/web/assets/{chunk-UA2S7LBM-DibfwtDn.js → chunk-UA2S7LBM-0SR2og6x.js} +1 -1
  71. package/dist/web/assets/{chunk-WEXAMYUT-DBouMgEO.js → chunk-WEXAMYUT-CJMSoM-Z.js} +1 -1
  72. package/dist/web/assets/{chunk-Z7XXMR3K-fAHToON5.js → chunk-Z7XXMR3K-q6fce66x.js} +1 -1
  73. package/dist/web/assets/{chunk-ZIGJFQKS-CJG1UIHO.js → chunk-ZIGJFQKS-DsPzen4Y.js} +1 -1
  74. package/dist/web/assets/{classDiagram-v2-NBCMYWYE-CPHP5ynj.js → classDiagram-v2-NBCMYWYE-Cpm_bvzb.js} +1 -1
  75. package/dist/web/assets/{cynefin-OW5HDTMX-BU7_LYGR.js → cynefin-OW5HDTMX-ZTr5XZMW.js} +1 -1
  76. package/dist/web/assets/{cynefinDiagram-VND7K2PF-BXFEy9Io.js → cynefinDiagram-VND7K2PF-ByUmUx58.js} +1 -1
  77. package/dist/web/assets/{dagre-6A5THRUB-aKpdpQ18.js → dagre-6A5THRUB-mQOy0E3r.js} +1 -1
  78. package/dist/web/assets/{diagram-22UHCM2B-ddbRgIQ9.js → diagram-22UHCM2B-BW0LPtyk.js} +1 -1
  79. package/dist/web/assets/{diagram-3UASUU5V-vNYy6WP_.js → diagram-3UASUU5V-jo148ec5.js} +1 -1
  80. package/dist/web/assets/{diagram-ATOU4E4O-B9Z3zFfp.js → diagram-ATOU4E4O-CJt4F0i4.js} +1 -1
  81. package/dist/web/assets/{diagram-CDSNMT55-D3TaDPXU.js → diagram-CDSNMT55-DEShJv56.js} +1 -1
  82. package/dist/web/assets/{diagram-MLGK6HIB-DOXcXm2Y.js → diagram-MLGK6HIB-DOFOADfs.js} +1 -1
  83. package/dist/web/assets/{diagram-MPIPVDR6-B5E2J8pO.js → diagram-MPIPVDR6-ux537BMl.js} +1 -1
  84. package/dist/web/assets/{dist-DSPzgfNo.js → dist-7xWvOKEv.js} +1 -1
  85. package/dist/web/assets/{dist-C9cJQvJH.js → dist-BThMRW5U.js} +1 -1
  86. package/dist/web/assets/{dist-BXUopzfH.js → dist-BWa3ecCV.js} +1 -1
  87. package/dist/web/assets/{dist-BX8UA0fl.js → dist-BbdrgL5V.js} +1 -1
  88. package/dist/web/assets/{dist-Kd0W7Y_b.js → dist-Bo7anpPj.js} +1 -1
  89. package/dist/web/assets/{dist-BFbA030x.js → dist-Bu9yRqTU.js} +1 -1
  90. package/dist/web/assets/{dist-Cxd9qKh7.js → dist-Bvnostf5.js} +1 -1
  91. package/dist/web/assets/{dist-C9STut2J.js → dist-CK1va5C_.js} +1 -1
  92. package/dist/web/assets/{dist-BGzvKGEu.js → dist-CLv-grWO.js} +1 -1
  93. package/dist/web/assets/{dist-CRoJr3t6.js → dist-D1EUrS6y.js} +1 -1
  94. package/dist/web/assets/{dist-PDsGXJi3.js → dist-DL0dBIkt.js} +1 -1
  95. package/dist/web/assets/{ebnfDiagram-ZINNZB2B-D-OCB_ud.js → ebnfDiagram-ZINNZB2B-DEybuZle.js} +1 -1
  96. package/dist/web/assets/{elk-276RUBZZ-CzjP8RHy.js → elk-276RUBZZ-CZXZRpGb.js} +1 -1
  97. package/dist/web/assets/{erDiagram-OPXOYQCR-Clohps7p.js → erDiagram-OPXOYQCR-BgRqGAt5.js} +1 -1
  98. package/dist/web/assets/eventmodeling-NTZA5JFV-ZuRq6Sae.js +1 -0
  99. package/dist/web/assets/flowDiagram-KWPJA3E3-DRyHAdLb.js +1 -0
  100. package/dist/web/assets/{ganttDiagram-FUAMR5RP-ZWH4HYq5.js → ganttDiagram-FUAMR5RP-CBJwX1gk.js} +1 -1
  101. package/dist/web/assets/{gitGraph-4MIJSDKK-C3AfRnC8.js → gitGraph-4MIJSDKK-Dw-XA3G1.js} +1 -1
  102. package/dist/web/assets/{gitGraphDiagram-X574FWY7-BobkQARX.js → gitGraphDiagram-X574FWY7-BpGccoWp.js} +1 -1
  103. package/dist/web/assets/i18n-sfJvmpVb.js +1 -0
  104. package/dist/web/assets/index-B2CzafCr.js +2 -0
  105. package/dist/web/assets/{info-A6RAGUB7-Dh_SAe09.js → info-A6RAGUB7-Dj5RfJsg.js} +1 -1
  106. package/dist/web/assets/{infoDiagram-VRGFBTTK-izDlhNTw.js → infoDiagram-VRGFBTTK-DyVSH991.js} +1 -1
  107. package/dist/web/assets/{init-app-C1a6BWWN.js → init-app-DSS44aRI.js} +1 -1
  108. package/dist/web/assets/{ishikawaDiagram-OU5B5YK6-BJ89XzVJ.js → ishikawaDiagram-OU5B5YK6-Cu0n2oqT.js} +1 -1
  109. package/dist/web/assets/{journeyDiagram-ZHPQQLJL-poV-f08K.js → journeyDiagram-ZHPQQLJL-Bz1Ot6Zg.js} +1 -1
  110. package/dist/web/assets/{kanban-definition-PNTS6WVX-Cn3FEnH7.js → kanban-definition-PNTS6WVX-C42vuY7H.js} +1 -1
  111. package/dist/web/assets/{line-bE9Mqcqy.js → line-C5WKurbS.js} +1 -1
  112. package/dist/web/assets/markdown-DBTEhbhQ.js +15 -0
  113. package/dist/web/assets/{mermaid-parser.core-Cua7H5pF.js → mermaid-parser.core-B7l8qh8B.js} +3 -3
  114. package/dist/web/assets/{mermaid.core-BMPOA8T-.js → mermaid.core-Drs2mjGg.js} +4 -4
  115. package/dist/web/assets/{mindmap-definition-NLK3R4M7-DaTGr8Fs.js → mindmap-definition-NLK3R4M7-BV1FNwCn.js} +1 -1
  116. package/dist/web/assets/{packet-AYTQ26CC-DmLfghd9.js → packet-AYTQ26CC-BPmUY1Ig.js} +1 -1
  117. package/dist/web/assets/{pegDiagram-GJSIUBJH-CTlxXcbf.js → pegDiagram-GJSIUBJH-D6NPxPdr.js} +1 -1
  118. package/dist/web/assets/{pie-WAS4IAKB-52oNsEtc.js → pie-WAS4IAKB-CTp2jNAc.js} +1 -1
  119. package/dist/web/assets/{pieDiagram-5QR66LMP-CbJtB1te.js → pieDiagram-5QR66LMP-BVkABrg7.js} +1 -1
  120. package/dist/web/assets/pinia-BVWaJ8NX.js +1 -0
  121. package/dist/web/assets/{quadrantDiagram-O4NWA36T-DCg-uD08.js → quadrantDiagram-O4NWA36T-LZCjVd08.js} +1 -1
  122. package/dist/web/assets/{radar-RG4KPBEZ-9uA3AOZR.js → radar-RG4KPBEZ-CCiUrmsg.js} +1 -1
  123. package/dist/web/assets/{railroad-74A4TZTK-BPxQfpxv.js → railroad-74A4TZTK-CZn8VJbK.js} +1 -1
  124. package/dist/web/assets/railroad-abnf-HS5TGJTU-B-9tG9pW.js +1 -0
  125. package/dist/web/assets/railroad-ebnf-LZEXJU2U-D7lUyGOq.js +1 -0
  126. package/dist/web/assets/railroad-peg-WCYAUIDC-ja94t32K.js +1 -0
  127. package/dist/web/assets/{railroadDiagram-XR7U4H2S-CwBNr6fr.js → railroadDiagram-XR7U4H2S-nopy-mdw.js} +1 -1
  128. package/dist/web/assets/{requirementDiagram-PLB6GJNP-BbtZt0St.js → requirementDiagram-PLB6GJNP-BOeMzf-x.js} +1 -1
  129. package/dist/web/assets/{safe-storage-Bn-sHAYw.js → safe-storage-aRoyeFxv.js} +1 -1
  130. package/dist/web/assets/{sankeyDiagram-IPEJSGJF-DRWdMnFl.js → sankeyDiagram-IPEJSGJF-Bbk0AQUe.js} +1 -1
  131. package/dist/web/assets/{sequenceDiagram-PO4LG4MO-C4tWeWpt.js → sequenceDiagram-PO4LG4MO-ZpubjKFc.js} +1 -1
  132. package/dist/web/assets/{stateDiagram-v2-GCMORJYK-pE0T1U5E.js → stateDiagram-v2-GCMORJYK-DNjDh9yZ.js} +1 -1
  133. package/dist/web/assets/{swimlanes-2SLR337P-DKky6E3p.js → swimlanes-2SLR337P-C-_UzZF_.js} +1 -1
  134. package/dist/web/assets/swimlanesDiagram-TC7HE7FX-CVSZNvS2.js +8 -0
  135. package/dist/web/assets/theme-CeQhGDjh.js +1 -0
  136. package/dist/web/assets/{timeline-definition-EJHVYXUP-CsRzcMLr.js → timeline-definition-EJHVYXUP-BiaJyHdJ.js} +1 -1
  137. package/dist/web/assets/{treeView-Q6P3EWNA-CQYqoPG3.js → treeView-Q6P3EWNA-CENjlerX.js} +1 -1
  138. package/dist/web/assets/{treemap-WGGIJYW6-BzFw4gFs.js → treemap-WGGIJYW6-CBpdf3L2.js} +1 -1
  139. package/dist/web/assets/use-add-agent-context-SApxn9fW.js +1 -0
  140. package/dist/web/assets/{use-app-theme-9qFZUBqz.js → use-app-theme-DP8Ymwwb.js} +1 -1
  141. package/dist/web/assets/use-panel-width-CTXD_csP.js +1 -0
  142. package/dist/web/assets/use-server-events-C13Sw-rU.js +1 -0
  143. package/dist/web/assets/{usecaseDiagram-POWQR4AR-IMfiBw6h.js → usecaseDiagram-POWQR4AR-CcJ83_Ve.js} +1 -1
  144. package/dist/web/assets/{vennDiagram-UO4OBE2U-JKdzpNy2.js → vennDiagram-UO4OBE2U-DBjzpbFr.js} +1 -1
  145. package/dist/web/assets/{wardley-WFR3VGLG-B9Yiwlp8.js → wardley-WFR3VGLG-DmlbUWUM.js} +1 -1
  146. package/dist/web/assets/{wardleyDiagram-VNRHLVJA-BVoc2hGS.js → wardleyDiagram-VNRHLVJA-CWSZCqf4.js} +1 -1
  147. package/dist/web/assets/window-store-DNnKUhxs.js +1 -0
  148. package/dist/web/assets/{xychartDiagram-PMCCYNJV-B_XV1aim.js → xychartDiagram-PMCCYNJV-CMnNSaZv.js} +1 -1
  149. package/dist/web/index.html +1 -1
  150. package/package.json +3 -1
  151. package/dist/web/assets/AppPage-cl-3VfAl.js +0 -1
  152. package/dist/web/assets/IndexPage-CYFIGWy9.css +0 -1
  153. package/dist/web/assets/IndexPage-CmBmgu4A.js +0 -1
  154. package/dist/web/assets/NoteVisualEditor-zjZr9aod.js +0 -1056
  155. package/dist/web/assets/NotesPage-C5VXA9Bb.css +0 -1
  156. package/dist/web/assets/NotesPage-Jeva3Jta.js +0 -114
  157. package/dist/web/assets/QSpace-BRLIXkHB.js +0 -1
  158. package/dist/web/assets/ReviewDetailPage-CpJPrSKO.js +0 -1
  159. package/dist/web/assets/StatusSwitcher-D5UG_YWP.js +0 -1
  160. package/dist/web/assets/agent-icon-Dx8WBYX_.js +0 -1
  161. package/dist/web/assets/agent-store-B1VK6S0D.js +0 -1
  162. package/dist/web/assets/app-info-store-D76h_OdJ.js +0 -1
  163. package/dist/web/assets/architecture-7GRP2DOG-Byc4Oz95.js +0 -1
  164. package/dist/web/assets/channel-DbRAFIx1.js +0 -1
  165. package/dist/web/assets/equal-arrays-GA_7ZCAb.js +0 -1
  166. package/dist/web/assets/eventmodeling-NTZA5JFV-FWK4tnN_.js +0 -1
  167. package/dist/web/assets/flowDiagram-KWPJA3E3-C10B5_6J.js +0 -1
  168. package/dist/web/assets/i18n-CC_rvfan.js +0 -1
  169. package/dist/web/assets/index-Ic8yFbJg.js +0 -2
  170. package/dist/web/assets/markdown-D85iiL_9.js +0 -15
  171. package/dist/web/assets/pinia-BlCzIRX-.js +0 -1
  172. package/dist/web/assets/railroad-abnf-HS5TGJTU-CPVHNg05.js +0 -1
  173. package/dist/web/assets/railroad-ebnf-LZEXJU2U-DAjTaOUg.js +0 -1
  174. package/dist/web/assets/railroad-peg-WCYAUIDC-DkxyKsj4.js +0 -1
  175. package/dist/web/assets/swimlanesDiagram-TC7HE7FX-CPhibEqM.js +0 -8
  176. package/dist/web/assets/theme-B-g7wQEs.js +0 -1
  177. package/dist/web/assets/use-panel-width-Dyh49bod.js +0 -1
  178. package/dist/web/assets/use-server-events-24_soFuj.js +0 -1
@@ -1,3 +1,5 @@
1
+ import PQueue from 'p-queue';
2
+ import { noteBasename, noteChildPath, noteParentPath } from "../../shared/notes/note-path.js";
1
3
  import { lstat } from 'node:fs/promises';
2
4
  import { isSystemError } from "../utils/system-error.js";
3
5
  import { randomUUID } from 'node:crypto';
@@ -13,6 +15,8 @@ import { sha256Hex } from "../utils/hash.js";
13
15
  import { safeDecodeURIComponent } from "../../shared/utils/safe-decode.js";
14
16
  // A second complete scan confirms absence, instead of treating an atomic save as deletion.
15
17
  const ABSENCE_GRACE_MS = 2500;
18
+ // Longest file name that common file systems accept, in bytes.
19
+ const MAX_FILENAME_BYTES = 255;
16
20
  function newRecord(id, path, version) {
17
21
  const now = new Date().toISOString();
18
22
  return {
@@ -22,6 +26,7 @@ function newRecord(id, path, version) {
22
26
  presence: 'present',
23
27
  createdAt: now,
24
28
  updatedAt: now,
29
+ deletedAt: null,
25
30
  redirectId: null
26
31
  };
27
32
  }
@@ -35,11 +40,26 @@ function portablePath(path) {
35
40
  }
36
41
  }
37
42
  }
43
+ /**
44
+ * Path a deleted note moves to. The suffix frees the original name for a new
45
+ * note. The stem is cut by bytes so the name fits the file system limit.
46
+ */
47
+ function deletedNotePath(path, uuid) {
48
+ const suffix = `_deleted_${uuid}.md`;
49
+ const stem = noteBasename(path).replace(/\.md$/i, '');
50
+ const room = MAX_FILENAME_BYTES - Buffer.byteLength(suffix);
51
+ let cut = stem;
52
+ while (Buffer.byteLength(cut) > room) {
53
+ cut = cut.slice(0, -1);
54
+ }
55
+ return noteChildPath(noteParentPath(path), cut + suffix);
56
+ }
38
57
  export class NotesIdentityService {
39
58
  catalog;
40
59
  writer;
41
60
  operations;
42
- queue = Promise.resolve();
61
+ // One action at a time: catalog and file changes must not interleave.
62
+ queue = new PQueue({ concurrency: 1 });
43
63
  cache = new Map();
44
64
  absentSince = new Map();
45
65
  // Signature of each file that failed to read, so a poll does not read it again.
@@ -72,19 +92,15 @@ export class NotesIdentityService {
72
92
  });
73
93
  }
74
94
  run(action) {
75
- const next = this.queue
76
- .catch(() => { })
77
- .then(async () => {
95
+ return this.queue.add(async () => {
78
96
  if (this.closed)
79
97
  throw new BadRequestError('Notes storage is closing.');
80
98
  return action();
81
99
  });
82
- this.queue = next;
83
- return next;
84
100
  }
85
101
  async close() {
86
102
  this.closed = true;
87
- await this.queue.catch(() => { });
103
+ await this.queue.onIdle();
88
104
  await this.writer.close();
89
105
  }
90
106
  record(id) {
@@ -167,7 +183,8 @@ export class NotesIdentityService {
167
183
  const records = this.catalog.all();
168
184
  const byId = new Map(records.map(record => [record.id, record]));
169
185
  const presentByPath = new Map(records
170
- .filter(record => record.presence === 'present' && !record.redirectId)
186
+ .filter(record => (record.presence === 'present' || record.deletedAt) &&
187
+ !record.redirectId)
171
188
  .map(record => [record.path, record]));
172
189
  const livePaths = new Set(documents.map(doc => doc.path));
173
190
  const counts = new Map();
@@ -245,7 +262,7 @@ export class NotesIdentityService {
245
262
  (identity.id && identity.id !== record.id
246
263
  ? 'The YAML ID conflicts with this note or is duplicated. The catalog identity is retained.'
247
264
  : null);
248
- if (warning) {
265
+ if (warning && !record.deletedAt) {
249
266
  this.issue('metadata:' + record.id, warning, record.id, null, 'metadata');
250
267
  }
251
268
  else {
@@ -268,7 +285,7 @@ export class NotesIdentityService {
268
285
  group.push(candidate);
269
286
  byVersion.set(candidate.version, group);
270
287
  }
271
- for (const old of current.filter(note => note.presence === 'missing' && !note.redirectId)) {
288
+ for (const old of current.filter(note => note.presence === 'missing' && !note.redirectId && !note.deletedAt)) {
272
289
  const samePath = currentByPath.get(old.path);
273
290
  const matches = (byVersion.get(old.version) ?? []).filter(candidate => !documentsByPath.get(candidate.path)?.yamlId);
274
291
  const candidate = samePath ?? (matches.length === 1 ? matches[0] : undefined);
@@ -278,7 +295,7 @@ export class NotesIdentityService {
278
295
  }
279
296
  for (const doc of documents) {
280
297
  const record = currentByPath.get(doc.path);
281
- if (!record || !changed.has(record.id))
298
+ if (!record || record.deletedAt || !changed.has(record.id))
282
299
  continue;
283
300
  const references = changes.length
284
301
  ? noteReferences((await this.files.read(doc.path)).text)
@@ -290,7 +307,9 @@ export class NotesIdentityService {
290
307
  this.cache.delete(path);
291
308
  }
292
309
  // Unreadable files stay visible through their issue, not as openable entries.
293
- return entries.filter(entry => this.unreadable.get(entry.path) !== entry.signature);
310
+ const hiddenPaths = new Set(current.filter(note => note.deletedAt).map(note => note.path));
311
+ return entries.filter(entry => !hiddenPaths.has(entry.path) &&
312
+ this.unreadable.get(entry.path) !== entry.signature);
294
313
  }
295
314
  indexLinks(id, text, snapshot) {
296
315
  this.indexReferences(id, noteReferences(text), snapshot);
@@ -367,7 +386,13 @@ export class NotesIdentityService {
367
386
  entries,
368
387
  this.catalog
369
388
  .all()
370
- .map(note => [note.id, note.path, note.version, note.presence])
389
+ .map(note => [
390
+ note.id,
391
+ note.path,
392
+ note.version,
393
+ note.presence,
394
+ note.deletedAt
395
+ ])
371
396
  ]));
372
397
  }
373
398
  index() {
@@ -381,7 +406,9 @@ export class NotesIdentityService {
381
406
  storage: this.files.root,
382
407
  storageId: this.catalog.storageId,
383
408
  revision: this.revision(entries),
384
- issues: this.catalog.issues(),
409
+ issues: this.catalog
410
+ .issues()
411
+ .filter(issue => !issue.noteId || !this.record(issue.noteId).deletedAt),
385
412
  entries: entries.map(entry => {
386
413
  const record = recordsByPath.get(entry.path);
387
414
  const metadata = entry.folder
@@ -405,13 +432,16 @@ export class NotesIdentityService {
405
432
  this.assertStorage(storageId);
406
433
  await this.synchronize();
407
434
  const registered = this.record(id);
408
- const note = registered.presence === 'present' &&
409
- !(await this.files.exists(registered.path))
410
- ? { ...registered, presence: 'missing' }
411
- : registered;
435
+ const note = registered.deletedAt
436
+ ? { ...registered, presence: 'deleted' }
437
+ : registered.presence === 'present' &&
438
+ !(await this.files.exists(registered.path))
439
+ ? { ...registered, presence: 'missing' }
440
+ : registered;
412
441
  const backlinks = this.catalog
413
442
  .all()
414
443
  .filter(source => source.presence === 'present' &&
444
+ !source.deletedAt &&
415
445
  this.catalog
416
446
  .links(source.id)
417
447
  .some(link => !!link.targetId && this.record(link.targetId).id === note.id));
@@ -432,7 +462,10 @@ export class NotesIdentityService {
432
462
  if (candidates.size !== 1) {
433
463
  throw new BadRequestError('This path belongs to more than one note over time. Open a stable UUID link instead.');
434
464
  }
435
- return this.record([...candidates][0]);
465
+ const record = this.record([...candidates][0]);
466
+ return record.deletedAt
467
+ ? { ...record, presence: 'deleted' }
468
+ : record;
436
469
  });
437
470
  }
438
471
  assertStorage(id) {
@@ -445,7 +478,7 @@ export class NotesIdentityService {
445
478
  this.assertStorage(storageId);
446
479
  await this.synchronize();
447
480
  const record = this.record(id);
448
- if (record.presence !== 'present') {
481
+ if (record.presence !== 'present' || record.deletedAt) {
449
482
  throw new NotFoundError('The note file is missing or deleted.');
450
483
  }
451
484
  return {
@@ -463,8 +496,9 @@ export class NotesIdentityService {
463
496
  .find(note => note.path === path &&
464
497
  note.presence === 'present' &&
465
498
  !note.redirectId);
466
- if (!record)
499
+ if (!record || record.deletedAt) {
467
500
  throw new NotFoundError('Note not found.');
501
+ }
468
502
  return {
469
503
  ...(await this.files.read(path)),
470
504
  id: record.id,
@@ -480,13 +514,21 @@ export class NotesIdentityService {
480
514
  await this.files.path(path);
481
515
  await this.writer.acquire(true);
482
516
  await this.synchronize();
483
- if (id)
484
- path = this.record(id).path;
517
+ if (id) {
518
+ const identified = this.record(id);
519
+ if (identified.deletedAt) {
520
+ throw new NotFoundError('This note was deleted.');
521
+ }
522
+ path = identified.path;
523
+ }
485
524
  let record = this.catalog
486
525
  .all()
487
526
  .find(note => note.path === path &&
488
527
  note.presence === 'present' &&
489
528
  !note.redirectId);
529
+ if (this.catalog.all().some(note => note.path === path && note.deletedAt)) {
530
+ throw new NoteExistsError();
531
+ }
490
532
  if (version === undefined) {
491
533
  portablePath(path);
492
534
  if (record)
@@ -528,6 +570,10 @@ export class NotesIdentityService {
528
570
  }
529
571
  if (id && this.record(id).path !== path)
530
572
  throw new NoteConflictError();
573
+ if (!folder &&
574
+ this.catalog.all().some(note => note.path === path && note.deletedAt)) {
575
+ throw new NotFoundError('This note was deleted.');
576
+ }
531
577
  if (!target &&
532
578
  (name !== name.trim() ||
533
579
  name.includes('/') ||
@@ -543,12 +589,17 @@ export class NotesIdentityService {
543
589
  if (folder ? !sourceStat.isDirectory() : !sourceStat.isFile()) {
544
590
  throw new BadRequestError('Entry type changed.');
545
591
  }
546
- const destination = target ?? [...path.split('/').slice(0, -1), name].join('/');
592
+ const destination = target ?? noteChildPath(noteParentPath(path), name);
547
593
  portablePath(destination);
548
594
  await this.files.path(destination, !folder);
549
595
  if (destination === path) {
550
596
  return { operationId: randomUUID(), complete: true };
551
597
  }
598
+ if (this.catalog
599
+ .all()
600
+ .some(note => note.deletedAt && note.path === destination)) {
601
+ throw new NoteExistsError();
602
+ }
552
603
  if (await this.files.exists(destination)) {
553
604
  const caseOnly = process.platform === 'win32' &&
554
605
  path.toLowerCase() === destination.toLowerCase() &&
@@ -585,6 +636,36 @@ export class NotesIdentityService {
585
636
  return { operationId: operation.id, complete };
586
637
  });
587
638
  }
639
+ deleteId(id, storageId) {
640
+ return this.run(async () => {
641
+ this.assertStorage(storageId);
642
+ await this.synchronize();
643
+ const note = this.record(id);
644
+ if (note.deletedAt)
645
+ return;
646
+ await this.writer.acquire(true);
647
+ await this.writer.assertOwner();
648
+ if (this.catalog
649
+ .operations()
650
+ .some(operation => operation.moves.some(move => move.id === note.id) ||
651
+ operation.writes.some(write => write.id === note.id && !write.done))) {
652
+ throw new NoteConflictError();
653
+ }
654
+ // The note leaves its name, so a new note can use it. The catalog keeps the identity.
655
+ // Only a present file exists on disk and needs the move.
656
+ const path = deletedNotePath(note.path, randomUUID());
657
+ if (note.presence === 'present' && !note.redirectId) {
658
+ await this.files.moveEntry(note.path, path, false);
659
+ }
660
+ this.catalog.put({
661
+ ...note,
662
+ path,
663
+ deletedAt: new Date().toISOString()
664
+ });
665
+ this.catalog.dismiss('missing:' + note.id, true);
666
+ this.catalog.dismiss('metadata:' + note.id);
667
+ });
668
+ }
588
669
  retry() {
589
670
  return this.run(() => this.operations.retry());
590
671
  }
@@ -644,9 +725,11 @@ export class NotesIdentityService {
644
725
  this.assertStorage(storageId);
645
726
  await this.synchronize();
646
727
  const source = this.record(sourceId);
728
+ if (source.deletedAt)
729
+ throw new NotFoundError('This note was deleted.');
647
730
  const paths = this.catalog
648
731
  .all()
649
- .filter(note => note.presence === 'present' && !note.redirectId)
732
+ .filter(note => note.presence === 'present' && !note.redirectId && !note.deletedAt)
650
733
  .map(note => note.path);
651
734
  const refs = noteReferences(text)
652
735
  .flatMap(ref => (ref.displayStarts ?? [ref.start]).map(displayPosition => ({
@@ -709,10 +792,12 @@ export class NotesIdentityService {
709
792
  graph() {
710
793
  return this.run(async () => {
711
794
  await this.synchronize();
712
- const nodes = this.catalog.all().filter(note => !note.redirectId);
795
+ const nodes = this.catalog
796
+ .all()
797
+ .filter(note => !note.redirectId && !note.deletedAt);
713
798
  const edges = nodes
714
799
  .filter(note => note.presence === 'present')
715
- .flatMap(note => this.catalog.links(note.id).flatMap(link => link.targetId
800
+ .flatMap(note => this.catalog.links(note.id).flatMap(link => link.targetId && !this.record(link.targetId).deletedAt
716
801
  ? [
717
802
  {
718
803
  sourceId: note.id,
@@ -1,3 +1,4 @@
1
+ import { noteChildPath, noteParentPath } from "../../shared/notes/note-path.js";
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { lstat } from 'node:fs/promises';
3
4
  import { noteReferences, referenceTarget, rewriteReferences, sameReference } from "../../shared/notes/references.js";
@@ -42,7 +43,7 @@ export class NotesOperationsService {
42
43
  };
43
44
  if (restore)
44
45
  operation.restore = restore;
45
- const records = (preview ?? this.catalog.all()).filter(note => note.presence === 'present' && !note.redirectId);
46
+ const records = (preview ?? this.catalog.all()).filter(note => note.presence === 'present' && !note.redirectId && !note.deletedAt);
46
47
  const oldPath = (note) => moves.find(move => move.id === note.id)?.from ?? note.path;
47
48
  const newPath = (note) => moves.find(move => move.id === note.id)?.to ?? note.path;
48
49
  const oldPaths = records.map(oldPath);
@@ -111,10 +112,7 @@ export class NotesOperationsService {
111
112
  operation.source !== operation.target &&
112
113
  operation.source.toLowerCase() === operation.target.toLowerCase();
113
114
  if (caseOnly && !operation.temporary) {
114
- operation.temporary = [
115
- ...operation.source.split('/').slice(0, -1),
116
- '__ght-case-' + operation.id + (operation.folder ? '' : '.md')
117
- ].join('/');
115
+ operation.temporary = noteChildPath(noteParentPath(operation.source), '__ght-case-' + operation.id + (operation.folder ? '' : '.md'));
118
116
  this.catalog.operation(operation);
119
117
  }
120
118
  let fromPath = operation.source;
@@ -10,6 +10,8 @@ import { BadRequestError } from "../errors/http.js";
10
10
  import { errorMessage } from "../../shared/utils/error-message.js";
11
11
  import { MAX_NOTE_ASSET_BYTES } from "../../shared/notes/constants.js";
12
12
  import { detectNoteAssetFormat } from "../utils/note-asset-format.js";
13
+ // Git error text depends on the locale. The "not a git repository" check below needs English.
14
+ const GIT_ENV = { ...process.env, LC_ALL: 'C' };
13
15
  const execFileAsync = promisify(execFile);
14
16
  export class NotesService {
15
17
  repository;
@@ -32,18 +34,18 @@ export class NotesService {
32
34
  }
33
35
  async visibility(ensureIgnored = false) {
34
36
  try {
35
- const { stdout: gitRoot } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd: this.projectPath });
37
+ const { stdout: gitRoot } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd: this.projectPath, env: GIT_ENV });
36
38
  const path = relative(gitRoot.trim(), this.root).replaceAll('\\', '/');
37
39
  if (path.startsWith('../'))
38
40
  return;
39
- const { stdout: tracked } = await execFileAsync('git', ['ls-files', '--', path + '/'], { cwd: gitRoot.trim() });
41
+ const { stdout: tracked } = await execFileAsync('git', ['ls-files', '--', path + '/'], { cwd: gitRoot.trim(), env: GIT_ENV });
40
42
  if (tracked && this.local) {
41
43
  this.warning =
42
44
  'Some notes are already tracked by Git. Ignoring them does not remove them from Git.';
43
45
  }
44
46
  if (!this.local || !ensureIgnored)
45
47
  return;
46
- const { stdout: exclude } = await execFileAsync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: this.projectPath });
48
+ const { stdout: exclude } = await execFileAsync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: this.projectPath, env: GIT_ENV });
47
49
  const target = resolve(this.projectPath, exclude.trim());
48
50
  const rule = '/' + path.replace(/[*?[\]#! ]/g, char => '\\' + char) + '/';
49
51
  const content = await readFile(target, 'utf8').catch(() => '');
@@ -67,7 +69,7 @@ export class NotesService {
67
69
  index,
68
70
  this.identity.catalog
69
71
  .all()
70
- .map(note => [note.id, note.version, note.presence]),
72
+ .map(note => [note.id, note.version, note.presence, note.deletedAt]),
71
73
  this.warning
72
74
  ]);
73
75
  return { ...index, warning: this.warning };
@@ -92,11 +94,18 @@ export class NotesService {
92
94
  this.bus.publish({ type: 'notes:changed' });
93
95
  return note;
94
96
  }
97
+ async delete(id, storageId) {
98
+ await this.identity.deleteId(id, storageId);
99
+ this.bus.publish({ type: 'notes:changed' });
100
+ }
95
101
  async uploadAsset(bytes) {
96
102
  if (!bytes.length || bytes.length > MAX_NOTE_ASSET_BYTES) {
97
103
  throw new BadRequestError('Image must be under 10 MB');
98
104
  }
99
105
  const format = await detectNoteAssetFormat(bytes);
106
+ if (format.display !== 'image') {
107
+ throw new BadRequestError('Only image uploads are supported');
108
+ }
100
109
  this.exclusion ??= this.visibility(true);
101
110
  await this.exclusion;
102
111
  const id = await this.identity.run(async () => {
@@ -107,6 +116,22 @@ export class NotesService {
107
116
  });
108
117
  return { url: '/api/notes/assets/' + id };
109
118
  }
119
+ async importAsset(chunks, extension, signal) {
120
+ this.exclusion ??= this.visibility(true);
121
+ await this.exclusion;
122
+ const staged = await this.repository.stageAsset(chunks, extension, signal);
123
+ try {
124
+ const id = await this.identity.run(async () => {
125
+ await this.identity.writer.acquire(true);
126
+ await this.identity.writer.assertOwner();
127
+ return staged.commit();
128
+ });
129
+ return '/api/notes/assets/' + id;
130
+ }
131
+ finally {
132
+ await staged.dispose();
133
+ }
134
+ }
110
135
  readAsset(id) {
111
136
  return this.repository.readAsset(id);
112
137
  }
@@ -13,7 +13,7 @@ export async function detectNoteAssetFormat(bytes) {
13
13
  NOTE_ASSET_FORMATS.find(candidate => candidate.extension === detected.ext ||
14
14
  candidate.aliases.some(alias => alias === detected.ext));
15
15
  if (!format) {
16
- throw new BadRequestError('Supported images: ' + SUPPORTED_NOTE_ASSETS);
16
+ throw new BadRequestError('Supported attachments: ' + SUPPORTED_NOTE_ASSETS);
17
17
  }
18
18
  return format;
19
19
  }
@@ -0,0 +1,193 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { BlockList, isIP } from 'node:net';
3
+ import { Agent, request } from 'undici';
4
+ import { BadRequestError } from "../errors/http.js";
5
+ import { isCredentialFreeWebUrl } from "../../shared/utils/web-url.js";
6
+ // Stop connection attempts that cannot establish a socket promptly.
7
+ const CONNECTION_TIMEOUT_MS = 10000;
8
+ // Stop responses that send no headers or no body data.
9
+ const RESPONSE_IDLE_TIMEOUT_MS = 15000;
10
+ // Bound the size of response headers.
11
+ const MAX_HEADER_BYTES = 16384;
12
+ // Stop redirect chains and loops.
13
+ const MAX_REDIRECTS = 5;
14
+ // Only these statuses ask the client to follow a Location header.
15
+ const REDIRECT_STATUSES = [301, 302, 303, 307, 308];
16
+ // Network failures that mean the remote server cannot serve the attachment.
17
+ const UNREACHABLE_CODES = new Set([
18
+ 'ENOTFOUND',
19
+ 'ECONNREFUSED',
20
+ 'ECONNRESET',
21
+ 'EHOSTUNREACH',
22
+ 'ENETUNREACH',
23
+ 'ETIMEDOUT',
24
+ 'UND_ERR_SOCKET'
25
+ ]);
26
+ // Ask servers for the exact bytes: compressed bodies are not supported.
27
+ const REQUEST_HEADERS = {
28
+ 'accept-encoding': 'identity',
29
+ 'user-agent': 'githuman-note-assets'
30
+ };
31
+ // Exclude non-public IPv4 ranges before opening any socket.
32
+ const BLOCKED_IPV4 = new BlockList();
33
+ // IPv6 routing must be public and must exclude special-purpose ranges.
34
+ const PUBLIC_IPV6 = new BlockList();
35
+ const BLOCKED_IPV6 = new BlockList();
36
+ for (const [address, prefix] of [
37
+ ['0.0.0.0', 8],
38
+ ['10.0.0.0', 8],
39
+ ['100.64.0.0', 10],
40
+ ['127.0.0.0', 8],
41
+ ['169.254.0.0', 16],
42
+ ['172.16.0.0', 12],
43
+ ['192.0.0.0', 24],
44
+ ['192.0.2.0', 24],
45
+ ['192.88.99.0', 24],
46
+ ['192.168.0.0', 16],
47
+ ['198.18.0.0', 15],
48
+ ['198.51.100.0', 24],
49
+ ['203.0.113.0', 24],
50
+ ['224.0.0.0', 3]
51
+ ]) {
52
+ BLOCKED_IPV4.addSubnet(address, prefix, 'ipv4');
53
+ }
54
+ // Only globally routable IPv6 is eligible, with special-purpose ranges excluded.
55
+ PUBLIC_IPV6.addSubnet('2000::', 3, 'ipv6');
56
+ BLOCKED_IPV6.addSubnet('2001::', 23, 'ipv6');
57
+ BLOCKED_IPV6.addSubnet('2001:db8::', 32, 'ipv6');
58
+ BLOCKED_IPV6.addSubnet('2002::', 16, 'ipv6');
59
+ BLOCKED_IPV6.addSubnet('3fff::', 20, 'ipv6');
60
+ export function isPublicAssetAddress(address) {
61
+ const family = isIP(address);
62
+ if (family === 4)
63
+ return !BLOCKED_IPV4.check(address, 'ipv4');
64
+ return (family === 6 &&
65
+ PUBLIC_IPV6.check(address, 'ipv6') &&
66
+ !BLOCKED_IPV6.check(address, 'ipv6'));
67
+ }
68
+ /**
69
+ * Resolves a host name and refuses non-public results. The socket connects to
70
+ * the checked address, so no second lookup can change the target.
71
+ */
72
+ export function createPublicLookup(resolveHost = lookup) {
73
+ return (hostname, options, callback) => {
74
+ void (async () => {
75
+ let addresses;
76
+ try {
77
+ addresses = await resolveHost(hostname, { all: true, verbatim: true });
78
+ }
79
+ catch (error) {
80
+ callback(error instanceof Error ? error : new Error(String(error)), '');
81
+ return;
82
+ }
83
+ const first = addresses[0];
84
+ if (!first ||
85
+ addresses.some(item => !isPublicAssetAddress(item.address))) {
86
+ callback(new BadRequestError('Attachment address is not public'), '');
87
+ }
88
+ else if (options.all) {
89
+ callback(null, addresses);
90
+ }
91
+ else {
92
+ callback(null, first.address, first.family);
93
+ }
94
+ })();
95
+ };
96
+ }
97
+ function createAgent(resolveHost) {
98
+ return new Agent({
99
+ connect: {
100
+ lookup: createPublicLookup(resolveHost),
101
+ timeout: CONNECTION_TIMEOUT_MS
102
+ },
103
+ connectTimeout: CONNECTION_TIMEOUT_MS,
104
+ headersTimeout: RESPONSE_IDLE_TIMEOUT_MS,
105
+ bodyTimeout: RESPONSE_IDLE_TIMEOUT_MS,
106
+ maxHeaderSize: MAX_HEADER_BYTES
107
+ });
108
+ }
109
+ // Undici ignores an abort while it waits for a socket, so the wait ends here.
110
+ async function untilAborted(work, signal, discard) {
111
+ let abort;
112
+ const aborted = new Promise((_resolve, reject) => {
113
+ abort = () => reject(signal.reason);
114
+ signal.addEventListener('abort', abort, { once: true });
115
+ });
116
+ try {
117
+ return await Promise.race([work, aborted]);
118
+ }
119
+ catch (error) {
120
+ // A request that finishes after the abort still owns a socket.
121
+ work.then(discard, () => undefined);
122
+ throw error;
123
+ }
124
+ finally {
125
+ if (abort)
126
+ signal.removeEventListener('abort', abort);
127
+ }
128
+ }
129
+ let sharedAgent;
130
+ function parseAssetUrl(value, message, base) {
131
+ let url;
132
+ try {
133
+ url = new URL(value, base);
134
+ }
135
+ catch {
136
+ throw new BadRequestError(message);
137
+ }
138
+ if (!isCredentialFreeWebUrl(url)) {
139
+ throw new BadRequestError('Only public HTTP and HTTPS attachments are supported');
140
+ }
141
+ return url;
142
+ }
143
+ export async function remoteAssetRequest(source, method, signal, options = {}) {
144
+ const dispatcher = options.dispatcher ??
145
+ (options.resolveHost
146
+ ? createAgent(options.resolveHost)
147
+ : (sharedAgent ??= createAgent()));
148
+ let url = parseAssetUrl(source, 'Invalid attachment URL');
149
+ for (let redirects = 0;; redirects++) {
150
+ signal.throwIfAborted();
151
+ // The socket layer skips the lookup for IP literals, so check them here.
152
+ const host = url.hostname.replace(/^\[|\]$/g, '');
153
+ if (isIP(host) && !isPublicAssetAddress(host)) {
154
+ throw new BadRequestError('Attachment address is not public');
155
+ }
156
+ const { statusCode, headers, body } = await untilAborted(request(url, { method, signal, dispatcher, headers: REQUEST_HEADERS }), signal, late => late.body.destroy());
157
+ if (!REDIRECT_STATUSES.includes(statusCode)) {
158
+ return Object.assign(body, { statusCode, headers });
159
+ }
160
+ body.destroy();
161
+ if (redirects >= MAX_REDIRECTS) {
162
+ throw new BadRequestError('Too many attachment redirects');
163
+ }
164
+ const location = headers.location;
165
+ if (!location || Array.isArray(location)) {
166
+ throw new BadRequestError('Attachment redirect has no valid location');
167
+ }
168
+ const next = parseAssetUrl(location, 'Invalid attachment redirect', url);
169
+ if (url.protocol === 'https:' && next.protocol !== 'https:') {
170
+ throw new BadRequestError('Insecure attachment redirect');
171
+ }
172
+ url = next;
173
+ }
174
+ }
175
+ /** Maps transport failures to client errors; other errors pass through. */
176
+ export function translateRemoteAssetError(error) {
177
+ const code = typeof error === 'object' && error !== null && 'code' in error
178
+ ? error.code
179
+ : undefined;
180
+ if (error instanceof DOMException && error.name === 'TimeoutError') {
181
+ return new BadRequestError('Attachment download took too long');
182
+ }
183
+ if (code === 'UND_ERR_CONNECT_TIMEOUT') {
184
+ return new BadRequestError('Attachment connection timed out');
185
+ }
186
+ if (code === 'UND_ERR_HEADERS_TIMEOUT' || code === 'UND_ERR_BODY_TIMEOUT') {
187
+ return new BadRequestError('Attachment download stalled');
188
+ }
189
+ if (typeof code === 'string' && UNREACHABLE_CODES.has(code)) {
190
+ return new BadRequestError('Attachment server could not be reached');
191
+ }
192
+ return error;
193
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Runs `work` with a signal that aborts when the client closes the connection
3
+ * before the response ends. A normal finish does not abort the signal.
4
+ */
5
+ export async function withRequestSignal(reply, work) {
6
+ const controller = new AbortController();
7
+ const abort = () => {
8
+ if (!reply.raw.writableFinished)
9
+ controller.abort();
10
+ };
11
+ if (reply.raw.destroyed)
12
+ abort();
13
+ reply.raw.once('close', abort);
14
+ try {
15
+ return await work(controller.signal);
16
+ }
17
+ finally {
18
+ reply.raw.removeListener('close', abort);
19
+ }
20
+ }
@@ -37,6 +37,20 @@ export const FileSearchQuery = Type.Object({
37
37
  }))
38
38
  });
39
39
  export const ContextItem = Type.Union([
40
+ Type.Object({
41
+ kind: Type.Literal('note', { description: 'A project note.' }),
42
+ noteId: Type.String({ minLength: 1, description: 'Stable note UUID.' }),
43
+ storageId: Type.String({
44
+ minLength: 1,
45
+ description: 'Notes storage identity.'
46
+ }),
47
+ name: Type.String({
48
+ minLength: 1,
49
+ description: 'Display name when attached.'
50
+ })
51
+ }, {
52
+ description: 'The current Markdown of a note, including YAML properties.'
53
+ }),
40
54
  Type.Object({
41
55
  kind: Type.Literal('diff', { description: 'Live working-tree diff.' }),
42
56
  source: DiffSourceNameSchema,