gloomberb 0.8.0 → 0.8.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.
- package/README.md +102 -52
- package/package.json +1 -1
- package/src/api-client/chat.ts +8 -0
- package/src/api-client/index.test.ts +30 -0
- package/src/api-client/index.ts +4 -0
- package/src/api-client/normalizers.ts +1 -0
- package/src/api-client/types.ts +1 -0
- package/src/capabilities/factories.ts +4 -0
- package/src/components/chart/comparison/data.test.ts +54 -0
- package/src/components/chart/comparison/data.ts +47 -24
- package/src/components/chart/core/projection.ts +51 -27
- package/src/components/data-table/stack-view.test.tsx +14 -11
- package/src/components/data-table/stack-view.tsx +1 -2
- package/src/components/data-table/view.test.tsx +24 -18
- package/src/components/data-table/view.tsx +313 -83
- package/src/components/feed-data-table/stack-view.tsx +6 -8
- package/src/components/layout/pane/footer/index.test.tsx +6 -0
- package/src/components/layout/pane/footer/index.tsx +14 -2
- package/src/components/layout/pane/title.ts +5 -1
- package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
- package/src/components/pane-settings-dialog/tui.tsx +1 -1
- package/src/components/ticker/list-table-view.tsx +11 -25
- package/src/components/ui/choice-dialog.tsx +12 -2
- package/src/components/ui/multi-select/dialog.tsx +17 -5
- package/src/components/ui/shortcut-hint.tsx +14 -2
- package/src/components/ui/table-layout.test.ts +2 -2
- package/src/components/ui/table-layout.ts +8 -2
- package/src/components/ui/tabs.tsx +20 -6
- package/src/components/ui/ui.test.tsx +30 -5
- package/src/market-data/coordinator/auxiliary.ts +29 -1
- package/src/market-data/coordinator/index.ts +17 -1
- package/src/market-data/hooks.tsx +17 -1
- package/src/market-data/selectors.ts +4 -0
- package/src/plugins/builtin/account-management/form-components.tsx +0 -4
- package/src/plugins/builtin/account-management/model.test.ts +54 -0
- package/src/plugins/builtin/account-management/model.ts +13 -3
- package/src/plugins/builtin/account-management/pane.tsx +5 -11
- package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
- package/src/plugins/builtin/alerts/pane.tsx +5 -3
- package/src/plugins/builtin/analytics/index.tsx +0 -3
- package/src/plugins/builtin/analytics/view.tsx +6 -5
- package/src/plugins/builtin/broker-manager/index.tsx +6 -6
- package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
- package/src/plugins/builtin/changelog/index.tsx +40 -27
- package/src/plugins/builtin/changelog/model.test.ts +77 -0
- package/src/plugins/builtin/changelog/model.ts +98 -0
- package/src/plugins/builtin/chat/channel-labels.ts +53 -0
- package/src/plugins/builtin/chat/channels.test.ts +28 -0
- package/src/plugins/builtin/chat/channels.ts +36 -19
- package/src/plugins/builtin/chat/chat.test.tsx +264 -5
- package/src/plugins/builtin/chat/content/channel-navigation.ts +6 -0
- package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
- package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
- package/src/plugins/builtin/chat/content/composer.tsx +23 -2
- package/src/plugins/builtin/chat/content/index.tsx +61 -0
- package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
- package/src/plugins/builtin/chat/content/shortcuts.ts +20 -1
- package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
- package/src/plugins/builtin/chat/content/types.ts +1 -0
- package/src/plugins/builtin/chat/controller/index.ts +41 -0
- package/src/plugins/builtin/chat/controller/messages.ts +18 -0
- package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
- package/src/plugins/builtin/chat/controller/utils.ts +6 -3
- package/src/plugins/builtin/chat/controller/view.ts +1 -1
- package/src/plugins/builtin/chat/controller.test.ts +189 -0
- package/src/plugins/builtin/chat/edit-window.ts +24 -0
- package/src/plugins/builtin/chat/layout.ts +94 -31
- package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
- package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
- package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
- package/src/plugins/builtin/chat/message/render-state.ts +5 -1
- package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
- package/src/plugins/builtin/chat/message/types.ts +2 -0
- package/src/plugins/builtin/chat/pane.tsx +35 -7
- package/src/plugins/builtin/chat/sidebar.test.tsx +1 -0
- package/src/plugins/builtin/chat/status-widget.tsx +29 -11
- package/src/plugins/builtin/chat/test-harness.tsx +2 -0
- package/src/plugins/builtin/cloud/plugin.tsx +3 -0
- package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
- package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
- package/src/plugins/builtin/earnings/index.tsx +8 -7
- package/src/plugins/builtin/econ/index.tsx +6 -8
- package/src/plugins/builtin/help/index.tsx +6 -0
- package/src/plugins/builtin/holders/pane.tsx +7 -6
- package/src/plugins/builtin/market-movers/index.tsx +6 -3
- package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
- package/src/plugins/builtin/options/view.tsx +16 -8
- package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
- package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
- package/src/plugins/builtin/research/index.test.ts +30 -1
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
- package/src/plugins/builtin/sec/filing-documents.ts +66 -0
- package/src/plugins/builtin/sec/index.tsx +132 -20
- package/src/plugins/builtin/sectors/index.tsx +13 -23
- package/src/plugins/builtin/shared/ticker-request.ts +1 -0
- package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
- package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
- package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
- package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
- package/src/plugins/builtin/world-indices/index.tsx +9 -6
- package/src/plugins/prediction-markets/pane.tsx +10 -9
- package/src/plugins/registry/index.ts +3 -0
- package/src/plugins/runtime/context.tsx +1 -0
- package/src/plugins/runtime/index.tsx +4 -0
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
- package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
- package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
- package/src/renderers/electrobun/view/host/input.tsx +23 -2
- package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
- package/src/sources/gloomberb-cloud/index.ts +4 -0
- package/src/sources/provider-router/cache.ts +1 -0
- package/src/sources/provider-router/document-routes.ts +45 -0
- package/src/sources/provider-router/index.ts +4 -0
- package/src/sources/sec-edgar/content.ts +9 -3
- package/src/sources/sec-edgar.test.ts +108 -1
- package/src/sources/sec-edgar.ts +105 -1
- package/src/sources/yahoo-finance.ts +4 -0
- package/src/test-support/plugin-runtime.ts +1 -0
- package/src/types/data-provider.ts +11 -0
- package/src/ui/host.tsx +1 -0
- package/src/version.ts +1 -1
|
@@ -69,6 +69,9 @@ export function usePluginAppActions() {
|
|
|
69
69
|
const hidePane = useCallback((paneId: string) => {
|
|
70
70
|
runtime.hidePane(paneId);
|
|
71
71
|
}, [runtime]);
|
|
72
|
+
const focusPane = useCallback((paneId: string) => {
|
|
73
|
+
runtime.focusPane(paneId);
|
|
74
|
+
}, [runtime]);
|
|
72
75
|
const openPaneSettings = useCallback((paneId?: string) => {
|
|
73
76
|
runtime.openPaneSettings(paneId);
|
|
74
77
|
}, [runtime]);
|
|
@@ -84,6 +87,7 @@ export function usePluginAppActions() {
|
|
|
84
87
|
showPane,
|
|
85
88
|
createPaneFromTemplate,
|
|
86
89
|
hidePane,
|
|
90
|
+
focusPane,
|
|
87
91
|
openPaneSettings,
|
|
88
92
|
openPluginCommandWorkflow,
|
|
89
93
|
notify,
|
|
@@ -251,6 +251,7 @@ function createRuntime(payload: CliPaneShotPayload): PluginRuntimeAccess {
|
|
|
251
251
|
openCommandBar: () => {},
|
|
252
252
|
showPane: () => {},
|
|
253
253
|
hidePane: () => {},
|
|
254
|
+
focusPane: () => {},
|
|
254
255
|
openPaneSettings: () => {},
|
|
255
256
|
openPluginCommandWorkflow: () => {},
|
|
256
257
|
notify: () => {},
|
|
@@ -145,15 +145,16 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
145
145
|
const nextValue = element ? toCellX(element.clientWidth) : 0;
|
|
146
146
|
setViewportWidth((current) => current === nextValue ? current : nextValue);
|
|
147
147
|
}, []);
|
|
148
|
+
const scheduleViewportWidthMeasure = useRafCallback(measureViewportWidth);
|
|
148
149
|
|
|
149
150
|
useEffect(() => {
|
|
150
151
|
measureViewportWidth();
|
|
151
152
|
const element = bodyElementRef.current;
|
|
152
153
|
if (!element || typeof ResizeObserver === "undefined") return;
|
|
153
|
-
const observer = new ResizeObserver(
|
|
154
|
+
const observer = new ResizeObserver(scheduleViewportWidthMeasure);
|
|
154
155
|
observer.observe(element);
|
|
155
156
|
return () => observer.disconnect();
|
|
156
|
-
}, [measureViewportWidth]);
|
|
157
|
+
}, [measureViewportWidth, scheduleViewportWidthMeasure]);
|
|
157
158
|
|
|
158
159
|
useEffect(() => {
|
|
159
160
|
if (scrollToIndex == null || items.length === 0) return;
|
|
@@ -69,7 +69,7 @@ export function WebDataTableHeader<C extends DataTableColumn>({
|
|
|
69
69
|
zIndex: 2,
|
|
70
70
|
display: "grid",
|
|
71
71
|
gridTemplateColumns,
|
|
72
|
-
columnGap: "
|
|
72
|
+
columnGap: "var(--cell-w)",
|
|
73
73
|
alignItems: "center",
|
|
74
74
|
width: "100%",
|
|
75
75
|
minWidth: 0,
|
|
@@ -177,7 +177,7 @@ function WebDataTableRowInner<
|
|
|
177
177
|
transform: `translateY(${rowStart}px)`,
|
|
178
178
|
display: "grid",
|
|
179
179
|
gridTemplateColumns,
|
|
180
|
-
columnGap: "
|
|
180
|
+
columnGap: "var(--cell-w)",
|
|
181
181
|
alignItems: "center",
|
|
182
182
|
width: "100%",
|
|
183
183
|
minWidth: 0,
|
|
@@ -53,6 +53,15 @@ function callTextHandler(handler: unknown, value: string): void {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
function applyDomCursorOffset(
|
|
57
|
+
element: HTMLInputElement | HTMLTextAreaElement | null,
|
|
58
|
+
offset: number,
|
|
59
|
+
) {
|
|
60
|
+
if (!element) return;
|
|
61
|
+
const clampedOffset = Math.max(0, Math.min(offset, element.value.length));
|
|
62
|
+
element.setSelectionRange(clampedOffset, clampedOffset);
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
function useEditableValue(props: Record<string, unknown>) {
|
|
57
66
|
const controlledValue = getStringProp(props, "value");
|
|
58
67
|
const [internalValue, setInternalValue] = useState(getStringProp(props, "initialValue") ?? "");
|
|
@@ -142,6 +151,11 @@ export const WebInput = forwardRef<InputRenderable, Record<string, unknown>>(fun
|
|
|
142
151
|
const elementRef = useRef<HTMLInputElement | null>(null);
|
|
143
152
|
const { value, valueRef, setValue } = useEditableValue(props);
|
|
144
153
|
const [cursorOffset, setCursorOffset] = useState(value.length);
|
|
154
|
+
const applyCursorOffset = (offset: number) => {
|
|
155
|
+
setCursorOffset(offset);
|
|
156
|
+
queueMicrotask(() => applyDomCursorOffset(elementRef.current, offset));
|
|
157
|
+
globalThis.requestAnimationFrame?.(() => applyDomCursorOffset(elementRef.current, offset));
|
|
158
|
+
};
|
|
145
159
|
|
|
146
160
|
useEffect(() => {
|
|
147
161
|
if (props.focused === true) {
|
|
@@ -157,8 +171,9 @@ export const WebInput = forwardRef<InputRenderable, Record<string, unknown>>(fun
|
|
|
157
171
|
get cursorOffset() {
|
|
158
172
|
return cursorOffset;
|
|
159
173
|
},
|
|
174
|
+
setCursorOffset: applyCursorOffset,
|
|
160
175
|
focus: () => elementRef.current?.focus(),
|
|
161
|
-
}), [cursorOffset, setValue, valueRef]);
|
|
176
|
+
}), [applyCursorOffset, cursorOffset, setValue, valueRef]);
|
|
162
177
|
|
|
163
178
|
const handleValueChange = (nextValue: string) => {
|
|
164
179
|
setValue(nextValue);
|
|
@@ -207,6 +222,11 @@ export const WebTextarea = forwardRef<TextareaRenderable, Record<string, unknown
|
|
|
207
222
|
const elementRef = useRef<HTMLTextAreaElement | null>(null);
|
|
208
223
|
const { value, valueRef, setValue } = useEditableValue(props);
|
|
209
224
|
const [cursorOffset, setCursorOffset] = useState(value.length);
|
|
225
|
+
const applyCursorOffset = (offset: number) => {
|
|
226
|
+
setCursorOffset(offset);
|
|
227
|
+
queueMicrotask(() => applyDomCursorOffset(elementRef.current, offset));
|
|
228
|
+
globalThis.requestAnimationFrame?.(() => applyDomCursorOffset(elementRef.current, offset));
|
|
229
|
+
};
|
|
210
230
|
|
|
211
231
|
useEffect(() => {
|
|
212
232
|
if (props.focused === true) {
|
|
@@ -222,6 +242,7 @@ export const WebTextarea = forwardRef<TextareaRenderable, Record<string, unknown
|
|
|
222
242
|
get cursorOffset() {
|
|
223
243
|
return cursorOffset;
|
|
224
244
|
},
|
|
245
|
+
setCursorOffset: applyCursorOffset,
|
|
225
246
|
get virtualLineCount() {
|
|
226
247
|
const metrics = textareaMetrics(
|
|
227
248
|
valueRef.current,
|
|
@@ -248,7 +269,7 @@ export const WebTextarea = forwardRef<TextareaRenderable, Record<string, unknown
|
|
|
248
269
|
syntaxStyle: null,
|
|
249
270
|
addHighlight: () => {},
|
|
250
271
|
clearLineHighlights: () => {},
|
|
251
|
-
}), [cursorOffset, props, setValue, valueRef]);
|
|
272
|
+
}), [applyCursorOffset, cursorOffset, props, setValue, valueRef]);
|
|
252
273
|
|
|
253
274
|
const handleValueChange = (nextValue: string) => {
|
|
254
275
|
setValue(nextValue);
|
|
@@ -34,6 +34,7 @@ const assetDataPayloads: Record<string, PayloadBuilder> = {
|
|
|
34
34
|
getHolders: (ticker, exchange, context) => ({ ticker, exchange, context }),
|
|
35
35
|
getAnalystResearch: (ticker, exchange, context) => ({ ticker, exchange, context }),
|
|
36
36
|
getCorporateActions: (ticker, exchange, context) => ({ ticker, exchange, context }),
|
|
37
|
+
getSecFilingDocuments: (filing) => ({ filing }),
|
|
37
38
|
getSecFilingContent: (filing) => ({ filing }),
|
|
38
39
|
getEarningsCalendar: (symbols, context) => ({ symbols, context }),
|
|
39
40
|
getArticleSummary: (url) => ({ url }),
|
|
@@ -279,6 +279,10 @@ export class GloomberbCloudProvider implements AssetDataProvider {
|
|
|
279
279
|
throw createProviderMiss("Cloud SEC filings are not available");
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
async getSecFilingDocuments(_filing: SecFilingItem) {
|
|
283
|
+
throw createProviderMiss("Cloud filing documents are not available");
|
|
284
|
+
}
|
|
285
|
+
|
|
282
286
|
async getSecFilingContent(_filing: SecFilingItem): Promise<string | null> {
|
|
283
287
|
throw createProviderMiss("Cloud filing content is not available");
|
|
284
288
|
}
|
|
@@ -19,6 +19,7 @@ const DEFAULT_CACHE_POLICIES = {
|
|
|
19
19
|
analystResearch: { staleMs: 24 * 60 * 60_000, expireMs: 7 * 24 * 60 * 60_000 },
|
|
20
20
|
corporateActions: { staleMs: 24 * 60 * 60_000, expireMs: 14 * 24 * 60 * 60_000 },
|
|
21
21
|
secFilings: { staleMs: 15 * 60_000, expireMs: 2 * 24 * 60 * 60_000 },
|
|
22
|
+
secFilingDocuments: { staleMs: 30 * 24 * 60 * 60_000, expireMs: 365 * 24 * 60 * 60_000 },
|
|
22
23
|
secFilingContent: { staleMs: 30 * 24 * 60 * 60_000, expireMs: 365 * 24 * 60 * 60_000 },
|
|
23
24
|
articleSummary: { staleMs: 30 * 24 * 60 * 60_000, expireMs: 90 * 24 * 60 * 60_000 },
|
|
24
25
|
optionsChain: { staleMs: 5 * 60_000, expireMs: 2 * 24 * 60 * 60_000 },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
MarketDataRequestContext,
|
|
3
|
+
SecFilingDocument,
|
|
3
4
|
SecFilingItem,
|
|
4
5
|
} from "../../types/data-provider";
|
|
5
6
|
import { canonicalExchange } from "../../utils/exchanges";
|
|
@@ -61,6 +62,23 @@ export class ProviderRouterDocumentRoutes {
|
|
|
61
62
|
return result.value;
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
async getSecFilingDocuments(filing: SecFilingItem): Promise<SecFilingDocument[]> {
|
|
66
|
+
const entityKey = compactUrl(filing.filingUrl);
|
|
67
|
+
const cached = selectCachedResource<SecFilingDocument[]>(this.deps.resources, "sec-filing-documents", entityKey, [""], this.deps.getProviderSourceKeys(), false);
|
|
68
|
+
if (cached) {
|
|
69
|
+
scheduleRouterRevalidation(this.revalidationInFlight, `sec-filing-documents:${entityKey}`, async () => {
|
|
70
|
+
await this.revalidateSecFilingDocuments(filing);
|
|
71
|
+
});
|
|
72
|
+
return cached.value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const result = await this.fetchProviderSecFilingDocuments(filing);
|
|
76
|
+
if (!result) {
|
|
77
|
+
throw new Error("No SEC filing documents provider available");
|
|
78
|
+
}
|
|
79
|
+
return result.value;
|
|
80
|
+
}
|
|
81
|
+
|
|
64
82
|
async getArticleSummary(url: string): Promise<string | null> {
|
|
65
83
|
const entityKey = compactUrl(url);
|
|
66
84
|
const cached = selectCachedResource<string>(this.deps.resources, "article-summary", entityKey, [""], this.deps.getProviderSourceKeys(), false);
|
|
@@ -134,6 +152,29 @@ export class ProviderRouterDocumentRoutes {
|
|
|
134
152
|
return null;
|
|
135
153
|
}
|
|
136
154
|
|
|
155
|
+
private async fetchProviderSecFilingDocuments(filing: SecFilingItem): Promise<SourceResult<SecFilingDocument[]> | null> {
|
|
156
|
+
const entityKey = compactUrl(filing.filingUrl);
|
|
157
|
+
let lastError: unknown = null;
|
|
158
|
+
|
|
159
|
+
for (const provider of this.deps.providersInPriorityOrder()) {
|
|
160
|
+
if (!provider.getSecFilingDocuments) continue;
|
|
161
|
+
try {
|
|
162
|
+
const value = await provider.getSecFilingDocuments(filing);
|
|
163
|
+
const sourceKey = this.deps.providerSourceKey(provider);
|
|
164
|
+
this.deps.cacheResource("sec-filing-documents", entityKey, "", sourceKey, value, this.deps.resolveProviderPolicy("secFilingDocuments", provider));
|
|
165
|
+
return { sourceKey, value };
|
|
166
|
+
} catch (error) {
|
|
167
|
+
lastError = error;
|
|
168
|
+
if (shouldLogProviderError(error)) {
|
|
169
|
+
this.deps.logProviderError(`${provider.id} failed: ${error}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (lastError) throw lastError;
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
137
178
|
private async revalidateSecFilings(ticker: string, count = 15, exchange?: string, context?: MarketDataRequestContext): Promise<void> {
|
|
138
179
|
await this.fetchProviderSecFilings(ticker, count, exchange, context);
|
|
139
180
|
}
|
|
@@ -142,6 +183,10 @@ export class ProviderRouterDocumentRoutes {
|
|
|
142
183
|
await this.fetchProviderSecFilingContent(filing);
|
|
143
184
|
}
|
|
144
185
|
|
|
186
|
+
private async revalidateSecFilingDocuments(filing: SecFilingItem): Promise<void> {
|
|
187
|
+
await this.fetchProviderSecFilingDocuments(filing);
|
|
188
|
+
}
|
|
189
|
+
|
|
145
190
|
private async revalidateArticleSummary(url: string): Promise<void> {
|
|
146
191
|
const result = await firstProviderResult(this.deps, (provider) => provider.getArticleSummary(url));
|
|
147
192
|
if (!result || !result.value) return;
|
|
@@ -219,6 +219,10 @@ export class AssetDataRouter implements DataProvider {
|
|
|
219
219
|
return this.documentRoutes.getSecFilings(ticker, count, exchange, context);
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
async getSecFilingDocuments(filing: SecFilingItem) {
|
|
223
|
+
return this.documentRoutes.getSecFilingDocuments(filing);
|
|
224
|
+
}
|
|
225
|
+
|
|
222
226
|
async getSecFilingContent(filing: SecFilingItem): Promise<string | null> {
|
|
223
227
|
return this.documentRoutes.getSecFilingContent(filing);
|
|
224
228
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const MAX_CONTENT_CHARS = 16_000;
|
|
2
2
|
const OWNERSHIP_FORMS = new Set(["3", "3/A", "4", "4/A", "5", "5/A"]);
|
|
3
3
|
|
|
4
|
-
export const PDF_FALLBACK_MESSAGE = "This
|
|
4
|
+
export const PDF_FALLBACK_MESSAGE = "This SEC document is a PDF. Inline PDF text extraction is not supported here.";
|
|
5
5
|
|
|
6
6
|
export function isPdfDocument(body: string, contentType = "", url = ""): boolean {
|
|
7
7
|
return /pdf/i.test(contentType)
|
|
@@ -43,11 +43,17 @@ function collapseDocumentLines(text: string): string[] {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
function trimLeadingNoise(lines: string[]): string[] {
|
|
46
|
-
const startIndex = lines.findIndex((line) => /^(UNITED STATES|FORM [0-9A-Z-]+|SCHEDULE 14A|PROXY STATEMENT|NOTICE OF|ANNUAL REPORT|QUARTERLY REPORT|CURRENT REPORT)/i.test(line));
|
|
46
|
+
const startIndex = lines.findIndex((line) => /^(UNITED STATES|FORM [0-9A-Z-]+|SCHEDULE 14A|PROXY STATEMENT|NOTICE OF|ANNUAL REPORT|QUARTERLY REPORT|CURRENT REPORT|EXHIBIT\s+\d)/i.test(line));
|
|
47
47
|
if (startIndex <= 0) return lines;
|
|
48
48
|
|
|
49
49
|
const prefix = lines.slice(0, startIndex);
|
|
50
|
-
const noiseCount = prefix.filter((line) =>
|
|
50
|
+
const noiseCount = prefix.filter((line) => (
|
|
51
|
+
/^Table of Contents$/i.test(line)
|
|
52
|
+
|| /^EX[-\s]?\d/i.test(line)
|
|
53
|
+
|| /\.(?:html?|xml|xsd|txt|pdf)$/i.test(line)
|
|
54
|
+
|| /^\d+$/.test(line)
|
|
55
|
+
|| line.length < 5
|
|
56
|
+
)).length;
|
|
51
57
|
return noiseCount >= Math.max(1, prefix.length - 1)
|
|
52
58
|
? lines.slice(startIndex)
|
|
53
59
|
: lines;
|
|
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
SecEdgarClient,
|
|
4
4
|
extractFilingContent,
|
|
5
|
+
parseFilingDocuments,
|
|
5
6
|
parseRecentFilings,
|
|
6
7
|
parseTickerLookup,
|
|
7
8
|
} from "./sec-edgar";
|
|
@@ -65,6 +66,59 @@ describe("parseRecentFilings", () => {
|
|
|
65
66
|
});
|
|
66
67
|
});
|
|
67
68
|
|
|
69
|
+
describe("parseFilingDocuments", () => {
|
|
70
|
+
test("maps filing index document rows into primary and exhibit documents", () => {
|
|
71
|
+
const documents = parseFilingDocuments(`
|
|
72
|
+
<html>
|
|
73
|
+
<body>
|
|
74
|
+
<table class="tableFile" summary="Document Format Files">
|
|
75
|
+
<tr>
|
|
76
|
+
<th>Seq</th><th>Description</th><th>Document</th><th>Type</th><th>Size</th>
|
|
77
|
+
</tr>
|
|
78
|
+
<tr>
|
|
79
|
+
<td>1</td>
|
|
80
|
+
<td>Current report</td>
|
|
81
|
+
<td><a href="/ixviewer/doc/action?doc=/Archives/edgar/data/320193/000032019324000123/aapl-8k.htm">aapl-8k.htm</a></td>
|
|
82
|
+
<td>8-K</td>
|
|
83
|
+
<td>10000</td>
|
|
84
|
+
</tr>
|
|
85
|
+
<tr>
|
|
86
|
+
<td>2</td>
|
|
87
|
+
<td>Results of Operations and Financial Condition</td>
|
|
88
|
+
<td><a href="/Archives/edgar/data/320193/000032019324000123/aapl-ex991.htm">aapl-ex991.htm</a></td>
|
|
89
|
+
<td>EX-99.1</td>
|
|
90
|
+
<td>42000</td>
|
|
91
|
+
</tr>
|
|
92
|
+
</table>
|
|
93
|
+
</body>
|
|
94
|
+
</html>
|
|
95
|
+
`, {
|
|
96
|
+
accessionNumber: "0000320193-24-000123",
|
|
97
|
+
form: "8-K",
|
|
98
|
+
filingDate: new Date("2024-08-01T00:00:00Z"),
|
|
99
|
+
cik: "0000320193",
|
|
100
|
+
filingUrl: "https://www.sec.gov/Archives/edgar/data/320193/0000320193-24-000123-index.htm",
|
|
101
|
+
primaryDocument: "aapl-8k.htm",
|
|
102
|
+
primaryDocumentUrl: "https://www.sec.gov/Archives/edgar/data/320193/000032019324000123/aapl-8k.htm",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(documents).toMatchObject([
|
|
106
|
+
{
|
|
107
|
+
type: "8-K",
|
|
108
|
+
document: "aapl-8k.htm",
|
|
109
|
+
isPrimary: true,
|
|
110
|
+
url: "https://www.sec.gov/Archives/edgar/data/320193/000032019324000123/aapl-8k.htm",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
type: "EX-99.1",
|
|
114
|
+
description: "Results of Operations and Financial Condition",
|
|
115
|
+
document: "aapl-ex991.htm",
|
|
116
|
+
isPrimary: false,
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
68
122
|
describe("SecEdgarClient", () => {
|
|
69
123
|
test("extracts readable text from filing html", () => {
|
|
70
124
|
const content = extractFilingContent(`
|
|
@@ -81,6 +135,30 @@ describe("SecEdgarClient", () => {
|
|
|
81
135
|
expect(content).toContain("Revenue increased 12% year over year.");
|
|
82
136
|
});
|
|
83
137
|
|
|
138
|
+
test("trims SEC document wrapper metadata before exhibit content", () => {
|
|
139
|
+
const content = extractFilingContent(`
|
|
140
|
+
<SEC-DOCUMENT>
|
|
141
|
+
<DOCUMENT>
|
|
142
|
+
<TYPE>EX-99.1
|
|
143
|
+
<SEQUENCE>2
|
|
144
|
+
<FILENAME>q42026er-991.htm
|
|
145
|
+
<DESCRIPTION>EX-99.1
|
|
146
|
+
<TEXT>
|
|
147
|
+
<html><body>
|
|
148
|
+
<p>Exhibit 99.1</p>
|
|
149
|
+
<h1>e.l.f. Beauty Announces Fourth Quarter Fiscal 2026 Results</h1>
|
|
150
|
+
<p>Delivered fiscal 2026 net sales growth of 25% year over year.</p>
|
|
151
|
+
</body></html>
|
|
152
|
+
</TEXT>
|
|
153
|
+
</DOCUMENT>
|
|
154
|
+
</SEC-DOCUMENT>
|
|
155
|
+
`, "text/html", { form: "EX-99.1" });
|
|
156
|
+
|
|
157
|
+
expect(content?.startsWith("Exhibit 99.1")).toBe(true);
|
|
158
|
+
expect(content).toContain("Fourth Quarter Fiscal 2026 Results");
|
|
159
|
+
expect(content).not.toContain("q42026er-991.htm");
|
|
160
|
+
});
|
|
161
|
+
|
|
84
162
|
test("drops hidden inline xbrl boilerplate from filing html", () => {
|
|
85
163
|
const content = extractFilingContent(`
|
|
86
164
|
<?xml version="1.0"?>
|
|
@@ -119,7 +197,7 @@ describe("SecEdgarClient", () => {
|
|
|
119
197
|
sourceUrl: "https://www.sec.gov/Archives/edgar/data/2488/example.pdf",
|
|
120
198
|
});
|
|
121
199
|
|
|
122
|
-
expect(content).toContain("
|
|
200
|
+
expect(content).toContain("document is a PDF");
|
|
123
201
|
expect(content).not.toContain("%PDF-1.6");
|
|
124
202
|
});
|
|
125
203
|
|
|
@@ -283,4 +361,33 @@ describe("SecEdgarClient", () => {
|
|
|
283
361
|
expect(content).toContain("Proxy Statement");
|
|
284
362
|
expect(content).toContain("Annual meeting details.");
|
|
285
363
|
});
|
|
364
|
+
|
|
365
|
+
test("loads filing documents from the filing index", async () => {
|
|
366
|
+
globalThis.fetch = (async () => new Response(`
|
|
367
|
+
<html>
|
|
368
|
+
<body>
|
|
369
|
+
<table class="tableFile">
|
|
370
|
+
<tr><td>1</td><td>Current report</td><td><a href="/Archives/edgar/data/320193/000032019324000123/aapl-8k.htm">aapl-8k.htm</a></td><td>8-K</td><td>10000</td></tr>
|
|
371
|
+
<tr><td>2</td><td>Investor presentation</td><td><a href="/Archives/edgar/data/320193/000032019324000123/aapl-ex992.pdf">aapl-ex992.pdf</a></td><td>EX-99.2</td><td>90000</td></tr>
|
|
372
|
+
</table>
|
|
373
|
+
</body>
|
|
374
|
+
</html>
|
|
375
|
+
`, {
|
|
376
|
+
status: 200,
|
|
377
|
+
headers: { "content-type": "text/html" },
|
|
378
|
+
})) as unknown as typeof fetch;
|
|
379
|
+
|
|
380
|
+
const client = new SecEdgarClient();
|
|
381
|
+
const documents = await client.getFilingDocuments({
|
|
382
|
+
accessionNumber: "0000320193-24-000123",
|
|
383
|
+
form: "8-K",
|
|
384
|
+
filingDate: new Date("2024-08-01T00:00:00Z"),
|
|
385
|
+
cik: "0000320193",
|
|
386
|
+
filingUrl: "https://www.sec.gov/Archives/edgar/data/320193/0000320193-24-000123-index.htm",
|
|
387
|
+
primaryDocument: "aapl-8k.htm",
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
expect(documents.map((document) => document.type)).toEqual(["8-K", "EX-99.2"]);
|
|
391
|
+
expect(documents[1]?.description).toBe("Investor presentation");
|
|
392
|
+
});
|
|
286
393
|
});
|
package/src/sources/sec-edgar.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SecFilingItem } from "../types/data-provider";
|
|
1
|
+
import type { SecFilingDocument, SecFilingItem } from "../types/data-provider";
|
|
2
2
|
import { truncateWithEllipsis } from "../utils/text-wrap";
|
|
3
3
|
import {
|
|
4
4
|
PDF_FALLBACK_MESSAGE,
|
|
@@ -84,6 +84,56 @@ function stripAccessionDashes(accessionNumber: string): string {
|
|
|
84
84
|
return accessionNumber.replace(/-/g, "");
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function documentNameFromUrl(url: string): string {
|
|
88
|
+
try {
|
|
89
|
+
return decodeURIComponent(new URL(url).pathname.split("/").pop() ?? "");
|
|
90
|
+
} catch {
|
|
91
|
+
return url.split("/").pop() ?? url;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeDocumentName(value: string | undefined): string {
|
|
96
|
+
return (value ?? "").trim().toLowerCase();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function decodeSecHtmlEntities(text: string): string {
|
|
100
|
+
return text
|
|
101
|
+
.replace(/ /gi, " ")
|
|
102
|
+
.replace(/&/gi, "&")
|
|
103
|
+
.replace(/</gi, "<")
|
|
104
|
+
.replace(/>/gi, ">")
|
|
105
|
+
.replace(/"/gi, "\"")
|
|
106
|
+
.replace(/'|'/gi, "'")
|
|
107
|
+
.replace(/&#(\d+);/g, (_match, digits: string) => String.fromCodePoint(Number(digits)))
|
|
108
|
+
.replace(/&#x([0-9a-f]+);/gi, (_match, hex: string) => String.fromCodePoint(parseInt(hex, 16)));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function cleanSecTableText(value: string): string {
|
|
112
|
+
return decodeSecHtmlEntities(
|
|
113
|
+
value
|
|
114
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
115
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
116
|
+
.replace(/<[^>]+>/g, " ")
|
|
117
|
+
.replace(/\s+/g, " ")
|
|
118
|
+
.trim(),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function resolveSecArchiveUrl(href: string, filingUrl: string): string | null {
|
|
123
|
+
const trimmed = href.trim();
|
|
124
|
+
if (!trimmed) return null;
|
|
125
|
+
try {
|
|
126
|
+
const url = new URL(trimmed, filingUrl);
|
|
127
|
+
const ixviewerDocument = url.searchParams.get("doc");
|
|
128
|
+
if (ixviewerDocument?.startsWith("/Archives/")) {
|
|
129
|
+
return new URL(ixviewerDocument, "https://www.sec.gov").toString();
|
|
130
|
+
}
|
|
131
|
+
return url.toString();
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
87
137
|
function isHtmlResponse(body: string): boolean {
|
|
88
138
|
return /^\s*<!DOCTYPE html/i.test(body) || /^\s*<html/i.test(body);
|
|
89
139
|
}
|
|
@@ -201,6 +251,52 @@ export function parseRecentFilings(payload: unknown, count = 15): SecFilingItem[
|
|
|
201
251
|
return results;
|
|
202
252
|
}
|
|
203
253
|
|
|
254
|
+
export function parseFilingDocuments(indexHtml: string, filing: SecFilingItem): SecFilingDocument[] {
|
|
255
|
+
const documents: SecFilingDocument[] = [];
|
|
256
|
+
const primaryUrl = normalizeDocumentName(filing.primaryDocumentUrl);
|
|
257
|
+
const primaryDocument = normalizeDocumentName(filing.primaryDocument);
|
|
258
|
+
const rows = [...indexHtml.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)];
|
|
259
|
+
|
|
260
|
+
for (const rowMatch of rows) {
|
|
261
|
+
const rowHtml = rowMatch[1] ?? "";
|
|
262
|
+
const cells = [...rowHtml.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)].map((match) => match[1] ?? "");
|
|
263
|
+
if (cells.length < 3) continue;
|
|
264
|
+
|
|
265
|
+
const hrefMatch = rowHtml.match(/<a\b[^>]*href="([^"]+)"[^>]*>/i);
|
|
266
|
+
if (!hrefMatch?.[1]) continue;
|
|
267
|
+
const url = resolveSecArchiveUrl(hrefMatch[1], filing.filingUrl);
|
|
268
|
+
if (!url || !url.includes("/Archives/edgar/data/")) continue;
|
|
269
|
+
|
|
270
|
+
const sequence = cleanSecTableText(cells[0] ?? "") || undefined;
|
|
271
|
+
const description = cleanSecTableText(cells[1] ?? "") || undefined;
|
|
272
|
+
const linkedDocument = cleanSecTableText(cells[2] ?? "") || documentNameFromUrl(url);
|
|
273
|
+
const type = cleanSecTableText(cells[3] ?? "") || linkedDocument;
|
|
274
|
+
const size = cleanSecTableText(cells[4] ?? "") || undefined;
|
|
275
|
+
const document = linkedDocument || documentNameFromUrl(url);
|
|
276
|
+
const normalizedDocument = normalizeDocumentName(document);
|
|
277
|
+
const normalizedUrl = normalizeDocumentName(url);
|
|
278
|
+
|
|
279
|
+
documents.push({
|
|
280
|
+
sequence,
|
|
281
|
+
type,
|
|
282
|
+
description,
|
|
283
|
+
document,
|
|
284
|
+
url,
|
|
285
|
+
size,
|
|
286
|
+
isPrimary: (
|
|
287
|
+
(primaryDocument.length > 0 && normalizedDocument === primaryDocument)
|
|
288
|
+
|| (primaryUrl.length > 0 && normalizedUrl === primaryUrl)
|
|
289
|
+
|| (
|
|
290
|
+
documents.length === 0
|
|
291
|
+
&& normalizeDocumentName(type) === normalizeDocumentName(filing.form)
|
|
292
|
+
)
|
|
293
|
+
),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return documents;
|
|
298
|
+
}
|
|
299
|
+
|
|
204
300
|
export class SecEdgarClient {
|
|
205
301
|
private lookupPromise: Promise<Map<string, LookupEntry>> | null = null;
|
|
206
302
|
|
|
@@ -311,6 +407,11 @@ export class SecEdgarClient {
|
|
|
311
407
|
return parseRecentFilings(payload, count);
|
|
312
408
|
}
|
|
313
409
|
|
|
410
|
+
async getFilingDocuments(filing: SecFilingItem): Promise<SecFilingDocument[]> {
|
|
411
|
+
const { body } = await this.fetchText(filing.filingUrl);
|
|
412
|
+
return parseFilingDocuments(body, filing);
|
|
413
|
+
}
|
|
414
|
+
|
|
314
415
|
async getFilingContent(filing: Pick<SecFilingItem, "primaryDocumentUrl" | "filingUrl" | "form">): Promise<string | null> {
|
|
315
416
|
let targetUrl = filing.primaryDocumentUrl || filing.filingUrl;
|
|
316
417
|
if (!targetUrl) return null;
|
|
@@ -328,6 +429,9 @@ export class SecEdgarClient {
|
|
|
328
429
|
}
|
|
329
430
|
|
|
330
431
|
if (filing.primaryDocumentUrl && isPdfDocument("", "", filing.primaryDocumentUrl)) {
|
|
432
|
+
if (filing.filingUrl === filing.primaryDocumentUrl) {
|
|
433
|
+
return PDF_FALLBACK_MESSAGE;
|
|
434
|
+
}
|
|
331
435
|
const alternativeUrl = filing.filingUrl
|
|
332
436
|
? await this.findAlternativeHtmlDocumentUrl(filing.filingUrl, filing.primaryDocumentUrl)
|
|
333
437
|
: null;
|
|
@@ -226,6 +226,10 @@ export class YahooFinanceClient implements DataProvider {
|
|
|
226
226
|
return this.secClient.getRecentFilings(ticker, count);
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
async getSecFilingDocuments(filing: SecFilingItem) {
|
|
230
|
+
return this.secClient.getFilingDocuments(filing);
|
|
231
|
+
}
|
|
232
|
+
|
|
229
233
|
async getSecFilingContent(filing: SecFilingItem): Promise<string | null> {
|
|
230
234
|
return this.secClient.getFilingContent(filing);
|
|
231
235
|
}
|
|
@@ -34,6 +34,16 @@ export interface SecFilingItem {
|
|
|
34
34
|
primaryDocumentUrl?: string;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export interface SecFilingDocument {
|
|
38
|
+
sequence?: string;
|
|
39
|
+
type: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
document: string;
|
|
42
|
+
url: string;
|
|
43
|
+
size?: string;
|
|
44
|
+
isPrimary: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
export interface EarningsEvent {
|
|
38
48
|
symbol: string;
|
|
39
49
|
name: string;
|
|
@@ -127,6 +137,7 @@ export interface AssetDataProvider {
|
|
|
127
137
|
getAnalystResearch?(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<AnalystResearchData>;
|
|
128
138
|
getCorporateActions?(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<CorporateActionsData>;
|
|
129
139
|
getEarningsCalendar?(symbols: string[], context?: MarketDataRequestContext): Promise<EarningsEvent[]>;
|
|
140
|
+
getSecFilingDocuments?(filing: SecFilingItem): Promise<SecFilingDocument[]>;
|
|
130
141
|
getSecFilingContent?(filing: SecFilingItem): Promise<string | null>;
|
|
131
142
|
/** Fetch article summary/description by URL (lazy-loaded on selection) */
|
|
132
143
|
getArticleSummary(url: string): Promise<string | null>;
|
package/src/ui/host.tsx
CHANGED
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.8.
|
|
1
|
+
export const VERSION = "0.8.2";
|