@anchrd/intel 0.59.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 (364) hide show
  1. package/README.md +496 -0
  2. package/bin/intel.mjs +134 -0
  3. package/dist/adapters/cloudflare/cloudflare-flow-workflow.d.ts +42 -0
  4. package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +89 -0
  5. package/dist/adapters/cloudflare/cloudflare.d.ts +29 -0
  6. package/dist/adapters/cloudflare/cloudflare.js +416 -0
  7. package/dist/adapters/cloudflare/cloudflare.types.d.ts +31 -0
  8. package/dist/adapters/cloudflare/cloudflare.types.js +1 -0
  9. package/dist/adapters/content/content.d.ts +3 -0
  10. package/dist/adapters/content/content.js +128 -0
  11. package/dist/adapters/content/content.types.d.ts +38 -0
  12. package/dist/adapters/content/content.types.js +1 -0
  13. package/dist/adapters/db/db-audit.d.ts +62 -0
  14. package/dist/adapters/db/db-audit.js +150 -0
  15. package/dist/adapters/db/db-boards.d.ts +6 -0
  16. package/dist/adapters/db/db-boards.js +324 -0
  17. package/dist/adapters/db/db-feed.d.ts +30 -0
  18. package/dist/adapters/db/db-feed.js +276 -0
  19. package/dist/adapters/db/db-flows.d.ts +42 -0
  20. package/dist/adapters/db/db-flows.js +1311 -0
  21. package/dist/adapters/db/db-grants.d.ts +133 -0
  22. package/dist/adapters/db/db-grants.js +269 -0
  23. package/dist/adapters/db/db-indexing.d.ts +3 -0
  24. package/dist/adapters/db/db-indexing.js +200 -0
  25. package/dist/adapters/db/db-oauth.d.ts +3 -0
  26. package/dist/adapters/db/db-oauth.js +26 -0
  27. package/dist/adapters/db/db-prompts.d.ts +46 -0
  28. package/dist/adapters/db/db-prompts.js +144 -0
  29. package/dist/adapters/db/db.d.ts +6 -0
  30. package/dist/adapters/db/db.js +1687 -0
  31. package/dist/adapters/db/db.types.d.ts +16 -0
  32. package/dist/adapters/db/db.types.js +1 -0
  33. package/dist/adapters/document-converter/document-converter.d.ts +2 -0
  34. package/dist/adapters/document-converter/document-converter.js +26 -0
  35. package/dist/adapters/document-converter/document-converter.types.d.ts +17 -0
  36. package/dist/adapters/document-converter/document-converter.types.js +1 -0
  37. package/dist/adapters/flow-runtime/flow-runtime.d.ts +3 -0
  38. package/dist/adapters/flow-runtime/flow-runtime.js +22 -0
  39. package/dist/adapters/flow-runtime/flow-runtime.types.d.ts +19 -0
  40. package/dist/adapters/flow-runtime/flow-runtime.types.js +1 -0
  41. package/dist/adapters/index-queue/index-queue.d.ts +10 -0
  42. package/dist/adapters/index-queue/index-queue.js +12 -0
  43. package/dist/adapters/index-queue/index-queue.types.d.ts +11 -0
  44. package/dist/adapters/index-queue/index-queue.types.js +1 -0
  45. package/dist/adapters/json-schema/json-schema.d.ts +2 -0
  46. package/dist/adapters/json-schema/json-schema.js +77 -0
  47. package/dist/adapters/openid/openid.d.ts +5 -0
  48. package/dist/adapters/openid/openid.js +301 -0
  49. package/dist/adapters/portal-tokens/portal-tokens.d.ts +8 -0
  50. package/dist/adapters/portal-tokens/portal-tokens.js +79 -0
  51. package/dist/adapters/remote-tools/remote-tools.d.ts +5 -0
  52. package/dist/adapters/remote-tools/remote-tools.js +129 -0
  53. package/dist/adapters/semantic-index/semantic-index.d.ts +2 -0
  54. package/dist/adapters/semantic-index/semantic-index.js +139 -0
  55. package/dist/adapters/semantic-index/semantic-index.types.d.ts +56 -0
  56. package/dist/adapters/semantic-index/semantic-index.types.js +1 -0
  57. package/dist/adapters/session-cookie/session-cookie.d.ts +5 -0
  58. package/dist/adapters/session-cookie/session-cookie.js +87 -0
  59. package/dist/adapters/tool-source-policy/tool-source-policy.d.ts +1 -0
  60. package/dist/adapters/tool-source-policy/tool-source-policy.js +22 -0
  61. package/dist/audit/audit.d.ts +2 -0
  62. package/dist/audit/audit.js +87 -0
  63. package/dist/audit/audit.types.d.ts +23 -0
  64. package/dist/audit/audit.types.js +1 -0
  65. package/dist/auth/auth.d.ts +2 -0
  66. package/dist/auth/auth.js +284 -0
  67. package/dist/auth/auth.types.d.ts +108 -0
  68. package/dist/auth/auth.types.js +1 -0
  69. package/dist/boards/boards.d.ts +44 -0
  70. package/dist/boards/boards.js +447 -0
  71. package/dist/boards/boards.types.d.ts +117 -0
  72. package/dist/boards/boards.types.js +1 -0
  73. package/dist/build/build.d.ts +2 -0
  74. package/dist/build/build.js +192 -0
  75. package/dist/build/build.types.d.ts +17 -0
  76. package/dist/build/build.types.js +1 -0
  77. package/dist/bundle/bundle.d.ts +4 -0
  78. package/dist/bundle/bundle.js +1194 -0
  79. package/dist/bundle/bundle.types.d.ts +33 -0
  80. package/dist/bundle/bundle.types.js +1 -0
  81. package/dist/cli/cli.d.ts +4 -0
  82. package/dist/cli/cli.js +262 -0
  83. package/dist/cli/cli.types.d.ts +14 -0
  84. package/dist/cli/cli.types.js +1 -0
  85. package/dist/contract/audit.d.ts +50 -0
  86. package/dist/contract/audit.js +72 -0
  87. package/dist/contract/board.d.ts +150 -0
  88. package/dist/contract/board.js +262 -0
  89. package/dist/contract/bundle.d.ts +147 -0
  90. package/dist/contract/bundle.js +139 -0
  91. package/dist/contract/contract.d.ts +112 -0
  92. package/dist/contract/contract.js +143 -0
  93. package/dist/contract/feed.d.ts +126 -0
  94. package/dist/contract/feed.js +149 -0
  95. package/dist/contract/flow-run.d.ts +346 -0
  96. package/dist/contract/flow-run.js +194 -0
  97. package/dist/contract/flow.d.ts +1057 -0
  98. package/dist/contract/flow.js +552 -0
  99. package/dist/contract/node.d.ts +551 -0
  100. package/dist/contract/node.js +456 -0
  101. package/dist/contract/share.d.ts +216 -0
  102. package/dist/contract/share.js +134 -0
  103. package/dist/contract/table.d.ts +171 -0
  104. package/dist/contract/table.js +117 -0
  105. package/dist/contract/tool.d.ts +122 -0
  106. package/dist/contract/tool.js +172 -0
  107. package/dist/feed/feed.d.ts +2 -0
  108. package/dist/feed/feed.js +77 -0
  109. package/dist/feed/feed.types.d.ts +23 -0
  110. package/dist/feed/feed.types.js +1 -0
  111. package/dist/flows/flows.d.ts +41 -0
  112. package/dist/flows/flows.js +2216 -0
  113. package/dist/flows/flows.types.d.ts +339 -0
  114. package/dist/flows/flows.types.js +1 -0
  115. package/dist/http/http.d.ts +9 -0
  116. package/dist/http/http.js +874 -0
  117. package/dist/http/http.types.d.ts +22 -0
  118. package/dist/http/http.types.js +1 -0
  119. package/dist/indexing/indexing.d.ts +6 -0
  120. package/dist/indexing/indexing.js +239 -0
  121. package/dist/indexing/indexing.types.d.ts +11 -0
  122. package/dist/indexing/indexing.types.js +1 -0
  123. package/dist/intel/intel.d.ts +3 -0
  124. package/dist/intel/intel.js +194 -0
  125. package/dist/intel/intel.types.d.ts +24 -0
  126. package/dist/intel/intel.types.js +1 -0
  127. package/dist/mcp/mcp.d.ts +2 -0
  128. package/dist/mcp/mcp.js +1292 -0
  129. package/dist/mcp/mcp.types.d.ts +25 -0
  130. package/dist/mcp/mcp.types.js +1 -0
  131. package/dist/nodes/document-links/document-links.d.ts +20 -0
  132. package/dist/nodes/document-links/document-links.js +57 -0
  133. package/dist/nodes/nodes.d.ts +2 -0
  134. package/dist/nodes/nodes.js +1667 -0
  135. package/dist/nodes/nodes.types.d.ts +511 -0
  136. package/dist/nodes/nodes.types.js +1 -0
  137. package/dist/prepare/prepare.d.ts +4 -0
  138. package/dist/prepare/prepare.js +16 -0
  139. package/dist/prepare/prepare.types.d.ts +9 -0
  140. package/dist/prepare/prepare.types.js +1 -0
  141. package/dist/prompts/prompts.d.ts +2 -0
  142. package/dist/prompts/prompts.js +65 -0
  143. package/dist/prompts/prompts.types.d.ts +71 -0
  144. package/dist/prompts/prompts.types.js +1 -0
  145. package/dist/shared/callers/callers.d.ts +28 -0
  146. package/dist/shared/callers/callers.js +30 -0
  147. package/dist/shared/csv/csv.d.ts +13 -0
  148. package/dist/shared/csv/csv.js +85 -0
  149. package/dist/shared/document-text/document-text.d.ts +21 -0
  150. package/dist/shared/document-text/document-text.js +31 -0
  151. package/dist/shared/gate-authorization/gate-authorization.d.ts +5 -0
  152. package/dist/shared/gate-authorization/gate-authorization.js +26 -0
  153. package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
  154. package/dist/shared/grant-expiry/grant-expiry.js +26 -0
  155. package/dist/shared/intel-error/intel-error.d.ts +22 -0
  156. package/dist/shared/intel-error/intel-error.js +27 -0
  157. package/dist/shared/plain-title/plain-title.d.ts +16 -0
  158. package/dist/shared/plain-title/plain-title.js +45 -0
  159. package/dist/shared/problem-details/problem-details.d.ts +30 -0
  160. package/dist/shared/problem-details/problem-details.js +18 -0
  161. package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
  162. package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
  163. package/dist/shared/safe-return-path/safe-return-path.d.ts +2 -0
  164. package/dist/shared/safe-return-path/safe-return-path.js +11 -0
  165. package/dist/shared/sha256/sha256.d.ts +2 -0
  166. package/dist/shared/sha256/sha256.js +14 -0
  167. package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
  168. package/dist/tools/tool-servers/tool-servers.js +177 -0
  169. package/dist/tools/tools.d.ts +2 -0
  170. package/dist/tools/tools.js +207 -0
  171. package/dist/tools/tools.types.d.ts +56 -0
  172. package/dist/tools/tools.types.js +1 -0
  173. package/examples/branding/favicon.svg +5 -0
  174. package/examples/branding/logo.svg +5 -0
  175. package/examples/branding/theme.css +16 -0
  176. package/examples/dev.vars.example +7 -0
  177. package/examples/intel.json +9 -0
  178. package/migrations/0000_intel_foundation.sql +203 -0
  179. package/migrations/0001_portal_native_tools.sql +21 -0
  180. package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
  181. package/migrations/0003_folder_permissions.sql +211 -0
  182. package/migrations/0004_subflow_runs.sql +14 -0
  183. package/migrations/0005_flow_node_cleanup.sql +28 -0
  184. package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
  185. package/migrations/0006_links_are_written_in_the_text.sql +20 -0
  186. package/migrations/0007_no_node_waits.sql +69 -0
  187. package/migrations/0008_three_layers.sql +130 -0
  188. package/migrations/0009_no_context_policy.sql +48 -0
  189. package/migrations/0010_no_run_waits_a_year.sql +27 -0
  190. package/migrations/0011_one_name_for_the_tree.sql +53 -0
  191. package/migrations/0012_table_snapshots.sql +29 -0
  192. package/migrations/0017_a_vector_per_card.sql +38 -0
  193. package/migrations/0018_no_context_policy_at_last.sql +97 -0
  194. package/migrations/0019_one_name_for_the_grants.sql +52 -0
  195. package/migrations/0020_cascade_purge_replay.sql +11 -0
  196. package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
  197. package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
  198. package/migrations/0023_a_board_and_its_tasks.sql +116 -0
  199. package/migrations/0024_the_archive_is_a_column.sql +33 -0
  200. package/migrations/0025_archiving_takes_the_subtree.sql +29 -0
  201. package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
  202. package/migrations/0027_the_runs_of_every_flow.sql +27 -0
  203. package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
  204. package/package.json +118 -0
  205. package/src/contract/CLAUDE.md +152 -0
  206. package/src/contract/README.md +102 -0
  207. package/src/contract/audit.ts +85 -0
  208. package/src/contract/board.ts +306 -0
  209. package/src/contract/bundle.ts +157 -0
  210. package/src/contract/contract.ts +161 -0
  211. package/src/contract/feed.ts +165 -0
  212. package/src/contract/flow-run.ts +241 -0
  213. package/src/contract/flow.ts +675 -0
  214. package/src/contract/node.ts +583 -0
  215. package/src/contract/share.ts +196 -0
  216. package/src/contract/table.ts +178 -0
  217. package/src/contract/tool.ts +202 -0
  218. package/ui/README.md +110 -0
  219. package/ui/components.json +21 -0
  220. package/ui/index.html +13 -0
  221. package/ui/package.json +11 -0
  222. package/ui/scripts/lint-tokens.mjs +187 -0
  223. package/ui/src/access-summary/access-summary.tsx +385 -0
  224. package/ui/src/app/action-slot/action-slot.tsx +27 -0
  225. package/ui/src/app/app-sidebar/app-sidebar.tsx +71 -0
  226. package/ui/src/app/app-tree/app-tree.tsx +1175 -0
  227. package/ui/src/app/app.tsx +87 -0
  228. package/ui/src/app/header-search/header-search.tsx +294 -0
  229. package/ui/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
  230. package/ui/src/app/settings-dialog/settings-dialog.tsx +135 -0
  231. package/ui/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
  232. package/ui/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
  233. package/ui/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
  234. package/ui/src/app/tree-expansion/tree-expansion.ts +107 -0
  235. package/ui/src/app/tree-expansion/tree-expansion.types.ts +31 -0
  236. package/ui/src/app/tree-move/tree-move.tsx +427 -0
  237. package/ui/src/app/tree-reveal/tree-reveal.ts +64 -0
  238. package/ui/src/app/tree-reveal/tree-reveal.types.ts +16 -0
  239. package/ui/src/app/user-footer/user-footer.tsx +188 -0
  240. package/ui/src/app/view-menu/view-menu.tsx +115 -0
  241. package/ui/src/app-root/app-root.tsx +109 -0
  242. package/ui/src/app-root/app-root.types.ts +27 -0
  243. package/ui/src/archive/archive.tsx +417 -0
  244. package/ui/src/attachment-detail/attachment-detail.ts +52 -0
  245. package/ui/src/attachment-viewer/attachment-viewer.tsx +166 -0
  246. package/ui/src/blocknote-view/blocknote-view.tsx +30 -0
  247. package/ui/src/board/board-assignee/board-assignee-picker.tsx +132 -0
  248. package/ui/src/board/board-assignee/board-assignee.ts +48 -0
  249. package/ui/src/board/board-assignee/board-assignee.tsx +75 -0
  250. package/ui/src/board/board-chip/board-chip.tsx +46 -0
  251. package/ui/src/board/board-crumbs/board-crumbs.ts +56 -0
  252. package/ui/src/board/board-crumbs/board-crumbs.tsx +111 -0
  253. package/ui/src/board/board-data/board-data.ts +195 -0
  254. package/ui/src/board/board-data/board-data.types.ts +38 -0
  255. package/ui/src/board/board-dates/board-dates.ts +40 -0
  256. package/ui/src/board/board-dates/board-dates.tsx +105 -0
  257. package/ui/src/board/board-kanban/board-kanban.ts +162 -0
  258. package/ui/src/board/board-kanban/board-kanban.tsx +680 -0
  259. package/ui/src/board/board-open-task/board-open-task.ts +32 -0
  260. package/ui/src/board/board-panel/board-panel.tsx +259 -0
  261. package/ui/src/board/board-settings/board-settings.tsx +226 -0
  262. package/ui/src/board/board-status/board-status.tsx +60 -0
  263. package/ui/src/board/board-stripes/board-stripes.ts +128 -0
  264. package/ui/src/board/board-table/board-table.ts +53 -0
  265. package/ui/src/board/board-table/board-table.tsx +610 -0
  266. package/ui/src/board/board-task/board-task.ts +108 -0
  267. package/ui/src/board/board-task/board-task.tsx +335 -0
  268. package/ui/src/board/board-title-row/board-title-row.tsx +66 -0
  269. package/ui/src/branding/branding.tsx +17 -0
  270. package/ui/src/branding/custom-logo.ts +1 -0
  271. package/ui/src/branding/favicon.default.svg +5 -0
  272. package/ui/src/branding/favicon.svg +5 -0
  273. package/ui/src/components/ui/avatar.tsx +39 -0
  274. package/ui/src/components/ui/button.tsx +64 -0
  275. package/ui/src/components/ui/collapsible.tsx +20 -0
  276. package/ui/src/components/ui/command.tsx +160 -0
  277. package/ui/src/components/ui/dialog.tsx +143 -0
  278. package/ui/src/components/ui/dropdown-menu.tsx +187 -0
  279. package/ui/src/components/ui/input.tsx +21 -0
  280. package/ui/src/components/ui/popover.tsx +41 -0
  281. package/ui/src/components/ui/select.tsx +163 -0
  282. package/ui/src/components/ui/separator.tsx +26 -0
  283. package/ui/src/components/ui/sheet.tsx +136 -0
  284. package/ui/src/components/ui/sidebar.tsx +693 -0
  285. package/ui/src/components/ui/skeleton.tsx +13 -0
  286. package/ui/src/components/ui/switch.tsx +25 -0
  287. package/ui/src/components/ui/table.tsx +82 -0
  288. package/ui/src/components/ui/tabs.tsx +52 -0
  289. package/ui/src/components/ui/tooltip.tsx +51 -0
  290. package/ui/src/data/intel-data-provider/intel-data-provider.ts +685 -0
  291. package/ui/src/data/intel-data-provider/intel-data-provider.types.ts +266 -0
  292. package/ui/src/data/query-client.ts +18 -0
  293. package/ui/src/data/request-refusal/refusal-notice.tsx +36 -0
  294. package/ui/src/data/request-refusal/request-refusal.ts +51 -0
  295. package/ui/src/data/sign-in/sign-in.ts +97 -0
  296. package/ui/src/data/sign-in/sign-in.types.ts +15 -0
  297. package/ui/src/document-link/document-link.tsx +114 -0
  298. package/ui/src/document-markdown/document-markdown.ts +195 -0
  299. package/ui/src/editor-schema/editor-schema.ts +23 -0
  300. package/ui/src/entry-picker/entry-picker.tsx +238 -0
  301. package/ui/src/feed/feed.tsx +344 -0
  302. package/ui/src/file-preview/file-preview-view.tsx +496 -0
  303. package/ui/src/file-preview/file-preview.tsx +28 -0
  304. package/ui/src/file-preview/pdf-file-preview.tsx +5 -0
  305. package/ui/src/file-preview/presentation-file-preview.tsx +21 -0
  306. package/ui/src/file-preview/spreadsheet-file-preview.tsx +5 -0
  307. package/ui/src/file-preview/word-file-preview.tsx +5 -0
  308. package/ui/src/flow-runs/flow-runs.tsx +238 -0
  309. package/ui/src/flows/flows.tsx +1646 -0
  310. package/ui/src/flows/node-icon/node-icon.ts +34 -0
  311. package/ui/src/flows/node-palette/node-palette.tsx +214 -0
  312. package/ui/src/flows/node-palette/node-palette.types.ts +15 -0
  313. package/ui/src/folder-contents/folder-contents.tsx +321 -0
  314. package/ui/src/folder-index/folder-index.ts +55 -0
  315. package/ui/src/frontmatter/frontmatter.tsx +193 -0
  316. package/ui/src/graph-pane/graph-pane.tsx +53 -0
  317. package/ui/src/hooks/use-mobile.ts +19 -0
  318. package/ui/src/i18n/custom.json +4 -0
  319. package/ui/src/i18n/de.json +609 -0
  320. package/ui/src/i18n/en.json +609 -0
  321. package/ui/src/i18n/es.json +609 -0
  322. package/ui/src/i18n/i18n-context.tsx +65 -0
  323. package/ui/src/i18n/i18n-languages/i18n-languages.ts +83 -0
  324. package/ui/src/i18n/i18n.ts +32 -0
  325. package/ui/src/i18n/i18n.types.ts +49 -0
  326. package/ui/src/kind-icon.ts +97 -0
  327. package/ui/src/lib/utils.ts +6 -0
  328. package/ui/src/main.tsx +68 -0
  329. package/ui/src/modal/modal.tsx +53 -0
  330. package/ui/src/node-details/node-details.tsx +545 -0
  331. package/ui/src/node-editor/node-editor.tsx +304 -0
  332. package/ui/src/node-graph/graph-notice.tsx +21 -0
  333. package/ui/src/node-graph/node-graph.ts +214 -0
  334. package/ui/src/node-graph/node-graph.tsx +295 -0
  335. package/ui/src/node-import/node-import.tsx +418 -0
  336. package/ui/src/node-save-error.ts +46 -0
  337. package/ui/src/node-source/node-source.tsx +128 -0
  338. package/ui/src/node-table/node-table.tsx +107 -0
  339. package/ui/src/nodes/nodes.tsx +240 -0
  340. package/ui/src/rename-resource/rename-resource.ts +80 -0
  341. package/ui/src/resolved-names/resolved-names.tsx +67 -0
  342. package/ui/src/resource-error.ts +33 -0
  343. package/ui/src/resource-menu/resource-menu.tsx +1156 -0
  344. package/ui/src/router/router-context.ts +6 -0
  345. package/ui/src/router/router.tsx +67 -0
  346. package/ui/src/router/router.types.ts +8 -0
  347. package/ui/src/router/selection-search.ts +88 -0
  348. package/ui/src/save-button/save-button.tsx +94 -0
  349. package/ui/src/section-hint/section-hint.tsx +40 -0
  350. package/ui/src/sign-in-refused/sign-in-refused.tsx +35 -0
  351. package/ui/src/styles.css +189 -0
  352. package/ui/src/table-columns/table-columns.tsx +379 -0
  353. package/ui/src/theme/custom.css +1 -0
  354. package/ui/src/theme/theme-context.tsx +89 -0
  355. package/ui/src/theme/theme.ts +70 -0
  356. package/ui/src/time/relative-time.tsx +41 -0
  357. package/ui/src/time/time-context.tsx +78 -0
  358. package/ui/src/time/time.ts +165 -0
  359. package/ui/src/title-row/title-row.tsx +185 -0
  360. package/ui/src/tools/tools.tsx +546 -0
  361. package/ui/src/user-name/user-name.ts +61 -0
  362. package/ui/src/verbatim/verbatim.tsx +68 -0
  363. package/ui/tsconfig.json +17 -0
  364. package/ui/vite.config.ts +129 -0
@@ -0,0 +1,418 @@
1
+ import { MaxAttachmentBytes, MaxIndexedAttachmentBytes } from "@contract/node.ts";
2
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
3
+ import { zipSync } from "fflate";
4
+ import { FileArchive, FolderUp, Upload } from "lucide-react";
5
+ import { useRef, useState } from "react";
6
+ import { createPortal } from "react-dom";
7
+ import { allTreeLevelsKey } from "@/app/tree-move/tree-move.tsx";
8
+ import { IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
9
+ import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
10
+ import { useI18n } from "@/i18n/i18n-context.tsx";
11
+ import { Modal } from "@/modal/modal.tsx";
12
+ import { useIntelRouterContext } from "@/router/router-context.ts";
13
+
14
+ /**
15
+ * Everything that brings something in from the machine, as one seam both menus share (#822).
16
+ *
17
+ * ⚠️ It used to be two of the three: the zip and the folder lived here, while a single file was
18
+ * uploaded by the tree alone. That was the whole of the gap #822 closes — at a folder one could
19
+ * bring in a zip and a directory but not a file, because the entry for it existed only in the
20
+ * tree's plus. One question, one door, and the door is here rather than in either menu.
21
+ *
22
+ * ⚠️ Import is offered in two places and they are not alternatives: the three-dot menu of a folder
23
+ * files into that folder, and the tree's root button is the ONLY way to reach the top level — a
24
+ * three-dot menu always has a resource, and the root is not one (#346).
25
+ */
26
+ export type ImportSource = "files" | "folder" | "zip";
27
+
28
+ // The server refuses anything larger anyway (#137); refusing here saves uploading it first.
29
+ const MaxImportBytes = 64 * 1024 * 1024;
30
+
31
+ // A picked directory as the zip the import endpoint reads. `webkitRelativePath` carries the folder
32
+ // structure, with the picked folder itself as the first segment — kept, so the folder lands as a
33
+ // folder rather than spilling its contents into the target.
34
+ async function zipOfDirectory(files: readonly File[]): Promise<Blob> {
35
+ const entries: Record<string, Uint8Array> = {};
36
+ let total = 0;
37
+ for (const file of files) {
38
+ const path = file.webkitRelativePath || file.name;
39
+ total += file.size;
40
+ if (total > MaxImportBytes) throw new Error("Import exceeds the 64 MB limit");
41
+ entries[path] = new Uint8Array(await file.arrayBuffer());
42
+ }
43
+ return new Blob([zipSync(entries).slice().buffer], { type: "application/zip" });
44
+ }
45
+
46
+ /**
47
+ * Which files become a node of their own kind rather than an attachment: markdown is a document,
48
+ * CSV is a table. They travel as a one-file bundle, because the importer is what knows how to read
49
+ * them — the attachment door would file the same bytes as a file nobody can edit.
50
+ */
51
+ function nativeUpload(file: File): boolean {
52
+ const name = file.name.toLowerCase();
53
+ return name.endsWith(".md") || name.endsWith(".csv");
54
+ }
55
+
56
+ async function singleFileBundle(file: File): Promise<Blob> {
57
+ const bytes = new Uint8Array(await file.arrayBuffer());
58
+ return new Blob([zipSync({ [file.name]: bytes }).slice().buffer], { type: "application/zip" });
59
+ }
60
+
61
+ /**
62
+ * A byte count as the sentence beside it says it (#821).
63
+ *
64
+ * ⚠️ Megabytes of a million, because that is what the limit IS: the constants are round decimal
65
+ * numbers, and dividing them by 1024 would put "47.7 MB" in front of somebody whose file manager
66
+ * says the same file is 48 MB. The number in the message and the number in the check have to be
67
+ * the same number.
68
+ */
69
+ function megabytes(bytes: number): string {
70
+ return `${Math.round(bytes / 1_000_000)} MB`;
71
+ }
72
+
73
+ // The refusal's own code, which is what tells "too big" from every other reason an upload fails.
74
+ // The prose cannot: it is a server sentence in one language.
75
+ function codeOf(error: unknown): string | null {
76
+ return error instanceof IntelRequestError ? error.code : null;
77
+ }
78
+
79
+ /** One file, filed under `parentId`, as the node its name says it should be (#821). */
80
+ async function uploadOne(
81
+ data: IntelDataProvider,
82
+ parentId: string | null,
83
+ file: File,
84
+ ): Promise<string> {
85
+ if (nativeUpload(file)) {
86
+ const result = await data.importNodeBundle({
87
+ nodeId: parentId,
88
+ zip: await singleFileBundle(file),
89
+ idempotencyKey: crypto.randomUUID(),
90
+ });
91
+ const id = result.rootNodeIds[0];
92
+ if (!id) throw new Error("Native upload created no root node");
93
+ return id;
94
+ }
95
+ // ⚠️ Behind the native branch, never in front of it. A markdown or CSV file becomes a document
96
+ // through the bundle import, which has a limit of its own — refusing it here would turn away a
97
+ // file this limit says nothing about, with a sentence about a door it never takes.
98
+ //
99
+ // ⚠️ Refused here rather than on the way back: 50 MB the server will not keep is 50 MB nobody
100
+ // has to send. The server refuses it too; this is the cheaper half of one rule, never its only
101
+ // half.
102
+ if (file.size > MaxAttachmentBytes) {
103
+ throw new IntelRequestError(
104
+ 413,
105
+ "attachment_too_large",
106
+ `Attachment exceeds ${MaxAttachmentBytes} bytes`,
107
+ );
108
+ }
109
+ const node = await data.createNodes({
110
+ parentId,
111
+ kind: "attachment",
112
+ title: file.name,
113
+ description: null,
114
+ idempotencyKey: crypto.randomUUID(),
115
+ });
116
+ try {
117
+ await data.saveNodeAttachmentBytes({
118
+ nodeId: node.id,
119
+ baseVersionId: null,
120
+ file,
121
+ idempotencyKey: crypto.randomUUID(),
122
+ });
123
+ } catch (error) {
124
+ // An attachment node without bytes is a row nobody can open. Take it back rather than leaving
125
+ // it in the tree, but never let the cleanup hide the real failure.
126
+ const current = await data.getNode(node.id).catch(() => null);
127
+ if (current?.version === null && current.node.updatedAt === node.updatedAt) {
128
+ await data
129
+ .archiveNode({
130
+ nodeId: node.id,
131
+ baseUpdatedAt: current.node.updatedAt,
132
+ archived: true,
133
+ idempotencyKey: crypto.randomUUID(),
134
+ })
135
+ .catch(() => undefined);
136
+ }
137
+ throw error;
138
+ }
139
+ return node.id;
140
+ }
141
+
142
+ /** What a run of n files left behind: what it made, and what it could not. */
143
+ export interface UploadOutcome {
144
+ parentId: string | null;
145
+ created: string[];
146
+ failed: { name: string; error: unknown }[];
147
+ /** Whether any of the uploaded files is too big for its contents to be indexed (#821). */
148
+ someUnsearchable: boolean;
149
+ }
150
+
151
+ export interface NodeImport {
152
+ /** Opens the one window that offers all three ways in, filing into `parentId`. */
153
+ open(parentId: string | null): void;
154
+ /**
155
+ * The window, the three hidden pickers and what the run has to say. Render once inside the
156
+ * component that called the hook.
157
+ *
158
+ * ⚠️ The pickers portal to `document.body` rather than landing where they are written. A file
159
+ * input is invisible but still an element, and the three-dot menu sits at the end of a title
160
+ * line whose rule is that the menu is the LAST thing in it (#53).
161
+ *
162
+ * ⚠️ `tabIndex={-1}`, and that is not a detail. The folder table renders one `ResourceMenu` PER
163
+ * ROW, so a folder holding twenty folders holds sixty of these — and `sr-only` hides an element
164
+ * from the eye, never from the tab order. The control that opens a picker is the button in the
165
+ * window; the input is the mechanism behind it and is clicked by code, never by a person.
166
+ */
167
+ inputs: React.ReactNode;
168
+ isError: boolean;
169
+ }
170
+
171
+ export function useNodeImport(options: {
172
+ /**
173
+ * Where this set of pickers files, in words — a folder title, or the name of the top level.
174
+ *
175
+ * ⚠️ Not decoration. Both callers render their inputs into `document.body`, so on a folder screen
176
+ * there are two of each in the document at once: the tree's (which files at the top level) and
177
+ * this menu's. `sr-only` keeps them out of sight but not out of the accessibility tree or the tab
178
+ * order, and two controls called "Import zip bundle" are two controls a reader cannot tell apart
179
+ * — nor, as the first browser run of #388 showed, can anything else looking one up by name.
180
+ */
181
+ where: string;
182
+ onImported?(parentId: string | null): void | Promise<void>;
183
+ /**
184
+ * What the caller does with the nodes an upload made. The tree navigates to a single one and
185
+ * reveals it; a menu on a folder only refreshes. The hook does neither by itself: where the
186
+ * reader lands is a decision of the screen they were on.
187
+ */
188
+ onUploaded?(outcome: UploadOutcome): void | Promise<void>;
189
+ }): NodeImport {
190
+ const { data } = useIntelRouterContext();
191
+ const i18n = useI18n();
192
+ const queryClient = useQueryClient();
193
+ const [importTo, setImportTo] = useState<string | null>(null);
194
+ const [open, setOpen] = useState(false);
195
+ const [progress, setProgress] = useState<{ done: number; total: number } | null>(null);
196
+ const [outcome, setOutcome] = useState<UploadOutcome | null>(null);
197
+ const zipInput = useRef<HTMLInputElement>(null);
198
+ const folderInput = useRef<HTMLInputElement>(null);
199
+ const filesInput = useRef<HTMLInputElement>(null);
200
+
201
+ async function refresh(parentId: string | null): Promise<void> {
202
+ await options.onImported?.(parentId);
203
+ await Promise.all([
204
+ // ⚠️ Every level, not the one imported into: the target folder's own row carries whether it
205
+ // can be opened, and it stands one level higher (#519). An import into an empty folder was
206
+ // otherwise unreachable in the tree until the page was reloaded.
207
+ queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
208
+ queryClient.invalidateQueries({ queryKey: ["node-graph"] }),
209
+ // The graph view of that same level draws exactly this list (#19).
210
+ queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
211
+ // A bundle can carry flows, so the flow collections go stale alongside the node ones.
212
+ queryClient.invalidateQueries({ queryKey: ["flows"] }),
213
+ ]);
214
+ }
215
+
216
+ // One import, one request, one answer (#137): the server creates the whole subtree or nothing, so
217
+ // this mutation never has a partial state to clean up after — unlike the upload beside it, whose
218
+ // two steps can strand a node.
219
+ const importBundle = useMutation({
220
+ mutationFn: async ({ parentId, zip }: { parentId: string | null; zip: Blob }) => {
221
+ if (zip.size > MaxImportBytes) throw new Error("Import exceeds the 64 MB limit");
222
+ const result = await data.importNodeBundle({
223
+ nodeId: parentId,
224
+ zip,
225
+ idempotencyKey: crypto.randomUUID(),
226
+ });
227
+ return { parentId, result };
228
+ },
229
+ onSuccess: async (imported) => {
230
+ setOpen(false);
231
+ await refresh(imported.parentId);
232
+ },
233
+ });
234
+
235
+ /**
236
+ * n files, one after another (#822).
237
+ *
238
+ * ⚠️ **Sequential, and that is a decision rather than the simpler code.** Each file may be 50 MB
239
+ * (#821); ten of them at once is half a gigabyte in flight from one browser, and a progress that
240
+ * says "3 of 10" while all ten are half-done says nothing. One at a time is also what makes the
241
+ * failure of one file a fact about that file.
242
+ *
243
+ * ⚠️ **A file that fails does not take the others with it, and nothing is rolled back.** Ten
244
+ * files are ten nodes, not one transaction — undoing the six that worked would delete what the
245
+ * reader asked for because the seventh did not. What the run owes them instead is the NAMES of
246
+ * the ones that did not make it, which is what the window shows when it is over.
247
+ */
248
+ const upload = useMutation({
249
+ mutationFn: async ({ parentId, files }: { parentId: string | null; files: File[] }) => {
250
+ const created: string[] = [];
251
+ const failed: { name: string; error: unknown }[] = [];
252
+ let someUnsearchable = false;
253
+ for (const [index, file] of files.entries()) {
254
+ setProgress({ done: index + 1, total: files.length });
255
+ try {
256
+ created.push(await uploadOne(data, parentId, file));
257
+ if (!nativeUpload(file) && file.size > MaxIndexedAttachmentBytes) {
258
+ someUnsearchable = true;
259
+ }
260
+ } catch (error) {
261
+ failed.push({ name: file.name, error });
262
+ }
263
+ }
264
+ return { parentId, created, failed, someUnsearchable };
265
+ },
266
+ onSettled: () => setProgress(null),
267
+ onSuccess: async (result) => {
268
+ setOutcome(result);
269
+ // ⚠️ The window closes only when there is nothing left to say. A run that lost a file has to
270
+ // stay on screen with the name of it — a toast that disappears is the same as silence for
271
+ // somebody who looked away while ten files uploaded.
272
+ if (result.failed.length === 0 && !result.someUnsearchable) setOpen(false);
273
+ if (result.created.length > 0) await refresh(result.parentId);
274
+ await options.onUploaded?.(result);
275
+ },
276
+ });
277
+
278
+ function pick(source: ImportSource): void {
279
+ setOutcome(null);
280
+ const input = source === "zip" ? zipInput : source === "folder" ? folderInput : filesInput;
281
+ input.current?.click();
282
+ }
283
+
284
+ const busy = upload.isPending || importBundle.isPending;
285
+
286
+ return {
287
+ open(parentId) {
288
+ setImportTo(parentId);
289
+ setOutcome(null);
290
+ setOpen(true);
291
+ },
292
+ isError: importBundle.isError || upload.isError,
293
+ inputs: createPortal(
294
+ <>
295
+ {open ? (
296
+ <Modal
297
+ title={i18n.t("import.title", { where: options.where })}
298
+ close={() => setOpen(false)}
299
+ >
300
+ <div className="flex flex-col gap-2">
301
+ {(
302
+ [
303
+ ["files", Upload, "import.files", "import.files.long"],
304
+ ["folder", FolderUp, "import.folder", "import.folder.long"],
305
+ ["zip", FileArchive, "import.zip", "import.zip.long"],
306
+ ] as const
307
+ ).map(([source, Icon, labelKey, descriptionKey]) => (
308
+ <button
309
+ key={source}
310
+ type="button"
311
+ disabled={busy}
312
+ onClick={() => pick(source)}
313
+ className="flex items-start gap-3 rounded-md border p-3 text-left outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
314
+ >
315
+ <Icon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
316
+ <span className="flex flex-col">
317
+ <span className="text-sm font-medium">{i18n.t(labelKey)}</span>
318
+ <span className="text-sm text-muted-foreground">{i18n.t(descriptionKey)}</span>
319
+ </span>
320
+ </button>
321
+ ))}
322
+ {/* ⚠️ `role="status"`, not `alert`: it is the same sentence over and over while the
323
+ run walks through the files, and an alert would interrupt a screen reader once per
324
+ file. */}
325
+ {progress ? (
326
+ <p role="status" className="text-sm text-muted-foreground">
327
+ {i18n.t("import.progress", {
328
+ done: progress.done,
329
+ total: progress.total,
330
+ })}
331
+ </p>
332
+ ) : null}
333
+ {outcome && outcome.failed.length > 0 ? (
334
+ <p role="alert" className="text-sm text-destructive">
335
+ {/* ⚠️ Two sentences, and which one is chosen is not a nicety: "everything else
336
+ was" is FALSE when nothing else existed. A run in which every file failed has
337
+ no rest to reassure anybody about, and telling somebody their other files
338
+ landed when none did is worse than saying nothing. */}
339
+ {i18n.t(outcome.created.length === 0 ? "import.failed.all" : "import.failed", {
340
+ names: outcome.failed.map((failure) => failure.name).join(", "),
341
+ })}
342
+ {/* The limit belongs beside the names only when the limit is the reason — every
343
+ one of them. A run that lost one file to its size and another to the network
344
+ would otherwise blame the size for both. */}
345
+ {outcome.failed.every(
346
+ (failure) => codeOf(failure.error) === "attachment_too_large",
347
+ )
348
+ ? ` ${i18n.t("import.failed.limit", { limit: megabytes(MaxAttachmentBytes) })}`
349
+ : ""}
350
+ </p>
351
+ ) : null}
352
+ {outcome?.someUnsearchable ? (
353
+ <p role="status" className="text-sm text-muted-foreground">
354
+ {i18n.t("import.notSearched", {
355
+ limit: megabytes(MaxIndexedAttachmentBytes),
356
+ })}
357
+ </p>
358
+ ) : null}
359
+ {importBundle.isError ? (
360
+ <p role="alert" className="text-sm text-destructive">
361
+ {i18n.t("tree.importFailed")}
362
+ </p>
363
+ ) : null}
364
+ </div>
365
+ </Modal>
366
+ ) : null}
367
+ <input
368
+ ref={filesInput}
369
+ type="file"
370
+ tabIndex={-1}
371
+ multiple
372
+ className="sr-only"
373
+ aria-label={i18n.t("tree.uploadInto", { where: options.where })}
374
+ onChange={(event) => {
375
+ const files = Array.from(event.target.files ?? []);
376
+ if (files.length > 0) upload.mutate({ parentId: importTo, files });
377
+ event.currentTarget.value = "";
378
+ }}
379
+ />
380
+ <input
381
+ ref={zipInput}
382
+ type="file"
383
+ tabIndex={-1}
384
+ accept=".zip,application/zip"
385
+ className="sr-only"
386
+ aria-label={i18n.t("tree.importZipInto", { where: options.where })}
387
+ onChange={(event) => {
388
+ const file = event.target.files?.[0];
389
+ if (file) importBundle.mutate({ parentId: importTo, zip: file });
390
+ event.currentTarget.value = "";
391
+ }}
392
+ />
393
+ {/* `webkitdirectory` is the one way a browser hands over a folder; React does not know the
394
+ attribute, so it is spread past the type checker rather than invented as a prop. */}
395
+ <input
396
+ ref={folderInput}
397
+ type="file"
398
+ tabIndex={-1}
399
+ multiple
400
+ {...({ webkitdirectory: "" } as object)}
401
+ className="sr-only"
402
+ aria-label={i18n.t("tree.importFolderInto", { where: options.where })}
403
+ onChange={(event) => {
404
+ const files = Array.from(event.target.files ?? []);
405
+ const parentId = importTo;
406
+ if (files.length > 0) {
407
+ void zipOfDirectory(files)
408
+ .then((zip) => importBundle.mutate({ parentId, zip }))
409
+ .catch(() => importBundle.mutate({ parentId, zip: new Blob([]) }));
410
+ }
411
+ event.currentTarget.value = "";
412
+ }}
413
+ />
414
+ </>,
415
+ document.body,
416
+ ),
417
+ };
418
+ }
@@ -0,0 +1,46 @@
1
+ import { IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
2
+
3
+ /**
4
+ * Which of the three sentences a failed save is told with.
5
+ *
6
+ * ⚠️ The refusal's `code`, never the server's prose: `POST /nodes/{id}/versions` words the conflict
7
+ * as `A newer version already exists`, and an English sentence from the API is not a translation
8
+ * source. The code is the contract (`ProblemDetails.code`), and `IntelRequestError` is what carries
9
+ * it this far.
10
+ *
11
+ * ⚠️ They are not a vaguer and a sharper wording of one event. `node.saveError` says "reload and
12
+ * try again", which is right for a network failure and dangerous for a conflict: reloading without
13
+ * knowing that a foreign version now stands loses either the reader's own work or, on the second
14
+ * attempt, the other one. That is why this distinction is worth a branch at all (#451).
15
+ *
16
+ * ⚠️ **And the third for the same reason, one step further** (#785): `document_content_required`
17
+ * says this node will NEVER take an editor body. "Reload and try again" then asks for a repetition
18
+ * that cannot succeed — the reader reloads, loses what they typed, retypes it and is refused again.
19
+ * The sentence has to say that the text belongs somewhere else.
20
+ *
21
+ * ⚠️ **No kind this UI renders the editor for can produce it — and it is still reachable.** The
22
+ * screen next door sends `folder`, `attachment`, `table` and `board` to their own panels before the
23
+ * editor is ever reached, and the board panel opens it for a card alone, so every kind that gets
24
+ * here is one the API accepts. What makes the branch live is that this package and the server ship
25
+ * separately, on separate version numbers (ADR-0002): a UI carrying this file, served against a
26
+ * server older than #785, answers exactly this code for a card — the one case where "reload and try
27
+ * again" is worst, because that deployment will never stop refusing.
28
+ *
29
+ * ⚠️ The server package is deliberately not named here. `check:boundaries` reads this file as text
30
+ * and cannot tell a comment from an import, so writing the name would fail the build over a
31
+ * sentence.
32
+ *
33
+ * ⚠️ Not `resourceErrorKey`: that one answers for the resource menu and the column dialog, and its
34
+ * sentences are the `resource.*` ones. Writing a document has its own three.
35
+ *
36
+ * ⚠️ Flat and on its own since #820, for the same reason `resource-error.ts` is: the editor and the
37
+ * code view save the same document through the same door, and both have to say the same sentence
38
+ * about a refusal. Importing it from the editor would pull that whole chunk into the code view,
39
+ * which is the one surface that exists to be cheap.
40
+ */
41
+ export function saveErrorKey(error: unknown): string {
42
+ if (!(error instanceof IntelRequestError)) return "node.saveError";
43
+ if (error.code === "version_conflict") return "node.saveConflict";
44
+ if (error.code === "document_content_required") return "node.saveUnsupported";
45
+ return "node.saveError";
46
+ }
@@ -0,0 +1,128 @@
1
+ import { BlockNoteEditor } from "@blocknote/core";
2
+ import { BlockNoteDocument, BlockNoteMediaType, type NodeDocument } from "@contract/node.ts";
3
+ import { useMutation } from "@tanstack/react-query";
4
+ import { useId, useMemo, useState } from "react";
5
+ import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
6
+ import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
7
+ import { documentMarkdown, markdownToBlocks } from "@/document-markdown/document-markdown.ts";
8
+ import { type IntelEditor, intelEditorSchema } from "@/editor-schema/editor-schema.ts";
9
+ import type { I18n } from "@/i18n/i18n.types.ts";
10
+ import { saveErrorKey } from "@/node-save-error.ts";
11
+ import { SaveButton, UnsavedChangesGuard } from "@/save-button/save-button.tsx";
12
+
13
+ /**
14
+ * The document as it really stands: its markdown, readable and writable (#820).
15
+ *
16
+ * ⚠️ **The markdown is the truth, the editor is a nicer view onto it** (Jack, 2026-08-30). That is
17
+ * why this view may write at all, and it is also why it could not be built before #826: until then
18
+ * the editor rewrote everything it did not understand on the way back out, so somebody who wrote
19
+ * here and then switched over lost it silently. Since #826 what the editor cannot show is parked
20
+ * and written back byte for byte, and the switch costs nothing.
21
+ *
22
+ * ⚠️ **It stores the same payload the editor stores, with `markdown` set to exactly what stands in
23
+ * the box.** Not `text/markdown`: a document that flips its media type between two saves would make
24
+ * `blocks` and `markdown` disagree about which of them is current, and `payload.markdown` is the
25
+ * field the search reads (`indexing.ts`). The blocks are derived from that same text, so both
26
+ * halves say the same thing, and the text is the half that was written by hand.
27
+ */
28
+ export function NodeSource({
29
+ data,
30
+ document,
31
+ i18n,
32
+ onSaved,
33
+ }: {
34
+ data: IntelDataProvider;
35
+ document: NodeDocument;
36
+ i18n: I18n;
37
+ onSaved(document: NodeDocument): void;
38
+ }) {
39
+ const fieldId = useId();
40
+ const stored = useMemo(() => documentMarkdown(document.content), [document.content]);
41
+ const [text, setText] = useState(stored);
42
+ /**
43
+ * What stands on the server. Saving moves it forward, and every question of "is there anything to
44
+ * save" is answered against it — the same line `node-editor.tsx` draws.
45
+ *
46
+ * ⚠️ State, not a ref, and the difference is visible: `dirty` is derived from it, so moving it
47
+ * has to draw the screen again. The editor next door keeps its own in a ref because it is read
48
+ * inside `onChange` on every keystroke and must be the current value there; here nothing reads it
49
+ * outside a render. A ref would leave the save button standing after a successful save until some
50
+ * other reason to render came along, which is a control that lies about the state of the
51
+ * document. Found in review on #833.
52
+ */
53
+ const [savedText, setSavedText] = useState(stored);
54
+ /**
55
+ * ⚠️ An editor with no screen, only to read the text into blocks.
56
+ *
57
+ * The blocks are not what this view shows, but they are half of what a document stores, and the
58
+ * one place that knows how to make them out of markdown is `markdownToBlocks`. Building them here
59
+ * from the text that was just written is what keeps the two halves of the payload in step; the
60
+ * alternative — writing the text and leaving the old blocks beside it — is a document that says
61
+ * two different things depending on which surface reads it.
62
+ */
63
+ const headless = useMemo(
64
+ () => BlockNoteEditor.create({ schema: intelEditorSchema } as never) as unknown as IntelEditor,
65
+ [],
66
+ );
67
+
68
+ const save = useMutation({
69
+ mutationFn: async () => {
70
+ const sent = text;
71
+ const payload = BlockNoteDocument.parse({
72
+ format: "blocknote",
73
+ schemaVersion: 1,
74
+ blocks: markdownToBlocks(headless, sent),
75
+ markdown: sent,
76
+ });
77
+ const node = await data.saveNode({
78
+ nodeId: document.node.id,
79
+ // ⚠️ The same conflict route as everywhere else: the version this text was written against.
80
+ // Without it two people writing at once end with one of them silently overwritten.
81
+ baseVersionId: document.version?.id ?? null,
82
+ content: JSON.stringify(payload),
83
+ mediaType: BlockNoteMediaType,
84
+ idempotencyKey: crypto.randomUUID(),
85
+ });
86
+ // ⚠️ An answer that carries no version did not save anything (#432). Letting it through would
87
+ // move the saved text forward, drop the unsaved warning and leave the reader free to close
88
+ // the tab over a document the server still reports as empty.
89
+ if (!node.version) throw new Error("save_without_version");
90
+ // ⚠️ What was SENT, not what stands in the box when the answer comes back. Saving crosses the
91
+ // network, and typing during it is normal.
92
+ return { node, sent };
93
+ },
94
+ onSuccess: ({ node, sent }) => {
95
+ setSavedText(sent);
96
+ onSaved(node);
97
+ },
98
+ });
99
+
100
+ const dirty = text !== savedText;
101
+
102
+ return (
103
+ <div className="flex min-h-0 flex-1 flex-col">
104
+ {/* Up into the title line, like the editor's own button: this is where `dirty` lives. */}
105
+ <ActionSlot name="title-actions">
106
+ <SaveButton dirty={dirty} saving={save.isPending} onSave={() => save.mutate()} />
107
+ </ActionSlot>
108
+ <UnsavedChangesGuard dirty={dirty} />
109
+ {save.isError && (
110
+ <p role="alert" className="mx-6 mt-4 text-sm text-destructive">
111
+ {i18n.t(saveErrorKey(save.error))}
112
+ </p>
113
+ )}
114
+ <label htmlFor={fieldId} className="sr-only">
115
+ {i18n.t("node.source.label")}
116
+ </label>
117
+ <textarea
118
+ id={fieldId}
119
+ value={text}
120
+ spellCheck={false}
121
+ onChange={(event) => setText(event.target.value)}
122
+ // ⚠️ `whitespace-pre` and no wrapping off: a source view that reflows long lines shows a
123
+ // shape the file does not have, and the head is exactly where that matters.
124
+ className="min-h-0 flex-1 resize-none bg-transparent px-6 py-6 font-mono text-sm leading-relaxed outline-none focus-visible:ring-0"
125
+ />
126
+ </div>
127
+ );
128
+ }