diff --git a/src/reactComponents/Menu.tsx b/src/reactComponents/Menu.tsx index d5543848..c51922bc 100644 --- a/src/reactComponents/Menu.tsx +++ b/src/reactComponents/Menu.tsx @@ -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'; @@ -128,7 +129,7 @@ function getMenuItems(t: (key: string) => string, project: commonStorage.Project return [ getItem(t('PROJECT'), 'project', , [ getItem(t('SAVE'), 'save', ), - getItem(t('DEPLOY'), 'deploy', undefined, undefined, true), + getItem(t('DEPLOY'), 'deploy'), getDivider(), getItem(t('MANAGE') + '...', 'manageProjects'), ]), @@ -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); @@ -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 => { + 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'); diff --git a/src/storage/client_side_storage.ts b/src/storage/client_side_storage.ts index ea35f19f..ebcc28aa 100644 --- a/src/storage/client_side_storage.ts +++ b/src/storage/client_side_storage.ts @@ -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: [], @@ -242,7 +242,7 @@ class ClientSideStorage implements commonStorage.Storage { async createProject(projectName: string, robotContent: string, opmodeContent : string): Promise { 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); @@ -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.')); diff --git a/src/storage/common_storage.ts b/src/storage/common_storage.ts index cbd5eff0..536f87a4 100644 --- a/src/storage/common_storage.ts +++ b/src/storage/common_storage.ts @@ -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'); } /** @@ -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, }; @@ -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,'; @@ -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; } @@ -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]; } diff --git a/src/storage/create_python_files.ts b/src/storage/create_python_files.ts new file mode 100644 index 00000000..d815c2bc --- /dev/null +++ b/src/storage/create_python_files.ts @@ -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 { + 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 { + 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> { + const filesMap = new Map(); + 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 { + // 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()); +}