Skip to content

Commit 9012f19

Browse files
committed
File cleanup
1 parent ba7d087 commit 9012f19

File tree

6 files changed

+216
-131
lines changed

6 files changed

+216
-131
lines changed

mito-ai/src/Extensions/ChartWizard/utils/chartExport.ts

Lines changed: 9 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -3,130 +3,15 @@
33
* Distributed under the terms of the GNU Affero General Public License v3.0 License.
44
*/
55

6-
import { CodeCell } from '@jupyterlab/cells';
76
import { ChartWizardData } from '../ChartWizardPlugin';
7+
import { findChartImageDataUrl } from './imageFinder';
8+
import { saveWithFilePicker, isFileSystemAccessAvailable } from './fileSaver';
9+
import { downloadImage } from './download';
10+
import { ExportImageFormat } from './types';
811

912
export type ExportChartResult = { success: true } | { success: false; error: string };
1013

11-
export type ExportImageFormat = 'png' | 'jpeg';
12-
13-
const SUGGESTED_NAMES: Record<ExportImageFormat, string> = {
14-
png: 'chart.png',
15-
jpeg: 'chart.jpg'
16-
};
17-
18-
type FindImageResult =
19-
| { ok: true; dataUrl: string }
20-
| { ok: false; error: string };
21-
22-
function findChartImageDataUrl(chartData: ChartWizardData): FindImageResult {
23-
const notebookPanel = chartData.notebookTracker.find(
24-
(panel) => panel.id === chartData.notebookPanelId
25-
);
26-
if (!notebookPanel) {
27-
return { ok: false, error: 'Could not find the notebook.' };
28-
}
29-
30-
const cellWidget = notebookPanel.content.widgets.find(
31-
(cell) => cell.model.id === chartData.cellId
32-
);
33-
if (!(cellWidget instanceof CodeCell)) {
34-
return { ok: false, error: 'Could not find the chart cell.' };
35-
}
36-
37-
const outputNode = cellWidget.outputArea.node;
38-
const img = outputNode.querySelector(
39-
'.jp-RenderedImage img[src^="data:image"]'
40-
) as HTMLImageElement | null;
41-
42-
if (!img || !img.src || !img.src.startsWith('data:image')) {
43-
return {
44-
ok: false,
45-
error: 'No chart image found. Re-run the chart cell and try again.'
46-
};
47-
}
48-
return { ok: true, dataUrl: img.src };
49-
}
50-
51-
const JPEG_QUALITY = 1.0; // Max quality
52-
53-
function dataUrlToJpegBlob(dataUrl: string): Promise<Blob> {
54-
return new Promise((resolve, reject) => {
55-
const img = new Image();
56-
img.crossOrigin = 'anonymous';
57-
img.onload = (): void => {
58-
const canvas = document.createElement('canvas');
59-
canvas.width = img.naturalWidth;
60-
canvas.height = img.naturalHeight;
61-
const ctx = canvas.getContext('2d');
62-
if (!ctx) {
63-
reject(new Error('Could not get canvas context'));
64-
return;
65-
}
66-
ctx.drawImage(img, 0, 0);
67-
canvas.toBlob(
68-
(blob) => (blob ? resolve(blob) : reject(new Error('toBlob failed'))),
69-
'image/jpeg',
70-
JPEG_QUALITY
71-
);
72-
};
73-
img.onerror = (): void => reject(new Error('Failed to load image'));
74-
img.src = dataUrl;
75-
});
76-
}
77-
78-
async function fallbackDownload(dataUrl: string, format: ExportImageFormat): Promise<void> {
79-
const download = (url: string, filename: string): void => {
80-
const a = document.createElement('a');
81-
a.href = url;
82-
a.download = filename;
83-
a.click();
84-
};
85-
if (format === 'jpeg') {
86-
const blob = await dataUrlToJpegBlob(dataUrl);
87-
const url = URL.createObjectURL(blob);
88-
download(url, SUGGESTED_NAMES.jpeg);
89-
URL.revokeObjectURL(url);
90-
} else {
91-
download(dataUrl, SUGGESTED_NAMES.png);
92-
}
93-
}
94-
95-
const FILE_PICKER_TYPES: Record<
96-
ExportImageFormat,
97-
Array<{ description: string; accept: Record<string, string[]> }>
98-
> = {
99-
png: [{ description: 'PNG Image', accept: { 'image/png': ['.png'] } }],
100-
jpeg: [{ description: 'JPEG Image', accept: { 'image/jpeg': ['.jpg', '.jpeg'] } }]
101-
};
102-
103-
async function saveWithFilePicker(dataUrl: string, format: ExportImageFormat): Promise<void> {
104-
const handle = await (window as Window & {
105-
showSaveFilePicker?: (options: {
106-
suggestedName?: string;
107-
types?: Array<{
108-
description: string;
109-
accept: Record<string, string[]>;
110-
}>;
111-
}) => Promise<FileSystemFileHandle>;
112-
}).showSaveFilePicker?.({
113-
suggestedName: SUGGESTED_NAMES[format],
114-
types: FILE_PICKER_TYPES[format]
115-
});
116-
if (!handle) return;
117-
const blob =
118-
format === 'jpeg'
119-
? await dataUrlToJpegBlob(dataUrl)
120-
: await fetch(dataUrl).then((r) => r.blob());
121-
const writable = await (handle as FileSystemFileHandle & {
122-
createWritable(): Promise<{ write(data: Blob): Promise<void>; close(): Promise<void> }>;
123-
}).createWritable();
124-
try {
125-
await writable.write(blob);
126-
} finally {
127-
await writable.close();
128-
}
129-
}
14+
export type { ExportImageFormat };
13015

13116
/**
13217
* Exports the chart image to the user's disk. Uses File System Access API when available
@@ -143,24 +28,17 @@ export async function exportChartImage(
14328
const found = findChartImageDataUrl(chartData);
14429
if (!found.ok) return { success: false, error: found.error };
14530

146-
const dataUrl = found.dataUrl;
147-
const fallback = (): Promise<void> => fallbackDownload(dataUrl, format);
148-
149-
if (
150-
'showSaveFilePicker' in window &&
151-
typeof (window as Window & { showSaveFilePicker?: unknown }).showSaveFilePicker ===
152-
'function'
153-
) {
31+
if (isFileSystemAccessAvailable()) {
15432
try {
155-
await saveWithFilePicker(dataUrl, format);
33+
await saveWithFilePicker(found.dataUrl, format);
15634
} catch (err) {
15735
if ((err as { name?: string }).name === 'AbortError') {
15836
return { success: true };
15937
}
160-
await fallback();
38+
await downloadImage(found.dataUrl, format);
16139
}
16240
} else {
163-
await fallback();
41+
await downloadImage(found.dataUrl, format);
16442
}
16543

16644
return { success: true };
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright (c) Saga Inc.
3+
* Distributed under the terms of the GNU Affero General Public License v3.0 License.
4+
*/
5+
6+
import { ExportImageFormat } from './types';
7+
import { dataUrlToBlob } from './imageConverter';
8+
9+
const SUGGESTED_NAMES: Record<ExportImageFormat, string> = {
10+
png: 'chart.png',
11+
jpeg: 'chart.jpg'
12+
};
13+
14+
/**
15+
* Triggers a browser download of a URL.
16+
*/
17+
function triggerDownload(url: string, filename: string): void {
18+
const a = document.createElement('a');
19+
a.href = url;
20+
a.download = filename;
21+
a.click();
22+
}
23+
24+
/**
25+
* Downloads an image using the browser's fallback download mechanism.
26+
*/
27+
export async function downloadImage(
28+
dataUrl: string,
29+
format: ExportImageFormat
30+
): Promise<void> {
31+
if (format === 'jpeg') {
32+
const blob = await dataUrlToBlob(dataUrl, format);
33+
const url = URL.createObjectURL(blob);
34+
triggerDownload(url, SUGGESTED_NAMES.jpeg);
35+
URL.revokeObjectURL(url);
36+
} else {
37+
triggerDownload(dataUrl, SUGGESTED_NAMES.png);
38+
}
39+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright (c) Saga Inc.
3+
* Distributed under the terms of the GNU Affero General Public License v3.0 License.
4+
*/
5+
6+
import { ExportImageFormat } from './types';
7+
import { dataUrlToBlob } from './imageConverter';
8+
9+
const SUGGESTED_NAMES: Record<ExportImageFormat, string> = {
10+
png: 'chart.png',
11+
jpeg: 'chart.jpg'
12+
};
13+
14+
const FILE_PICKER_TYPES: Record<
15+
ExportImageFormat,
16+
Array<{ description: string; accept: Record<string, string[]> }>
17+
> = {
18+
png: [{ description: 'PNG Image', accept: { 'image/png': ['.png'] } }],
19+
jpeg: [{ description: 'JPEG Image', accept: { 'image/jpeg': ['.jpg', '.jpeg'] } }]
20+
};
21+
22+
/**
23+
* Checks if the File System Access API is available.
24+
*/
25+
export function isFileSystemAccessAvailable(): boolean {
26+
return (
27+
'showSaveFilePicker' in window &&
28+
typeof (window as Window & { showSaveFilePicker?: unknown }).showSaveFilePicker ===
29+
'function'
30+
);
31+
}
32+
33+
/**
34+
* Saves a blob to disk using the File System Access API.
35+
*/
36+
async function writeBlobToFile(blob: Blob, handle: FileSystemFileHandle): Promise<void> {
37+
const writable = await (handle as FileSystemFileHandle & {
38+
createWritable(): Promise<{ write(data: Blob): Promise<void>; close(): Promise<void> }>;
39+
}).createWritable();
40+
try {
41+
await writable.write(blob);
42+
} finally {
43+
await writable.close();
44+
}
45+
}
46+
47+
/**
48+
* Saves an image to disk using the File System Access API.
49+
*/
50+
export async function saveWithFilePicker(
51+
dataUrl: string,
52+
format: ExportImageFormat
53+
): Promise<void> {
54+
const handle = await (window as Window & {
55+
showSaveFilePicker?: (options: {
56+
suggestedName?: string;
57+
types?: Array<{
58+
description: string;
59+
accept: Record<string, string[]>;
60+
}>;
61+
}) => Promise<FileSystemFileHandle>;
62+
}).showSaveFilePicker?.({
63+
suggestedName: SUGGESTED_NAMES[format],
64+
types: FILE_PICKER_TYPES[format]
65+
});
66+
if (!handle) return;
67+
68+
const blob = await dataUrlToBlob(dataUrl, format);
69+
await writeBlobToFile(blob, handle);
70+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright (c) Saga Inc.
3+
* Distributed under the terms of the GNU Affero General Public License v3.0 License.
4+
*/
5+
6+
import { ExportImageFormat } from './types';
7+
8+
const JPEG_QUALITY = 1.0;
9+
10+
/**
11+
* Converts a data URL image to a JPEG blob.
12+
*/
13+
export function dataUrlToJpegBlob(dataUrl: string): Promise<Blob> {
14+
return new Promise((resolve, reject) => {
15+
const img = new Image();
16+
img.crossOrigin = 'anonymous';
17+
img.onload = (): void => {
18+
const canvas = document.createElement('canvas');
19+
canvas.width = img.naturalWidth;
20+
canvas.height = img.naturalHeight;
21+
const ctx = canvas.getContext('2d');
22+
if (!ctx) {
23+
reject(new Error('Could not get canvas context'));
24+
return;
25+
}
26+
ctx.drawImage(img, 0, 0);
27+
canvas.toBlob(
28+
(blob) => (blob ? resolve(blob) : reject(new Error('toBlob failed'))),
29+
'image/jpeg',
30+
JPEG_QUALITY
31+
);
32+
};
33+
img.onerror = (): void => reject(new Error('Failed to load image'));
34+
img.src = dataUrl;
35+
});
36+
}
37+
38+
/**
39+
* Converts a data URL to a blob in the specified format.
40+
*/
41+
export async function dataUrlToBlob(
42+
dataUrl: string,
43+
format: ExportImageFormat
44+
): Promise<Blob> {
45+
if (format === 'jpeg') {
46+
return dataUrlToJpegBlob(dataUrl);
47+
}
48+
return fetch(dataUrl).then((r) => r.blob());
49+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright (c) Saga Inc.
3+
* Distributed under the terms of the GNU Affero General Public License v3.0 License.
4+
*/
5+
6+
import { CodeCell } from '@jupyterlab/cells';
7+
import { ChartWizardData } from '../ChartWizardPlugin';
8+
9+
export type FindImageResult =
10+
| { ok: true; dataUrl: string }
11+
| { ok: false; error: string };
12+
13+
/**
14+
* Finds the chart image data URL from the notebook cell output.
15+
*/
16+
export function findChartImageDataUrl(chartData: ChartWizardData): FindImageResult {
17+
const notebookPanel = chartData.notebookTracker.find(
18+
(panel) => panel.id === chartData.notebookPanelId
19+
);
20+
if (!notebookPanel) {
21+
return { ok: false, error: 'Could not find the notebook.' };
22+
}
23+
24+
const cellWidget = notebookPanel.content.widgets.find(
25+
(cell) => cell.model.id === chartData.cellId
26+
);
27+
if (!(cellWidget instanceof CodeCell)) {
28+
return { ok: false, error: 'Could not find the chart cell.' };
29+
}
30+
31+
const outputNode = cellWidget.outputArea.node;
32+
const img = outputNode.querySelector(
33+
'.jp-RenderedImage img[src^="data:image"]'
34+
) as HTMLImageElement | null;
35+
36+
if (!img || !img.src || !img.src.startsWith('data:image')) {
37+
return {
38+
ok: false,
39+
error: 'No chart image found. Re-run the chart cell and try again.'
40+
};
41+
}
42+
return { ok: true, dataUrl: img.src };
43+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/*
2+
* Copyright (c) Saga Inc.
3+
* Distributed under the terms of the GNU Affero General Public License v3.0 License.
4+
*/
5+
6+
export type ExportImageFormat = 'png' | 'jpeg';

0 commit comments

Comments
 (0)