From 98d0d1854c0e0bf36ce916f65eb7d5dea834b373 Mon Sep 17 00:00:00 2001 From: Liz Looney Date: Wed, 27 Aug 2025 22:51:36 -0700 Subject: [PATCH 1/5] Updated the Storage interface to use the term "file" instead of "module". Replaced listModulePaths with listFilePaths. Replaced fetchModuleContentText with fetchFileContentText. Replaced saveModule with saveFile. Replaced deleteModule with deleteFile. --- src/editor/editor.ts | 8 +-- src/storage/client_side_storage.ts | 82 +++++++++++++++--------------- src/storage/common_storage.ts | 10 ++-- src/storage/create_python_files.ts | 2 +- src/storage/project.ts | 47 ++++++++--------- 5 files changed, 75 insertions(+), 74 deletions(-) diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 6d7bbf46..5233a790 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -137,15 +137,15 @@ export class Editor { if (this.currentModule && this.currentProject) { // Fetch the content for the current module, the robot, and the mechanisms. const promises: { [modulePath: string]: Promise } = {}; // value is promise of module content. - promises[this.modulePath] = this.storage.fetchModuleContentText(this.modulePath); + promises[this.modulePath] = this.storage.fetchFileContentText(this.modulePath); if (this.robotPath !== this.modulePath) { // Also fetch the robot module content. It contains components, etc, that can be used in OpModes. - promises[this.robotPath] = this.storage.fetchModuleContentText(this.robotPath) + promises[this.robotPath] = this.storage.fetchFileContentText(this.robotPath) } for (const mechanism of this.currentProject.mechanisms) { // Fetch the module content text for the mechanism. if (mechanism.modulePath !== this.modulePath) { - promises[mechanism.modulePath] = this.storage.fetchModuleContentText(mechanism.modulePath) + promises[mechanism.modulePath] = this.storage.fetchFileContentText(mechanism.modulePath); } } @@ -318,7 +318,7 @@ export class Editor { public async saveBlocks() { const moduleContentText = this.getModuleContentText(); try { - await this.storage.saveModule(this.modulePath, moduleContentText); + await this.storage.saveFile(this.modulePath, moduleContentText); this.moduleContentText = moduleContentText; } catch (e) { throw e; diff --git a/src/storage/client_side_storage.ts b/src/storage/client_side_storage.ts index 291ed909..8a0493e8 100644 --- a/src/storage/client_side_storage.ts +++ b/src/storage/client_side_storage.ts @@ -23,15 +23,15 @@ import * as commonStorage from './common_storage'; import * as storageModuleContent from './module_content'; import * as storageNames from './names'; -// Functions for saving blocks modules to client side storage. +// Functions for saving blocks files to client side storage. const DATABASE_NAME = 'systemcore-blocks-interface'; const ENTRIES_STORE_NAME = 'entries'; const ENTRIES_KEY = 'key'; -const MODULES_STORE_NAME = 'modules'; -const MODULES_KEY = 'path'; +const FILES_STORE_NAME = 'modules'; +const FILES_KEY = 'path'; export async function openClientSideStorage(): Promise { return new Promise((resolve, reject) => { @@ -51,9 +51,9 @@ export async function openClientSideStorage(): Promise { db.createObjectStore(ENTRIES_STORE_NAME, { keyPath: ENTRIES_KEY }); } - if (!stores.contains(MODULES_STORE_NAME)) { - // Create the object store for modules. - db.createObjectStore(MODULES_STORE_NAME, { keyPath: MODULES_KEY }); + if (!stores.contains(FILES_STORE_NAME)) { + // Create the object store for files. + db.createObjectStore(FILES_STORE_NAME, { keyPath: FILES_KEY }); } }; openRequest.onsuccess = () => { @@ -69,7 +69,7 @@ export async function openClientSideStorage(): Promise { // TODO(lizlooney): Remove this function. async function fixOldModules(db: IDBDatabase): Promise { return new Promise((resolve, reject) => { - const transaction = db.transaction([MODULES_STORE_NAME], 'readwrite'); + const transaction = db.transaction([FILES_STORE_NAME], 'readwrite'); transaction.oncomplete = () => { resolve(); }; @@ -77,8 +77,8 @@ async function fixOldModules(db: IDBDatabase): Promise { console.log('IndexedDB transaction aborted.'); reject(new Error('IndexedDB transaction aborted.')); }; - const modulesObjectStore = transaction.objectStore(MODULES_STORE_NAME); - const openCursorRequest = modulesObjectStore.openCursor(); + const filesObjectStore = transaction.objectStore(FILES_STORE_NAME); + const openCursorRequest = filesObjectStore.openCursor(); openCursorRequest.onerror = () => { console.log('IndexedDB openCursor request failed. openCursorRequest.error is...'); console.log(openCursorRequest.error); @@ -96,13 +96,13 @@ async function fixOldModules(db: IDBDatabase): Promise { const className = result[2]; const moduleType = storageModuleContent.parseModuleContentText(value.content).getModuleType(); value.path = storageNames.makeModulePath(projectName, className, moduleType); - const putRequest = modulesObjectStore.put(value); + const putRequest = filesObjectStore.put(value); putRequest.onerror = () => { console.log('IndexedDB put request failed. putRequest.error is...'); console.log(putRequest.error); throw new Error('IndexedDB put request failed.'); }; - const deleteRequest = modulesObjectStore.delete(oldModulePath); + const deleteRequest = filesObjectStore.delete(oldModulePath); deleteRequest.onerror = () => { console.log('IndexedDB delete request failed. deleteRequest.error is...'); console.log(deleteRequest.error); @@ -111,7 +111,7 @@ async function fixOldModules(db: IDBDatabase): Promise { } cursor.continue(); } else { - // The cursor is done. We have finished reading all the modules. + // The cursor is done. We have finished reading all the files. resolve(); } }; @@ -181,15 +181,15 @@ class ClientSideStorage implements commonStorage.Storage { }); } - async listModulePaths(opt_modulePathRegexPattern?: string): Promise { + async listFilePaths(opt_filePathRegexPattern?: string): Promise { - const regExp = opt_modulePathRegexPattern - ? new RegExp(opt_modulePathRegexPattern) + const regExp = opt_filePathRegexPattern + ? new RegExp(opt_filePathRegexPattern) : null; return new Promise((resolve, reject) => { - const modulePaths: string[] = []; - const openKeyCursorRequest = this.db.transaction([MODULES_STORE_NAME], 'readonly') - .objectStore(MODULES_STORE_NAME) + const filePaths: string[] = []; + const openKeyCursorRequest = this.db.transaction([FILES_STORE_NAME], 'readonly') + .objectStore(FILES_STORE_NAME) .openKeyCursor(); openKeyCursorRequest.onerror = () => { console.log('IndexedDB openKeyCursor request failed. openKeyCursorRequest.error is...'); @@ -199,23 +199,23 @@ class ClientSideStorage implements commonStorage.Storage { openKeyCursorRequest.onsuccess = () => { const cursor = openKeyCursorRequest.result; if (cursor && cursor.key) { - const modulePath: string = cursor.key as string; - if (!regExp || regExp.test(modulePath)) { - modulePaths.push(modulePath); + const filePath: string = cursor.key as string; + if (!regExp || regExp.test(filePath)) { + filePaths.push(filePath); } cursor.continue(); } else { - // The cursor is done. We have finished reading all the modules. - resolve(modulePaths); + // The cursor is done. We have finished reading all the files. + resolve(filePaths); } }; }); } - async fetchModuleContentText(modulePath: string): Promise { + async fetchFileContentText(filePath: string): Promise { return new Promise((resolve, reject) => { - const getRequest = this.db.transaction([MODULES_STORE_NAME], 'readonly') - .objectStore(MODULES_STORE_NAME).get(modulePath); + const getRequest = this.db.transaction([FILES_STORE_NAME], 'readonly') + .objectStore(FILES_STORE_NAME).get(filePath); getRequest.onerror = () => { console.log('IndexedDB get request failed. getRequest.error is...'); console.log(getRequest.error); @@ -223,8 +223,8 @@ class ClientSideStorage implements commonStorage.Storage { }; getRequest.onsuccess = () => { if (getRequest.result === undefined) { - // Module does not exist. - reject(new Error('IndexedDB get request succeeded, but the module does not exist.')); + // File does not exist. + reject(new Error('IndexedDB get request succeeded, but the file does not exist.')); return; } resolve(getRequest.result.content); @@ -232,9 +232,9 @@ class ClientSideStorage implements commonStorage.Storage { }); } - async saveModule(modulePath: string, moduleContentText: string): Promise { + async saveFile(filePath: string, fileContentText: string): Promise { return new Promise((resolve, reject) => { - const transaction = this.db.transaction([MODULES_STORE_NAME], 'readwrite'); + const transaction = this.db.transaction([FILES_STORE_NAME], 'readwrite'); transaction.oncomplete = () => { resolve(); }; @@ -242,8 +242,8 @@ class ClientSideStorage implements commonStorage.Storage { console.log('IndexedDB transaction aborted.'); reject(new Error('IndexedDB transaction aborted.')); }; - const modulesObjectStore = transaction.objectStore(MODULES_STORE_NAME); - const getRequest = modulesObjectStore.get(modulePath); + const filesObjectStore = transaction.objectStore(FILES_STORE_NAME); + const getRequest = filesObjectStore.get(filePath); getRequest.onerror = () => { console.log('IndexedDB get request failed. getRequest.error is...'); console.log(getRequest.error); @@ -252,15 +252,15 @@ class ClientSideStorage implements commonStorage.Storage { getRequest.onsuccess = () => { let value; if (getRequest.result === undefined) { - // The module does not exist. Create it now. + // The file does not exist. Create it now. value = Object.create(null); - value.path = modulePath; + value.path = filePath; } else { - // The module already exists. + // The file already exists. value = getRequest.result; } - value.content = moduleContentText; - const putRequest = modulesObjectStore.put(value); + value.content = fileContentText; + const putRequest = filesObjectStore.put(value); putRequest.onerror = () => { console.log('IndexedDB put request failed. putRequest.error is...'); console.log(putRequest.error); @@ -270,9 +270,9 @@ class ClientSideStorage implements commonStorage.Storage { }); } - async deleteModule(modulePath: string): Promise { + async deleteFile(filePath: string): Promise { return new Promise((resolve, reject) => { - const transaction = this.db.transaction([MODULES_STORE_NAME], 'readwrite'); + const transaction = this.db.transaction([FILES_STORE_NAME], 'readwrite'); transaction.oncomplete = () => { resolve(); }; @@ -280,8 +280,8 @@ class ClientSideStorage implements commonStorage.Storage { console.log('IndexedDB transaction aborted.'); reject(new Error('IndexedDB transaction aborted.')); }; - const modulesObjectStore = transaction.objectStore(MODULES_STORE_NAME); - const deleteRequest = modulesObjectStore.delete(modulePath); + const filesObjectStore = transaction.objectStore(FILES_STORE_NAME); + const deleteRequest = filesObjectStore.delete(filePath); deleteRequest.onerror = () => { console.log('IndexedDB delete request failed. deleteRequest.error is...'); console.log(deleteRequest.error); diff --git a/src/storage/common_storage.ts b/src/storage/common_storage.ts index c624b424..96b70e4e 100644 --- a/src/storage/common_storage.ts +++ b/src/storage/common_storage.ts @@ -26,13 +26,13 @@ export interface Storage { fetchEntry(entryKey: string, defaultValue: string): Promise; - // Functions for storing modules. + // Functions for storing files. - listModulePaths(opt_modulePathRegexPattern?: string): Promise; + listFilePaths(opt_filePathRegexPattern?: string): Promise; - fetchModuleContentText(modulePath: string): Promise; + fetchFileContentText(filePath: string): Promise; - saveModule(modulePath: string, moduleContentText: string): Promise; + saveFile(filePath: string, fileContentText: string): Promise; - deleteModule(modulePath: string): Promise; + deleteFile(filePath: string): Promise; } diff --git a/src/storage/create_python_files.ts b/src/storage/create_python_files.ts index 8544835e..300a00be 100644 --- a/src/storage/create_python_files.ts +++ b/src/storage/create_python_files.ts @@ -58,7 +58,7 @@ async function generatePythonForModule(module: Module, storage: Storage): Promis try { // Fetch the module content from storage - const moduleContentText = await storage.fetchModuleContentText(module.modulePath); + const moduleContentText = await storage.fetchFileContentText(module.modulePath); const moduleContent = parseModuleContentText(moduleContentText); // Create a headless workspace diff --git a/src/storage/project.ts b/src/storage/project.ts index 61a5fc07..9a36f98b 100644 --- a/src/storage/project.ts +++ b/src/storage/project.ts @@ -39,8 +39,8 @@ export type Project = { * Returns the list of project names. */ export async function listProjectNames(storage: commonStorage.Storage): Promise { - const modulePathRegexPattern = '.*\.robot\.json$'; - const robotModulePaths: string[] = await storage.listModulePaths(modulePathRegexPattern); + const filePathRegexPattern = '.*/Robot\.robot\.json$'; + const robotModulePaths: string[] = await storage.listFilePaths(filePathRegexPattern); const projectNames: string[] = []; for (const robotModulePath of robotModulePaths) { @@ -54,7 +54,7 @@ export async function listProjectNames(storage: commonStorage.Storage): Promise< */ export async function fetchProject( storage: commonStorage.Storage, projectName: string): Promise { - const modulePaths: string[] = await storage.listModulePaths( + const modulePaths: string[] = await storage.listFilePaths( storageNames.makeModulePathRegexPattern(projectName)); let project: Project | null = null; @@ -62,7 +62,7 @@ export async function fetchProject( const opModes: storageModule.OpMode[] = []; for (const modulePath of modulePaths) { - const moduleContentText = await storage.fetchModuleContentText(modulePath); + const moduleContentText = await storage.fetchFileContentText(modulePath); const moduleContent: storageModuleContent.ModuleContent = storageModuleContent.parseModuleContentText(moduleContentText); const moduleType = storageNames.getModuleType(modulePath); @@ -112,13 +112,13 @@ export async function createProject( storage: commonStorage.Storage, newProjectName: string): Promise { const modulePath = storageNames.makeRobotPath(newProjectName); const robotContent = storageModuleContent.newRobotContent(newProjectName); - await storage.saveModule(modulePath, robotContent); + await storage.saveFile(modulePath, robotContent); const opmodePath = storageNames.makeModulePath( newProjectName, storageNames.CLASS_NAME_TELEOP, storageModule.ModuleType.OPMODE); const opmodeContent = storageModuleContent.newOpModeContent( newProjectName, storageNames.CLASS_NAME_TELEOP); - await storage.saveModule(opmodePath, opmodeContent); + await storage.saveFile(opmodePath, opmodeContent); } /** @@ -148,17 +148,17 @@ export async function copyProject( async function renameOrCopyProject( storage: commonStorage.Storage, projectName: string, newProjectName: string, rename: boolean): Promise { - const modulePaths: string[] = await storage.listModulePaths( + const modulePaths: string[] = await storage.listFilePaths( storageNames.makeModulePathRegexPattern(projectName)); for (const modulePath of modulePaths) { const className = storageNames.getClassName(modulePath); const moduleType = storageNames.getModuleType(modulePath); const newModulePath = storageNames.makeModulePath(newProjectName, className, moduleType); - const moduleContentText = await storage.fetchModuleContentText(modulePath); - await storage.saveModule(newModulePath, moduleContentText); + const moduleContentText = await storage.fetchFileContentText(modulePath); + await storage.saveFile(newModulePath, moduleContentText); if (rename) { - await storage.deleteModule(modulePath); + await storage.deleteFile(modulePath); } } } @@ -171,11 +171,11 @@ async function renameOrCopyProject( */ export async function deleteProject( storage: commonStorage.Storage, projectName: string): Promise { - const modulePaths: string[] = await storage.listModulePaths( + const modulePaths: string[] = await storage.listFilePaths( storageNames.makeModulePathRegexPattern(projectName)); for (const modulePath of modulePaths) { - await storage.deleteModule(modulePath); + await storage.deleteFile(modulePath); } } @@ -196,7 +196,7 @@ export async function addModuleToProject( switch (moduleType) { case storageModule.ModuleType.MECHANISM: const mechanismContent = storageModuleContent.newMechanismContent(project.projectName, newClassName); - await storage.saveModule(newModulePath, mechanismContent); + await storage.saveFile(newModulePath, mechanismContent); project.mechanisms.push({ modulePath: newModulePath, moduleType: storageModule.ModuleType.MECHANISM, @@ -206,7 +206,7 @@ export async function addModuleToProject( break; case storageModule.ModuleType.OPMODE: const opModeContent = storageModuleContent.newOpModeContent(project.projectName, newClassName); - await storage.saveModule(newModulePath, opModeContent); + await storage.saveFile(newModulePath, opModeContent); project.opModes.push({ modulePath: newModulePath, moduleType: storageModule.ModuleType.OPMODE, @@ -216,6 +216,7 @@ export async function addModuleToProject( break; } } + /** * Removes a module from the project. * @param storage The storage interface to use for deleting the module. @@ -236,7 +237,7 @@ export async function removeModuleFromProject( project.opModes = project.opModes.filter(o => o.modulePath !== modulePath); break; } - await storage.deleteModule(modulePath); + await storage.deleteFile(modulePath); } } @@ -284,17 +285,17 @@ async function renameOrCopyModule( storage: commonStorage.Storage, project: Project, newClassName: string, oldModule: storageModule.Module, rename: boolean): Promise { const newModulePath = storageNames.makeModulePath(project.projectName, newClassName, oldModule.moduleType); - let moduleContentText = await storage.fetchModuleContentText(oldModule.modulePath); + let moduleContentText = await storage.fetchFileContentText(oldModule.modulePath); if (!rename) { // Change the ids in the module. const moduleContent = storageModuleContent.parseModuleContentText(moduleContentText); moduleContent.changeIds(); moduleContentText = moduleContent.getModuleContentText(); } - await storage.saveModule(newModulePath, moduleContentText); + await storage.saveFile(newModulePath, moduleContentText); if (rename) { // For rename, delete the old module. - await storage.deleteModule(oldModule.modulePath); + await storage.deleteFile(oldModule.modulePath); // Update the project's mechanisms or opModes. switch (oldModule.moduleType) { @@ -403,13 +404,13 @@ export function findModuleByModulePath(project: Project, modulePath: string): st */ export async function downloadProject( storage: commonStorage.Storage, projectName: string): Promise { - const modulePaths: string[] = await storage.listModulePaths( + const modulePaths: string[] = await storage.listFilePaths( storageNames.makeModulePathRegexPattern(projectName)); const fileNameToModuleContentText: {[fileName: string]: string} = {}; // value is module content text for (const modulePath of modulePaths) { const fileName = storageNames.getFileName(modulePath); - const moduleContentText = await storage.fetchModuleContentText(modulePath); + const moduleContentText = await storage.fetchFileContentText(modulePath); fileNameToModuleContentText[fileName] = moduleContentText; } @@ -443,14 +444,14 @@ export async function uploadProject( const className = storageNames.getClassName(fileName); const moduleType = storageNames.getModuleType(fileName); const modulePath = storageNames.makeModulePath(projectName, className, moduleType); - await storage.saveModule(modulePath, moduleContentText); + await storage.saveFile(modulePath, moduleContentText); } } /** - * Process the uploaded blob to get the module class names and contents. + * Process the uploaded blob to get the module file names and file contents. */ -async function processUploadedBlob(blobUrl: string): Promise<{ [className: string]: string }> { +async function processUploadedBlob(blobUrl: string): Promise<{ [fileName: string]: string }> { const prefix = 'data:application/octet-stream;base64,'; if (!blobUrl.startsWith(prefix)) { From 05a82229040eb3e337805165382dbdb633feed98 Mon Sep 17 00:00:00 2001 From: Liz Looney Date: Wed, 27 Aug 2025 22:54:43 -0700 Subject: [PATCH 2/5] In names.ts: Added comments. Updated regular expressions so they work for modules and non-module files. Added functions makeFilePathRegexPattern, makeFilePath, makeProjectInfoPath, isValidModuleFileName, isValidProjectInfoFileName. --- src/storage/names.ts | 169 +++++++++++++++++++++++++++++++++---------- 1 file changed, 131 insertions(+), 38 deletions(-) diff --git a/src/storage/names.ts b/src/storage/names.ts index 733bd71e..04d5c1c4 100644 --- a/src/storage/names.ts +++ b/src/storage/names.ts @@ -22,30 +22,80 @@ import * as storageModule from './module'; +/** + * Paths and file names for Blocks Projects + * + * Files in a project are stored in a directory whose name is the project name. All files have + * the extension '.json' and contain JSON text. + * + * Project information is stored in a file called 'project.info.json'. + * + * Modules + * Files that contain blocks are called modules. During deployment, a Python file is created for + * each module. Each Python file contains a Python class definition. + * There are three types of modules: + * robot + * mechanism + * opmode + * A project consists of: + * one project information file named 'project.info.json' + * one robot module, named 'Robot.robot.json' + * zero or more mechanisms, with the extension '.mechanism.json' + * zero or more opmodes, with the extension '.opmode.json' + * + * The file path of the project info file is /project.info.json. + * The file path of a module is /..json. + */ + +// The class name of the Robot module that is created automatically when a new project is created. export const CLASS_NAME_ROBOT = 'Robot'; + +// The class name of the OpMode module that is created automatically when a new project is created. export const CLASS_NAME_TELEOP = 'Teleop'; +// The extension of all JSON files is .json. export const JSON_FILE_EXTENSION = '.json'; + +// The extension of a downloaded project is .blocks. export const UPLOAD_DOWNLOAD_FILE_EXTENSION = '.blocks'; +// The file name of the project info file. +const PROJECT_INFO_FILE_NAME = 'project.info.json'; + +// The file name of the project info file. +const ROBOT_MODULE_FILE_NAME = 'Robot.robot.json'; + +// A project name starts with an uppercase letter, followed by alphanumeric characters. const REGEX_PROJECT_NAME_PART = '[A-Z][A-Za-z0-9]*'; + +// A module's class name starts with an uppercase letter, followed by alphanumeric characters. const REGEX_CLASS_NAME_PART = '[A-Z][A-Za-z0-9]*'; + +// This regex is used to validate a class name +const REGEX_CLASS_NAME = '^' + REGEX_CLASS_NAME_PART + '$' + +// The module type of a module path is either .robot, .mechanism, or .opmode. const REGEX_MODULE_TYPE_PART = '\.(robot|mechanism|opmode)'; -const REGEX_MODULE_PATH = '^(' + REGEX_PROJECT_NAME_PART + ')/(' + REGEX_CLASS_NAME_PART + ')' + + +// This regex is used to match the robot module path in any project. +export const REGEX_ROBOT_MODULE_PATH = '^' + REGEX_PROJECT_NAME_PART + '/' + escapeRegExp(ROBOT_MODULE_FILE_NAME) + '$'; + +// This regex is used to extract the project name and file name from a file path. +const REGEX_FILE_PATH = '^(' + REGEX_PROJECT_NAME_PART + ')/(.*' + escapeRegExp(JSON_FILE_EXTENSION) + ')$'; + +// This regex is used to extract the class name from a module path. +const REGEX_MODULE_PATH = '^' + REGEX_PROJECT_NAME_PART + '/(' + REGEX_CLASS_NAME_PART + ')' + REGEX_MODULE_TYPE_PART + escapeRegExp(JSON_FILE_EXTENSION) + '$'; -const REGEX_MODULE_PATH_TO_FILE_NAME = '^' + REGEX_PROJECT_NAME_PART + '/(' + REGEX_CLASS_NAME_PART + - REGEX_MODULE_TYPE_PART + escapeRegExp(JSON_FILE_EXTENSION) + ')$'; -const REGEX_FILE_NAME = '^(' + REGEX_CLASS_NAME_PART + ')' + + +// This regex is used to extract the class name from a module file name. +const REGEX_MODULE_FILE_NAME = '^(' + REGEX_CLASS_NAME_PART + ')' + REGEX_MODULE_TYPE_PART + escapeRegExp(JSON_FILE_EXTENSION) + '$'; /** * Returns true if the given name is a valid class name. */ export function isValidClassName(name: string): boolean { - if (name) { - return new RegExp('^' + REGEX_CLASS_NAME_PART + '$').test(name); - } - return false; + return new RegExp(REGEX_CLASS_NAME).test(name); } /** @@ -84,88 +134,131 @@ export function snakeCaseToPascalCase(snakeCaseName: string): string { } /** - * Returns the module path regex pattern for modules in the given project. + * Returns a regex pattern that matches all file paths in the given project. + */ +export function makeFilePathRegexPattern(projectName: string): string { + return '^' + escapeRegExp(projectName) + '/' + + '.*' + escapeRegExp(JSON_FILE_EXTENSION) + '$'; +} + +/** + * Returns a regex pattern that matches all module paths in the given project. */ export function makeModulePathRegexPattern(projectName: string): string { - return '^' + escapeRegExp(projectName) + '/' + REGEX_CLASS_NAME_PART + - REGEX_MODULE_TYPE_PART + escapeRegExp(JSON_FILE_EXTENSION) + '$'; + return '^' + escapeRegExp(projectName) + '/' + + REGEX_CLASS_NAME_PART + REGEX_MODULE_TYPE_PART + escapeRegExp(JSON_FILE_EXTENSION) + '$'; } +/** + * Escapes the given text so it can be used literally in a regular expression. + */ function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +/** + * Returns the file path for the given project name and file name. + */ +export function makeFilePath(projectName: string, fileName: string): string { + return projectName + '/' + fileName; +} + +/** + * Returns the project info path for the given project name. + */ +export function makeProjectInfoPath(projectName: string): string { + return makeFilePath(projectName, PROJECT_INFO_FILE_NAME); +} + /** * Returns the module path for the given project name and class name. */ export function makeModulePath( projectName: string, className: string, moduleType: storageModule.ModuleType): string { - return projectName + '/' + className + '.' + moduleType + JSON_FILE_EXTENSION; + return makeFilePath(projectName, className + '.' + moduleType + JSON_FILE_EXTENSION); } /** - * Returns the robot module path for the given project names. + * Returns the robot module path for the given project name. */ export function makeRobotPath(projectName: string): string { return makeModulePath(projectName, CLASS_NAME_ROBOT, storageModule.ModuleType.ROBOT); } /** - * Returns the project name for given module path. + * Returns the project name for given file path. */ -export function getProjectName(modulePath: string): string { - const regex = new RegExp(REGEX_MODULE_PATH); - const result = regex.exec(modulePath) +export function getProjectName(filePath: string): string { + const regex = new RegExp(REGEX_FILE_PATH); + const result = regex.exec(filePath) if (!result) { - throw new Error('Unable to extract the project name from "' + modulePath + '"'); + throw new Error('Unable to extract the project name from "' + filePath + '"'); } return result[1]; } /** - * Returns the file name for given module path. + * Returns the file name for given file path. */ -export function getFileName(modulePath: string): string { - const regex = new RegExp(REGEX_MODULE_PATH_TO_FILE_NAME); - const result = regex.exec(modulePath) +export function getFileName(filePath: string): string { + const regex = new RegExp(REGEX_FILE_PATH); + const result = regex.exec(filePath) if (!result) { - throw new Error('Unable to extract the project name from "' + modulePath + '"'); + throw new Error('Unable to extract the file name from "' + filePath + '"'); } - return result[1]; + return result[2]; +} + +/** + * Returns true if the given file name is a valid module file name. + */ +export function isValidModuleFileName(fileName: string): boolean { + return new RegExp(REGEX_MODULE_FILE_NAME).test(fileName); +} + +/** + * Returns true if the given file name is a valid project info file name. + */ +export function isValidProjectInfoFileName(fileName: string): boolean { + if (fileName === PROJECT_INFO_FILE_NAME) { + return true; + } + return false; } + /** - * Returns the class name for given module path or file name. + * Returns the class name for given module path or module file name. */ -export function getClassName(modulePathOrFileName: string): string { +export function getClassName(modulePathOrModuleFileName: string): string { let regex = new RegExp(REGEX_MODULE_PATH); - let result = regex.exec(modulePathOrFileName); + let result = regex.exec(modulePathOrModuleFileName); if (result) { - return result[2]; + return result[1]; } - regex = new RegExp(REGEX_FILE_NAME); - result = regex.exec(modulePathOrFileName); + regex = new RegExp(REGEX_MODULE_FILE_NAME); + result = regex.exec(modulePathOrModuleFileName); if (result) { return result[1]; } - throw new Error('Unable to extract the class name from "' + modulePathOrFileName + '"'); + throw new Error('Unable to extract the class name from "' + modulePathOrModuleFileName + '"'); } /** - * Returns the module type for given module path or file name. + * Returns the module type for given module path or module file name. */ -export function getModuleType(modulePathOrFileName: string): storageModule.ModuleType { +export function getModuleType(modulePathOrModuleFileName: string): storageModule.ModuleType { let regex = new RegExp(REGEX_MODULE_PATH); - let result = regex.exec(modulePathOrFileName); + let result = regex.exec(modulePathOrModuleFileName); if (result) { - return storageModule.stringToModuleType(result[3]); + return storageModule.stringToModuleType(result[2]); } - regex = new RegExp(REGEX_FILE_NAME); - result = regex.exec(modulePathOrFileName); + regex = new RegExp(REGEX_MODULE_FILE_NAME); + result = regex.exec(modulePathOrModuleFileName); if (result) { return storageModule.stringToModuleType(result[2]); } - throw new Error('Unable to extract the module type from "' + modulePathOrFileName + '"'); + throw new Error('Unable to extract the module type from "' + modulePathOrModuleFileName + '"'); } /** From 2a60d3859de59cee5428591cf5520fc42afc9992 Mon Sep 17 00:00:00 2001 From: Liz Looney Date: Wed, 27 Aug 2025 23:19:48 -0700 Subject: [PATCH 3/5] In project.ts: Added CURRENT_VERSION. Added type ProjectInfo. Added functions saveProjectInfo, and deleteProjectInfo. Call saveProjectInfo after saving or deleting a module. Call deleteProjectInfo when deleting a project. Updated downloadProject and uploadProject to download/upload all files in the project. Updated processUploadedBlob to handle the project.info.json file and module files. In module_content.ts: Updated parseModuleContentText to check module content. --- src/editor/editor.ts | 3 + src/storage/module_content.ts | 9 +++ src/storage/project.ts | 112 ++++++++++++++++++++++++---------- 3 files changed, 92 insertions(+), 32 deletions(-) diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 5233a790..a8b6fc0a 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -320,6 +320,9 @@ export class Editor { try { await this.storage.saveFile(this.modulePath, moduleContentText); this.moduleContentText = moduleContentText; + if (this.currentProject) { + await storageProject.saveProjectInfo(this.storage, this.currentProject.projectName); + } } catch (e) { throw e; } diff --git a/src/storage/module_content.ts b/src/storage/module_content.ts index d3dc23c9..8afab1ea 100644 --- a/src/storage/module_content.ts +++ b/src/storage/module_content.ts @@ -147,6 +147,15 @@ export function makeModuleContentText( export function parseModuleContentText(moduleContentText: string): ModuleContent { const parsedContent = JSON.parse(moduleContentText); fixOldParsedContent(parsedContent); + if (!('moduleType' in parsedContent) || + !('moduleId' in parsedContent) || + !('blocks' in parsedContent) || + !('mechanisms' in parsedContent) || + !('components' in parsedContent) || + !('events' in parsedContent) || + !('methods' in parsedContent)) { + throw new Error('Module content text is not valid.'); + } return new ModuleContent( parsedContent.moduleType, parsedContent.moduleId, diff --git a/src/storage/project.ts b/src/storage/project.ts index 9a36f98b..10ed92bd 100644 --- a/src/storage/project.ts +++ b/src/storage/project.ts @@ -35,11 +35,17 @@ export type Project = { opModes: storageModule.OpMode[], }; +const CURRENT_VERSION = '0.0.1'; + +type ProjectInfo = { + version: string, +}; + /** * Returns the list of project names. */ export async function listProjectNames(storage: commonStorage.Storage): Promise { - const filePathRegexPattern = '.*/Robot\.robot\.json$'; + const filePathRegexPattern = storageNames.REGEX_ROBOT_MODULE_PATH; const robotModulePaths: string[] = await storage.listFilePaths(filePathRegexPattern); const projectNames: string[] = []; @@ -119,6 +125,7 @@ export async function createProject( const opmodeContent = storageModuleContent.newOpModeContent( newProjectName, storageNames.CLASS_NAME_TELEOP); await storage.saveFile(opmodePath, opmodeContent); + await saveProjectInfo(storage, newProjectName); } /** @@ -161,6 +168,10 @@ async function renameOrCopyProject( await storage.deleteFile(modulePath); } } + await saveProjectInfo(storage, newProjectName); + if (rename) { + await deleteProjectInfo(storage, projectName); + } } /** @@ -177,6 +188,7 @@ export async function deleteProject( for (const modulePath of modulePaths) { await storage.deleteFile(modulePath); } + await deleteProjectInfo(storage, projectName); } /** @@ -215,6 +227,7 @@ export async function addModuleToProject( } as storageModule.OpMode); break; } + await saveProjectInfo(storage, project.projectName); } /** @@ -238,6 +251,7 @@ export async function removeModuleFromProject( break; } await storage.deleteFile(modulePath); + await saveProjectInfo(storage, project.projectName); } } @@ -331,6 +345,7 @@ async function renameOrCopyModule( break; } } + await saveProjectInfo(storage, project.projectName); return newModulePath; } @@ -404,20 +419,20 @@ export function findModuleByModulePath(project: Project, modulePath: string): st */ export async function downloadProject( storage: commonStorage.Storage, projectName: string): Promise { - const modulePaths: string[] = await storage.listFilePaths( - storageNames.makeModulePathRegexPattern(projectName)); - - const fileNameToModuleContentText: {[fileName: string]: string} = {}; // value is module content text - for (const modulePath of modulePaths) { - const fileName = storageNames.getFileName(modulePath); - const moduleContentText = await storage.fetchFileContentText(modulePath); - fileNameToModuleContentText[fileName] = moduleContentText; + const filePaths: string[] = await storage.listFilePaths( + storageNames.makeFilePathRegexPattern(projectName)); + + const fileNameToFileContentText: {[fileName: string]: string} = {}; // value is file content text + for (const filePath of filePaths) { + const fileName = storageNames.getFileName(filePath); + const fileContentText = await storage.fetchFileContentText(filePath); + fileNameToFileContentText[fileName] = fileContentText; } const zip = new JSZip(); - for (const fileName in fileNameToModuleContentText) { - const moduleContentText = fileNameToModuleContentText[fileName]; - zip.file(fileName, moduleContentText); + for (const fileName in fileNameToFileContentText) { + const fileContentText = fileNameToFileContentText[fileName]; + zip.file(fileName, fileContentText); } const content = await zip.generateAsync({ type: "blob" }); return URL.createObjectURL(content); @@ -436,20 +451,19 @@ export function makeUploadProjectName( export async function uploadProject( storage: commonStorage.Storage, projectName: string, blobUrl: string): Promise { // Process the uploaded blob to get the file names and contents. - const fileNameToModuleContentText = await processUploadedBlob(blobUrl); - - // Save each module. - for (const fileName in fileNameToModuleContentText) { - const moduleContentText = fileNameToModuleContentText[fileName]; - const className = storageNames.getClassName(fileName); - const moduleType = storageNames.getModuleType(fileName); - const modulePath = storageNames.makeModulePath(projectName, className, moduleType); - await storage.saveFile(modulePath, moduleContentText); + const fileNameToFileContentText = await processUploadedBlob(blobUrl); + + // Save each file. + for (const fileName in fileNameToFileContentText) { + const fileContentText = fileNameToFileContentText[fileName]; + const filePath = storageNames.makeFilePath(projectName, fileName); + await storage.saveFile(filePath, fileContentText); } + await saveProjectInfo(storage, projectName); } /** - * Process the uploaded blob to get the module file names and file contents. + * Process the uploaded blob to get the file names and file contents. */ async function processUploadedBlob(blobUrl: string): Promise<{ [fileName: string]: string }> { @@ -473,22 +487,56 @@ async function processUploadedBlob(blobUrl: string): Promise<{ [fileName: string }) ); - // Process each module's content. + // Process each file's content. let foundRobot = false; - const fileNameToModuleContentText: { [fileName: string]: string } = {}; // value is module content text + const fileNameToFileContentText: { [fileName: string]: string } = {}; // value is file content text for (const fileName in files) { - const moduleType = storageNames.getModuleType(fileName); - if (moduleType === storageModule.ModuleType.ROBOT) { - foundRobot = true; + if (storageNames.isValidProjectInfoFileName(fileName)) { + // Make sure we can parse the content. + parseProjectInfoContentText(files[fileName]); + } else if (storageNames.isValidModuleFileName(fileName)) { + const moduleType = storageNames.getModuleType(fileName); + if (moduleType === storageModule.ModuleType.ROBOT) { + foundRobot = true; + } + // Make sure we can parse the content. + storageModuleContent.parseModuleContentText(files[fileName]); + } else { + throw new Error('Uploaded project file contains one or more unexpected files.'); } - // Make sure we can parse the content. - const moduleContent = storageModuleContent.parseModuleContentText(files[fileName]); - fileNameToModuleContentText[fileName] = moduleContent.getModuleContentText(); + fileNameToFileContentText[fileName] = files[fileName]; } if (!foundRobot) { - throw new Error('Uploaded file did not contain a Robot.'); + throw new Error('Uploaded project file did not contain a Robot.'); + } + + return fileNameToFileContentText; +} + +export async function saveProjectInfo( + storage: commonStorage.Storage, projectName: string): Promise { + const projectInfo: ProjectInfo = { + version: CURRENT_VERSION, + }; + const projectInfoContentText = JSON.stringify(projectInfo, null, 2); + const projectInfoPath = storageNames.makeProjectInfoPath(projectName); + await storage.saveFile(projectInfoPath, projectInfoContentText); +} + +function parseProjectInfoContentText(projectInfoContentText: string): ProjectInfo { + const parsedContent = JSON.parse(projectInfoContentText); + if (!('version' in parsedContent)) { + throw new Error('Project info content text is not valid.'); } + const projectInfo: ProjectInfo = { + version: parsedContent.version, + }; + return projectInfo; +} - return fileNameToModuleContentText; +async function deleteProjectInfo( + storage: commonStorage.Storage, projectName: string): Promise { + const projectInfoPath = storageNames.makeProjectInfoPath(projectName); + await storage.deleteFile(projectInfoPath); } From 32fc8dd5901060e7e7c79faa3bc73c6fa7d84e44 Mon Sep 17 00:00:00 2001 From: Liz Looney Date: Wed, 27 Aug 2025 23:32:47 -0700 Subject: [PATCH 4/5] Install npm package semver. In project.ts: Added functions fetchProjectInfo and updateProjectIfNecessary. Call updateProjectIfNecessary in fetchProject. --- package-lock.json | 39 +++++++++++++++++++++++++++++++++++---- package.json | 2 ++ src/storage/project.ts | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1c9a3673..510bc021 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "react-dom": "^19.1.0", "react-i18next": "^15.6.0", "react-syntax-highlighter": "^15.6.1", + "semver": "^7.7.2", "web-vitals": "^5.0.3" }, "devDependencies": { @@ -35,6 +36,7 @@ "@shadcn/ui": "^0.0.4", "@types/node": "^24.0.15", "@types/react-syntax-highlighter": "^15.5.13", + "@types/semver": "^7.7.0", "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "^10.4.21", "playwright": "^1.54.1", @@ -255,6 +257,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", @@ -289,6 +301,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -2110,6 +2132,13 @@ "@types/react": "*" } }, + "node_modules/@types/semver": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", + "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", @@ -6187,13 +6216,15 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/setimmediate": { diff --git a/package.json b/package.json index 8d46a41d..1ac56815 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react-dom": "^19.1.0", "react-i18next": "^15.6.0", "react-syntax-highlighter": "^15.6.1", + "semver": "^7.7.2", "web-vitals": "^5.0.3" }, "scripts": { @@ -56,6 +57,7 @@ "@shadcn/ui": "^0.0.4", "@types/node": "^24.0.15", "@types/react-syntax-highlighter": "^15.5.13", + "@types/semver": "^7.7.0", "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "^10.4.21", "playwright": "^1.54.1", diff --git a/src/storage/project.ts b/src/storage/project.ts index 10ed92bd..b899f891 100644 --- a/src/storage/project.ts +++ b/src/storage/project.ts @@ -20,6 +20,7 @@ */ import JSZip from 'jszip'; +import * as semver from 'semver'; import * as commonStorage from './common_storage'; import * as storageModule from './module'; @@ -35,6 +36,7 @@ export type Project = { opModes: storageModule.OpMode[], }; +const NO_VERSION = '0.0.0'; const CURRENT_VERSION = '0.0.1'; type ProjectInfo = { @@ -60,6 +62,8 @@ export async function listProjectNames(storage: commonStorage.Storage): Promise< */ export async function fetchProject( storage: commonStorage.Storage, projectName: string): Promise { + await updateProjectIfNecessary(storage, projectName); + const modulePaths: string[] = await storage.listFilePaths( storageNames.makeModulePathRegexPattern(projectName)); @@ -540,3 +544,34 @@ async function deleteProjectInfo( const projectInfoPath = storageNames.makeProjectInfoPath(projectName); await storage.deleteFile(projectInfoPath); } + +async function fetchProjectInfo( + storage: commonStorage.Storage, projectName: string): Promise { + const projectInfoPath = storageNames.makeProjectInfoPath(projectName); + let projectInfo: ProjectInfo; + try { + const projectInfoContentText = await storage.fetchFileContentText(projectInfoPath); + projectInfo = parseProjectInfoContentText(projectInfoContentText); + } catch (error) { + // The file doesn't exist. + projectInfo = { + version: NO_VERSION, + }; + } + return projectInfo; +} + +async function updateProjectIfNecessary( + storage: commonStorage.Storage, projectName: string): Promise { + const projectInfo = await fetchProjectInfo(storage, projectName); + if (semver.lt(projectInfo.version, CURRENT_VERSION)) { + switch (projectInfo.version) { + case '0.0.0': + // Project was saved without a project.info.json file. + // Nothing needs to be done to update to '0.0.1'; + projectInfo.version = '0.0.1'; + break; + } + await saveProjectInfo(storage, projectName); + } +} From 44319eebf89e8e912acfd73da922cb8f6ac68693 Mon Sep 17 00:00:00 2001 From: Liz Looney Date: Thu, 28 Aug 2025 13:33:09 -0700 Subject: [PATCH 5/5] Update project.ts --- src/storage/project.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/storage/project.ts b/src/storage/project.ts index b899f891..215f7fcc 100644 --- a/src/storage/project.ts +++ b/src/storage/project.ts @@ -561,6 +561,7 @@ async function fetchProjectInfo( return projectInfo; } +// TODO(lizlooney): Move updateProjectIfNecessary to it's own file. async function updateProjectIfNecessary( storage: commonStorage.Storage, projectName: string): Promise { const projectInfo = await fetchProjectInfo(storage, projectName);