Skip to content

Commit f31ee20

Browse files
authored
Deploy now downloads a zip of all python files (#182)
1 parent 21fecfb commit f31ee20

File tree

4 files changed

+239
-11
lines changed

4 files changed

+239
-11
lines changed

src/reactComponents/Menu.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import * as Antd from 'antd';
2222
import * as React from 'react';
2323
import * as commonStorage from '../storage/common_storage';
24+
import * as createPythonFiles from '../storage/create_python_files';
2425
import * as I18Next from 'react-i18next';
2526
import {TabType } from '../types/TabType';
2627

@@ -128,7 +129,7 @@ function getMenuItems(t: (key: string) => string, project: commonStorage.Project
128129
return [
129130
getItem(t('PROJECT'), 'project', <FolderOutlined />, [
130131
getItem(t('SAVE'), 'save', <SaveOutlined />),
131-
getItem(t('DEPLOY'), 'deploy', undefined, undefined, true),
132+
getItem(t('DEPLOY'), 'deploy'),
132133
getDivider(),
133134
getItem(t('MANAGE') + '...', 'manageProjects'),
134135
]),
@@ -280,6 +281,12 @@ export function Component(props: MenuProps): React.JSX.Element {
280281
props.openWPIToolboxSettings();
281282
} else if (key === 'theme') {
282283
setThemeModalOpen(true);
284+
} else if (key == 'deploy') {
285+
if (props.project && props.storage) {
286+
handleDeploy();
287+
} else {
288+
props.setAlertErrorMessage(t('NO_PROJECT_SELECTED'));
289+
}
283290
} else if (key.startsWith('setlang:')) {
284291
const lang = key.split(':')[1];
285292
i18n.changeLanguage(lang);
@@ -290,6 +297,31 @@ export function Component(props: MenuProps): React.JSX.Element {
290297
}
291298
};
292299

300+
/** Handles the deploy action to generate and download Python files. */
301+
const handleDeploy = async (): Promise<void> => {
302+
if (!props.project || !props.storage) {
303+
return;
304+
}
305+
306+
try {
307+
const blobUrl = await createPythonFiles.producePythonProjectBlob(props.project, props.storage);
308+
309+
// Create a temporary link to download the file
310+
const link = document.createElement('a');
311+
link.href = blobUrl;
312+
link.download = `${props.project.projectName}.zip`;
313+
document.body.appendChild(link);
314+
link.click();
315+
document.body.removeChild(link);
316+
317+
// Clean up the blob URL
318+
URL.revokeObjectURL(blobUrl);
319+
} catch (error) {
320+
console.error('Failed to deploy project:', error);
321+
props.setAlertErrorMessage(t('DEPLOY_FAILED') || 'Failed to deploy project');
322+
}
323+
};
324+
293325
/** Handles closing the file management modal. */
294326
const handleFileModalClose = (): void => {
295327
console.log('Modal onCancel called');

src/storage/client_side_storage.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,8 @@ class ClientSideStorage implements commonStorage.Storage {
154154
...module,
155155
};
156156
const project: commonStorage.Project = {
157-
projectName: moduleName,
158-
userVisibleName: commonStorage.snakeCaseToPascalCase(moduleName),
157+
projectName: module.projectName,
158+
userVisibleName: commonStorage.snakeCaseToPascalCase(module.projectName),
159159
robot: robot,
160160
mechanisms: [],
161161
opModes: [],
@@ -242,7 +242,7 @@ class ClientSideStorage implements commonStorage.Storage {
242242

243243
async createProject(projectName: string, robotContent: string, opmodeContent : string): Promise<void> {
244244
const modulePath = commonStorage.makeRobotPath(projectName);
245-
const opmodePath = commonStorage.makeModulePath(projectName, 'Teleop');
245+
const opmodePath = commonStorage.makeModulePath(projectName, 'teleop');
246246

247247
await this._saveModule(commonStorage.MODULE_TYPE_ROBOT, modulePath, robotContent);
248248
await this._saveModule(commonStorage.MODULE_TYPE_OPMODE, opmodePath, opmodeContent);
@@ -580,7 +580,7 @@ class ClientSideStorage implements commonStorage.Storage {
580580
let moduleNameToContentText: {[key: string]: string}; // key is module name, value is module content
581581
try {
582582
[moduleNameToType, moduleNameToContentText] = await commonStorage.processUploadedBlob(
583-
projectName, blobUrl);
583+
blobUrl);
584584
} catch (e) {
585585
console.log('commonStorage.processUploadedBlob failed.');
586586
reject(new Error('commonStorage.processUploadedBlob failed.'));

src/storage/common_storage.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,7 @@ export function makeModulePath(projectName: string, moduleName: string): string
454454
* Returns the robot module path for the given project names.
455455
*/
456456
export function makeRobotPath(projectName: string): string {
457-
return makeModulePath(projectName, projectName);
457+
return makeModulePath(projectName, 'robot');
458458
}
459459

460460
/**
@@ -502,7 +502,7 @@ export function newRobotContent(projectName: string): string {
502502
modulePath: makeRobotPath(projectName),
503503
moduleType: MODULE_TYPE_ROBOT,
504504
projectName: projectName,
505-
moduleName: projectName,
505+
moduleName: 'robot',
506506
dateModifiedMillis: 0,
507507
className: CLASS_NAME_ROBOT,
508508
};
@@ -654,7 +654,7 @@ export function makeUploadProjectName(
654654
* Process the uploaded blob to get the module types and contents.
655655
*/
656656
export async function processUploadedBlob(
657-
projectName: string, blobUrl: string)
657+
blobUrl: string)
658658
: Promise<[{ [key: string]: string }, { [key: string]: string }]> {
659659

660660
const prefix = 'data:application/octet-stream;base64,';
@@ -683,7 +683,7 @@ export async function processUploadedBlob(
683683
for (const filename in files) {
684684
const uploadedContent = files[filename];
685685
const [moduleName, moduleType, moduleContent] = _processUploadedModule(
686-
projectName, filename, uploadedContent);
686+
filename, uploadedContent);
687687
moduleNameToType[moduleName] = moduleType;
688688
moduleNameToContentText[moduleName] = moduleContent;
689689
}
@@ -695,12 +695,12 @@ export async function processUploadedBlob(
695695
* Processes an uploaded module to get the module name, type, and content text.
696696
*/
697697
export function _processUploadedModule(
698-
projectName: string, filename: string, uploadedContent: string)
698+
filename: string, uploadedContent: string)
699699
: [string, string, string] {
700700

701701
const moduleContent = parseModuleContentText(uploadedContent);
702702
const moduleType = moduleContent.getModuleType();
703-
const moduleName = (moduleType === MODULE_TYPE_ROBOT) ? projectName : filename;
703+
const moduleName = (moduleType === MODULE_TYPE_ROBOT) ? 'robot' : filename;
704704
const moduleContentText = moduleContent.getModuleContentText();
705705
return [moduleName, moduleType, moduleContentText];
706706
}

src/storage/create_python_files.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Porpoiseful LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* https://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
/**
19+
* @fileoverview Functions for generating Python code from Blockly projects.
20+
* This module uses headless Blockly to convert block-based projects into
21+
* Python code files and provides utilities for packaging them into downloadable zip files.
22+
*/
23+
24+
import * as Blockly from 'blockly/core';
25+
import { extendedPythonGenerator } from '../editor/extended_python_generator';
26+
import { Project, Module, Storage, parseModuleContentText } from './common_storage';
27+
import JSZip from 'jszip';
28+
import { GeneratorContext } from '../editor/generator_context';
29+
30+
/** Result of Python code generation for a single module */
31+
export interface ModulePythonResult {
32+
moduleName: string;
33+
pythonCode: string;
34+
success: boolean;
35+
error?: string;
36+
}
37+
38+
/** Result of Python code generation for an entire project */
39+
export interface ProjectPythonResult {
40+
projectName: string;
41+
modules: ModulePythonResult[];
42+
success: boolean;
43+
errorCount: number;
44+
}
45+
46+
/**
47+
* Generate Python code for a single module using headless Blockly
48+
* @param module The module containing Blockly JSON
49+
* @param storage The storage interface to fetch module content
50+
* @returns Result containing generated Python code or error
51+
*/
52+
export async function generatePythonForModule(module: Module, storage: Storage): Promise<ModulePythonResult> {
53+
try {
54+
// Fetch the module content from storage
55+
const moduleContentText = await storage.fetchModuleContentText(module.modulePath);
56+
const moduleContent = parseModuleContentText(moduleContentText);
57+
58+
// Create a headless workspace
59+
const workspace = new Blockly.Workspace();
60+
61+
// Parse and load the JSON into the workspace
62+
const blocks = moduleContent.getBlocks();
63+
64+
Blockly.serialization.workspaces.load(blocks, workspace);
65+
66+
67+
// Create and set up generator context like the editor does
68+
const generatorContext = new GeneratorContext();
69+
generatorContext.setModule(module);
70+
71+
// Initialize the generator if not already done
72+
if (!extendedPythonGenerator.isInitialized) {
73+
extendedPythonGenerator.init(workspace);
74+
}
75+
76+
// Generate Python code using the same method as the editor
77+
const pythonCode = extendedPythonGenerator.mrcWorkspaceToCode(workspace, generatorContext);
78+
79+
// Clean up the workspace
80+
workspace.dispose();
81+
82+
return {
83+
moduleName: module.moduleName,
84+
pythonCode,
85+
success: true,
86+
};
87+
} catch (error) {
88+
console.error('Error generating Python for module', module.moduleName, ':', error);
89+
return {
90+
moduleName: module.moduleName,
91+
pythonCode: '',
92+
success: false,
93+
error: error instanceof Error ? error.message : String(error),
94+
};
95+
}
96+
}
97+
98+
/**
99+
* Generate Python code for all modules in a project using headless Blockly
100+
* @param project The project containing multiple modules
101+
* @param storage The storage interface to fetch module content
102+
* @returns Result containing Python code for all modules
103+
*/
104+
export async function generatePythonForProject(project: Project, storage: Storage): Promise<ProjectPythonResult> {
105+
const moduleResults: ModulePythonResult[] = [];
106+
let errorCount = 0;
107+
108+
// Process the robot module
109+
const robotResult = await generatePythonForModule(project.robot, storage);
110+
moduleResults.push(robotResult);
111+
if (!robotResult.success) {
112+
errorCount++;
113+
}
114+
115+
// Process all mechanism modules
116+
for (const mechanism of project.mechanisms) {
117+
const result = await generatePythonForModule(mechanism, storage);
118+
moduleResults.push(result);
119+
if (!result.success) {
120+
errorCount++;
121+
}
122+
}
123+
124+
// Process all opmode modules
125+
for (const opMode of project.opModes) {
126+
const result = await generatePythonForModule(opMode, storage);
127+
moduleResults.push(result);
128+
if (!result.success) {
129+
errorCount++;
130+
}
131+
}
132+
133+
return {
134+
projectName: project.projectName,
135+
modules: moduleResults,
136+
success: errorCount === 0,
137+
errorCount,
138+
};
139+
}
140+
141+
/**
142+
* Generate Python files content as a map for easy file creation
143+
* @param project The project containing multiple modules
144+
* @param storage The storage interface to fetch module content
145+
* @returns Map of filename to Python code content
146+
*/
147+
export async function generatePythonFilesMap(project: Project, storage: Storage): Promise<Map<string, string>> {
148+
const filesMap = new Map<string, string>();
149+
const result = await generatePythonForProject(project, storage);
150+
151+
for (const moduleResult of result.modules) {
152+
if (moduleResult.success) {
153+
const filename = `${moduleResult.moduleName}.py`;
154+
filesMap.set(filename, moduleResult.pythonCode);
155+
}
156+
}
157+
158+
return filesMap;
159+
}
160+
161+
/**
162+
* Generate Python files for a project and create a downloadable zip blob
163+
* @param project The project containing multiple modules
164+
* @param storage The storage interface to fetch module content
165+
* @returns Promise that resolves to a blob URL for downloading the zip file
166+
*/
167+
export async function producePythonProjectBlob(project: Project, storage: Storage): Promise<string> {
168+
// Initialize the generator first
169+
initializeHeadlessBlockly();
170+
171+
const pythonFilesMap = await generatePythonFilesMap(project, storage);
172+
173+
174+
if (pythonFilesMap.size === 0) {
175+
throw new Error('No Python files were generated successfully');
176+
}
177+
178+
const zip = new JSZip();
179+
for (const [filename, pythonCode] of pythonFilesMap) {
180+
zip.file(filename, pythonCode);
181+
}
182+
183+
const content = await zip.generateAsync({ type: "blob" });
184+
const blobUrl = URL.createObjectURL(content);
185+
return blobUrl;
186+
}
187+
188+
/**
189+
* Initialize Blockly for headless operation
190+
* This should be called once before using the generation functions
191+
*/
192+
export function initializeHeadlessBlockly(): void {
193+
// Initialize Blockly for headless operation
194+
// This ensures all necessary generators and blocks are loaded
195+
extendedPythonGenerator.init(new Blockly.Workspace());
196+
}

0 commit comments

Comments
 (0)