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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/api-client/data.ts +5 -0
- package/src/api-client/index.ts +5 -0
- package/src/api-client/types.ts +24 -0
- package/src/capabilities/chart-series.ts +3 -2
- package/src/cli/commands/market.ts +2 -2
- package/src/cli/pane-functions/capabilities.ts +3 -3
- package/src/cli/registry.ts +0 -22
- package/src/cli/result.ts +7 -10
- package/src/components/data-table/export.test.ts +21 -0
- package/src/components/data-table/export.ts +21 -0
- package/src/components/ui/data-table/index.tsx +19 -2
- package/src/data/config/chart-settings.ts +4 -4
- package/src/layout-marketplace/gallery-terminal.tsx +126 -96
- package/src/layout-marketplace/gallery.test.tsx +65 -1
- package/src/plugins/builtin/chart-composer/cli-options.ts +12 -9
- package/src/plugins/builtin/chart-composer/settings.ts +2 -13
- package/src/plugins/builtin/composite-plugins.ts +2 -0
- package/src/plugins/builtin/earnings/index.tsx +1 -0
- package/src/plugins/builtin/econ/index.tsx +1 -0
- package/src/plugins/builtin/futures/index.tsx +1 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +1 -0
- package/src/plugins/builtin/ipo-calendar/index.tsx +1 -0
- package/src/plugins/builtin/market-halts/index.tsx +1 -0
- package/src/plugins/builtin/market-movers/index.tsx +1 -0
- package/src/plugins/builtin/options/analytics.test.ts +83 -0
- package/src/plugins/builtin/options/analytics.ts +110 -0
- package/src/plugins/builtin/options/index.tsx +17 -2
- package/src/plugins/builtin/options/table.ts +121 -51
- package/src/plugins/builtin/options/types.ts +22 -15
- package/src/plugins/builtin/options/view.test.tsx +24 -0
- package/src/plugins/builtin/options/view.tsx +91 -19
- package/src/plugins/builtin/plugin-module.test.ts +2 -4
- package/src/plugins/builtin/plugin-module.ts +0 -7
- package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
- package/src/plugins/builtin/scanner/index.ts +2 -0
- package/src/plugins/builtin/sectors/index.tsx +1 -0
- package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +3 -5
- package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
- package/src/plugins/builtin/treasury-auctions/index.tsx +1 -0
- package/src/plugins/builtin/world-indices/index.tsx +1 -0
- package/src/plugins/builtin/world-venue-map/index.ts +27 -0
- package/src/plugins/builtin/world-venue-map/map.tsx +337 -0
- package/src/plugins/builtin/world-venue-map/model.test.ts +53 -0
- package/src/plugins/builtin/world-venue-map/model.ts +147 -0
- package/src/plugins/builtin/world-venue-map/pane.tsx +300 -0
- package/src/plugins/builtin/world-venue-map/world-outlines.ts +2 -0
- package/src/plugins/catalog-backend.ts +6 -44
- package/src/plugins/catalog-browser.ts +2 -0
- package/src/plugins/registry/pane-settings.test.ts +53 -0
- package/src/plugins/registry/pane-settings.ts +36 -3
- package/src/renderers/browser/ui-host.tsx +11 -0
- package/src/renderers/electrobun/bun/desktop/host-requests.ts +9 -2
- package/src/renderers/electrobun/bun/index.ts +1 -0
- package/src/renderers/electrobun/shared/protocol.ts +4 -0
- package/src/renderers/electrobun/view/ui-host.tsx +3 -0
- package/src/renderers/opentui/host.tsx +4 -0
- package/src/state/pane-table-export-registry.ts +31 -0
- package/src/time-series/range.ts +12 -0
- package/src/time-series/resolution.ts +3 -15
- package/src/time-series/spec.ts +5 -5
- package/src/types/plugin.ts +2 -20
- package/src/ui/host.tsx +7 -0
- package/src/utils/csv.test.ts +13 -0
- package/src/utils/csv.ts +37 -0
- package/src/utils/save-text-file.test.ts +24 -0
- 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({
|
|
@@ -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
|
+
}
|
package/src/time-series/range.ts
CHANGED
|
@@ -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
|
|
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",
|
package/src/time-series/spec.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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
|
|
35
|
-
const RESOLUTIONS = new Set<ChartResolution>(
|
|
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:
|
|
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 (!
|
|
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
|
}
|
package/src/types/plugin.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/utils/csv.ts
ADDED
|
@@ -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
|
+
}
|