gloomberb 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/README.md +106 -54
  2. package/package.json +14 -13
  3. package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
  4. package/src/api-client/chat.ts +8 -0
  5. package/src/api-client/index.test.ts +30 -0
  6. package/src/api-client/index.ts +4 -0
  7. package/src/api-client/normalizers.ts +1 -0
  8. package/src/api-client/types.ts +1 -0
  9. package/src/app/global-shortcuts.test.tsx +54 -4
  10. package/src/app/global-shortcuts.ts +11 -5
  11. package/src/capabilities/factories.ts +4 -0
  12. package/src/components/chart/comparison/data.test.ts +54 -0
  13. package/src/components/chart/comparison/data.ts +47 -24
  14. package/src/components/chart/core/projection.ts +51 -27
  15. package/src/components/command-bar/action-runtime.ts +1 -0
  16. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  17. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  18. package/src/components/command-bar/list/view.tsx +3 -1
  19. package/src/components/command-bar/pane-templates/items.ts +4 -1
  20. package/src/components/command-bar/panel/body.tsx +1 -0
  21. package/src/components/command-bar/routes/root/results.ts +0 -30
  22. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  23. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  24. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  25. package/src/components/command-bar/routing/launch-request.ts +14 -7
  26. package/src/components/command-bar/routing/list-state.ts +1 -3
  27. package/src/components/command-bar/surface/index.test.tsx +76 -4
  28. package/src/components/command-bar/surface/index.tsx +0 -2
  29. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  30. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  31. package/src/components/command-bar/view-model.ts +3 -3
  32. package/src/components/data-table/stack-view.test.tsx +14 -11
  33. package/src/components/data-table/stack-view.tsx +1 -2
  34. package/src/components/data-table/view.test.tsx +24 -18
  35. package/src/components/data-table/view.tsx +313 -83
  36. package/src/components/feed-data-table/stack-view.tsx +6 -8
  37. package/src/components/layout/pane/footer/index.test.tsx +6 -0
  38. package/src/components/layout/pane/footer/index.tsx +14 -2
  39. package/src/components/layout/pane/title.ts +5 -1
  40. package/src/components/layout/shell/index.test.tsx +120 -2
  41. package/src/components/layout/shell/index.tsx +2 -0
  42. package/src/components/layout/shell/menu.ts +1 -7
  43. package/src/components/layout/shell/pane/actions.ts +8 -0
  44. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  45. package/src/components/layout/shell/shortcuts.ts +14 -6
  46. package/src/components/layout/shell/window-mode/index.ts +8 -3
  47. package/src/components/layout/window-edit/presentation.ts +4 -4
  48. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  49. package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
  50. package/src/components/pane-settings-dialog/tui.tsx +1 -1
  51. package/src/components/ticker/list-table-view.tsx +17 -25
  52. package/src/components/ui/choice-dialog.tsx +12 -2
  53. package/src/components/ui/multi-select/dialog.tsx +17 -5
  54. package/src/components/ui/remote-image.tsx +1 -14
  55. package/src/components/ui/shortcut-hint.tsx +14 -2
  56. package/src/components/ui/table-layout.test.ts +2 -2
  57. package/src/components/ui/table-layout.ts +8 -2
  58. package/src/components/ui/tabs.tsx +115 -86
  59. package/src/components/ui/ui.test.tsx +83 -4
  60. package/src/core/state/app/types.ts +12 -4
  61. package/src/market-data/coordinator/auxiliary.ts +29 -1
  62. package/src/market-data/coordinator/index.ts +17 -1
  63. package/src/market-data/hooks.tsx +17 -1
  64. package/src/market-data/selectors.ts +4 -0
  65. package/src/plugins/builtin/account-management/form-components.tsx +0 -4
  66. package/src/plugins/builtin/account-management/model.test.ts +54 -0
  67. package/src/plugins/builtin/account-management/model.ts +13 -3
  68. package/src/plugins/builtin/account-management/pane.tsx +5 -11
  69. package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
  70. package/src/plugins/builtin/alerts/pane.tsx +5 -3
  71. package/src/plugins/builtin/analytics/index.tsx +0 -3
  72. package/src/plugins/builtin/analytics/view.tsx +6 -5
  73. package/src/plugins/builtin/broker-manager/index.tsx +6 -6
  74. package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
  75. package/src/plugins/builtin/changelog/index.tsx +40 -27
  76. package/src/plugins/builtin/changelog/model.test.ts +77 -0
  77. package/src/plugins/builtin/changelog/model.ts +98 -0
  78. package/src/plugins/builtin/chat/channel-labels.ts +53 -0
  79. package/src/plugins/builtin/chat/channels.test.ts +28 -0
  80. package/src/plugins/builtin/chat/channels.ts +36 -19
  81. package/src/plugins/builtin/chat/chat.test.tsx +264 -5
  82. package/src/plugins/builtin/chat/content/channel-navigation.ts +50 -11
  83. package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
  84. package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
  85. package/src/plugins/builtin/chat/content/composer.tsx +23 -2
  86. package/src/plugins/builtin/chat/content/index.tsx +66 -4
  87. package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
  88. package/src/plugins/builtin/chat/content/shortcuts.ts +27 -1
  89. package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
  90. package/src/plugins/builtin/chat/content/types.ts +1 -0
  91. package/src/plugins/builtin/chat/controller/index.ts +41 -0
  92. package/src/plugins/builtin/chat/controller/messages.ts +18 -0
  93. package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
  94. package/src/plugins/builtin/chat/controller/utils.ts +6 -3
  95. package/src/plugins/builtin/chat/controller/view.ts +1 -1
  96. package/src/plugins/builtin/chat/controller.test.ts +189 -0
  97. package/src/plugins/builtin/chat/edit-window.ts +24 -0
  98. package/src/plugins/builtin/chat/layout.ts +94 -31
  99. package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
  100. package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
  101. package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
  102. package/src/plugins/builtin/chat/message/render-state.ts +5 -1
  103. package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
  104. package/src/plugins/builtin/chat/message/types.ts +2 -0
  105. package/src/plugins/builtin/chat/pane.tsx +35 -7
  106. package/src/plugins/builtin/chat/sidebar.test.tsx +72 -0
  107. package/src/plugins/builtin/chat/status-widget.tsx +29 -11
  108. package/src/plugins/builtin/chat/test-harness.tsx +2 -0
  109. package/src/plugins/builtin/cloud/plugin.tsx +3 -0
  110. package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
  111. package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
  112. package/src/plugins/builtin/earnings/index.tsx +8 -7
  113. package/src/plugins/builtin/econ/index.tsx +6 -8
  114. package/src/plugins/builtin/help/components.tsx +1 -1
  115. package/src/plugins/builtin/help/index.test.tsx +105 -30
  116. package/src/plugins/builtin/help/index.tsx +326 -164
  117. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  118. package/src/plugins/builtin/holders/pane.tsx +7 -6
  119. package/src/plugins/builtin/market-movers/index.tsx +6 -3
  120. package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
  121. package/src/plugins/builtin/options/view.tsx +16 -8
  122. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  123. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  124. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  125. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  126. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  127. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  128. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  129. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  130. package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
  131. package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
  132. package/src/plugins/builtin/research/index.test.ts +30 -1
  133. package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
  134. package/src/plugins/builtin/sec/filing-documents.ts +66 -0
  135. package/src/plugins/builtin/sec/index.tsx +132 -20
  136. package/src/plugins/builtin/sectors/index.tsx +13 -23
  137. package/src/plugins/builtin/shared/ticker-request.ts +1 -0
  138. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  139. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  140. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  141. package/src/plugins/builtin/substack/api/store.ts +186 -0
  142. package/src/plugins/builtin/substack/api/types.ts +40 -0
  143. package/src/plugins/builtin/substack/api.test.ts +298 -0
  144. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  145. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  146. package/src/plugins/builtin/substack/content.ts +478 -0
  147. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  148. package/src/plugins/builtin/substack/index.tsx +49 -0
  149. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  150. package/src/plugins/builtin/substack/model.test.ts +155 -0
  151. package/src/plugins/builtin/substack/normalize.ts +343 -0
  152. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  153. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  154. package/src/plugins/builtin/substack/pane.tsx +500 -0
  155. package/src/plugins/builtin/substack/table.ts +111 -0
  156. package/src/plugins/builtin/substack/types.ts +71 -0
  157. package/src/plugins/builtin/substack/utils.ts +131 -0
  158. package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
  159. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
  160. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
  161. package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
  162. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
  163. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  164. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  165. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  166. package/src/plugins/builtin/world-indices/index.tsx +9 -6
  167. package/src/plugins/catalog-ui.ts +2 -0
  168. package/src/plugins/pane-manager/layout-state.ts +11 -0
  169. package/src/plugins/pane-manager.ts +1 -1
  170. package/src/plugins/prediction-markets/pane.tsx +10 -9
  171. package/src/plugins/registry/index.ts +3 -0
  172. package/src/plugins/runtime/context.tsx +1 -0
  173. package/src/plugins/runtime/index.tsx +4 -0
  174. package/src/react/use-throttled-commit-value.ts +116 -0
  175. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  176. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  177. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  178. package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
  179. package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
  180. package/src/renderers/electrobun/view/host/input.tsx +23 -2
  181. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  182. package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
  183. package/src/sources/gloomberb-cloud/index.ts +4 -0
  184. package/src/sources/provider-router/cache.ts +1 -0
  185. package/src/sources/provider-router/document-routes.ts +45 -0
  186. package/src/sources/provider-router/index.ts +4 -0
  187. package/src/sources/sec-edgar/content.ts +9 -3
  188. package/src/sources/sec-edgar.test.ts +108 -1
  189. package/src/sources/sec-edgar.ts +105 -1
  190. package/src/sources/yahoo-finance.ts +4 -0
  191. package/src/state/app/context/index.test.tsx +16 -0
  192. package/src/test-support/plugin-runtime.ts +1 -0
  193. package/src/tickers/search/index.ts +1 -0
  194. package/src/types/data-provider.ts +11 -0
  195. package/src/ui/host.tsx +1 -0
  196. package/src/version.ts +1 -1
@@ -0,0 +1,155 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { extractArticleContent } from "./content";
3
+ import {
4
+ articleMatchesPublication,
5
+ normalizeFeedItems,
6
+ normalizePostDetail,
7
+ normalizeSubscriptions,
8
+ sortSubscriptionsByLatest,
9
+ } from "./normalize";
10
+
11
+ describe("Substack article extraction", () => {
12
+ test("keeps article structure while removing chrome and image-only links", () => {
13
+ const attrs = JSON.stringify({
14
+ url: "https://x.com/jukan05/status/2062809398284812532?s=46",
15
+ full_text: "* Important: SK hynix has no plans to add any new NAND flash capacity.\n\n=> Pure-play NAND manufacturers add capacity.",
16
+ username: "jukan05",
17
+ name: "Jukan @COMPUTEX",
18
+ date: "2026-06-05T08:11:00.000Z",
19
+ photos: [{ img_url: "https://pbs.substack.com/media/HKCSNYaa8AAq-06.jpg" }],
20
+ quoted_tweet: {
21
+ full_text: "[Exclusive] Jensen Huang asked Hynix to make more.",
22
+ username: "jukan05",
23
+ },
24
+ }).replace(/"/g, """);
25
+ const content = extractArticleContent(`
26
+ <article>
27
+ <script>window.bad = true</script>
28
+ <h1>Ignored detail title</h1>
29
+ <h2>Victory Giant: -11% as US weighs subsidies</h2>
30
+ <p>We bought <a href="/p/company">$NVDA</a> after earnings.</p>
31
+ <div class="twitter-embed" data-attrs="${attrs}" data-component-name="Twitter2ToDOM"></div>
32
+ <blockquote>Seems very knee jerkish - subsidies seem very small.</blockquote>
33
+ <figure>
34
+ <a href="https://substackcdn.com/image/fetch/$s_!abc/f_auto,q_auto:good/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fchart.png">image</a>
35
+ <img src="/company-card.png" alt="CQME chart" />
36
+ </figure>
37
+ <p>See <a href="https://example.com/report">the report</a>.</p>
38
+ </article>
39
+ `, {
40
+ baseUrl: "https://collyerbridge.substack.com",
41
+ title: "Ignored detail title",
42
+ });
43
+
44
+ expect(content.text).toContain("$NVDA");
45
+ expect(content.text).toContain("Seems very knee jerkish");
46
+ expect(content.text).not.toContain("Ignored detail title");
47
+ expect(content.text).not.toContain("window.bad");
48
+ expect(content.text).not.toContain("substackcdn.com/image/fetch");
49
+ expect(content.imageUrls).toEqual(["https://collyerbridge.substack.com/company-card.png"]);
50
+ expect(content.linkUrls).toEqual([
51
+ "https://collyerbridge.substack.com/p/company",
52
+ "https://example.com/report",
53
+ ]);
54
+ expect(content.blocks.map((block) => block.type)).toEqual([
55
+ "heading",
56
+ "paragraph",
57
+ "embed",
58
+ "quote",
59
+ "image",
60
+ "paragraph",
61
+ ]);
62
+ expect(content.blocks[2]).toEqual({
63
+ type: "embed",
64
+ kind: "tweet",
65
+ text: "* Important: SK hynix has no plans to add any new NAND flash capacity.\n\n=> Pure-play NAND manufacturers add capacity.\n\nQuoted: [Exclusive] Jensen Huang asked Hynix to make more.",
66
+ url: "https://x.com/jukan05/status/2062809398284812532?s=46",
67
+ username: "jukan05",
68
+ authorName: "Jukan @COMPUTEX",
69
+ dateLabel: "Jun 5, 2026",
70
+ imageUrls: ["https://pbs.substack.com/media/HKCSNYaa8AAq-06.jpg"],
71
+ });
72
+ });
73
+ });
74
+
75
+ describe("Substack feed normalization", () => {
76
+ test("normalizes reader rows and orders subscriptions by newest matching post", () => {
77
+ const subscriptions = normalizeSubscriptions({
78
+ result: {
79
+ subscriptions: [
80
+ { id: "sa", publication_id: "a" },
81
+ { id: "sb", publication_id: "b" },
82
+ ],
83
+ publicationMap: {
84
+ a: { id: "a", name: "Alpha", base_url: "https://alpha.example" },
85
+ b: { id: "b", name: "Beta", base_url: "https://beta.example" },
86
+ },
87
+ },
88
+ });
89
+ const feed = normalizeFeedItems({
90
+ items: [
91
+ {
92
+ post: {
93
+ id: "older",
94
+ title: "Older",
95
+ post_date: "2026-06-01T10:00:00Z",
96
+ publication: { id: "a", name: "Alpha", base_url: "https://alpha.example" },
97
+ },
98
+ },
99
+ {
100
+ post: {
101
+ id: "newer",
102
+ title: "Semis and $NVDA",
103
+ subtitle: "A quick note",
104
+ post_date: "2026-06-05T10:00:00Z",
105
+ canonical_url: "https://beta.example/p/nvda",
106
+ slug: "nvda",
107
+ wordcount: 560,
108
+ publication: { id: "b", name: "Beta", base_url: "https://beta.example" },
109
+ },
110
+ },
111
+ ],
112
+ });
113
+
114
+ const sorted = sortSubscriptionsByLatest(subscriptions, feed);
115
+ expect(feed[1]).toMatchObject({
116
+ id: "newer",
117
+ title: "Semis and $NVDA",
118
+ publicationName: "Beta",
119
+ publicationBaseUrl: "https://beta.example",
120
+ url: "https://beta.example/p/nvda",
121
+ readMinutes: 3,
122
+ });
123
+ expect(sorted.map((publication) => publication.name)).toEqual(["Beta", "Alpha"]);
124
+ expect(articleMatchesPublication(feed[1]!, sorted[0]!)).toBe(true);
125
+ });
126
+
127
+ test("merges fetched post body into an existing summary", () => {
128
+ const summary = normalizeFeedItems({
129
+ items: [
130
+ {
131
+ post: {
132
+ id: 99,
133
+ title: "Macro Links",
134
+ post_date: "2026-06-05T10:00:00Z",
135
+ publication: { id: "macro", name: "Macro", base_url: "https://macro.example" },
136
+ },
137
+ },
138
+ ],
139
+ })[0]!;
140
+
141
+ const detail = normalizePostDetail({
142
+ id: 99,
143
+ title: "Macro Links",
144
+ body_html: "<p>$SPY broke higher. <a href=\"/p/chart\">Chart</a></p>",
145
+ publication: { id: "macro", name: "Macro", base_url: "https://macro.example" },
146
+ }, summary);
147
+
148
+ expect(detail.contentText).toContain("$SPY");
149
+ expect(detail.linkUrls).toEqual(["https://macro.example/p/chart"]);
150
+ expect(detail.contentBlocks).toEqual([
151
+ { type: "paragraph", text: "$SPY broke higher. Chart" },
152
+ ]);
153
+ expect(detail.id).toBe("99");
154
+ });
155
+ });
@@ -0,0 +1,343 @@
1
+ import {
2
+ estimateReadingMinutes,
3
+ extractArticleContent,
4
+ wordCount,
5
+ } from "./content";
6
+ import type {
7
+ SubstackArticleDetail,
8
+ SubstackArticleSummary,
9
+ SubstackPublication,
10
+ } from "./types";
11
+ import {
12
+ asRecord,
13
+ firstNumber,
14
+ firstRawString,
15
+ firstRecord,
16
+ firstString,
17
+ firstValue,
18
+ maxIso,
19
+ normalizeUrl,
20
+ parseDateIso,
21
+ timestamp,
22
+ uniqueStrings,
23
+ type JsonRecord,
24
+ } from "./utils";
25
+
26
+ function extractPayloadArray(payload: unknown, preferredKeys: string[]): unknown[] {
27
+ if (Array.isArray(payload)) return payload;
28
+ const record = asRecord(payload);
29
+ if (!record) return [];
30
+ for (const key of preferredKeys) {
31
+ const value = record[key];
32
+ if (Array.isArray(value)) return value;
33
+ }
34
+ const data = asRecord(record.data);
35
+ if (data) {
36
+ for (const key of preferredKeys) {
37
+ const value = data[key];
38
+ if (Array.isArray(value)) return value;
39
+ }
40
+ }
41
+ const result = asRecord(record.result);
42
+ if (result) {
43
+ for (const key of preferredKeys) {
44
+ const value = result[key];
45
+ if (Array.isArray(value)) return value;
46
+ }
47
+ }
48
+ return [];
49
+ }
50
+
51
+ export function normalizePublication(raw: unknown): SubstackPublication | null {
52
+ const record = asRecord(raw);
53
+ if (!record) return null;
54
+ const subdomain = firstString(record, ["subdomain", "handle", "slug"]);
55
+ const customDomain = firstString(record, ["custom_domain", "customDomain", "domain"]);
56
+ const baseUrl = normalizeUrl(firstRawString(record, ["base_url", "baseUrl", "url", "web_url", "canonical_url"]))
57
+ ?? (customDomain ? `https://${customDomain}` : null)
58
+ ?? (subdomain ? `https://${subdomain}.substack.com` : null);
59
+ const idValue = firstValue(record, ["id", "publication_id", "publicationId", "subdomain"]);
60
+ const name = firstString(record, ["name", "publication_name", "publicationName", "title"]) ?? subdomain ?? "Substack";
61
+ const id = String(idValue ?? baseUrl ?? name).trim();
62
+ return {
63
+ id,
64
+ name,
65
+ subdomain,
66
+ baseUrl,
67
+ description: firstString(record, ["description", "hero_text", "heroText", "author_bio"]),
68
+ logoUrl: normalizeUrl(firstRawString(record, ["logo_url", "logoUrl", "logo", "cover_image", "coverImage"]), baseUrl),
69
+ latestPublishedAt: parseDateIso(firstValue(record, [
70
+ "latestPublishedAt",
71
+ "last_published_at",
72
+ "last_post_date",
73
+ "lastPostDate",
74
+ "updated_at",
75
+ ])),
76
+ };
77
+ }
78
+
79
+ export function normalizeSubscriptions(payload: unknown): SubstackPublication[] {
80
+ const rows = extractPayloadArray(payload, [
81
+ "subscriptions",
82
+ "publications",
83
+ "results",
84
+ "items",
85
+ ]);
86
+ const rootRecord = asRecord(payload);
87
+ const publicationLookup = buildPublicationLookup(rootRecord);
88
+ const deduped = new Map<string, SubstackPublication>();
89
+ for (const row of rows) {
90
+ const record = asRecord(row);
91
+ const publication = normalizePublication(
92
+ record?.publication
93
+ ?? record?.pub
94
+ ?? (record ? publicationLookup.get(String(record.publication_id ?? record.publicationId ?? "")) : null)
95
+ ?? row,
96
+ );
97
+ if (!publication) continue;
98
+ const key = publicationKey(publication);
99
+ const existing = deduped.get(key);
100
+ if (!existing) {
101
+ deduped.set(key, publication);
102
+ continue;
103
+ }
104
+ deduped.set(key, {
105
+ ...existing,
106
+ latestPublishedAt: maxIso(existing.latestPublishedAt, publication.latestPublishedAt),
107
+ });
108
+ }
109
+ return [...deduped.values()].sort((a, b) => a.name.localeCompare(b.name));
110
+ }
111
+
112
+ function buildPublicationLookup(record: JsonRecord | null): Map<string, unknown> {
113
+ const lookup = new Map<string, unknown>();
114
+ if (!record) return lookup;
115
+ const maps = [
116
+ asRecord(record.publicationMap),
117
+ asRecord(record.publication_map),
118
+ asRecord(asRecord(record.result)?.publicationMap),
119
+ asRecord(asRecord(record.result)?.publication_map),
120
+ ];
121
+ for (const map of maps) {
122
+ if (!map) continue;
123
+ for (const [key, value] of Object.entries(map)) {
124
+ lookup.set(key, value);
125
+ }
126
+ }
127
+ const arrays = [
128
+ record.publications,
129
+ asRecord(record.result)?.publications,
130
+ ];
131
+ for (const array of arrays) {
132
+ if (!Array.isArray(array)) continue;
133
+ for (const publication of array) {
134
+ const normalized = normalizePublication(publication);
135
+ if (!normalized) continue;
136
+ lookup.set(normalized.id, publication);
137
+ }
138
+ }
139
+ return lookup;
140
+ }
141
+
142
+ function articlePostRecord(row: unknown): JsonRecord | null {
143
+ const record = asRecord(row);
144
+ return firstRecord(
145
+ record?.post,
146
+ record?.item,
147
+ record?.entity,
148
+ record?.object,
149
+ record,
150
+ );
151
+ }
152
+
153
+ function articlePublicationRecord(row: JsonRecord | null, post: JsonRecord | null): JsonRecord | null {
154
+ return firstRecord(
155
+ post?.publication,
156
+ post?.pub,
157
+ row?.publication,
158
+ row?.pub,
159
+ );
160
+ }
161
+
162
+ function buildArticleUrl(post: JsonRecord | null, publication: SubstackPublication | null): string | null {
163
+ const direct = normalizeUrl(firstRawString(post, ["canonical_url", "canonicalUrl", "url", "web_url"]), publication?.baseUrl);
164
+ if (direct) return direct;
165
+ const slug = firstString(post, ["slug"]);
166
+ if (!slug || !publication?.baseUrl) return null;
167
+ return normalizeUrl(`/p/${slug}`, publication.baseUrl);
168
+ }
169
+
170
+ function normalizeArticle(row: unknown, sourcePublication?: SubstackPublication | null): SubstackArticleSummary | null {
171
+ const rowRecord = asRecord(row);
172
+ const post = articlePostRecord(row);
173
+ if (!post) return null;
174
+ const publication = normalizePublication(articlePublicationRecord(rowRecord, post)) ?? sourcePublication ?? null;
175
+ const title = firstString(post, ["title", "name", "social_title", "email_subject"]) ?? firstString(rowRecord, ["title"]);
176
+ const subtitle = firstString(post, ["subtitle", "description", "search_engine_description", "social_description"]);
177
+ const bodyHtml = firstRawString(post, ["body_html", "bodyHtml", "body"]);
178
+ const coverImage = normalizeUrl(firstRawString(post, [
179
+ "cover_image",
180
+ "coverImage",
181
+ "cover_image_url",
182
+ "coverImageUrl",
183
+ "thumbnail_url",
184
+ "thumbnailUrl",
185
+ "image",
186
+ ]), publication?.baseUrl);
187
+ const extracted = bodyHtml
188
+ ? extractArticleContent(bodyHtml, { baseUrl: publication?.baseUrl, imageUrls: coverImage ? [coverImage] : [], title })
189
+ : { text: "", blocks: [], imageUrls: uniqueStrings([coverImage]), linkUrls: [], wordCount: 0, readMinutes: 1 };
190
+ const previewText = firstString(post, [
191
+ "truncated_body_text",
192
+ "body_preview",
193
+ "preview",
194
+ "subtitle",
195
+ "description",
196
+ "search_engine_description",
197
+ ]) ?? (extracted.text ? extracted.text.slice(0, 500) : null);
198
+ const rawWordCount = firstNumber(post, ["wordcount", "word_count", "words"]);
199
+ const words = Math.max(0, Math.round(rawWordCount ?? extracted.wordCount ?? wordCount(previewText ?? "")));
200
+ const rawReadMinutes = firstNumber(post, [
201
+ "reading_time_minutes",
202
+ "read_time_minutes",
203
+ "readMinutes",
204
+ "readingTime",
205
+ ]);
206
+ const publishedAt = parseDateIso(firstValue(post, [
207
+ "post_date",
208
+ "published_at",
209
+ "publish_date",
210
+ "date",
211
+ "created_at",
212
+ "updated_at",
213
+ ]) ?? firstValue(rowRecord, ["published_at", "timestamp", "date"]));
214
+ const url = buildArticleUrl(post, publication);
215
+ const slug = firstString(post, ["slug"]);
216
+ const rawId = firstValue(post, ["id", "post_id", "postId", "canonical_slug"]) ?? firstValue(rowRecord, ["id", "post_id"]);
217
+ const id = String(rawId ?? url ?? `${publication?.id ?? "substack"}:${slug ?? title ?? publishedAt ?? Math.random()}`).trim();
218
+
219
+ if (!title && !url && !bodyHtml) return null;
220
+
221
+ return {
222
+ id,
223
+ title: title ?? "Untitled",
224
+ publicationId: publication?.id ?? null,
225
+ publicationName: publication?.name ?? null,
226
+ publicationSubdomain: publication?.subdomain ?? null,
227
+ publicationBaseUrl: publication?.baseUrl ?? null,
228
+ url,
229
+ slug,
230
+ publishedAt,
231
+ subtitle,
232
+ previewText,
233
+ bodyHtml,
234
+ imageUrls: extracted.imageUrls,
235
+ wordCount: words,
236
+ readMinutes: rawReadMinutes != null ? Math.max(1, Math.ceil(rawReadMinutes)) : estimateReadingMinutes(words || extracted.text),
237
+ };
238
+ }
239
+
240
+ export function normalizeFeedItems(payload: unknown, sourcePublication?: SubstackPublication | null): SubstackArticleSummary[] {
241
+ const rows = extractPayloadArray(payload, ["items", "posts", "results", "feed"]);
242
+ const items = rows
243
+ .map((row) => normalizeArticle(row, sourcePublication))
244
+ .filter((row): row is SubstackArticleSummary => !!row);
245
+ const deduped = new Map<string, SubstackArticleSummary>();
246
+ for (const item of items) {
247
+ const existing = deduped.get(item.id);
248
+ if (!existing || timestamp(item.publishedAt) > timestamp(existing.publishedAt)) {
249
+ deduped.set(item.id, item);
250
+ }
251
+ }
252
+ return [...deduped.values()];
253
+ }
254
+
255
+ export function normalizePostDetail(
256
+ payload: unknown,
257
+ fallback: SubstackArticleSummary,
258
+ ): SubstackArticleDetail {
259
+ const post = articlePostRecord(payload) ?? asRecord(payload);
260
+ const normalized = normalizeArticle(post ?? payload, {
261
+ id: fallback.publicationId ?? fallback.publicationBaseUrl ?? fallback.publicationName ?? "substack",
262
+ name: fallback.publicationName ?? "Substack",
263
+ subdomain: fallback.publicationSubdomain,
264
+ baseUrl: fallback.publicationBaseUrl,
265
+ description: null,
266
+ logoUrl: null,
267
+ latestPublishedAt: null,
268
+ }) ?? fallback;
269
+ const bodyHtml = normalized.bodyHtml ?? fallback.bodyHtml;
270
+ const extracted = extractArticleContent(bodyHtml, {
271
+ baseUrl: normalized.publicationBaseUrl ?? fallback.publicationBaseUrl,
272
+ imageUrls: uniqueStrings([...fallback.imageUrls, ...normalized.imageUrls]),
273
+ title: normalized.title || fallback.title,
274
+ });
275
+ const contentText = extracted.text || normalized.previewText || fallback.previewText || "";
276
+ const words = Math.max(normalized.wordCount, extracted.wordCount, wordCount(contentText));
277
+ return {
278
+ ...fallback,
279
+ ...normalized,
280
+ id: fallback.id,
281
+ title: normalized.title || fallback.title,
282
+ url: normalized.url ?? fallback.url,
283
+ bodyHtml,
284
+ imageUrls: uniqueStrings([...normalized.imageUrls, ...fallback.imageUrls, ...extracted.imageUrls]),
285
+ contentText,
286
+ contentBlocks: extracted.blocks,
287
+ linkUrls: extracted.linkUrls,
288
+ wordCount: words,
289
+ readMinutes: estimateReadingMinutes(words || contentText),
290
+ };
291
+ }
292
+
293
+ export function publicationKey(publication: Pick<SubstackPublication, "id" | "name" | "baseUrl" | "subdomain">): string {
294
+ return (
295
+ publication.baseUrl
296
+ ?? publication.subdomain
297
+ ?? publication.id
298
+ ?? publication.name
299
+ ).toLowerCase();
300
+ }
301
+
302
+ function articlePublicationKeys(article: SubstackArticleSummary): string[] {
303
+ return uniqueStrings([
304
+ article.publicationBaseUrl?.toLowerCase(),
305
+ article.publicationSubdomain?.toLowerCase(),
306
+ article.publicationId?.toLowerCase(),
307
+ article.publicationName?.toLowerCase(),
308
+ ]);
309
+ }
310
+
311
+ export function articleMatchesPublication(
312
+ article: SubstackArticleSummary,
313
+ publication: SubstackPublication,
314
+ ): boolean {
315
+ const keys = new Set(articlePublicationKeys(article));
316
+ return [
317
+ publication.baseUrl,
318
+ publication.subdomain,
319
+ publication.id,
320
+ publication.name,
321
+ ].some((value) => !!value && keys.has(value.toLowerCase()));
322
+ }
323
+
324
+ export function sortSubscriptionsByLatest(
325
+ subscriptions: SubstackPublication[],
326
+ feedItems: SubstackArticleSummary[],
327
+ ): SubstackPublication[] {
328
+ return [...subscriptions]
329
+ .map((publication) => {
330
+ const latestFeedAt = feedItems
331
+ .filter((article) => articleMatchesPublication(article, publication))
332
+ .reduce<string | null>((latest, article) => maxIso(latest, article.publishedAt), null);
333
+ return {
334
+ publication,
335
+ latestAt: maxIso(publication.latestPublishedAt, latestFeedAt),
336
+ };
337
+ })
338
+ .sort((a, b) => {
339
+ const byDate = timestamp(b.latestAt) - timestamp(a.latestAt);
340
+ return byDate !== 0 ? byDate : a.publication.name.localeCompare(b.publication.name);
341
+ })
342
+ .map((entry) => ({ ...entry.publication, latestPublishedAt: entry.latestAt }));
343
+ }
@@ -0,0 +1,71 @@
1
+ import { usePaneFooter } from "../../../components";
2
+ import type { SubstackAuthState } from "./api/types";
3
+ import {
4
+ formatReadTime,
5
+ formatWordCount,
6
+ } from "./table";
7
+ import {
8
+ SUBSTACK_PANE_ID,
9
+ type SubstackArticleSummary,
10
+ } from "./types";
11
+ import { cacheStatusLabel, type ActiveFeedState, type DetailState } from "./pane-state";
12
+
13
+ export function useSubstackPaneFooter({
14
+ auth,
15
+ detailOpen,
16
+ activeFeedState,
17
+ activeDetail,
18
+ selectedArticle,
19
+ refreshActive,
20
+ openSelectedArticle,
21
+ }: {
22
+ auth: SubstackAuthState | null;
23
+ detailOpen: boolean;
24
+ activeFeedState: ActiveFeedState;
25
+ activeDetail: DetailState;
26
+ selectedArticle: SubstackArticleSummary | null;
27
+ refreshActive: () => void;
28
+ openSelectedArticle: () => void;
29
+ }) {
30
+ const statusLabel = cacheStatusLabel(activeFeedState.fetchedAt, activeFeedState.stale);
31
+ const articleMetaLabel = detailOpen && selectedArticle
32
+ ? [
33
+ selectedArticle.publicationName,
34
+ formatReadTime(activeDetail.data?.readMinutes ?? selectedArticle.readMinutes),
35
+ formatWordCount(activeDetail.data?.wordCount ?? selectedArticle.wordCount),
36
+ ].filter(Boolean).join(" | ")
37
+ : null;
38
+
39
+ usePaneFooter(SUBSTACK_PANE_ID, () => ({
40
+ info: [
41
+ ...(!auth ? [{ id: "auth", parts: [{ text: "login required", tone: "warning" as const }] }] : []),
42
+ ...(articleMetaLabel ? [{ id: "article-meta", parts: [{ text: articleMetaLabel, tone: "muted" as const }] }] : []),
43
+ ...(activeFeedState.loading || activeFeedState.loadingMore ? [{ id: "loading", parts: [{ text: activeFeedState.loadingMore ? "loading more" : "loading", tone: "muted" as const }] }] : []),
44
+ ...(activeDetail.loading && detailOpen ? [{ id: "detail-loading", parts: [{ text: "loading article", tone: "muted" as const }] }] : []),
45
+ ...(statusLabel && auth ? [{ id: "cache", parts: [{ text: statusLabel, tone: activeFeedState.stale ? "warning" as const : "muted" as const }] }] : []),
46
+ ...(activeFeedState.error ? [{ id: "error", parts: [{ text: activeFeedState.error, tone: "warning" as const }] }] : []),
47
+ ...(activeDetail.error && detailOpen ? [{ id: "detail-error", parts: [{ text: activeDetail.error, tone: "warning" as const }] }] : []),
48
+ ],
49
+ hints: auth ? [
50
+ { id: "refresh", key: "r", label: "efresh", onPress: refreshActive },
51
+ { id: "open", key: "o", label: "pen", onPress: openSelectedArticle, disabled: !selectedArticle?.url },
52
+ ] : [],
53
+ }), [
54
+ activeDetail.error,
55
+ activeDetail.loading,
56
+ activeDetail.data?.readMinutes,
57
+ activeDetail.data?.wordCount,
58
+ activeFeedState.error,
59
+ activeFeedState.fetchedAt,
60
+ activeFeedState.loading,
61
+ activeFeedState.loadingMore,
62
+ activeFeedState.stale,
63
+ articleMetaLabel,
64
+ auth,
65
+ detailOpen,
66
+ openSelectedArticle,
67
+ refreshActive,
68
+ selectedArticle?.url,
69
+ statusLabel,
70
+ ]);
71
+ }