gloomberb 0.11.2 → 0.11.3

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 (68) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/api-client/data.ts +5 -0
  4. package/src/api-client/index.ts +5 -0
  5. package/src/api-client/types.ts +24 -0
  6. package/src/capabilities/chart-series.ts +3 -2
  7. package/src/cli/commands/market.ts +2 -2
  8. package/src/cli/pane-functions/capabilities.ts +3 -3
  9. package/src/cli/registry.ts +0 -22
  10. package/src/cli/result.ts +7 -10
  11. package/src/components/data-table/export.test.ts +21 -0
  12. package/src/components/data-table/export.ts +21 -0
  13. package/src/components/ui/data-table/index.tsx +19 -2
  14. package/src/data/config/chart-settings.ts +4 -4
  15. package/src/layout-marketplace/gallery-terminal.tsx +126 -96
  16. package/src/layout-marketplace/gallery.test.tsx +65 -1
  17. package/src/plugins/builtin/chart-composer/cli-options.ts +12 -9
  18. package/src/plugins/builtin/chart-composer/settings.ts +2 -13
  19. package/src/plugins/builtin/composite-plugins.ts +2 -0
  20. package/src/plugins/builtin/earnings/index.tsx +1 -0
  21. package/src/plugins/builtin/econ/index.tsx +1 -0
  22. package/src/plugins/builtin/futures/index.tsx +1 -0
  23. package/src/plugins/builtin/fx-matrix/index.tsx +1 -0
  24. package/src/plugins/builtin/ipo-calendar/index.tsx +1 -0
  25. package/src/plugins/builtin/market-halts/index.tsx +1 -0
  26. package/src/plugins/builtin/market-movers/index.tsx +1 -0
  27. package/src/plugins/builtin/options/analytics.test.ts +83 -0
  28. package/src/plugins/builtin/options/analytics.ts +110 -0
  29. package/src/plugins/builtin/options/index.tsx +17 -2
  30. package/src/plugins/builtin/options/table.ts +121 -51
  31. package/src/plugins/builtin/options/types.ts +22 -15
  32. package/src/plugins/builtin/options/view.test.tsx +24 -0
  33. package/src/plugins/builtin/options/view.tsx +91 -19
  34. package/src/plugins/builtin/plugin-module.test.ts +2 -4
  35. package/src/plugins/builtin/plugin-module.ts +0 -7
  36. package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
  37. package/src/plugins/builtin/scanner/index.ts +2 -0
  38. package/src/plugins/builtin/sectors/index.tsx +1 -0
  39. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +3 -5
  40. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  41. package/src/plugins/builtin/treasury-auctions/index.tsx +1 -0
  42. package/src/plugins/builtin/world-indices/index.tsx +1 -0
  43. package/src/plugins/builtin/world-venue-map/index.ts +27 -0
  44. package/src/plugins/builtin/world-venue-map/map.tsx +337 -0
  45. package/src/plugins/builtin/world-venue-map/model.test.ts +53 -0
  46. package/src/plugins/builtin/world-venue-map/model.ts +147 -0
  47. package/src/plugins/builtin/world-venue-map/pane.tsx +300 -0
  48. package/src/plugins/builtin/world-venue-map/world-outlines.ts +2 -0
  49. package/src/plugins/catalog-backend.ts +6 -44
  50. package/src/plugins/catalog-browser.ts +2 -0
  51. package/src/plugins/registry/pane-settings.test.ts +53 -0
  52. package/src/plugins/registry/pane-settings.ts +36 -3
  53. package/src/renderers/browser/ui-host.tsx +11 -0
  54. package/src/renderers/electrobun/bun/desktop/host-requests.ts +9 -2
  55. package/src/renderers/electrobun/bun/index.ts +1 -0
  56. package/src/renderers/electrobun/shared/protocol.ts +4 -0
  57. package/src/renderers/electrobun/view/ui-host.tsx +3 -0
  58. package/src/renderers/opentui/host.tsx +4 -0
  59. package/src/state/pane-table-export-registry.ts +31 -0
  60. package/src/time-series/range.ts +12 -0
  61. package/src/time-series/resolution.ts +3 -15
  62. package/src/time-series/spec.ts +5 -5
  63. package/src/types/plugin.ts +2 -20
  64. package/src/ui/host.tsx +7 -0
  65. package/src/utils/csv.test.ts +13 -0
  66. package/src/utils/csv.ts +37 -0
  67. package/src/utils/save-text-file.test.ts +24 -0
  68. package/src/utils/save-text-file.ts +36 -0
@@ -10,6 +10,7 @@ import type {
10
10
  import { safeExternalUrl } from "../../../../utils/external-url";
11
11
  import { getContextMenuRequestId, normalizeContextMenuItems } from "../context-menu/normalize";
12
12
  import { MAIN_WINDOW_RPC_KEY } from "../window/focus";
13
+ import { saveTextFileToDownloads } from "../../../../utils/save-text-file";
13
14
 
14
15
  interface DesktopHostRequestOptions<TRpc> {
15
16
  clearMainWindow: () => void;
@@ -51,7 +52,7 @@ function normalizeWindowControlAction(action: unknown): DesktopWindowControlActi
51
52
  throw new Error("host.windowControl requires a valid action.");
52
53
  }
53
54
 
54
- export function handleDesktopHostRequest<TRpc>({
55
+ export async function handleDesktopHostRequest<TRpc>({
55
56
  clearMainWindow,
56
57
  closeAllDetachedWindows,
57
58
  controlWindowForRpcKey,
@@ -63,7 +64,7 @@ export function handleDesktopHostRequest<TRpc>({
63
64
  rpc,
64
65
  teardownServices,
65
66
  trackContextMenuRequest,
66
- }: DesktopHostRequestOptions<TRpc>): DesktopBackendRequestResponse<DesktopHostRequest["method"]> {
67
+ }: DesktopHostRequestOptions<TRpc>): Promise<DesktopBackendRequestResponse<DesktopHostRequest["method"]>> {
67
68
  switch (request.method) {
68
69
  case "host.restart":
69
70
  restartDesktopApp({
@@ -127,6 +128,12 @@ export function handleDesktopHostRequest<TRpc>({
127
128
  }
128
129
  case "host.readText":
129
130
  return Utils.clipboardReadText() ?? "";
131
+ case "host.saveTextFile": {
132
+ if (typeof request.payload.name !== "string" || typeof request.payload.text !== "string") {
133
+ throw new Error("host.saveTextFile requires a name and text.");
134
+ }
135
+ return saveTextFileToDownloads(request.payload.name, request.payload.text);
136
+ }
130
137
  case "host.notify":
131
138
  playNotificationSound(normalizeText(request.payload.sound));
132
139
  Utils.showNotification({
@@ -476,6 +476,7 @@ async function handleBackendRequest(
476
476
  case "host.focusWindow":
477
477
  case "host.copyPngImage":
478
478
  case "host.readText":
479
+ case "host.saveTextFile":
479
480
  case "host.notify":
480
481
  case "host.showContextMenu":
481
482
  return handleDesktopHostRequest({
@@ -119,6 +119,10 @@ export interface DesktopBackendRequestMap {
119
119
  "host.focusWindow": { request: null; response: null };
120
120
  "host.copyPngImage": { request: { pngBase64: string }; response: null };
121
121
  "host.readText": { request: null; response: string };
122
+ "host.saveTextFile": {
123
+ request: { name: string; text: string; mimeType: string };
124
+ response: string;
125
+ };
122
126
  "host.notify": {
123
127
  request: { title?: string; body?: string; subtitle?: string; sound?: string };
124
128
  response: null;
@@ -61,6 +61,9 @@ export const webRendererHost: RendererHost = {
61
61
  return backendRequest("host.readText");
62
62
  }
63
63
  },
64
+ async saveTextFile({ name, text, mimeType }) {
65
+ return backendRequest("host.saveTextFile", { name, text, mimeType });
66
+ },
64
67
  notify(notification) {
65
68
  void backendRequest("host.notify", {
66
69
  title: notification.title,
@@ -7,6 +7,7 @@ import type { NativeRendererHost, PixelResolution, RendererHost } from "../../ui
7
7
  import { colors } from "../../theme/colors";
8
8
  import { safeExternalUrl } from "../../utils/external-url";
9
9
  import { createTerminalMediaReaper, terminalMediaStateFile } from "./terminal-media";
10
+ import { saveTextFileToDownloads } from "../../utils/save-text-file";
10
11
 
11
12
  export { useKeyboard, useTerminalDimensions };
12
13
 
@@ -134,6 +135,9 @@ export async function createOpenTuiHost(): Promise<OpenTuiHost> {
134
135
  });
135
136
  return await new Response(proc.stdout).text();
136
137
  },
138
+ async saveTextFile({ name, text }) {
139
+ return saveTextFileToDownloads(name, text);
140
+ },
137
141
  notify() {
138
142
  // The app-level notifier still owns toast/desktop notification behavior.
139
143
  },
@@ -0,0 +1,31 @@
1
+ export type PaneTableExporter = (filename: string) => Promise<string>;
2
+
3
+ const exporters = new Map<string, Map<symbol, PaneTableExporter>>();
4
+
5
+ export function registerPaneTableExporter(
6
+ paneId: string,
7
+ exporter: PaneTableExporter,
8
+ ): () => void {
9
+ const token = Symbol(paneId);
10
+ const paneExporters = exporters.get(paneId) ?? new Map<symbol, PaneTableExporter>();
11
+ paneExporters.set(token, exporter);
12
+ exporters.set(paneId, paneExporters);
13
+
14
+ return () => {
15
+ const current = exporters.get(paneId);
16
+ current?.delete(token);
17
+ if (current?.size === 0) exporters.delete(paneId);
18
+ };
19
+ }
20
+
21
+ export function hasPaneTableExporter(paneId: string): boolean {
22
+ return exporters.get(paneId)?.size === 1;
23
+ }
24
+
25
+ export async function exportPaneTable(paneId: string, filename: string): Promise<string> {
26
+ const paneExporters = exporters.get(paneId);
27
+ if (paneExporters?.size !== 1) {
28
+ throw new Error("This pane does not have one active exportable table.");
29
+ }
30
+ return [...paneExporters.values()][0]!(filename);
31
+ }
@@ -7,3 +7,15 @@ export interface ChartDateWindow {
7
7
  }
8
8
 
9
9
  export const TIME_RANGES: TimeRange[] = ["1D", "1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"];
10
+ export const CHART_RESOLUTIONS: ChartResolution[] = [
11
+ "auto",
12
+ "1m",
13
+ "5m",
14
+ "15m",
15
+ "30m",
16
+ "45m",
17
+ "1h",
18
+ "1d",
19
+ "1wk",
20
+ "1mo",
21
+ ];
@@ -1,5 +1,5 @@
1
1
  import type { ChartResolution, TimeRange } from "./range";
2
- import { TIME_RANGES } from "./range";
2
+ import { CHART_RESOLUTIONS, TIME_RANGES } from "./range";
3
3
  import { isDateWindowWithinTimeRange } from "./date-window";
4
4
 
5
5
  export type ManualChartResolution = Exclude<ChartResolution, "auto">;
@@ -9,20 +9,8 @@ export interface ChartResolutionSupport {
9
9
  maxRange: TimeRange;
10
10
  }
11
11
 
12
- export const TIME_RANGE_ORDER: TimeRange[] = [...TIME_RANGES];
13
-
14
- const CHART_RESOLUTION_ORDER: ChartResolution[] = [
15
- "auto",
16
- "1m",
17
- "5m",
18
- "15m",
19
- "30m",
20
- "45m",
21
- "1h",
22
- "1d",
23
- "1wk",
24
- "1mo",
25
- ];
12
+ export const TIME_RANGE_ORDER = TIME_RANGES;
13
+ const CHART_RESOLUTION_ORDER = CHART_RESOLUTIONS;
26
14
 
27
15
  const CHART_RESOLUTION_LABELS: Record<ChartResolution, string> = {
28
16
  auto: "AUTO",
@@ -1,4 +1,4 @@
1
- import type { ChartResolution, TimeRange } from "./range";
1
+ import { CHART_RESOLUTIONS, TIME_RANGES, type ChartResolution, type TimeRange } from "./range";
2
2
  import { getChartResolutionLabel } from "./resolution";
3
3
  import {
4
4
  canonicalTimeSeriesFieldId,
@@ -31,8 +31,8 @@ import {
31
31
  isValidChartSeriesId,
32
32
  } from "../capabilities/chart-series";
33
33
 
34
- const TIME_RANGES = new Set<TimeRange>(["1D", "1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"]);
35
- const RESOLUTIONS = new Set<ChartResolution>(["auto", "1m", "5m", "15m", "30m", "45m", "1h", "1d", "1wk", "1mo"]);
34
+ const RANGE_SET = new Set<TimeRange>(TIME_RANGES);
35
+ const RESOLUTIONS = new Set<ChartResolution>(CHART_RESOLUTIONS);
36
36
  const PERIODS = new Set<SeriesPeriod>(["auto", "daily", "weekly", "monthly", "quarterly", "annual", "ttm"]);
37
37
  const STYLES = new Set<SeriesStyle>(["line", "area", "step", "columns", "points", "candles", "ohlc", "hlc"]);
38
38
  const ECONOMIC_STYLES = new Set<SeriesStyle>(["line", "area", "step", "columns", "points"]);
@@ -306,7 +306,7 @@ export function normalizeChartSpec(value: unknown, fallback: ChartSpec = DEFAULT
306
306
  return {
307
307
  version: CHART_SPEC_VERSION,
308
308
  viewport: {
309
- range: TIME_RANGES.has(viewport?.range as TimeRange)
309
+ range: RANGE_SET.has(viewport?.range as TimeRange)
310
310
  ? viewport!.range as TimeRange
311
311
  : fallbackCopy.viewport.range,
312
312
  resolution: RESOLUTIONS.has(viewport?.resolution as ChartResolution)
@@ -340,7 +340,7 @@ export function validateChartSpec(spec: ChartSpec): ChartSpecValidationResult {
340
340
  if (spec.version !== CHART_SPEC_VERSION) {
341
341
  errors.push(issue("version", "unsupported-version", `Unsupported chart spec version ${String(spec.version)}.`));
342
342
  }
343
- if (!TIME_RANGES.has(spec.viewport.range)) errors.push(issue("viewport.range", "invalid-range", "Invalid date range."));
343
+ if (!RANGE_SET.has(spec.viewport.range)) errors.push(issue("viewport.range", "invalid-range", "Invalid date range."));
344
344
  if (!RESOLUTIONS.has(spec.viewport.resolution)) {
345
345
  errors.push(issue("viewport.resolution", "invalid-resolution", "Invalid chart resolution."));
346
346
  }
@@ -67,6 +67,8 @@ export interface PaneDef {
67
67
  defaultMode?: "docked" | "floating";
68
68
  /** Pane publishes its selected symbol as pane-state `cursorSymbol`, so ticker panes can follow it. */
69
69
  tickerSource?: boolean;
70
+ /** Add an Excel-compatible CSV action for the pane's single active DataTable. */
71
+ tableExport?: true;
70
72
  settings?: PaneSettingsDef | ((context: PaneSettingsContext) => PaneSettingsDef | null);
71
73
  /** Portable sharing is public by default; list the few pane-owned fields that must remain local. */
72
74
  portableShare?: PanePortableShareDef;
@@ -356,25 +358,6 @@ export interface CliCommandDef {
356
358
  execute(args: string[], ctx: CliCommandContext): void | CliDispatchResult | Promise<void | CliDispatchResult>;
357
359
  }
358
360
 
359
- export interface PluginCliCommandDescriptor {
360
- name: string;
361
- aliases?: string[];
362
- summary: string;
363
- inputShape?: string;
364
- outputShape?: string;
365
- examples?: string[];
366
- sideEffectLevel?: "none" | "local-write" | "network-write" | "external-trade" | "external-side-effect";
367
- requirements?: string[];
368
- batch?: boolean;
369
- formats?: Array<"text" | "json" | "csv" | "ndjson">;
370
- safety?: string[];
371
- execute?: CliCommandDef["execute"];
372
- }
373
-
374
- export interface PluginCliDescriptor {
375
- commands?: PluginCliCommandDescriptor[];
376
- }
377
-
378
361
  export interface CommandDef {
379
362
  id: string;
380
363
  label: string;
@@ -602,7 +585,6 @@ export interface GloomPlugin {
602
585
  toggleable?: boolean;
603
586
  order?: number;
604
587
  cliCommands?: CliCommandDef[];
605
- cli?: PluginCliDescriptor;
606
588
 
607
589
  setup?(ctx: GloomPluginContext): void | Promise<void>;
608
590
  dispose?(): void;
package/src/ui/host.tsx CHANGED
@@ -378,6 +378,12 @@ export interface UiHost {
378
378
  colorFromHex?(hex: string): unknown;
379
379
  }
380
380
 
381
+ export interface SaveTextFileRequest {
382
+ name: string;
383
+ text: string;
384
+ mimeType: string;
385
+ }
386
+
381
387
  export interface RendererHost {
382
388
  requestExit(): void;
383
389
  startWindowDrag?(): Promise<void> | void;
@@ -386,6 +392,7 @@ export interface RendererHost {
386
392
  copyText(text: string): Promise<void>;
387
393
  copyPngImage?(pngBase64: string): Promise<void>;
388
394
  readText(): Promise<string>;
395
+ saveTextFile?(request: SaveTextFileRequest): Promise<string>;
389
396
  supportsNativeDesktopNotifications?: boolean;
390
397
  notify(notification: AppNotificationRequest): void;
391
398
  showContextMenu?(items: ContextMenuItem[]): Promise<boolean>;
@@ -0,0 +1,13 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { EXCEL_CSV_BOM, serializeCsv } from "./csv";
3
+
4
+ describe("serializeCsv", () => {
5
+ test("escapes cells and can emit an Excel UTF-8 BOM", () => {
6
+ const csv = serializeCsv([
7
+ ["Name", "Note", "Value"],
8
+ ["ACME, Inc.", "said \"hello\"\nagain", "=1+1"],
9
+ ], { excelCompatible: true });
10
+
11
+ expect(csv).toBe(`${EXCEL_CSV_BOM}Name,Note,Value\n"ACME, Inc.","said ""hello""\nagain",'=1+1`);
12
+ });
13
+ });
@@ -0,0 +1,37 @@
1
+ export const EXCEL_CSV_BOM = "\uFEFF";
2
+
3
+ function normalizeCsvCell(value: unknown): string {
4
+ if (value == null) return "";
5
+ if (value instanceof Date) return value.toISOString();
6
+ if (typeof value === "object") return JSON.stringify(value);
7
+ return String(value);
8
+ }
9
+
10
+ function escapeCsvCell(value: unknown, excelCompatible: boolean): string {
11
+ const normalized = normalizeCsvCell(value);
12
+ const text = excelCompatible && /^[\t\r ]*[=+\-@]/.test(normalized)
13
+ ? `'${normalized}`
14
+ : normalized;
15
+ if (!/[",\n\r]/.test(text)) return text;
16
+ return `"${text.replace(/"/g, '""')}"`;
17
+ }
18
+
19
+ export function serializeCsv(
20
+ rows: readonly (readonly unknown[])[],
21
+ options: { excelCompatible?: boolean } = {},
22
+ ): string {
23
+ const excelCompatible = options.excelCompatible === true;
24
+ const csv = rows.map((row) => row.map((cell) => escapeCsvCell(cell, excelCompatible)).join(",")).join("\n");
25
+ return excelCompatible ? `${EXCEL_CSV_BOM}${csv}` : csv;
26
+ }
27
+
28
+ export function createCsvExportFilename(title: string): string {
29
+ const stem = title.trim()
30
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "-")
31
+ .replace(/\s+/g, "-")
32
+ .replace(/-+/g, "-")
33
+ .replace(/^[. -]+|[. -]+$/g, "")
34
+ .slice(0, 80) || "gloomberb-table";
35
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
36
+ return `${stem}-${timestamp}.csv`;
37
+ }
@@ -0,0 +1,24 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { saveTextFileToDirectory } from "./save-text-file";
6
+
7
+ let directory: string | null = null;
8
+
9
+ afterEach(async () => {
10
+ if (directory) await rm(directory, { recursive: true, force: true });
11
+ directory = null;
12
+ });
13
+
14
+ test("keeps existing exports instead of overwriting them", async () => {
15
+ directory = await mkdtemp(join(tmpdir(), "gloomberb-export-"));
16
+
17
+ const first = await saveTextFileToDirectory(directory, "prices.csv", "first");
18
+ const second = await saveTextFileToDirectory(directory, "prices.csv", "second");
19
+
20
+ expect(first).toBe("prices.csv");
21
+ expect(second).toBe("prices (2).csv");
22
+ expect(await readFile(join(directory, first), "utf-8")).toBe("first");
23
+ expect(await readFile(join(directory, second), "utf-8")).toBe("second");
24
+ });
@@ -0,0 +1,36 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, extname, join } from "node:path";
4
+
5
+ function safeFilename(name: string): string {
6
+ const safe = basename(name.replace(/\\/g, "/"))
7
+ .replace(/[<>:"|?*\u0000-\u001f]/g, "-")
8
+ .replace(/[. ]+$/g, "");
9
+ return !safe || safe === "." || safe === ".." ? "gloomberb-export.txt" : safe;
10
+ }
11
+
12
+ export async function saveTextFileToDirectory(
13
+ directory: string,
14
+ name: string,
15
+ text: string,
16
+ ): Promise<string> {
17
+ await mkdir(directory, { recursive: true });
18
+ const safeName = safeFilename(name);
19
+ const extension = extname(safeName);
20
+ const stem = safeName.slice(0, safeName.length - extension.length);
21
+
22
+ for (let copy = 1; ; copy += 1) {
23
+ const filename = copy === 1 ? safeName : `${stem} (${copy})${extension}`;
24
+ try {
25
+ await writeFile(join(directory, filename), text, { encoding: "utf-8", flag: "wx" });
26
+ return filename;
27
+ } catch (error) {
28
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
29
+ }
30
+ }
31
+ }
32
+
33
+ export async function saveTextFileToDownloads(name: string, text: string): Promise<string> {
34
+ const filename = await saveTextFileToDirectory(join(homedir(), "Downloads"), name, text);
35
+ return `~/Downloads/${filename}`;
36
+ }