gloomberb 0.11.0 → 0.11.2

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 (392) hide show
  1. package/README.md +38 -15
  2. package/README.zh-CN.md +12 -12
  3. package/package.json +10 -3
  4. package/src/api-client/auth.ts +14 -10
  5. package/src/api-client/data.ts +54 -0
  6. package/src/api-client/index.test.ts +124 -0
  7. package/src/api-client/index.ts +139 -4
  8. package/src/api-client/paths.ts +89 -0
  9. package/src/api-client/request.ts +15 -2
  10. package/src/api-client/socket-health.test.ts +1 -0
  11. package/src/api-client/socket.ts +2 -1
  12. package/src/api-client/types.ts +163 -0
  13. package/src/app/pane-runtime/index.tsx +4 -1
  14. package/src/app/pane-runtime/plugin-bindings.test.ts +117 -0
  15. package/src/app/pane-runtime/plugin-bindings.ts +25 -4
  16. package/src/app/pane-runtime/ticker-inspector-runtime.ts +25 -14
  17. package/src/app/runtime/desktop-deeplink.test.ts +16 -0
  18. package/src/app/runtime/desktop-deeplink.ts +66 -2
  19. package/src/app/runtime/desktop-menu.ts +3 -0
  20. package/src/app/runtime/plugin-bindings.ts +30 -28
  21. package/src/app/runtime/update.ts +8 -6
  22. package/src/app.tsx +21 -7
  23. package/src/architecture/import-boundaries.test.ts +5 -1
  24. package/src/capabilities/factories.ts +4 -0
  25. package/src/cli/commands/automation.test.ts +9 -0
  26. package/src/cli/commands/automation.ts +22 -73
  27. package/src/cli/index.ts +10 -6
  28. package/src/cli/registry.ts +1 -1
  29. package/src/components/chart/composite/composite-chart.test.tsx +45 -2
  30. package/src/components/chart/composite/composite-chart.tsx +16 -12
  31. package/src/components/chart/composite/format.test.ts +34 -0
  32. package/src/components/chart/composite/format.ts +33 -10
  33. package/src/components/chart/composite/scene.ts +0 -21
  34. package/src/components/chart/composite/tools.ts +49 -0
  35. package/src/components/chart/core/axis-format.ts +13 -3
  36. package/src/components/chart/core/index.test.ts +7 -0
  37. package/src/components/chart/core/renderer.ts +3 -4
  38. package/src/components/command-bar/commands/direct/index.ts +4 -0
  39. package/src/components/command-bar/commands/registry.ts +8 -1
  40. package/src/components/command-bar/layout-items/current-layout.ts +1 -1
  41. package/src/components/command-bar/pane-templates/workflow.ts +4 -0
  42. package/src/components/command-bar/routes/root/shortcut-feedback.ts +3 -0
  43. package/src/components/command-bar/routes/root/shortcuts.test.ts +19 -0
  44. package/src/components/command-bar/routes/root/shortcuts.ts +3 -1
  45. package/src/components/command-bar/surface/index.test.tsx +6 -6
  46. package/src/components/command-bar/surface/index.tsx +4 -1
  47. package/src/components/command-bar/surface/pane.test.tsx +66 -2
  48. package/src/components/command-bar/view-model.test.ts +2 -1
  49. package/src/components/command-bar/workflow/ops.test.ts +54 -0
  50. package/src/components/command-bar/workflow/ops.ts +1 -1
  51. package/src/components/company-logo.test.ts +19 -0
  52. package/src/components/company-logo.tsx +60 -0
  53. package/src/components/feed-data-table/stack-view.tsx +7 -1
  54. package/src/components/index.ts +1 -0
  55. package/src/components/layout/detached-pane-shell.tsx +85 -15
  56. package/src/components/layout/header.tsx +5 -5
  57. package/src/components/layout/pane/title.ts +31 -3
  58. package/src/components/layout/shell/active-drag.ts +4 -11
  59. package/src/components/layout/shell/drag/index.ts +4 -20
  60. package/src/components/layout/shell/drag/runtime.ts +3 -4
  61. package/src/components/layout/shell/index.test.tsx +17 -3
  62. package/src/components/layout/shell/index.tsx +89 -31
  63. package/src/components/layout/shell/menu.test.ts +37 -1
  64. package/src/components/layout/shell/menu.ts +14 -0
  65. package/src/components/layout/shell/native/pointer-runtime.ts +22 -8
  66. package/src/components/layout/shell/pane/actions.ts +0 -5
  67. package/src/components/layout/shell/pane/management-shortcuts.ts +9 -4
  68. package/src/components/layout/shell/shortcuts.ts +6 -3
  69. package/src/components/layout/shell/ticker-link-menu.test.ts +82 -0
  70. package/src/components/layout/shell/ticker-link-menu.ts +78 -0
  71. package/src/components/layout/status-bar.test.tsx +62 -61
  72. package/src/components/layout/status-bar.tsx +53 -75
  73. package/src/components/onboarding/account-step/form-panel.tsx +4 -2
  74. package/src/components/onboarding/account-step/index.tsx +1 -1
  75. package/src/components/onboarding/account-step/signed-in-panel.tsx +1 -1
  76. package/src/components/onboarding/onboarding-wizard.test.tsx +44 -1
  77. package/src/components/onboarding/onboarding-wizard.tsx +13 -5
  78. package/src/components/onboarding/wizard-account.ts +4 -23
  79. package/src/components/table-view-shared.test.ts +11 -0
  80. package/src/components/table-view-shared.tsx +26 -0
  81. package/src/components/ui/data-table/index.tsx +19 -1
  82. package/src/components/ui/fields.tsx +5 -1
  83. package/src/core/state/app/layout-reducer.ts +36 -0
  84. package/src/core/state/app/layout.ts +10 -5
  85. package/src/core/state/app/state.ts +0 -13
  86. package/src/core/state/app/types.ts +6 -4
  87. package/src/data/config/chart-settings.ts +1 -1
  88. package/src/data/config/layout.ts +2 -0
  89. package/src/data/config/store/index.test.ts +27 -6
  90. package/src/data/config/store/normalize.ts +3 -5
  91. package/src/data/plugin-state-store.ts +11 -0
  92. package/src/data/sqlite/cache.test.ts +10 -0
  93. package/src/i18n/es.ts +13 -5
  94. package/src/i18n/ja.ts +13 -5
  95. package/src/i18n/ko.ts +13 -5
  96. package/src/i18n/zh-cn.ts +13 -5
  97. package/src/i18n/zh-tw.ts +13 -5
  98. package/src/layout-marketplace/api.test.ts +54 -0
  99. package/src/layout-marketplace/api.ts +65 -0
  100. package/src/layout-marketplace/gallery-desktop.test.tsx +262 -0
  101. package/src/layout-marketplace/gallery-desktop.tsx +375 -0
  102. package/src/layout-marketplace/gallery-terminal.tsx +376 -0
  103. package/src/layout-marketplace/gallery.test.tsx +84 -0
  104. package/src/layout-marketplace/gallery.tsx +323 -0
  105. package/src/layout-marketplace/geometry.test.ts +47 -0
  106. package/src/layout-marketplace/geometry.ts +80 -0
  107. package/src/layout-marketplace/mini-workspace.test.tsx +122 -0
  108. package/src/layout-marketplace/mini-workspace.tsx +428 -0
  109. package/src/layout-marketplace/model.test.ts +118 -0
  110. package/src/layout-marketplace/model.ts +171 -0
  111. package/src/layout-marketplace/name-dialog.tsx +60 -0
  112. package/src/layout-marketplace/pane-imagery.ts +85 -0
  113. package/src/layout-marketplace/pane.tsx +20 -0
  114. package/src/layout-marketplace/payload.test.ts +246 -0
  115. package/src/layout-marketplace/payload.ts +624 -0
  116. package/src/layout-marketplace/use-marketplace.ts +63 -0
  117. package/src/market-data/coordinator/index.test.ts +2 -2
  118. package/src/market-data/coordinator/index.ts +1 -1
  119. package/src/market-data/hooks.tsx +0 -41
  120. package/src/market-data/market/format.test.ts +7 -0
  121. package/src/market-data/market/freshness.ts +5 -2
  122. package/src/market-data/quotes/freshness.test.ts +16 -0
  123. package/src/news/aggregator.test.ts +92 -3
  124. package/src/news/aggregator.ts +98 -18
  125. package/src/news/hooks.test.tsx +2 -0
  126. package/src/news/hooks.ts +27 -1
  127. package/src/news/news-model.ts +5 -2
  128. package/src/news/types.ts +7 -0
  129. package/src/plugins/broker-sync/broker-sync.test.ts +58 -0
  130. package/src/plugins/broker-sync/normalize.ts +42 -5
  131. package/src/plugins/builtin/account-management/pane.tsx +5 -5
  132. package/src/plugins/builtin/ai/index.tsx +18 -2
  133. package/src/plugins/builtin/ai/screener/pane.test.tsx +47 -4
  134. package/src/plugins/builtin/ai/screener/pane.tsx +35 -12
  135. package/src/plugins/builtin/alerts/alert-engine.test.ts +53 -1
  136. package/src/plugins/builtin/alerts/alert-engine.ts +34 -1
  137. package/src/plugins/builtin/alerts/index.test.tsx +49 -6
  138. package/src/plugins/builtin/alerts/index.tsx +11 -6
  139. package/src/plugins/builtin/alerts/pane.tsx +51 -9
  140. package/src/plugins/builtin/alerts/quotes.ts +11 -3
  141. package/src/plugins/builtin/alerts/types.ts +1 -0
  142. package/src/plugins/builtin/analytics/index.tsx +11 -1
  143. package/src/plugins/builtin/analytics/pane-model.ts +6 -4
  144. package/src/plugins/builtin/broker-manager/index.tsx +3 -0
  145. package/src/plugins/builtin/buildout/model/index.ts +1 -1
  146. package/src/plugins/builtin/buildout/pane/index.tsx +17 -18
  147. package/src/plugins/builtin/cds/client.test.ts +83 -0
  148. package/src/plugins/builtin/cds/client.ts +67 -0
  149. package/src/plugins/builtin/cds/index.tsx +41 -0
  150. package/src/plugins/builtin/cds/model.test.ts +232 -0
  151. package/src/plugins/builtin/cds/model.ts +401 -0
  152. package/src/plugins/builtin/cds/pane.test.tsx +132 -0
  153. package/src/plugins/builtin/cds/pane.tsx +358 -0
  154. package/src/plugins/builtin/chart-composer/chart-spec.ts +31 -0
  155. package/src/plugins/builtin/chart-composer/index.tsx +88 -2
  156. package/src/plugins/builtin/chart-composer/pane-share.test.ts +71 -0
  157. package/src/plugins/builtin/chart-composer/pane.tsx +103 -15
  158. package/src/plugins/builtin/chat/content/index.tsx +4 -1
  159. package/src/plugins/builtin/chat/controller/channels.ts +7 -1
  160. package/src/plugins/builtin/chat/controller/index.ts +14 -9
  161. package/src/plugins/builtin/chat/controller/notifications.ts +13 -3
  162. package/src/plugins/builtin/chat/controller/persistence.ts +0 -3
  163. package/src/plugins/builtin/chat/controller/realtime.ts +3 -3
  164. package/src/plugins/builtin/chat/controller/send.ts +1 -3
  165. package/src/plugins/builtin/chat/controller/session-runtime.ts +18 -11
  166. package/src/plugins/builtin/chat/controller/state.ts +1 -3
  167. package/src/plugins/builtin/chat/controller/storage.ts +0 -2
  168. package/src/plugins/builtin/chat/controller/utils.ts +4 -6
  169. package/src/plugins/builtin/chat/controller/view.ts +2 -2
  170. package/src/plugins/builtin/chat/controller.test.ts +142 -14
  171. package/src/plugins/builtin/chat/message/desktop.test.tsx +88 -0
  172. package/src/plugins/builtin/chat/message/desktop.tsx +3 -1
  173. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +1 -29
  174. package/src/plugins/builtin/chat/profile-popover.ts +53 -22
  175. package/src/plugins/builtin/chat/sidebar.test.tsx +81 -0
  176. package/src/plugins/builtin/chat/sidebar.tsx +63 -0
  177. package/src/plugins/builtin/chat/test-harness.tsx +29 -1
  178. package/src/plugins/builtin/cloud/auth-actions.tsx +14 -7
  179. package/src/plugins/builtin/cloud/auth-commands.ts +21 -51
  180. package/src/plugins/builtin/cloud/auth-dialog.tsx +296 -0
  181. package/src/{components/onboarding/account-step/model.test.ts → plugins/builtin/cloud/auth-model.test.ts} +1 -1
  182. package/src/{components/onboarding/account-step/model.ts → plugins/builtin/cloud/auth-model.ts} +37 -3
  183. package/src/plugins/builtin/cloud/browser.tsx +9 -0
  184. package/src/plugins/builtin/cloud/buildout-module.tsx +23 -0
  185. package/src/plugins/builtin/cloud/device-signin.ts +2 -8
  186. package/src/plugins/builtin/cloud/index.tsx +2 -0
  187. package/src/plugins/builtin/cloud/plugin.tsx +14 -24
  188. package/src/plugins/builtin/cloud/upgrade-command.ts +13 -6
  189. package/src/plugins/builtin/cloud-tweets/pane-share.test.ts +56 -0
  190. package/src/plugins/builtin/cloud-tweets/pane.tsx +20 -5
  191. package/src/plugins/builtin/cloud-tweets/registration.ts +50 -6
  192. package/src/plugins/builtin/composite-plugins.ts +3 -1
  193. package/src/plugins/builtin/congress-trades/detail.tsx +43 -1
  194. package/src/plugins/builtin/congress-trades/model.test.ts +85 -0
  195. package/src/plugins/builtin/congress-trades/model.ts +69 -1
  196. package/src/plugins/builtin/congress-trades/pane.tsx +51 -2
  197. package/src/plugins/builtin/connections/index.ts +3 -0
  198. package/src/plugins/builtin/dividend-yield/client.ts +18 -0
  199. package/src/plugins/builtin/dividend-yield/pane.tsx +7 -6
  200. package/src/plugins/builtin/earnings/index.tsx +5 -3
  201. package/src/plugins/builtin/earnings/model.test.ts +19 -1
  202. package/src/plugins/builtin/earnings/model.ts +17 -15
  203. package/src/plugins/builtin/econ/calendar-model.ts +0 -5
  204. package/src/plugins/builtin/econ/index.test.tsx +6 -1
  205. package/src/plugins/builtin/fear-greed/charts.tsx +12 -8
  206. package/src/plugins/builtin/help/index.tsx +2 -2
  207. package/src/plugins/builtin/holders/thirteenf-match.ts +1 -2
  208. package/src/plugins/builtin/insider/index.tsx +27 -11
  209. package/src/plugins/builtin/kelly-sizer/index.tsx +4 -1
  210. package/src/plugins/builtin/layout-manager/index.tsx +17 -4
  211. package/src/plugins/builtin/news/index.tsx +11 -6
  212. package/src/plugins/builtin/news/wire/breaking/notifications.test.ts +2 -0
  213. package/src/plugins/builtin/news/wire/breaking/pane.test.tsx +2 -0
  214. package/src/plugins/builtin/news/wire/breaking/pane.tsx +4 -1
  215. package/src/plugins/builtin/news/wire/feed-config.test.ts +15 -1
  216. package/src/plugins/builtin/news/wire/feed-config.ts +25 -5
  217. package/src/plugins/builtin/news/wire/index.tsx +3 -3
  218. package/src/plugins/builtin/news/wire/industry-pane.tsx +7 -2
  219. package/src/plugins/builtin/news/wire/news/footer.ts +34 -1
  220. package/src/plugins/builtin/news/wire/news/preset-pane.tsx +4 -1
  221. package/src/plugins/builtin/news/wire/news/table.tsx +8 -2
  222. package/src/plugins/builtin/news/wire/rss/source.test.ts +12 -0
  223. package/src/plugins/builtin/news/wire/rss/source.ts +1 -1
  224. package/src/plugins/builtin/notes/files.ts +80 -0
  225. package/src/plugins/builtin/notes/index.tsx +3 -0
  226. package/src/plugins/builtin/notes/sync.test.ts +49 -0
  227. package/src/plugins/builtin/notes/sync.ts +98 -0
  228. package/src/plugins/builtin/options/index.tsx +1 -0
  229. package/src/plugins/builtin/options/view.test.tsx +25 -3
  230. package/src/plugins/builtin/options/view.tsx +1 -0
  231. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +22 -0
  232. package/src/plugins/builtin/portfolio-list/account-metrics.ts +12 -7
  233. package/src/plugins/builtin/portfolio-list/cli/render.ts +5 -3
  234. package/src/plugins/builtin/portfolio-list/column-values.ts +15 -4
  235. package/src/plugins/builtin/portfolio-list/header.tsx +13 -3
  236. package/src/plugins/builtin/portfolio-list/index.tsx +8 -0
  237. package/src/plugins/builtin/portfolio-list/metrics.test.ts +31 -1
  238. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +1 -0
  239. package/src/plugins/builtin/portfolio-list/pane/index.tsx +10 -7
  240. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +6 -1
  241. package/src/plugins/builtin/portfolio-list/position-metrics.ts +25 -6
  242. package/src/plugins/builtin/portfolio-list/summary/index.tsx +35 -21
  243. package/src/plugins/builtin/portfolio-list/summary/totals.ts +20 -6
  244. package/src/plugins/builtin/research/corporate-actions-pane.tsx +5 -12
  245. package/src/plugins/builtin/research/equity-diagnostic-pane.test.tsx +329 -0
  246. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +582 -0
  247. package/src/plugins/builtin/research/index.test.ts +14 -0
  248. package/src/plugins/builtin/research/index.tsx +29 -0
  249. package/src/plugins/builtin/sec/index.tsx +24 -9
  250. package/src/plugins/builtin/sectors/index.tsx +14 -0
  251. package/src/plugins/builtin/shared/cloud-upgrade.ts +18 -11
  252. package/src/plugins/builtin/shared/public-pane.test.ts +12 -0
  253. package/src/plugins/builtin/shared/public-pane.ts +9 -0
  254. package/src/plugins/builtin/shared/public-share.ts +23 -0
  255. package/src/plugins/builtin/shared/ticker-source.ts +32 -0
  256. package/src/plugins/builtin/shared/ticker-surface.test.ts +27 -0
  257. package/src/plugins/builtin/shared/ticker-surface.ts +17 -0
  258. package/src/plugins/builtin/substack/api/cache.ts +7 -3
  259. package/src/plugins/builtin/substack/api/loaders.ts +36 -12
  260. package/src/plugins/builtin/substack/api/types.ts +2 -0
  261. package/src/plugins/builtin/substack/pane-state.ts +1 -1
  262. package/src/plugins/builtin/substack/pane.tsx +55 -12
  263. package/src/plugins/builtin/thirteenf/api.test.ts +1 -1
  264. package/src/plugins/builtin/thirteenf/api.ts +40 -18
  265. package/src/plugins/builtin/thirteenf/data.ts +15 -7
  266. package/src/plugins/builtin/thirteenf/pane.tsx +45 -10
  267. package/src/plugins/builtin/ticker-detail/index.tsx +10 -0
  268. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +12 -4
  269. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +8 -2
  270. package/src/plugins/builtin/ticker-detail/settings.ts +12 -0
  271. package/src/plugins/builtin/treasury-auctions/index.tsx +2 -0
  272. package/src/plugins/builtin/world-indices/index.tsx +2 -0
  273. package/src/plugins/catalog-browser.test.ts +78 -0
  274. package/src/plugins/catalog-browser.ts +136 -0
  275. package/src/plugins/gridlock-notification.ts +2 -1
  276. package/src/plugins/ibkr/flex/index.test.ts +19 -0
  277. package/src/plugins/ibkr/flex/index.ts +1 -1
  278. package/src/plugins/pane-manager/gridlock.ts +87 -3
  279. package/src/plugins/pane-manager.test.ts +30 -0
  280. package/src/plugins/pane-manager.ts +6 -1
  281. package/src/plugins/prediction-markets/chart.tsx +2 -2
  282. package/src/plugins/prediction-markets/controller/catalog.ts +77 -2
  283. package/src/plugins/prediction-markets/controller/data.ts +7 -1
  284. package/src/plugins/prediction-markets/controller/index.ts +9 -2
  285. package/src/plugins/prediction-markets/controller/keyboard.ts +10 -14
  286. package/src/plugins/prediction-markets/detail/overview.tsx +21 -0
  287. package/src/plugins/prediction-markets/detail/pane.tsx +43 -34
  288. package/src/plugins/prediction-markets/detail/trades.tsx +34 -9
  289. package/src/plugins/prediction-markets/detail.test.tsx +29 -2
  290. package/src/plugins/prediction-markets/index.tsx +8 -4
  291. package/src/plugins/prediction-markets/navigation.ts +0 -1
  292. package/src/plugins/prediction-markets/pane.test.tsx +77 -0
  293. package/src/plugins/prediction-markets/pane.tsx +12 -0
  294. package/src/plugins/prediction-markets/plugin.test.tsx +36 -0
  295. package/src/plugins/prediction-markets/services/kalshi/adapter.ts +49 -9
  296. package/src/plugins/prediction-markets/services/polymarket/adapter.ts +31 -0
  297. package/src/plugins/prediction-markets/test-helpers.tsx +9 -0
  298. package/src/plugins/registry/index.ts +2 -0
  299. package/src/plugins/ticker-navigation.test.ts +24 -0
  300. package/src/plugins/ticker-navigation.ts +40 -0
  301. package/src/remote/schema.ts +1 -1
  302. package/src/renderers/browser/app-services.ts +61 -0
  303. package/src/renderers/browser/cloud-transport.test.ts +68 -0
  304. package/src/renderers/browser/cloud-transport.ts +48 -0
  305. package/src/renderers/browser/config-host.ts +58 -0
  306. package/src/renderers/browser/deeplink-bridge.test.ts +41 -0
  307. package/src/renderers/browser/deeplink-bridge.ts +22 -0
  308. package/src/renderers/browser/error-boundary.tsx +23 -0
  309. package/src/renderers/browser/main.tsx +64 -0
  310. package/src/renderers/browser/persistence.ts +74 -0
  311. package/src/renderers/browser/storage.test.ts +66 -0
  312. package/src/renderers/browser/storage.ts +50 -0
  313. package/src/renderers/browser/ticker-repository.ts +59 -0
  314. package/src/renderers/browser/ui-host.tsx +40 -0
  315. package/src/renderers/cloudflare/worker.test.ts +136 -0
  316. package/src/renderers/cloudflare/worker.ts +116 -0
  317. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  318. package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
  319. package/src/renderers/electrobun/bun/desktop/plugin-state.ts +7 -1
  320. package/src/renderers/electrobun/view/app-services.ts +14 -3
  321. package/src/renderers/electrobun/view/build-assets.ts +12 -10
  322. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -1
  323. package/src/renderers/electrobun/view/data-table/index.tsx +10 -1
  324. package/src/renderers/electrobun/view/desktop/controls.tsx +2 -0
  325. package/src/renderers/electrobun/view/dom-ui-host.tsx +143 -0
  326. package/src/renderers/electrobun/view/host/box.test.tsx +33 -0
  327. package/src/renderers/electrobun/view/host/box.tsx +11 -6
  328. package/src/renderers/electrobun/view/host/chart-surface.tsx +7 -1
  329. package/src/renderers/electrobun/view/host/input.tsx +11 -7
  330. package/src/renderers/electrobun/view/host/native.ts +0 -5
  331. package/src/renderers/electrobun/view/main.tsx +2 -0
  332. package/src/renderers/electrobun/view/remote/persistence.ts +5 -0
  333. package/src/renderers/electrobun/view/styles.css +107 -7
  334. package/src/renderers/electrobun/view/toast-host.test.tsx +78 -0
  335. package/src/renderers/electrobun/view/toast-host.tsx +69 -43
  336. package/src/renderers/electrobun/view/ui-host.tsx +6 -137
  337. package/src/renderers/opentui/start.tsx +2 -0
  338. package/src/renderers/share/layout-view.test.tsx +58 -0
  339. package/src/renderers/share/layout-view.tsx +133 -0
  340. package/src/renderers/share/main.tsx +102 -0
  341. package/src/renderers/share/styles.css +54 -0
  342. package/src/renderers/share/view.test.tsx +100 -0
  343. package/src/renderers/share/view.tsx +150 -0
  344. package/src/shares/api.test.ts +105 -0
  345. package/src/shares/api.ts +127 -0
  346. package/src/shares/chart-snapshot.test.ts +34 -0
  347. package/src/shares/chart-snapshot.ts +28 -0
  348. package/src/shares/location.test.ts +19 -0
  349. package/src/shares/location.ts +11 -0
  350. package/src/shares/pane.test.ts +110 -0
  351. package/src/shares/pane.ts +54 -0
  352. package/src/shares/payload.ts +177 -0
  353. package/src/sources/gloomberb-cloud/index.test.ts +61 -1
  354. package/src/sources/gloomberb-cloud/index.ts +86 -14
  355. package/src/sources/gloomberb-cloud/normalizers.test.ts +34 -0
  356. package/src/sources/gloomberb-cloud/normalizers.ts +5 -5
  357. package/src/sources/provider-router/history.test.ts +2 -2
  358. package/src/sources/provider-router/news.test.ts +34 -0
  359. package/src/sources/provider-router/news.ts +15 -1
  360. package/src/sources/sec-edgar.test.ts +59 -0
  361. package/src/sources/sec-edgar.ts +63 -23
  362. package/src/sources/yahoo-finance/mappers.ts +3 -0
  363. package/src/sources/yahoo-finance/snapshots.ts +10 -2
  364. package/src/sources/yahoo-finance/symbols.test.ts +10 -0
  365. package/src/sources/yahoo-finance/symbols.ts +13 -8
  366. package/src/sources/yahoo-finance.test.ts +1 -1
  367. package/src/sources/yahoo-finance.ts +6 -2
  368. package/src/state/app/bootstrap.ts +6 -7
  369. package/src/state/app/context/index.test.ts +41 -1
  370. package/src/state/app/context/index.test.tsx +34 -16
  371. package/src/state/selectors-ui.ts +0 -8
  372. package/src/sync/baseline.ts +32 -0
  373. package/src/sync/controller.test.ts +124 -1
  374. package/src/sync/controller.ts +54 -12
  375. package/src/sync/core-contributors.test.ts +44 -0
  376. package/src/sync/core-contributors.ts +59 -10
  377. package/src/sync/react.ts +22 -2
  378. package/src/sync/types.ts +12 -0
  379. package/src/theme/font-scale.ts +0 -11
  380. package/src/tickers/search/index.test.ts +27 -0
  381. package/src/tickers/search/upsert.ts +10 -0
  382. package/src/types/capability-route-source.ts +2 -1
  383. package/src/types/config.ts +35 -5
  384. package/src/types/desktop-menu.ts +1 -0
  385. package/src/types/plugin.ts +36 -0
  386. package/src/ui/context-menu.tsx +1 -1
  387. package/src/ui/host.tsx +4 -0
  388. package/src/ui/toast.tsx +2 -0
  389. package/src/utils/back-navigation.ts +0 -4
  390. package/src/utils/price-history.ts +11 -10
  391. package/src/utils/sec.test.ts +6 -0
  392. package/src/utils/sec.ts +1 -1
@@ -5,7 +5,7 @@ import { useArticleSummary, useResolvedEntryValue } from "../../../market-data/h
5
5
  import { instrumentFromTicker } from "../../../market-data/request-types";
6
6
  import { useDebouncedPluginPaneState, usePluginPaneState } from "../../runtime";
7
7
  import { EmptyState } from "../../../components";
8
- import { useLoadNewsStory, useNewsArticles } from "../../../news/hooks";
8
+ import { useLoadNewsStory, useNewsArticles, useNewsTableLoadMore } from "../../../news/hooks";
9
9
  import { newsWireModule } from "./wire";
10
10
  import { NewsDetailView, useNewsArticleDetail } from "./wire/news/detail-view";
11
11
  import {
@@ -33,18 +33,20 @@ function TickerNewsView({ width, height, focused }: { width: number; height: num
33
33
  DEFAULT_SORT,
34
34
  );
35
35
  const instrument = instrumentFromTicker(ticker, ticker?.metadata.ticker ?? null);
36
- const newsState = useNewsArticles(instrument ? {
37
- feed: "ticker",
36
+ const newsQuery = instrument ? {
37
+ feed: "ticker" as const,
38
38
  ticker: instrument.symbol,
39
39
  exchange: instrument.exchange,
40
- tickerTier: "primary",
40
+ tickerTier: "primary" as const,
41
41
  limit: NEWS_ITEM_LIMIT,
42
- } : null);
42
+ } : null;
43
+ const newsState = useNewsArticles(newsQuery);
43
44
  const news = usePersistedNewsArticles(
44
45
  `articles:${instrument?.symbol ?? "none"}:${instrument?.exchange ?? ""}`,
45
46
  newsState.articles,
46
47
  );
47
48
  const { readArticleIds, markArticleRead } = useNewsReadState();
49
+ const { scrollRef, onBodyScrollActivity } = useNewsTableLoadMore(newsQuery, newsState);
48
50
  const loadNewsStory = useLoadNewsStory();
49
51
  const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(news, loadNewsStory);
50
52
  const loading = newsState.phase === "loading"
@@ -118,11 +120,13 @@ function TickerNewsView({ width, height, focused }: { width: number; height: num
118
120
  })}
119
121
  emptyStateTitle={`No news for ${ticker.metadata.ticker}`}
120
122
  emptyStateHint="Stories appear as sources publish them."
123
+ scrollRef={scrollRef}
124
+ onBodyScrollActivity={onBodyScrollActivity}
121
125
  />
122
126
  );
123
127
  }
124
128
 
125
- const tickerNewsModule: PluginModule = {
129
+ export const tickerNewsModule: PluginModule = {
126
130
  panes: [
127
131
  {
128
132
  id: "ticker-news",
@@ -143,6 +147,7 @@ const tickerNewsModule: PluginModule = {
143
147
  description: "Company news for the selected ticker.",
144
148
  keywords: ["company", "ticker", "news", "headlines", "cn"],
145
149
  shortcut: "CN",
150
+ publicShare: true,
146
151
  }),
147
152
  ],
148
153
 
@@ -38,6 +38,8 @@ function ready(articles: MarketNewsItem[]): NewsQueryState {
38
38
  error: null,
39
39
  updatedAt: Date.now(),
40
40
  sourceIds: ["test"],
41
+ nextCursor: null,
42
+ loadingMore: false,
41
43
  };
42
44
  }
43
45
 
@@ -52,6 +52,8 @@ function createReadyNewsService(articles: NewsArticle[]): { service: NewsService
52
52
  error: null,
53
53
  updatedAt: Date.now(),
54
54
  sourceIds: ["test"],
55
+ nextCursor: null,
56
+ loadingMore: false,
55
57
  };
56
58
  const listeners = new Set<(state: NewsQueryState) => void>();
57
59
  let queryStateCalls = 0;
@@ -1,6 +1,6 @@
1
1
  import { Box } from "../../../../../ui";
2
2
  import type { PaneProps } from "../../../../../types/plugin";
3
- import { useLoadNewsStory, useNewsArticles } from "../../../../../news/hooks";
3
+ import { useLoadNewsStory, useNewsArticles, useNewsTableLoadMore } from "../../../../../news/hooks";
4
4
  import { useDebouncedPluginPaneState, usePluginPaneState } from "../../../../runtime";
5
5
  import { NewsDetailView, useNewsArticleDetail } from "../news/detail-view";
6
6
  import {
@@ -18,6 +18,7 @@ const DEFAULT_SORT: NewsSortPreference = { columnId: "importance", direction: "d
18
18
  export function BreakingPane({ focused, width, height }: PaneProps) {
19
19
  const breakingState = useNewsArticles(NEWS_QUERY_PRESETS.breaking);
20
20
  const articles = usePersistedNewsArticles("breaking:articles", breakingState.articles);
21
+ const { scrollRef, onBodyScrollActivity } = useNewsTableLoadMore(NEWS_QUERY_PRESETS.breaking, breakingState);
21
22
  const loading = breakingState.phase === "loading"
22
23
  || (breakingState.phase === "refreshing" && articles.length === 0);
23
24
  const error = breakingState.error;
@@ -73,6 +74,8 @@ export function BreakingPane({ focused, width, height }: PaneProps) {
73
74
  })}
74
75
  emptyStateTitle="No breaking news"
75
76
  emptyStateHint="Breaking stories appear when high-priority headlines arrive."
77
+ scrollRef={scrollRef}
78
+ onBodyScrollActivity={onBodyScrollActivity}
76
79
  />
77
80
  );
78
81
  }
@@ -6,6 +6,7 @@ import {
6
6
  getEnabledNewsFeeds,
7
7
  loadNewsFeedSettings,
8
8
  removeUserNewsFeed,
9
+ saveNewsFeedSettings,
9
10
  setDefaultNewsFeedEnabled,
10
11
  updateUserNewsFeed,
11
12
  } from "./feed-config";
@@ -31,7 +32,7 @@ class MemoryConfigState implements PluginConfigState {
31
32
  }
32
33
 
33
34
  describe("news feed config", () => {
34
- test("normalizes legacy JSON feed storage and migrates disabled default feed names to ids", () => {
35
+ test("normalizes legacy JSON feed storage and migrates disabled default feed names to ids", async () => {
35
36
  const config = new MemoryConfigState();
36
37
  config.values.set("feeds", JSON.stringify([
37
38
  { url: "https://example.com/rss.xml", name: "Example", authority: 120 },
@@ -41,10 +42,23 @@ describe("news feed config", () => {
41
42
 
42
43
  const settings = loadNewsFeedSettings(config);
43
44
 
45
+ expect(settings.needsMigration).toBe(true);
44
46
  expect(settings.userFeeds).toHaveLength(1);
45
47
  expect(settings.userFeeds[0]!.id).toMatch(/^user-/);
46
48
  expect(settings.userFeeds[0]!.authority).toBe(100);
47
49
  expect(settings.disabledDefaultFeedIds).toEqual(["default-cnbc-top"]);
50
+
51
+ await saveNewsFeedSettings(config, settings);
52
+ expect(loadNewsFeedSettings(config).needsMigration).toBe(false);
53
+ });
54
+
55
+ test("does not rewrite canonical feed settings", async () => {
56
+ const config = new MemoryConfigState();
57
+ await addUserNewsFeed(config, {
58
+ url: "https://example.com/feed",
59
+ name: "Example",
60
+ });
61
+ expect(loadNewsFeedSettings(config).needsMigration).toBe(false);
48
62
  });
49
63
 
50
64
  test("adds, updates, and removes user feeds through typed helpers", async () => {
@@ -12,7 +12,7 @@ const DEFAULT_FEED_IDS = new Set(DEFAULT_FEEDS.map((feed) => feed.id));
12
12
  export interface NewsFeedSettings {
13
13
  userFeeds: RssFeedConfig[];
14
14
  disabledDefaultFeedIds: string[];
15
- migrated: boolean;
15
+ needsMigration: boolean;
16
16
  }
17
17
 
18
18
  export interface UserNewsFeedInput {
@@ -109,6 +109,26 @@ function normalizeDisabledDefaultFeedIds(values: unknown[]): string[] {
109
109
  return [...ids];
110
110
  }
111
111
 
112
+ function sameUserFeeds(raw: unknown, normalized: RssFeedConfig[]): boolean {
113
+ if (!Array.isArray(raw) || raw.length !== normalized.length) return false;
114
+ return raw.every((entry, index) => {
115
+ const feed = normalized[index];
116
+ if (!feed || !entry || typeof entry !== "object") return false;
117
+ const record = entry as Record<string, unknown>;
118
+ return record.id === feed.id
119
+ && record.url === feed.url
120
+ && record.name === feed.name
121
+ && record.category === feed.category
122
+ && record.authority === feed.authority
123
+ && record.enabled === feed.enabled;
124
+ });
125
+ }
126
+
127
+ function sameIdList(raw: unknown, normalized: string[]): boolean {
128
+ if (!Array.isArray(raw) || raw.length !== normalized.length) return false;
129
+ return raw.every((value, index) => value === normalized[index]);
130
+ }
131
+
112
132
  export function loadNewsFeedSettings(configState: PluginConfigState): NewsFeedSettings {
113
133
  const rawUserFeeds = configState.get<unknown>(USER_FEEDS_KEY);
114
134
  const userFeedValues = parseJsonArray(rawUserFeeds);
@@ -123,11 +143,11 @@ export function loadNewsFeedSettings(configState: PluginConfigState): NewsFeedSe
123
143
  ...parseJsonArray(rawLegacyDisabled),
124
144
  ]);
125
145
 
126
- const migrated = rawUserFeeds !== null
127
- || rawDisabledIds !== null
128
- || rawLegacyDisabled !== null;
146
+ const needsMigration = rawLegacyDisabled !== null
147
+ || (rawUserFeeds !== null && !sameUserFeeds(rawUserFeeds, userFeeds))
148
+ || (rawDisabledIds !== null && !sameIdList(rawDisabledIds, disabledDefaultFeedIds));
129
149
 
130
- return { userFeeds, disabledDefaultFeedIds, migrated };
150
+ return { userFeeds, disabledDefaultFeedIds, needsMigration };
131
151
  }
132
152
 
133
153
  export async function saveNewsFeedSettings(
@@ -41,13 +41,13 @@ function createNewsPresetPane(config: NewsPresetPaneConfig) {
41
41
  }
42
42
 
43
43
  const TopPane = createNewsPresetPane({
44
- paneKey: "top",
44
+ paneKey: "top:curated",
45
45
  title: "Top News",
46
46
  query: NEWS_QUERY_PRESETS.top,
47
47
  columns: ["time", "source", "title", "tickers", "categories", "importance"],
48
48
  defaultSort: { columnId: "importance", direction: "desc" },
49
49
  emptyStateTitle: "No top stories yet",
50
- emptyStateHint: "Run the Add News Feed command to wire up another source.",
50
+ emptyStateHint: "Top stories appear when curated market sources publish them.",
51
51
  });
52
52
 
53
53
  const FeedPane = createNewsPresetPane({
@@ -95,7 +95,7 @@ export const newsWireModule: PluginModule = {
95
95
  ],
96
96
  setup(ctx) {
97
97
  const initialSettings = loadNewsFeedSettings(ctx.configState);
98
- if (initialSettings.migrated) {
98
+ if (initialSettings.needsMigration) {
99
99
  void saveNewsFeedSettings(ctx.configState, initialSettings);
100
100
  }
101
101
 
@@ -2,7 +2,7 @@ import { Box } from "../../../../ui";
2
2
  import { useEffect, useMemo } from "react";
3
3
  import type { PaneProps } from "../../../../types/plugin";
4
4
  import type { MarketNewsItem } from "../../../../types/news-source";
5
- import { useLoadNewsStory, useNewsArticles } from "../../../../news/hooks";
5
+ import { useLoadNewsStory, useNewsArticles, useNewsTableLoadMore } from "../../../../news/hooks";
6
6
  import { useDebouncedPluginPaneState, usePluginPaneState } from "../../../runtime";
7
7
  import { Tabs } from "../../../../components";
8
8
  import { NewsDetailView, useNewsArticleDetail } from "./news/detail-view";
@@ -34,6 +34,7 @@ function useIndustryArticles(sector: SectorNewsSelection): {
34
34
  allArticles: MarketNewsItem[];
35
35
  loading: boolean;
36
36
  error: string | null;
37
+ newsState: ReturnType<typeof useNewsArticles>;
37
38
  } {
38
39
  const allState = useNewsArticles(NEWS_QUERY_PRESETS.sectorAll);
39
40
  const allArticles = usePersistedNewsArticles("industry:sector:all:articles", allState.articles);
@@ -50,6 +51,7 @@ function useIndustryArticles(sector: SectorNewsSelection): {
50
51
  loading: allState.phase === "loading"
51
52
  || (allState.phase === "refreshing" && allArticles.length === 0),
52
53
  error: allState.error,
54
+ newsState: allState,
53
55
  };
54
56
  }
55
57
 
@@ -57,7 +59,8 @@ export function IndustryPane({ focused, width, height }: PaneProps) {
57
59
  const [category, setCategory] = usePluginPaneState<SectorNewsSelection>("industry:category", "all");
58
60
  const [selectedArticleId, setSelectedArticleId] = useDebouncedPluginPaneState<string | null>("industry:selectedArticleId", null);
59
61
  const [sortPreference, setSortPreference] = usePluginPaneState<NewsSortPreference>("industry:sort", DEFAULT_SORT);
60
- const { articles, allArticles, loading, error } = useIndustryArticles(category);
62
+ const { articles, allArticles, loading, error, newsState } = useIndustryArticles(category);
63
+ const { scrollRef, onBodyScrollActivity } = useNewsTableLoadMore(NEWS_QUERY_PRESETS.sectorAll, newsState);
61
64
  const loadNewsStory = useLoadNewsStory();
62
65
  const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles, loadNewsStory);
63
66
  const { readArticleIds, markArticleRead } = useNewsReadState();
@@ -140,6 +143,8 @@ export function IndustryPane({ focused, width, height }: PaneProps) {
140
143
  })}
141
144
  emptyStateTitle="No news in this category"
142
145
  emptyStateHint="Try another category or wait for the next feed refresh."
146
+ scrollRef={scrollRef}
147
+ onBodyScrollActivity={onBodyScrollActivity}
143
148
  />
144
149
  );
145
150
  }
@@ -1,14 +1,21 @@
1
- import { useMemo } from "react";
1
+ import { useCallback, useMemo } from "react";
2
2
  import type { PaneFooterSegment } from "../../../../../components";
3
3
  import { t, tf } from "../../../../../i18n";
4
4
  import { useAppLanguage } from "../../../../../i18n/react";
5
+ import { useShortcut } from "../../../../../react/input";
6
+ import { useUiCapabilities } from "../../../../../ui";
7
+ import { isPlainKey } from "../../../../../utils/keyboard";
5
8
  import { useCloudAccessFooter } from "../../../shared/cloud-upgrade";
6
9
  import { CLOUD_NEWS_DELAY_HOURS } from "../../../shared/plan-access";
7
10
  import { usePaneStatusLinkFooter } from "../../../shared/pane-footer";
11
+ import { usePublicShare } from "../../../shared/public-share";
8
12
 
9
13
  interface NewsFooterArticle {
14
+ title?: string | null;
15
+ summary?: string | null;
10
16
  source?: string | null;
11
17
  url?: string | null;
18
+ items?: Array<{ title?: string | null; summary?: string | null }>;
12
19
  }
13
20
 
14
21
  interface UseNewsArticleFooterOptions {
@@ -29,6 +36,29 @@ export function useNewsArticleFooter({
29
36
  error,
30
37
  }: UseNewsArticleFooterOptions) {
31
38
  const language = useAppLanguage();
39
+ const { publicSharing } = useUiCapabilities();
40
+ const createPublicShare = usePublicShare();
41
+ const shareArticle = useCallback(() => {
42
+ if (!article?.title) return;
43
+ const text = [
44
+ article.summary,
45
+ ...(article.items ?? []).map((item) => item.summary || item.title),
46
+ ].filter((value): value is string => !!value?.trim()).join("\n\n").slice(0, 50_000);
47
+ void createPublicShare({
48
+ kind: "article",
49
+ data: {
50
+ title: article.title,
51
+ text,
52
+ ...(article.url ? { sourceUrl: article.url } : {}),
53
+ },
54
+ });
55
+ }, [article, createPublicShare]);
56
+ useShortcut((event) => {
57
+ if (!focused || !publicSharing || !article?.title || !isPlainKey(event, "y")) return;
58
+ event.preventDefault();
59
+ event.stopPropagation();
60
+ shareArticle();
61
+ });
32
62
  const { access, segment } = useCloudAccessFooter({
33
63
  delayLabel: tf("{count}h", { count: CLOUD_NEWS_DELAY_HOURS }),
34
64
  focused,
@@ -50,6 +80,9 @@ export function useNewsArticleFooter({
50
80
  url: article?.url,
51
81
  source: article?.source,
52
82
  info: footerInfo,
83
+ hints: publicSharing && article?.title
84
+ ? [{ id: "share", key: "y", label: " share", onPress: shareArticle }]
85
+ : undefined,
53
86
  showOpenHint: true,
54
87
  loading,
55
88
  error,
@@ -1,6 +1,6 @@
1
1
  import { Box } from "../../../../../ui";
2
2
  import type { NewsQuery } from "../../../../../news/types";
3
- import { useLoadNewsStory, useNewsArticles } from "../../../../../news/hooks";
3
+ import { useLoadNewsStory, useNewsArticles, useNewsTableLoadMore } from "../../../../../news/hooks";
4
4
  import type { PaneProps } from "../../../../../types/plugin";
5
5
  import { useDebouncedPluginPaneState, usePluginPaneState } from "../../../../runtime";
6
6
  import { NewsDetailView, useNewsArticleDetail } from "./detail-view";
@@ -36,6 +36,7 @@ export function NewsPresetPane({
36
36
  }) {
37
37
  const newsState = useNewsArticles(query);
38
38
  const articles = usePersistedNewsArticles(`${paneKey}:articles`, newsState.articles);
39
+ const { scrollRef, onBodyScrollActivity } = useNewsTableLoadMore(query, newsState);
39
40
  // The aggregator opens a query in "loading", so the first paint is a loading
40
41
  // body rather than a definitive empty wire.
41
42
  const loading = newsState.phase === "loading"
@@ -102,6 +103,8 @@ export function NewsPresetPane({
102
103
  })}
103
104
  emptyStateTitle={emptyStateTitle}
104
105
  emptyStateHint={emptyStateHint}
106
+ scrollRef={scrollRef}
107
+ onBodyScrollActivity={onBodyScrollActivity}
105
108
  />
106
109
  );
107
110
  }
@@ -1,5 +1,5 @@
1
- import { useCallback, useEffect, useMemo, type ReactNode } from "react";
2
- import { TextAttributes } from "../../../../../ui";
1
+ import { useCallback, useEffect, useMemo, type ReactNode, type RefObject } from "react";
2
+ import { TextAttributes, type ScrollBoxRenderable } from "../../../../../ui";
3
3
  import {
4
4
  DataTableStackView,
5
5
  PaneStatusBody,
@@ -96,6 +96,8 @@ interface NewsArticleStackBaseProps {
96
96
  emptyStateTitle: string;
97
97
  emptyStateHint?: string;
98
98
  titleForArticle?: (article: MarketNewsItem) => string;
99
+ scrollRef?: RefObject<ScrollBoxRenderable | null>;
100
+ onBodyScrollActivity?: () => void;
99
101
  }
100
102
 
101
103
  function compareText(a: string, b: string): number {
@@ -223,6 +225,8 @@ export function NewsArticleStackView({
223
225
  onArticleRead,
224
226
  detailOpen,
225
227
  onBack,
228
+ scrollRef,
229
+ onBodyScrollActivity,
226
230
  detailContent,
227
231
  detailTitle,
228
232
  rootBefore,
@@ -349,6 +353,8 @@ export function NewsArticleStackView({
349
353
  emptyStateTitle={emptyStateTitle}
350
354
  emptyStateHint={emptyStateHint}
351
355
  showHorizontalScrollbar={false}
356
+ scrollRef={scrollRef}
357
+ onBodyScrollActivity={onBodyScrollActivity}
352
358
  />
353
359
  );
354
360
  }
@@ -20,6 +20,18 @@ const RSS_FIXTURE = `<rss version="2.0"><channel><item>
20
20
  </item></channel></rss>`;
21
21
 
22
22
  describe("createRssNewsCapability", () => {
23
+ test("does not serve unclassified RSS stories as top news", async () => {
24
+ const fetchText = mock(async () => ({
25
+ ok: true,
26
+ text: async () => RSS_FIXTURE,
27
+ }));
28
+ const source = createRssNewsCapability([FEED], { fetchText });
29
+
30
+ expect(source.provider.supports?.({ feed: "top" })).toBe(false);
31
+ expect(await source.provider.fetchNews({ feed: "top" })).toEqual([]);
32
+ expect(fetchText).not.toHaveBeenCalled();
33
+ });
34
+
23
35
  test("caches fetched feed items with feed authority scoring", async () => {
24
36
  const persistence = new MemoryPersistence();
25
37
  const fetchText = mock(async () => ({
@@ -37,7 +37,7 @@ export interface RssNewsCapabilityOptions {
37
37
 
38
38
  function supportsQuery(query: NewsQuery): boolean {
39
39
  const feed = query.feed ?? (query.scope === "ticker" ? "ticker" : "latest");
40
- return feed === "latest" || feed === "top";
40
+ return feed === "latest";
41
41
  }
42
42
 
43
43
  function serializeItem(item: MarketNewsItem): CachedNewsItem {
@@ -4,7 +4,15 @@ export interface QuickNoteEntry {
4
4
  updatedAt?: number;
5
5
  }
6
6
 
7
+ /** A stored note: `key` is what `load`/`save` take, not the file path. */
8
+ export interface NoteFileEntry {
9
+ key: string;
10
+ text: string;
11
+ updatedAt: number;
12
+ }
13
+
7
14
  const QUICK_NOTES_INDEX = "__quick-notes-index__";
15
+ const LOCAL_TIMESTAMP_KEY = "gloomberb:notes:__updated-at__";
8
16
 
9
17
  function joinPath(...parts: string[]): string {
10
18
  return parts.join("/").replace(/\/+/g, "/");
@@ -29,6 +37,28 @@ async function readTextFile(path: string): Promise<string> {
29
37
  return getLocalStorage()?.getItem(`gloomberb:notes:${path}`) ?? "";
30
38
  }
31
39
 
40
+ /** Browsers have no mtime, so note writes keep their own timestamp index. */
41
+ function readLocalTimestamps(): Record<string, number> {
42
+ try {
43
+ const raw = getLocalStorage()?.getItem(LOCAL_TIMESTAMP_KEY);
44
+ const parsed: unknown = raw ? JSON.parse(raw) : null;
45
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
46
+ ? parsed as Record<string, number>
47
+ : {};
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ function writeLocalTimestamp(path: string, updatedAt: number | null): void {
54
+ const storage = getLocalStorage();
55
+ if (!storage) return;
56
+ const timestamps = readLocalTimestamps();
57
+ if (updatedAt == null) delete timestamps[path];
58
+ else timestamps[path] = updatedAt;
59
+ try { storage.setItem(LOCAL_TIMESTAMP_KEY, JSON.stringify(timestamps)); } catch {}
60
+ }
61
+
32
62
  async function writeTextFile(path: string, value: string): Promise<void> {
33
63
  if (typeof Bun !== "undefined") {
34
64
  const fsModulePath = "fs/promises";
@@ -37,6 +67,7 @@ async function writeTextFile(path: string, value: string): Promise<void> {
37
67
  return;
38
68
  }
39
69
  getLocalStorage()?.setItem(`gloomberb:notes:${path}`, value);
70
+ writeLocalTimestamp(path, Date.now());
40
71
  }
41
72
 
42
73
  async function deleteTextFile(path: string): Promise<void> {
@@ -47,6 +78,7 @@ async function deleteTextFile(path: string): Promise<void> {
47
78
  return;
48
79
  }
49
80
  getLocalStorage()?.removeItem(`gloomberb:notes:${path}`);
81
+ writeLocalTimestamp(path, null);
50
82
  }
51
83
 
52
84
  export class NotesFiles {
@@ -82,6 +114,54 @@ export class NotesFiles {
82
114
  }
83
115
  }
84
116
 
117
+ /** Every stored note, ticker notes and quick notes alike, newest first. */
118
+ async list(): Promise<NoteFileEntry[]> {
119
+ const entries: NoteFileEntry[] = [];
120
+ for (const [key, updatedAt] of await this.listKeys()) {
121
+ try {
122
+ entries.push({ key, text: await this.load(key), updatedAt });
123
+ } catch {
124
+ // An unreadable note is skipped rather than synced as empty.
125
+ }
126
+ }
127
+ return entries.sort((left, right) => right.updatedAt - left.updatedAt);
128
+ }
129
+
130
+ private async listKeys(): Promise<Array<[string, number]>> {
131
+ if (typeof Bun !== "undefined") {
132
+ const fsModulePath = "fs/promises";
133
+ const { readdir, stat } = await import(fsModulePath) as typeof import("fs/promises");
134
+ let names: string[];
135
+ try {
136
+ names = await readdir(this.dataDir);
137
+ } catch {
138
+ return [];
139
+ }
140
+ const keys: Array<[string, number]> = [];
141
+ for (const name of names) {
142
+ if (!name.endsWith(".md")) continue;
143
+ try {
144
+ const info = await stat(joinPath(this.dataDir, name));
145
+ keys.push([name.slice(0, -3), Math.round(info.mtimeMs)]);
146
+ } catch {}
147
+ }
148
+ return keys;
149
+ }
150
+ const prefix = joinPath(this.dataDir, "");
151
+ const timestamps = readLocalTimestamps();
152
+ const storageKeyPrefix = `gloomberb:notes:${prefix}`;
153
+ // Notes written before the index existed are stamped now rather than at
154
+ // the epoch, so an older cloud copy cannot overwrite them on first sync.
155
+ for (const storageKey of Object.keys(globalThis.localStorage ?? {})) {
156
+ if (!storageKey.startsWith(storageKeyPrefix) || !storageKey.endsWith(".md")) continue;
157
+ const path = storageKey.slice("gloomberb:notes:".length);
158
+ if (timestamps[path] == null) writeLocalTimestamp(path, timestamps[path] = Date.now());
159
+ }
160
+ return Object.entries(timestamps)
161
+ .filter(([path]) => path.startsWith(prefix) && path.endsWith(".md"))
162
+ .map(([path, updatedAt]) => [path.slice(prefix.length, -3), updatedAt]);
163
+ }
164
+
85
165
  private indexPath(): string {
86
166
  return joinPath(this.dataDir, `${QUICK_NOTES_INDEX}.json`);
87
167
  }
@@ -1,6 +1,7 @@
1
1
  import type { GloomPlugin } from "../../../types/plugin";
2
2
  import { NotesFiles } from "./files";
3
3
  import { createQuickNotesPane } from "./quick-notes-pane";
4
+ import { createNotesSyncContributor } from "./sync";
4
5
  import { createNotesTab } from "./ticker-notes-tab";
5
6
 
6
7
  export const notesPlugin: GloomPlugin = {
@@ -22,6 +23,8 @@ export const notesPlugin: GloomPlugin = {
22
23
  });
23
24
  });
24
25
 
26
+ ctx.registerSyncContributor(createNotesSyncContributor(notesFiles));
27
+
25
28
  ctx.registerTickerResearchTab({
26
29
  id: "notes",
27
30
  name: "Notes",
@@ -0,0 +1,49 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import type { SyncApplyContext } from "../../../sync/types";
6
+ import { NotesFiles } from "./files";
7
+ import { createNotesSyncContributor } from "./sync";
8
+
9
+ const directories: string[] = [];
10
+
11
+ afterEach(async () => {
12
+ for (const directory of directories.splice(0)) {
13
+ await rm(directory, { recursive: true, force: true });
14
+ }
15
+ });
16
+
17
+ async function createNotesFiles(): Promise<NotesFiles> {
18
+ const directory = await mkdtemp(join(tmpdir(), "gloomberb-notes-sync-"));
19
+ directories.push(directory);
20
+ return new NotesFiles(directory);
21
+ }
22
+
23
+ const applyContext = { isCurrent: () => true } as unknown as SyncApplyContext;
24
+
25
+ test("resolves each note to whichever side wrote it last", async () => {
26
+ const notesFiles = await createNotesFiles();
27
+ await notesFiles.save("AAPL", "local aapl");
28
+ await notesFiles.save("MSFT", "local msft");
29
+ const contributor = createNotesSyncContributor(notesFiles);
30
+
31
+ await contributor.apply?.({
32
+ notes: [
33
+ { key: "AAPL", text: "remote aapl", updatedAt: Date.now() + 60_000 },
34
+ { key: "MSFT", text: "remote msft", updatedAt: Date.now() - 60_000 },
35
+ { key: "TSLA", text: "remote tsla", updatedAt: Date.now() },
36
+ ],
37
+ quickNotes: [{ id: "note-1", title: "Remote", updatedAt: Date.now() }],
38
+ }, applyContext);
39
+
40
+ expect(await notesFiles.load("AAPL")).toBe("remote aapl");
41
+ expect(await notesFiles.load("MSFT")).toBe("local msft");
42
+ expect(await notesFiles.load("TSLA")).toBe("remote tsla");
43
+ expect(await notesFiles.loadQuickNotesIndex()).toEqual([
44
+ { id: "note-1", title: "Remote", updatedAt: expect.any(Number) },
45
+ ]);
46
+
47
+ const payload = await contributor.collect({} as never) as { notes: Array<{ key: string }> };
48
+ expect(payload.notes.map((note) => note.key).sort()).toEqual(["AAPL", "MSFT", "TSLA"]);
49
+ });