gloomberb 0.18.0 → 0.18.1
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/package.json +1 -1
- package/src/api-client/market-movers.ts +7 -0
- package/src/api-client/types.ts +9 -0
- package/src/cli/commands/fred-series.ts +15 -5
- package/src/cli/commands/market.ts +9 -5
- package/src/cli/commands/overview.ts +31 -6
- package/src/cli/commands/ticker.ts +5 -2
- package/src/cli/pane-functions/function-help.ts +4 -4
- package/src/cli/symbol-list.ts +51 -16
- package/src/components/chart/composite/composite-chart.tsx +11 -5
- package/src/components/chart/composite/panel-surface.tsx +6 -8
- package/src/components/chart/composite/price-axis-labels.tsx +2 -1
- package/src/components/chart/composite/types.ts +4 -0
- package/src/components/command-bar/workflow/collection-actions.ts +4 -0
- package/src/components/onboarding/wizard-positions.ts +8 -0
- package/src/docs/snapshot.json +52 -17
- package/src/plugins/builtin/analytics/index.tsx +1 -1
- package/src/plugins/builtin/analytics/risk-client.ts +44 -11
- package/src/plugins/builtin/analytics/risk-fx.ts +61 -0
- package/src/plugins/builtin/analytics/risk-headless.ts +10 -1
- package/src/plugins/builtin/analytics/risk-model.ts +79 -16
- package/src/plugins/builtin/analytics/risk-pane.tsx +8 -2
- package/src/plugins/builtin/analytics/risk-test-data.ts +3 -0
- package/src/plugins/builtin/cloud/team/pane-sections.tsx +10 -3
- package/src/plugins/builtin/cloud/team/pane.tsx +30 -9
- package/src/plugins/builtin/custom-view/loader.ts +3 -2
- package/src/plugins/builtin/custom-view/pane.tsx +5 -4
- package/src/plugins/builtin/custom-view/view-spec.ts +1 -0
- package/src/plugins/builtin/econ/calendar-model.ts +3 -2
- package/src/plugins/builtin/econ/fred-catalog.ts +6 -2
- package/src/plugins/builtin/econ/fred-series-map.ts +12 -1
- package/src/plugins/builtin/executives/data.ts +24 -8
- package/src/plugins/builtin/executives/index.tsx +2 -18
- package/src/plugins/builtin/executives/pane.tsx +34 -10
- package/src/plugins/builtin/market-movers/client.ts +2 -2
- package/src/plugins/builtin/market-movers/index.tsx +0 -3
- package/src/plugins/builtin/market-movers/session-body.tsx +27 -11
- package/src/plugins/builtin/market-movers/session.ts +25 -10
- package/src/plugins/builtin/news/wire/feed-config.ts +4 -1
- package/src/plugins/builtin/news/wire/letters/client.ts +1 -0
- package/src/plugins/builtin/news/wire/news/command-bar-search.ts +6 -3
- package/src/plugins/builtin/news/wire/news/detail-view.tsx +2 -1
- package/src/plugins/builtin/news/wire/news/table.tsx +2 -1
- package/src/plugins/builtin/news/wire/rss/feed-xml.ts +6 -1
- package/src/plugins/builtin/news/wire/rss/managed-feed.ts +1 -0
- package/src/plugins/builtin/notes/cli.ts +33 -8
- package/src/plugins/builtin/notes/export.ts +49 -3
- package/src/plugins/builtin/notes/files.ts +14 -3
- package/src/plugins/builtin/notes/index.tsx +3 -3
- package/src/plugins/builtin/notes/quick-notes-pane.tsx +45 -10
- package/src/plugins/builtin/notes/store.ts +13 -3
- package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -7
- package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +166 -15
- package/src/plugins/builtin/portfolio-list/cli/render.ts +35 -5
- package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +2 -2
- package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -3
- package/src/plugins/builtin/portfolio-list/summary/index.tsx +10 -1
- package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +42 -7
- package/src/plugins/builtin/shared/broker-reconnect.ts +105 -0
- package/src/plugins/builtin/shared/pro-feature-wall.tsx +9 -4
- package/src/plugins/builtin/thirteenf/api.ts +4 -1
- package/src/plugins/builtin/thirteenf/data.ts +5 -1
- package/src/plugins/builtin/thirteenf/headless.ts +3 -1
- package/src/plugins/builtin/thirteenf/pane.tsx +8 -3
- package/src/portfolio/cost-fx.ts +84 -0
- package/src/portfolio/mutations.ts +16 -2
- package/src/portfolio/position-metrics.ts +45 -4
- package/src/portfolio/position-quantity.ts +43 -0
- package/src/sources/gloomberb-cloud/fred-series.ts +27 -2
- package/src/sync/core-contributors.ts +1 -0
- package/src/team/collections.ts +2 -0
- package/src/tickers/metadata.ts +10 -0
- package/src/types/ticker.ts +9 -0
- package/src/utils/currency-units.ts +9 -0
|
@@ -27,11 +27,14 @@ import {
|
|
|
27
27
|
} from "../../../ui";
|
|
28
28
|
import { isPermanentClientError } from "../../../api-client/errors";
|
|
29
29
|
import type { IssuerListingParams } from "../../../api-client/paths";
|
|
30
|
+
import { usePlanAccess } from "../../../api-client/plan-access";
|
|
31
|
+
import { CLOUD_PLAN_KEY, useCloudUpgradeAction } from "../shared/cloud-upgrade";
|
|
30
32
|
import { useFilingYearReader } from "../shared/filing-year-reader";
|
|
33
|
+
import { UpgradeLabel } from "../shared/locked-rows";
|
|
31
34
|
import { useProFeatureWall, type ProFeatureWallCopy, type ReadGuard } from "../shared/pro-feature-wall";
|
|
32
35
|
import { useBoundTicker, useIssuerListing } from "../shared/ticker-request";
|
|
33
36
|
import { SplitBar } from "../../../components/ui/split-bar";
|
|
34
|
-
import { loadProxyStatement, loadProxyStatements } from "./data";
|
|
37
|
+
import { loadProxyStatement, loadProxyStatements, type ProxyTier } from "./data";
|
|
35
38
|
import {
|
|
36
39
|
equityShare,
|
|
37
40
|
formatChange,
|
|
@@ -127,9 +130,12 @@ function renderOfficerCell(row: CloudExecutiveRowPayload, column: OfficerColumn)
|
|
|
127
130
|
function ExecutiveRows({
|
|
128
131
|
rows,
|
|
129
132
|
width,
|
|
133
|
+
breakdown,
|
|
130
134
|
}: {
|
|
131
135
|
rows: CloudExecutiveRowPayload[];
|
|
132
136
|
width: number;
|
|
137
|
+
/** False on the free preview, whose rows carry the total alone: no EQ% column. */
|
|
138
|
+
breakdown: boolean;
|
|
133
139
|
}) {
|
|
134
140
|
const isDesktopWeb = useUiCapabilities().nativePaneChrome === true;
|
|
135
141
|
if (width < 52) {
|
|
@@ -146,7 +152,9 @@ function ExecutiveRows({
|
|
|
146
152
|
}
|
|
147
153
|
// The name column fits the longest name so the title starts after a kit gutter.
|
|
148
154
|
const nameWidth = Math.min(24, Math.max(10, ...rows.map((row) => row.name.length)));
|
|
149
|
-
const columns = OFFICER_COLUMNS
|
|
155
|
+
const columns = OFFICER_COLUMNS
|
|
156
|
+
.filter((column) => breakdown || column.id !== "equity")
|
|
157
|
+
.map((column) => column.id === "name" ? { ...column, width: nameWidth } : column);
|
|
150
158
|
const height = rows.length + 1;
|
|
151
159
|
return (
|
|
152
160
|
<Box flexDirection="column" height={isDesktopWeb ? undefined : height} flexShrink={0}>
|
|
@@ -251,22 +259,26 @@ export function ExecutivesPane({
|
|
|
251
259
|
/** Inside Ticker Research, whose own tab strip keeps h/l and the arrows. */
|
|
252
260
|
nested?: boolean;
|
|
253
261
|
}) {
|
|
254
|
-
|
|
262
|
+
// Every plan reads EXEC: without Pro the server serves each executive's
|
|
263
|
+
// total pay, so only its refusal (an older server, an expired session) walls it.
|
|
264
|
+
const { wall, guard } = useProFeatureWall(EXEC_WALL, { width, height }, { preview: true });
|
|
265
|
+
const tier: ProxyTier = usePlanAccess().hasProAccess ? "full" : "preview";
|
|
255
266
|
const { symbol, ticker: boundTicker } = useBoundTicker();
|
|
256
267
|
const ticker = symbol ? symbol.toUpperCase() : null;
|
|
257
268
|
const listing = useIssuerListing(boundTicker);
|
|
258
269
|
if (wall) return wall;
|
|
259
270
|
if (!ticker) return <EmptyState title="Select a ticker." />;
|
|
260
|
-
return <ExecutiveResearch key={ticker} ticker={ticker} listing={listing} focused={focused} width={width} nested={nested} guard={guard} />;
|
|
271
|
+
return <ExecutiveResearch key={ticker} ticker={ticker} listing={listing} tier={tier} focused={focused} width={width} nested={nested} guard={guard} />;
|
|
261
272
|
}
|
|
262
273
|
|
|
263
274
|
export function ExecutivesResearchTab(props: { focused: boolean; width: number; height: number }) {
|
|
264
275
|
return <ExecutivesPane {...props} nested />;
|
|
265
276
|
}
|
|
266
277
|
|
|
267
|
-
function ExecutiveResearch({ ticker, listing, focused, width, nested, guard }: {
|
|
278
|
+
function ExecutiveResearch({ ticker, listing, tier, focused, width, nested, guard }: {
|
|
268
279
|
ticker: string;
|
|
269
280
|
listing: IssuerListingParams;
|
|
281
|
+
tier: ProxyTier;
|
|
270
282
|
focused: boolean;
|
|
271
283
|
width: number;
|
|
272
284
|
nested: boolean;
|
|
@@ -274,15 +286,20 @@ function ExecutiveResearch({ ticker, listing, focused, width, nested, guard }: {
|
|
|
274
286
|
}) {
|
|
275
287
|
const nativePaneChrome = useUiCapabilities().nativePaneChrome === true;
|
|
276
288
|
const rendererHost = useRendererHost();
|
|
289
|
+
const openUpgrade = useCloudUpgradeAction("exec");
|
|
277
290
|
const [selectedYear, setYear] = usePaneStateValue<number | null>("proxyYear", null);
|
|
278
|
-
const loadYears = useCallback((force: boolean) => guard(loadProxyStatements(ticker, { force, listing })), [guard, listing, ticker]);
|
|
291
|
+
const loadYears = useCallback((force: boolean) => guard(loadProxyStatements(ticker, { force, listing, tier })), [guard, listing, ticker, tier]);
|
|
279
292
|
const list = useAsyncResource(loadYears, { clearOnError: isPermanentClientError });
|
|
280
293
|
const years = useMemo(() => (list.data?.data?.proxies ?? []).map((entry) => entry.proxyYear), [list.data]);
|
|
281
|
-
|
|
294
|
+
// Without Pro only the latest proxy is served: an earlier pick from a Pro
|
|
295
|
+
// session waits for Pro again rather than asking for a year that is refused.
|
|
296
|
+
const year = tier === "full" && selectedYear !== null && years.includes(selectedYear)
|
|
282
297
|
? selectedYear : years[0] ?? null;
|
|
283
|
-
const loadStatement = useCallback((force: boolean) => guard(loadProxyStatement(ticker, year!, { force, listing })), [guard, listing, ticker, year]);
|
|
298
|
+
const loadStatement = useCallback((force: boolean) => guard(loadProxyStatement(ticker, year!, { force, listing, tier })), [guard, listing, ticker, year, tier]);
|
|
284
299
|
const detail = useAsyncResource(year === null ? null : loadStatement, { clearOnError: isPermanentClientError });
|
|
285
300
|
const statement = detail.data?.data ?? null;
|
|
301
|
+
// The free preview: each executive's total pay; the breakdown and earlier years are Pro.
|
|
302
|
+
const preview = statement?.access === "preview" || list.data?.data?.access === "preview";
|
|
286
303
|
const loading = list.loading || detail.loading;
|
|
287
304
|
const listError = list.error ?? list.data?.refreshError;
|
|
288
305
|
const statementError = detail.error ?? detail.data?.refreshError;
|
|
@@ -332,8 +349,9 @@ function ExecutiveResearch({ ticker, listing, focused, width, nested, guard }: {
|
|
|
332
349
|
// `r` refreshes every pane, so it gets no hint here.
|
|
333
350
|
const hints: PaneHint[] = statement ? [{ id: "open", key: "o", label: "pen filing", onPress: openFiling }] : [];
|
|
334
351
|
if (yearHint) hints.push(yearHint);
|
|
352
|
+
if (preview) hints.push({ id: "upgrade", key: CLOUD_PLAN_KEY, label: "upgrade", title: "Upgrade to Pro", onPress: openUpgrade });
|
|
335
353
|
return { info, hints };
|
|
336
|
-
}, [loading, statement, openFiling, yearHint]);
|
|
354
|
+
}, [loading, statement, openFiling, yearHint, preview, openUpgrade]);
|
|
337
355
|
|
|
338
356
|
const figures = useMemo(
|
|
339
357
|
() => (statement ? figuresOf(statement) : []),
|
|
@@ -390,7 +408,7 @@ function ExecutiveResearch({ ticker, listing, focused, width, nested, guard }: {
|
|
|
390
408
|
<FigureList figures={figures} width={proseWidth} minValueWidth={4} maxValueWidth={14} />
|
|
391
409
|
</Box>
|
|
392
410
|
)}
|
|
393
|
-
{statement.ceo && (
|
|
411
|
+
{statement.ceo && !preview && (
|
|
394
412
|
<Box flexDirection="column">
|
|
395
413
|
<SectionHeading marginTop={figures.length > 0 ? 1 : 0}
|
|
396
414
|
title={`How ${statement.ceo.name.trim().split(/\s+/).pop() || "the CEO"} Was Paid`}
|
|
@@ -404,9 +422,15 @@ function ExecutiveResearch({ ticker, listing, focused, width, nested, guard }: {
|
|
|
404
422
|
<ExecutiveRows
|
|
405
423
|
rows={statement.namedExecutives}
|
|
406
424
|
width={proseWidth}
|
|
425
|
+
breakdown={!preview}
|
|
407
426
|
/>
|
|
408
427
|
</Box>
|
|
409
428
|
)}
|
|
429
|
+
{preview && (
|
|
430
|
+
<Box marginTop={1}>
|
|
431
|
+
<UpgradeLabel text="Upgrade for the pay breakdown and earlier years" onPress={openUpgrade} role="exec-upgrade" />
|
|
432
|
+
</Box>
|
|
433
|
+
)}
|
|
410
434
|
{statement.highlights && (
|
|
411
435
|
<Box flexDirection="column">
|
|
412
436
|
<SectionHeading marginTop={1} title="What Changed" />
|
|
@@ -110,10 +110,10 @@ export async function loadMarketMoverTab(
|
|
|
110
110
|
return { ...result, quotes: quotes.slice(0, LIST_ROWS), rankedBy, tab };
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
/** The server refused the list for the account's plan. */
|
|
113
|
+
/** The server refused the list for the account's plan (gaps, without Pro). */
|
|
114
114
|
export class SessionMoversAccessError extends Error {
|
|
115
115
|
constructor() {
|
|
116
|
-
super("
|
|
116
|
+
super("Gap lists need Pro");
|
|
117
117
|
this.name = "SessionMoversAccessError";
|
|
118
118
|
}
|
|
119
119
|
}
|
|
@@ -8,7 +8,6 @@ import type { PluginModule } from "../plugin-module";
|
|
|
8
8
|
import { TICKER_RESEARCH_PANE_ID } from "../../../types/config";
|
|
9
9
|
import { publicTickerKey } from "../../../utils/exchanges";
|
|
10
10
|
import { usePaneSettingValue } from "../../../state/app/context";
|
|
11
|
-
import { usePlanAccess } from "../../../api-client/plan-access";
|
|
12
11
|
import { useAssetData, usePluginPaneState, usePluginTickerActions } from "../../runtime";
|
|
13
12
|
import { useLiveQuoteEntries } from "../../../state/hooks/quote-streaming";
|
|
14
13
|
import { useAutoRefresh } from "../../../react/auto-refresh";
|
|
@@ -87,7 +86,6 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
|
|
|
87
86
|
const [savedTab, setSavedTab] = usePluginPaneState<TabId>("activeTab", tabs[0]!.id);
|
|
88
87
|
const [pickedIn, setPickedIn] = usePluginPaneState<string | null>("activeTabSession", null);
|
|
89
88
|
const [requestedTab, setRequestedTab] = usePluginPaneState<TabId | null>("requestedTab", null);
|
|
90
|
-
const access = usePlanAccess();
|
|
91
89
|
const session = useUsSession();
|
|
92
90
|
const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]);
|
|
93
91
|
const activeTab = resolveActiveTab({
|
|
@@ -96,7 +94,6 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
|
|
|
96
94
|
pickedIn,
|
|
97
95
|
session,
|
|
98
96
|
requested: requestedTab,
|
|
99
|
-
sessionListsOpen: access.signedIn && access.emailVerified && access.hasProAccess,
|
|
100
97
|
});
|
|
101
98
|
|
|
102
99
|
// The index summary is a quote board like any other, so it runs on the shared
|
|
@@ -24,6 +24,7 @@ import { summaryFooterSegments } from "./footer";
|
|
|
24
24
|
import type { MarketSummaryQuote } from "./screener";
|
|
25
25
|
import {
|
|
26
26
|
earlierSessionLabel,
|
|
27
|
+
listAsOfLabel,
|
|
27
28
|
overlaySessionMovers,
|
|
28
29
|
resolveSide,
|
|
29
30
|
sessionListIsLive,
|
|
@@ -46,7 +47,13 @@ interface LoadedList {
|
|
|
46
47
|
payload: CloudSessionMoversPayload;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Pre-market, after-hours and gap lists. Pre-market and after hours open on
|
|
52
|
+
* every plan: without Pro the server answers with the list as it was built a
|
|
53
|
+
* delay before (`access: "delayed"`), as old as the account's quotes, and the
|
|
54
|
+
* footer gives that time. Gaps are Pro only, like the ranked lists they sit
|
|
55
|
+
* beside.
|
|
56
|
+
*/
|
|
50
57
|
export function SessionMoversBody(props: {
|
|
51
58
|
view: CloudSessionMoversCategory;
|
|
52
59
|
session: UsSession;
|
|
@@ -57,29 +64,31 @@ export function SessionMoversBody(props: {
|
|
|
57
64
|
liveStreaming: boolean;
|
|
58
65
|
}) {
|
|
59
66
|
const access = usePlanAccess();
|
|
67
|
+
const proOnly = props.view === "gaps";
|
|
60
68
|
const [denied, setDenied] = useState(false);
|
|
61
69
|
const deny = useCallback(() => setDenied(true), []);
|
|
62
70
|
// A refusal is about the plan the account had then; a new plan asks again.
|
|
63
71
|
useEffect(() => setDenied(false), [access.signedIn, access.emailVerified, access.hasProAccess]);
|
|
64
|
-
const entitled = access.emailVerified && access.hasProAccess && !denied;
|
|
72
|
+
const entitled = !proOnly || (access.emailVerified && access.hasProAccess && !denied);
|
|
65
73
|
// Behind a wall the footer still carries the index summary; the list registers its own.
|
|
66
74
|
usePaneFooter("market-movers-wall", () => (
|
|
67
75
|
entitled ? null : { info: summaryFooterSegments(props.summaryQuotes) }
|
|
68
76
|
), [entitled, props.summaryQuotes]);
|
|
69
77
|
|
|
70
|
-
if (!access.signedIn) return <SignInWall placement="most-signin" width={props.width} height={props.height} action="see
|
|
71
|
-
if (!access.emailVerified) return <SignInWall placement="most-signin" width={props.width} height={props.height} action="see
|
|
78
|
+
if (proOnly && !access.signedIn) return <SignInWall placement="most-signin" width={props.width} height={props.height} action="see gap movers" />;
|
|
79
|
+
if (proOnly && !access.emailVerified) return <SignInWall placement="most-signin" width={props.width} height={props.height} action="see gap movers" needsVerification />;
|
|
72
80
|
if (!entitled) {
|
|
73
81
|
return (
|
|
74
82
|
<ProWall
|
|
75
83
|
placement="most-wall"
|
|
76
84
|
width={props.width} height={props.height}
|
|
77
|
-
title="
|
|
78
|
-
message="
|
|
85
|
+
title="Gap movers are part of Gloom Cloud Pro."
|
|
86
|
+
message="Every listed US stock that opened away from its prior close, with relative volume, VWAP, float and catalysts."
|
|
79
87
|
/>
|
|
80
88
|
);
|
|
81
89
|
}
|
|
82
|
-
|
|
90
|
+
// A new plan reads the list again: live with Pro, a delay before without.
|
|
91
|
+
return <SessionMoversTable key={access.hasProAccess ? "pro" : "free"} {...props} onDenied={proOnly ? deny : undefined} />;
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
function SessionMoversTable({ view, session, focused, width, summaryQuotes, liveStreaming, onDenied }: {
|
|
@@ -89,7 +98,8 @@ function SessionMoversTable({ view, session, focused, width, summaryQuotes, live
|
|
|
89
98
|
width: number;
|
|
90
99
|
summaryQuotes: MarketSummaryQuote[];
|
|
91
100
|
liveStreaming: boolean;
|
|
92
|
-
|
|
101
|
+
/** Gaps: the server refused the list for the account's plan. */
|
|
102
|
+
onDenied?: () => void;
|
|
93
103
|
}) {
|
|
94
104
|
const { pinTicker } = usePluginTickerActions();
|
|
95
105
|
const [savedSide, setSavedSide] = usePluginPaneState<CloudSessionMoversSide>("sessionSide", "up");
|
|
@@ -106,7 +116,10 @@ function SessionMoversTable({ view, session, focused, width, summaryQuotes, live
|
|
|
106
116
|
|
|
107
117
|
const payload = loaded?.key === listKey ? loaded.payload : null;
|
|
108
118
|
const listRows = payload?.items ?? NO_ROWS;
|
|
109
|
-
|
|
119
|
+
// A free account's list is the one built a delay before: no live prices on its rows.
|
|
120
|
+
const delayed = payload?.access === "delayed";
|
|
121
|
+
const sessionTrading = !!payload && sessionListIsLive(view, payload.session, session);
|
|
122
|
+
const live = sessionTrading && !delayed;
|
|
110
123
|
// While the list's session trades it is rebuilt every minute, and a failed or
|
|
111
124
|
// stale answer is asked for again as often; a finished list does not change,
|
|
112
125
|
// so it follows the refresh interval the user set.
|
|
@@ -126,7 +139,7 @@ function SessionMoversTable({ view, session, focused, width, summaryQuotes, live
|
|
|
126
139
|
setLastLoadedAt(Date.now());
|
|
127
140
|
} catch (error) {
|
|
128
141
|
if (fetchGenRef.current !== gen) return;
|
|
129
|
-
if (error instanceof SessionMoversAccessError) {
|
|
142
|
+
if (error instanceof SessionMoversAccessError && onDenied) {
|
|
130
143
|
onDenied();
|
|
131
144
|
return;
|
|
132
145
|
}
|
|
@@ -172,11 +185,14 @@ function SessionMoversTable({ view, session, focused, width, summaryQuotes, live
|
|
|
172
185
|
}, [rows, selectedId]);
|
|
173
186
|
|
|
174
187
|
const sessionLabel = payload ? earlierSessionLabel(payload.session, session) : null;
|
|
188
|
+
// While its session trades, a free list says when it was built; a finished one is final and its date says enough.
|
|
189
|
+
const asOfLabel = payload && delayed && sessionTrading ? listAsOfLabel(payload.asOf, payload.session, session) : null;
|
|
175
190
|
usePaneFooter("market-movers", () => ({
|
|
176
191
|
info: [
|
|
177
192
|
...(loadError ? [{ id: "load-error", parts: [{ text: loadError, tone: "warning" as const }] }] : []),
|
|
178
193
|
...summaryFooterSegments(summaryQuotes),
|
|
179
194
|
...(sessionLabel ? [{ id: "session", parts: [{ text: sessionLabel, tone: "muted" as const }] }] : []),
|
|
195
|
+
...(asOfLabel ? [{ id: "as-of", asOf: true, parts: [{ text: asOfLabel, tone: "muted" as const }] }] : []),
|
|
180
196
|
...(loading ? [{ id: "loading", parts: [{ text: "loading", tone: "muted" as const }] }] : []),
|
|
181
197
|
...(feedStatus ? [{
|
|
182
198
|
id: "feed",
|
|
@@ -184,7 +200,7 @@ function SessionMoversTable({ view, session, focused, width, summaryQuotes, live
|
|
|
184
200
|
}] : []),
|
|
185
201
|
...(payload?.stale ? [{ id: "stale", parts: [{ text: "stale", tone: "muted" as const }] }] : []),
|
|
186
202
|
],
|
|
187
|
-
}), [feedStatus, loadError, loading, payload?.stale, sessionLabel, summaryQuotes]);
|
|
203
|
+
}), [asOfLabel, feedStatus, loadError, loading, payload?.stale, sessionLabel, summaryQuotes]);
|
|
188
204
|
|
|
189
205
|
const openRow = useCallback((row: SessionMoverRow) => {
|
|
190
206
|
pinTicker(rowKey(row), { floating: true, paneType: TICKER_RESEARCH_PANE_ID, instrument: null });
|
|
@@ -11,7 +11,7 @@ import { buildQuoteKey, resolveEntryData } from "../../../market-data/selectors"
|
|
|
11
11
|
import type { QueryEntry } from "../../../market-data/result-types";
|
|
12
12
|
import type { Quote } from "../../../types/financials";
|
|
13
13
|
import { compareSortValues, type SortDirection } from "../../../utils/sort-values";
|
|
14
|
-
import { zonedDateKey, zonedWallClockToUtcMs } from "../../../utils/zoned-date-time";
|
|
14
|
+
import { zonedDateKey, zonedDateTimeParts, zonedWallClockToUtcMs } from "../../../utils/zoned-date-time";
|
|
15
15
|
import type { SessionTabId } from "./model";
|
|
16
16
|
|
|
17
17
|
const NEW_YORK = "America/New_York";
|
|
@@ -67,9 +67,8 @@ export function usSessionAt(now: number): UsSession {
|
|
|
67
67
|
* stays while the session it was picked in lasts; otherwise the pane follows
|
|
68
68
|
* the session that is trading: Pre-market before 09:30, the day's lists during
|
|
69
69
|
* the regular session, After hours after the close. Overnight and on closed
|
|
70
|
-
* days the last pick stands.
|
|
71
|
-
*
|
|
72
|
-
* onto a wall.
|
|
70
|
+
* days the last pick stands. Every plan opens the two lists it follows, a
|
|
71
|
+
* free one as of a delay before (session-body.tsx).
|
|
73
72
|
*/
|
|
74
73
|
export function resolveActiveTab<T extends string>(args: {
|
|
75
74
|
tabs: readonly T[];
|
|
@@ -77,14 +76,13 @@ export function resolveActiveTab<T extends string>(args: {
|
|
|
77
76
|
pickedIn: string | null | undefined;
|
|
78
77
|
session: UsSession;
|
|
79
78
|
requested?: string | null;
|
|
80
|
-
sessionListsOpen: boolean;
|
|
81
79
|
}): T {
|
|
82
80
|
const { tabs, session } = args;
|
|
83
81
|
const requested = tabs.find((tab) => tab === args.requested);
|
|
84
82
|
if (requested) return requested;
|
|
85
83
|
const saved = tabs.find((tab) => tab === args.saved) ?? tabs[0]!;
|
|
86
84
|
if (session.phase === "closed" || args.pickedIn === session.key) return saved;
|
|
87
|
-
if (session.phase === "regular"
|
|
85
|
+
if (session.phase === "regular") {
|
|
88
86
|
return REGULAR_TABS.has(saved) ? saved : tabs.find((tab) => REGULAR_TABS.has(tab)) ?? saved;
|
|
89
87
|
}
|
|
90
88
|
const sessionTab = session.phase === "pre" ? "premarket" : "afterhours";
|
|
@@ -223,11 +221,28 @@ export function sortSessionRows(rows: SessionMoverRow[], preference: SessionMove
|
|
|
223
221
|
return [...rows].sort((left, right) => compareSortValues(sortValue(columnId, left), sortValue(columnId, right), preference.direction));
|
|
224
222
|
}
|
|
225
223
|
|
|
224
|
+
function shortDay(date: string): string | null {
|
|
225
|
+
const day = new Date(`${date}T12:00:00Z`);
|
|
226
|
+
return Number.isFinite(day.getTime())
|
|
227
|
+
? day.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
|
|
228
|
+
: null;
|
|
229
|
+
}
|
|
230
|
+
|
|
226
231
|
/** The session date when it is not today's, e.g. last evening's after-hours list. */
|
|
227
232
|
export function earlierSessionLabel(listSession: string | null, session: UsSession): string | null {
|
|
228
233
|
if (!listSession || listSession === session.date) return null;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
234
|
+
return shortDay(listSession);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* When a free account's list was built, New York time: "as of 07:42 ET", and
|
|
239
|
+
* "as of Oct 19 19:58 ET" when that is not on the list's own session day.
|
|
240
|
+
*/
|
|
241
|
+
export function listAsOfLabel(asOf: string, listSession: string | null, session: UsSession): string | null {
|
|
242
|
+
const at = Date.parse(asOf);
|
|
243
|
+
if (!Number.isFinite(at)) return null;
|
|
244
|
+
const { hour, minute } = zonedDateTimeParts(at, NEW_YORK);
|
|
245
|
+
const clock = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} ET`;
|
|
246
|
+
const day = zonedDateKey(at, NEW_YORK);
|
|
247
|
+
return day === (listSession ?? session.date) ? `as of ${clock}` : `as of ${shortDay(day) ?? day} ${clock}`;
|
|
233
248
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PluginConfigState } from "../../../../types/plugin";
|
|
2
|
+
import { logFailure } from "../../../../utils/log-failure";
|
|
2
3
|
import { DEFAULT_FEEDS } from "./default-feeds";
|
|
3
4
|
import { hashString } from "./hash";
|
|
4
5
|
import type { RssFeedConfig } from "./rss/parser";
|
|
@@ -30,7 +31,8 @@ function parseJsonArray(value: unknown): unknown[] {
|
|
|
30
31
|
try {
|
|
31
32
|
const parsed = JSON.parse(value);
|
|
32
33
|
return Array.isArray(parsed) ? parsed : [];
|
|
33
|
-
} catch {
|
|
34
|
+
} catch (error) {
|
|
35
|
+
logFailure("news.feed-config", "Read stored news feed settings failed")(error);
|
|
34
36
|
return [];
|
|
35
37
|
}
|
|
36
38
|
}
|
|
@@ -93,6 +95,7 @@ function normalizeUserFeed(value: unknown): RssFeedConfig | null {
|
|
|
93
95
|
enabled: record.enabled !== false,
|
|
94
96
|
});
|
|
95
97
|
} catch {
|
|
98
|
+
// Intentional ignore: invalid stored feed entries are omitted during normalization.
|
|
96
99
|
return null;
|
|
97
100
|
}
|
|
98
101
|
}
|
|
@@ -31,6 +31,7 @@ const isWebUrl = (value: unknown): value is string => {
|
|
|
31
31
|
const url = new URL(value);
|
|
32
32
|
return url.protocol === "https:" || url.protocol === "http:";
|
|
33
33
|
} catch {
|
|
34
|
+
// Intentional ignore: malformed links fail letter validation like other invalid fields.
|
|
34
35
|
return false;
|
|
35
36
|
}
|
|
36
37
|
};
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
GloomPluginContext,
|
|
9
9
|
} from "../../../../../types/plugin";
|
|
10
10
|
import { formatFeedTime } from "../../../../../utils/datetime-format";
|
|
11
|
+
import { logFailure } from "../../../../../utils/log-failure";
|
|
11
12
|
import { openNewsStoryPane } from "./pop-out";
|
|
12
13
|
import { NEWS_QUERY_PRESETS } from "./query-presets";
|
|
13
14
|
|
|
@@ -75,9 +76,11 @@ let warming: Promise<unknown> | null = null;
|
|
|
75
76
|
*/
|
|
76
77
|
async function warmLatestStories(service: NewsService): Promise<void> {
|
|
77
78
|
if (service.getQueryState(NEWS_QUERY_PRESETS.feed).articles.length > 0) return;
|
|
78
|
-
warming ??= service.load(NEWS_QUERY_PRESETS.feed)
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
warming ??= service.load(NEWS_QUERY_PRESETS.feed)
|
|
80
|
+
.catch(logFailure("news.command-bar-search", "Load news for command bar search failed", null))
|
|
81
|
+
.finally(() => {
|
|
82
|
+
warming = null;
|
|
83
|
+
});
|
|
81
84
|
await warming;
|
|
82
85
|
}
|
|
83
86
|
|
|
@@ -14,6 +14,7 @@ import { useInlineTickers } from "../../../../../state/hooks/inline-tickers";
|
|
|
14
14
|
import { truncateToDisplayWidth } from "../../../../../utils/format";
|
|
15
15
|
import { wrapTextLines } from "../../../../../utils/text-wrap";
|
|
16
16
|
import { formatDetailDate } from "../../../../../utils/datetime-format";
|
|
17
|
+
import { logFailure } from "../../../../../utils/log-failure";
|
|
17
18
|
import { mergeNewsArticle } from "../../../../../news/news-model";
|
|
18
19
|
import { usePluginPaneState } from "../../../../runtime";
|
|
19
20
|
import { formatNewsCategory } from "../categories";
|
|
@@ -277,7 +278,7 @@ export function NewsDetailView({ item, focused, width, showTitle = true }: {
|
|
|
277
278
|
}).then((choice) => {
|
|
278
279
|
const url = choice ? coverage[Number(choice)]?.url : undefined;
|
|
279
280
|
if (url) void rendererHost.openExternal(url);
|
|
280
|
-
}).catch((
|
|
281
|
+
}).catch(logFailure("news.detail-view", "Choose news coverage failed"));
|
|
281
282
|
}, [coverage, dialog, rendererHost]);
|
|
282
283
|
usePaneFooter("news-detail:story", () => ({
|
|
283
284
|
order: -1,
|
|
@@ -15,6 +15,7 @@ import { colors } from "../../../../../theme/colors";
|
|
|
15
15
|
import { collectNewsDisplayTickers } from "../../../../../news/ticker-symbols";
|
|
16
16
|
import { useLoadNewsStory } from "../../../../../news/hooks";
|
|
17
17
|
import { formatFeedTime } from "../../../../../utils/datetime-format";
|
|
18
|
+
import { logFailure } from "../../../../../utils/log-failure";
|
|
18
19
|
import { nextHeaderSort } from "../../../../../utils/sort-values";
|
|
19
20
|
import { formatNewsCategory } from "../categories";
|
|
20
21
|
import { useOpenTickerChoice } from "../../../shared/ticker-choice";
|
|
@@ -270,7 +271,7 @@ export function NewsArticleStackView({
|
|
|
270
271
|
const loadNewsStory = useLoadNewsStory();
|
|
271
272
|
const prefetchStory = useCallback((article: MarketNewsItem) => {
|
|
272
273
|
if ((article.items?.length ?? 0) > 0) return;
|
|
273
|
-
void loadNewsStory(article.id).catch((
|
|
274
|
+
void loadNewsStory(article.id).catch(logFailure("news.table", "Prefetch news story failed"));
|
|
274
275
|
}, [loadNewsStory]);
|
|
275
276
|
|
|
276
277
|
// A crosshair pinned in the tab picks the newest story out by then, and
|
|
@@ -14,7 +14,12 @@ export interface FeedElement {
|
|
|
14
14
|
|
|
15
15
|
export function resolveFeedUrl(value: string, base: string): string {
|
|
16
16
|
if (!value.trim()) return "";
|
|
17
|
-
try {
|
|
17
|
+
try {
|
|
18
|
+
return new URL(value.trim(), base).href;
|
|
19
|
+
} catch {
|
|
20
|
+
// Intentional ignore: malformed optional feed links are treated as absent.
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
// Read the structure of a syndication document without executing declarations or
|
|
@@ -14,6 +14,7 @@ export function isManagedNewsFeedUrl(value: string): boolean {
|
|
|
14
14
|
const url = new URL(value);
|
|
15
15
|
return url.origin === "https://api.gloom.sh" && url.pathname.replace(/\/$/, "") === "/news/rss";
|
|
16
16
|
} catch {
|
|
17
|
+
// Intentional ignore: malformed URLs cannot identify the managed news feed.
|
|
17
18
|
return false;
|
|
18
19
|
}
|
|
19
20
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { apiClient } from "../../../api-client";
|
|
2
2
|
import { withCliServices, withConfigData } from "../../../cli/scoped-context";
|
|
3
|
+
import type { CliServicesContext } from "../../../cli/types";
|
|
3
4
|
import { CLI_COMMAND_GROUPS } from "../../../cli/help";
|
|
4
5
|
import { dryRunNote } from "../../../cli/helpers";
|
|
5
6
|
import { requireArg, takeOption } from "../../../cli/commands/command-utils";
|
|
@@ -7,9 +8,10 @@ import { EXCHANGE_OPTION, listingReadNotes, requireListingArg } from "../../../c
|
|
|
7
8
|
import { createPluginPersistence } from "../../plugin-persistence";
|
|
8
9
|
import type { CliCommandDef } from "../../../types/plugin";
|
|
9
10
|
import { cliStyles } from "../../../utils/cli-output";
|
|
11
|
+
import { errorMessage } from "../../../utils/errors";
|
|
10
12
|
import { logFailure } from "../../../utils/log-failure";
|
|
11
13
|
import { readChatSessionState } from "../chat/controller/persistence";
|
|
12
|
-
import { exportNotesToDirectory, type NotesExportSource } from "./export";
|
|
14
|
+
import { describeExportSkip, exportNotesToDirectory, type NotesExportSource } from "./export";
|
|
13
15
|
import { NotesFiles } from "./files";
|
|
14
16
|
import { CloudNotesStore } from "./store";
|
|
15
17
|
|
|
@@ -42,29 +44,52 @@ export const notesCliCommand: CliCommandDef = {
|
|
|
42
44
|
// (personal and every team), one folder each.
|
|
43
45
|
const dir = args[1] ?? `${config.dataDir}/notes-export-${new Date().toISOString().slice(0, 10)}`;
|
|
44
46
|
const sources: NotesExportSource[] = [{ label: "local", store: notes }];
|
|
45
|
-
|
|
47
|
+
// A Cloud source left out or unreadable is a warning, as `quote` reports
|
|
48
|
+
// a symbol it found nothing for: the local files are always written.
|
|
49
|
+
const warnings: string[] = [];
|
|
50
|
+
const leftOut = (what: string, failure: string) => (error: unknown) => {
|
|
51
|
+
logFailure("notes.cli", failure)(error);
|
|
52
|
+
warnings.push(`${what} (${errorMessage(error)}).`);
|
|
53
|
+
return null;
|
|
54
|
+
};
|
|
55
|
+
const addCloudSources = async (services: CliServicesContext) => {
|
|
46
56
|
const cloudPersistence = createPluginPersistence(services.persistence.pluginState, services.persistence.resources, "plugin:gloomberb-cloud", "gloomberb-cloud");
|
|
47
57
|
const session = readChatSessionState(cloudPersistence, null);
|
|
48
58
|
if (!session?.sessionToken) return;
|
|
49
59
|
apiClient.setSessionToken(session.sessionToken);
|
|
50
60
|
const user = await apiClient.ensureVerifiedSession()
|
|
51
|
-
.catch(
|
|
61
|
+
.catch(leftOut("Skipped Gloom Cloud notes: the saved session could not be checked", "Verify the saved session for the notes export failed"));
|
|
52
62
|
if (!user) return;
|
|
53
63
|
const notesPersistence = createPluginPersistence(services.persistence.pluginState, services.persistence.resources, "plugin:notes", "notes");
|
|
54
64
|
sources.push({ label: "mine", store: new CloudNotesStore({ kind: "user" }, notesPersistence) });
|
|
55
|
-
const teams = await apiClient.listTeams()
|
|
65
|
+
const teams = await apiClient.listTeams()
|
|
66
|
+
.catch(leftOut("Skipped team notes: the team list could not be read", "List teams for the notes export failed")) ?? [];
|
|
56
67
|
for (const team of teams) {
|
|
57
68
|
sources.push({ label: team.name, store: new CloudNotesStore({ kind: "team", teamId: team.id }, notesPersistence) });
|
|
58
69
|
}
|
|
70
|
+
};
|
|
71
|
+
// The Cloud stores read and cache through these services, so the
|
|
72
|
+
// export runs before they close.
|
|
73
|
+
const result = await withCliServices(ctx, async (services) => {
|
|
74
|
+
await addCloudSources(services);
|
|
75
|
+
return ctx.cliOptions.dryRun ? null : await exportNotesToDirectory(dir, sources);
|
|
59
76
|
});
|
|
60
|
-
if (
|
|
61
|
-
ctx.printResult({ data: { dryRun: true, dir, sources: sources.map((source) => source.label) } }, {
|
|
77
|
+
if (!result) {
|
|
78
|
+
ctx.printResult({ data: { dryRun: true, dir, sources: sources.map((source) => source.label) }, ...(warnings.length > 0 ? { warnings } : {}) }, {
|
|
62
79
|
text: (data) => `Would export ${data.sources.join(", ")} notes to ${data.dir}.${dryRunNote(true)}`,
|
|
63
80
|
});
|
|
64
81
|
return;
|
|
65
82
|
}
|
|
66
|
-
|
|
67
|
-
ctx.printResult({
|
|
83
|
+
warnings.push(...result.skipped.map(describeExportSkip));
|
|
84
|
+
ctx.printResult({
|
|
85
|
+
data: {
|
|
86
|
+
dir: result.dir,
|
|
87
|
+
files: result.files,
|
|
88
|
+
sources: result.exported,
|
|
89
|
+
...(result.skipped.length > 0 ? { skipped: result.skipped } : {}),
|
|
90
|
+
},
|
|
91
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
92
|
+
}, {
|
|
68
93
|
text: (data) => `Exported ${data.files} notes from ${data.sources.join(", ")} to ${data.dir}.`,
|
|
69
94
|
});
|
|
70
95
|
return;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { errorMessage } from "../../../utils/errors";
|
|
1
2
|
import type { NoteFileEntry, QuickNoteEntry } from "./files";
|
|
2
3
|
import type { NotesStore } from "./store";
|
|
3
4
|
import { noteOwnerKey, splitNoteKey } from "./store";
|
|
@@ -7,9 +8,23 @@ export interface NotesExportSource {
|
|
|
7
8
|
store: NotesStore;
|
|
8
9
|
}
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* What an export left out, and why: a whole source whose notes could not be
|
|
13
|
+
* listed, or with `note`, one note of that source that could not be read.
|
|
14
|
+
*/
|
|
15
|
+
export interface NotesExportSkip {
|
|
16
|
+
label: string;
|
|
17
|
+
/** The note's symbol, or a quick note's title. */
|
|
18
|
+
note?: string;
|
|
19
|
+
reason: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
export interface NotesExportResult {
|
|
11
23
|
dir: string;
|
|
12
24
|
files: number;
|
|
25
|
+
/** The sources whose notes were written, in the order given. */
|
|
26
|
+
exported: string[];
|
|
27
|
+
skipped: NotesExportSkip[];
|
|
13
28
|
}
|
|
14
29
|
|
|
15
30
|
function safeFileName(value: string): string {
|
|
@@ -29,16 +44,27 @@ export async function exportNotesToDirectory(
|
|
|
29
44
|
const fsModulePath = "fs/promises";
|
|
30
45
|
const { mkdir, writeFile } = await import(fsModulePath) as typeof import("fs/promises");
|
|
31
46
|
let files = 0;
|
|
47
|
+
const exported: string[] = [];
|
|
48
|
+
const skipped: NotesExportResult["skipped"] = [];
|
|
32
49
|
for (const source of sources) {
|
|
33
50
|
const folder = sources.length > 1 ? `${dir}/${safeFileName(source.label)}` : dir;
|
|
34
51
|
let entries: NoteFileEntry[];
|
|
35
52
|
let quick: QuickNoteEntry[];
|
|
53
|
+
const unreadable: Array<{ key: string; error: unknown }> = [];
|
|
36
54
|
try {
|
|
37
|
-
[entries, quick] = await Promise.all([
|
|
38
|
-
|
|
55
|
+
[entries, quick] = await Promise.all([
|
|
56
|
+
source.store.list({ strict: true, skipped: (key, error) => unreadable.push({ key, error }) }),
|
|
57
|
+
source.store.loadQuickNotesIndex(),
|
|
58
|
+
]);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
skipped.push({ label: source.label, reason: errorMessage(error) });
|
|
39
61
|
continue;
|
|
40
62
|
}
|
|
41
63
|
const titles = new Map(quick.map((entry) => [entry.id, entry.title]));
|
|
64
|
+
for (const { key: noteKey, error } of unreadable) {
|
|
65
|
+
const { kind, key } = splitNoteKey(noteKey);
|
|
66
|
+
skipped.push({ label: source.label, note: kind === "quick" ? (titles.get(key) ?? key) : key, reason: errorMessage(error) });
|
|
67
|
+
}
|
|
42
68
|
for (const entry of entries) {
|
|
43
69
|
const { kind, key } = splitNoteKey(entry.key);
|
|
44
70
|
const path = kind === "quick"
|
|
@@ -48,8 +74,28 @@ export async function exportNotesToDirectory(
|
|
|
48
74
|
await writeFile(path, entry.text, "utf-8");
|
|
49
75
|
files += 1;
|
|
50
76
|
}
|
|
77
|
+
exported.push(source.label);
|
|
51
78
|
}
|
|
52
|
-
return { dir, files };
|
|
79
|
+
return { dir, files, exported, skipped };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One line per skip, as `notes export` warns and the Export Notes command reports. */
|
|
83
|
+
export function describeExportSkip(skip: NotesExportSkip): string {
|
|
84
|
+
return skip.note === undefined
|
|
85
|
+
? `Skipped ${skip.label}: its notes could not be read (${skip.reason}).`
|
|
86
|
+
: `Skipped the ${skip.label} note ${skip.note}: it could not be read (${skip.reason}).`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The Export Notes command's notice: the count, then what was skipped. */
|
|
90
|
+
export function exportNotice(result: NotesExportResult): { body: string; type: "success" | "error" } {
|
|
91
|
+
const exported = `Exported ${result.files} ${result.files === 1 ? "note" : "notes"} to ${result.dir}`;
|
|
92
|
+
if (result.skipped.length === 0) return { body: exported, type: "success" };
|
|
93
|
+
const shown = result.skipped.slice(0, 3).map(describeExportSkip);
|
|
94
|
+
const more = result.skipped.length - shown.length;
|
|
95
|
+
return {
|
|
96
|
+
body: [`${exported}.`, ...shown, ...(more > 0 ? [`${more} more could not be read.`] : [])].join(" "),
|
|
97
|
+
type: "error",
|
|
98
|
+
};
|
|
53
99
|
}
|
|
54
100
|
|
|
55
101
|
export function exportSourceLabel(store: NotesStore, teamName?: string): string {
|