Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion src/reactComponents/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import * as Antd from 'antd';
import * as React from 'react';
import * as commonStorage from '../storage/common_storage';
import * as createPythonFiles from '../storage/create_python_files';
import * as I18Next from 'react-i18next';
import {TabType } from '../types/TabType';

Expand Down Expand Up @@ -128,7 +129,7 @@ function getMenuItems(t: (key: string) => string, project: commonStorage.Project
return [
getItem(t('PROJECT'), 'project', <FolderOutlined />, [
getItem(t('SAVE'), 'save', <SaveOutlined />),
getItem(t('DEPLOY'), 'deploy', undefined, undefined, true),
getItem(t('DEPLOY'), 'deploy'),
getDivider(),
getItem(t('MANAGE') + '...', 'manageProjects'),
]),
Expand Down Expand Up @@ -280,6 +281,12 @@ export function Component(props: MenuProps): React.JSX.Element {
props.openWPIToolboxSettings();
} else if (key === 'theme') {
setThemeModalOpen(true);
} else if (key == 'deploy') {
if (props.project && props.storage) {
handleDeploy();
} else {
props.setAlertErrorMessage(t('NO_PROJECT_SELECTED'));
}
} else if (key.startsWith('setlang:')) {
const lang = key.split(':')[1];
i18n.changeLanguage(lang);
Expand All @@ -290,6 +297,31 @@ export function Component(props: MenuProps): React.JSX.Element {
}
};

/** Handles the deploy action to generate and download Python files. */
const handleDeploy = async (): Promise<void> => {
if (!props.project || !props.storage) {
return;
}

try {
const blobUrl = await createPythonFiles.producePythonProjectBlob(props.project, props.storage);

// Create a temporary link to download the file
const link = document.createElement('a');
link.href = blobUrl;
link.download = `${props.project.projectName}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);

// Clean up the blob URL
URL.revokeObjectURL(blobUrl);
} catch (error) {
console.error('Failed to deploy project:', error);
props.setAlertErrorMessage(t('DEPLOY_FAILED') || 'Failed to deploy project');
}
};

/** Handles closing the file management modal. */
const handleFileModalClose = (): void => {
console.log('Modal onCancel called');
Expand Down
8 changes: 4 additions & 4 deletions src/storage/client_side_storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,8 @@ class ClientSideStorage implements commonStorage.Storage {
...module,
};
const project: commonStorage.Project = {
projectName: moduleName,
userVisibleName: commonStorage.snakeCaseToPascalCase(moduleName),
projectName: module.projectName,
userVisibleName: commonStorage.snakeCaseToPascalCase(module.projectName),
robot: robot,
mechanisms: [],
opModes: [],
Expand Down Expand Up @@ -242,7 +242,7 @@ class ClientSideStorage implements commonStorage.Storage {

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

await this._saveModule(commonStorage.MODULE_TYPE_ROBOT, modulePath, robotContent);
await this._saveModule(commonStorage.MODULE_TYPE_OPMODE, opmodePath, opmodeContent);
Expand Down Expand Up @@ -580,7 +580,7 @@ class ClientSideStorage implements commonStorage.Storage {
let moduleNameToContentText: {[key: string]: string}; // key is module name, value is module content
try {
[moduleNameToType, moduleNameToContentText] = await commonStorage.processUploadedBlob(
projectName, blobUrl);
blobUrl);
} catch (e) {
console.log('commonStorage.processUploadedBlob failed.');
reject(new Error('commonStorage.processUploadedBlob failed.'));
Expand Down
12 changes: 6 additions & 6 deletions src/storage/common_storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,7 @@ export function makeModulePath(projectName: string, moduleName: string): string
* Returns the robot module path for the given project names.
*/
export function makeRobotPath(projectName: string): string {
return makeModulePath(projectName, projectName);
return makeModulePath(projectName, 'robot');
}

/**
Expand Down Expand Up @@ -502,7 +502,7 @@ export function newRobotContent(projectName: string): string {
modulePath: makeRobotPath(projectName),
moduleType: MODULE_TYPE_ROBOT,
projectName: projectName,
moduleName: projectName,
moduleName: 'robot',
dateModifiedMillis: 0,
className: CLASS_NAME_ROBOT,
};
Expand Down Expand Up @@ -654,7 +654,7 @@ export function makeUploadProjectName(
* Process the uploaded blob to get the module types and contents.
*/
export async function processUploadedBlob(
projectName: string, blobUrl: string)
blobUrl: string)
: Promise<[{ [key: string]: string }, { [key: string]: string }]> {

const prefix = 'data:application/octet-stream;base64,';
Expand Down Expand Up @@ -683,7 +683,7 @@ export async function processUploadedBlob(
for (const filename in files) {
const uploadedContent = files[filename];
const [moduleName, moduleType, moduleContent] = _processUploadedModule(
projectName, filename, uploadedContent);
filename, uploadedContent);
moduleNameToType[moduleName] = moduleType;
moduleNameToContentText[moduleName] = moduleContent;
}
Expand All @@ -695,12 +695,12 @@ export async function processUploadedBlob(
* Processes an uploaded module to get the module name, type, and content text.
*/
export function _processUploadedModule(
projectName: string, filename: string, uploadedContent: string)
filename: string, uploadedContent: string)
: [string, string, string] {

const moduleContent = parseModuleContentText(uploadedContent);
const moduleType = moduleContent.getModuleType();
const moduleName = (moduleType === MODULE_TYPE_ROBOT) ? projectName : filename;
const moduleName = (moduleType === MODULE_TYPE_ROBOT) ? 'robot' : filename;
const moduleContentText = moduleContent.getModuleContentText();
return [moduleName, moduleType, moduleContentText];
}
196 changes: 196 additions & 0 deletions src/storage/create_python_files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/**
* @license
* Copyright 2025 Porpoiseful LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* @fileoverview Functions for generating Python code from Blockly projects.
* This module uses headless Blockly to convert block-based projects into
* Python code files and provides utilities for packaging them into downloadable zip files.
*/

import * as Blockly from 'blockly/core';
import { extendedPythonGenerator } from '../editor/extended_python_generator';
import { Project, Module, Storage, parseModuleContentText } from './common_storage';
import JSZip from 'jszip';
import { GeneratorContext } from '../editor/generator_context';

/** Result of Python code generation for a single module */
export interface ModulePythonResult {
moduleName: string;
pythonCode: string;
success: boolean;
error?: string;
}

/** Result of Python code generation for an entire project */
export interface ProjectPythonResult {
projectName: string;
modules: ModulePythonResult[];
success: boolean;
errorCount: number;
}

/**
* Generate Python code for a single module using headless Blockly
* @param module The module containing Blockly JSON
* @param storage The storage interface to fetch module content
* @returns Result containing generated Python code or error
*/
export async function generatePythonForModule(module: Module, storage: Storage): Promise<ModulePythonResult> {
try {
// Fetch the module content from storage
const moduleContentText = await storage.fetchModuleContentText(module.modulePath);
const moduleContent = parseModuleContentText(moduleContentText);

// Create a headless workspace
const workspace = new Blockly.Workspace();

// Parse and load the JSON into the workspace
const blocks = moduleContent.getBlocks();

Blockly.serialization.workspaces.load(blocks, workspace);


// Create and set up generator context like the editor does
const generatorContext = new GeneratorContext();
generatorContext.setModule(module);

// Initialize the generator if not already done
if (!extendedPythonGenerator.isInitialized) {
extendedPythonGenerator.init(workspace);
}

// Generate Python code using the same method as the editor
const pythonCode = extendedPythonGenerator.mrcWorkspaceToCode(workspace, generatorContext);

// Clean up the workspace
workspace.dispose();

return {
moduleName: module.moduleName,
pythonCode,
success: true,
};
} catch (error) {
console.error('Error generating Python for module', module.moduleName, ':', error);
return {
moduleName: module.moduleName,
pythonCode: '',
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}

/**
* Generate Python code for all modules in a project using headless Blockly
* @param project The project containing multiple modules
* @param storage The storage interface to fetch module content
* @returns Result containing Python code for all modules
*/
export async function generatePythonForProject(project: Project, storage: Storage): Promise<ProjectPythonResult> {
const moduleResults: ModulePythonResult[] = [];
let errorCount = 0;

// Process the robot module
const robotResult = await generatePythonForModule(project.robot, storage);
moduleResults.push(robotResult);
if (!robotResult.success) {
errorCount++;
}

// Process all mechanism modules
for (const mechanism of project.mechanisms) {
const result = await generatePythonForModule(mechanism, storage);
moduleResults.push(result);
if (!result.success) {
errorCount++;
}
}

// Process all opmode modules
for (const opMode of project.opModes) {
const result = await generatePythonForModule(opMode, storage);
moduleResults.push(result);
if (!result.success) {
errorCount++;
}
}

return {
projectName: project.projectName,
modules: moduleResults,
success: errorCount === 0,
errorCount,
};
}

/**
* Generate Python files content as a map for easy file creation
* @param project The project containing multiple modules
* @param storage The storage interface to fetch module content
* @returns Map of filename to Python code content
*/
export async function generatePythonFilesMap(project: Project, storage: Storage): Promise<Map<string, string>> {
const filesMap = new Map<string, string>();
const result = await generatePythonForProject(project, storage);

for (const moduleResult of result.modules) {
if (moduleResult.success) {
const filename = `${moduleResult.moduleName}.py`;
filesMap.set(filename, moduleResult.pythonCode);
}
}

return filesMap;
}

/**
* Generate Python files for a project and create a downloadable zip blob
* @param project The project containing multiple modules
* @param storage The storage interface to fetch module content
* @returns Promise that resolves to a blob URL for downloading the zip file
*/
export async function producePythonProjectBlob(project: Project, storage: Storage): Promise<string> {
// Initialize the generator first
initializeHeadlessBlockly();

const pythonFilesMap = await generatePythonFilesMap(project, storage);


if (pythonFilesMap.size === 0) {
throw new Error('No Python files were generated successfully');
}

const zip = new JSZip();
for (const [filename, pythonCode] of pythonFilesMap) {
zip.file(filename, pythonCode);
}

const content = await zip.generateAsync({ type: "blob" });
const blobUrl = URL.createObjectURL(content);
return blobUrl;
}

/**
* Initialize Blockly for headless operation
* This should be called once before using the generation functions
*/
export function initializeHeadlessBlockly(): void {
// Initialize Blockly for headless operation
// This ensures all necessary generators and blocks are loaded
extendedPythonGenerator.init(new Blockly.Workspace());
}