diff --git a/CHANGELOG.md b/CHANGELOG.md index fd24f76..e03ad6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how ## [Unreleased] - Added: Generator command for a ViewModel class - Added: Generator command for data patches +- Added: Generator command for cron jobs - Added: Jump-to-definition for magento modules (in module.xml and routes.xml) - Fixed: Method plugin hover messages are now grouped and include a link to di.xml diff --git a/package.json b/package.json index 5bf15f0..398153d 100644 --- a/package.json +++ b/package.json @@ -200,6 +200,11 @@ "title": "Generate Data Patch", "category": "Magento Toolbox" }, + { + "command": "magento-toolbox.generateCronJob", + "title": "Generate Cron Job", + "category": "Magento Toolbox" + }, { "command": "magento-toolbox.jumpToModule", "title": "Jump to Module", @@ -337,6 +342,10 @@ { "command": "magento-toolbox.generateDataPatch", "when": "resourcePath =~ /app\\/code\\/.+\\/.+/i" + }, + { + "command": "magento-toolbox.generateCronJob", + "when": "resourcePath =~ /app\\/code\\/.+\\/.+/i" } ] } diff --git a/src/command/GenerateCronJobCommand.ts b/src/command/GenerateCronJobCommand.ts new file mode 100644 index 0000000..233a2a7 --- /dev/null +++ b/src/command/GenerateCronJobCommand.ts @@ -0,0 +1,62 @@ +import { Command } from 'command/Command'; +import CronJobWizard, { CronJobWizardData } from 'wizard/CronJobWizard'; +import WizzardClosedError from 'webview/error/WizzardClosedError'; +import FileGeneratorManager from 'generator/FileGeneratorManager'; +import Common from 'util/Common'; +import { Uri, window } from 'vscode'; +import CronJobClassGenerator from 'generator/cronJob/CronJobClassGenerator'; +import CronJobXmlGenerator from 'generator/cronJob/CronJobXmlGenerator'; +import IndexManager from 'indexer/IndexManager'; +import ModuleIndexer from 'indexer/module/ModuleIndexer'; + +export default class GenerateCronJobCommand extends Command { + constructor() { + super('magento-toolbox.generateCronJob'); + } + + public async execute(uri?: Uri): Promise { + const moduleIndex = IndexManager.getIndexData(ModuleIndexer.KEY); + let contextModule: string | undefined; + + const contextUri = uri || window.activeTextEditor?.document.uri; + + if (moduleIndex && contextUri) { + const module = moduleIndex.getModuleByUri(contextUri); + + if (module) { + contextModule = module.name; + } + } + + const cronJobWizard = new CronJobWizard(); + + let data: CronJobWizardData; + + try { + data = await cronJobWizard.show(contextModule); + } catch (error) { + if (error instanceof WizzardClosedError) { + return; + } + + throw error; + } + + const manager = new FileGeneratorManager([ + new CronJobClassGenerator(data), + new CronJobXmlGenerator(data), + ]); + + const workspaceFolder = Common.getActiveWorkspaceFolder(); + + if (!workspaceFolder) { + window.showErrorMessage('No active workspace folder'); + return; + } + + await manager.generate(workspaceFolder.uri); + await manager.writeFiles(); + await manager.refreshIndex(workspaceFolder); + manager.openAllFiles(); + } +} diff --git a/src/command/index.ts b/src/command/index.ts index bdae859..a200497 100644 --- a/src/command/index.ts +++ b/src/command/index.ts @@ -26,5 +26,6 @@ export { default as GenerateWidgetXmlFileCommand } from './GenerateWidgetXmlFile export { default as GenerateExtensionAttributesXmlFileCommand } from './GenerateExtensionAttributesXmlFileCommand'; export { default as GenerateSystemXmlFileCommand } from './GenerateSystemXmlFileCommand'; export { default as GenerateConfigXmlFileCommand } from './GenerateConfigXmlFileCommand'; -export { default as JumpToModuleCommand } from './JumpToModuleCommand'; export { default as GenerateDataPatchCommand } from './GenerateDataPatchCommand'; +export { default as GenerateCronJobCommand } from './GenerateCronJobCommand'; +export { default as JumpToModuleCommand } from './JumpToModuleCommand'; diff --git a/src/generator/cronJob/CronJobClassGenerator.ts b/src/generator/cronJob/CronJobClassGenerator.ts new file mode 100644 index 0000000..b9b45a3 --- /dev/null +++ b/src/generator/cronJob/CronJobClassGenerator.ts @@ -0,0 +1,56 @@ +import FileHeader from 'common/php/FileHeader'; +import PhpNamespace from 'common/PhpNamespace'; +import GeneratedFile from 'generator/GeneratedFile'; +import FileGenerator from 'generator/FileGenerator'; +import { PhpFile, PsrPrinter } from 'node-php-generator'; +import { Uri } from 'vscode'; +import { CronJobWizardData } from 'wizard/CronJobWizard'; +import Magento from 'util/Magento'; +import * as fs from 'fs'; +import * as path from 'path'; + +export default class CronJobClassGenerator extends FileGenerator { + public constructor(protected data: CronJobWizardData) { + super(); + } + + public async generate(workspaceUri: Uri): Promise { + const [vendor, module] = this.data.module.split('_'); + const cronDir = 'Cron'; + const namespaceParts = [vendor, module, 'Cron']; + const moduleDirectory = Magento.getModuleDirectory(vendor, module, workspaceUri); + + // Create cron directory if it doesn't exist + const cronDirPath = path.join(moduleDirectory.fsPath, cronDir); + if (!fs.existsSync(cronDirPath)) { + fs.mkdirSync(cronDirPath, { recursive: true }); + } + + const phpFile = new PhpFile(); + phpFile.setStrictTypes(true); + + const header = FileHeader.getHeader(this.data.module); + + if (header) { + phpFile.addComment(header); + } + + const namespace = phpFile.addNamespace(PhpNamespace.fromParts(namespaceParts).toString()); + + const cronClass = namespace.addClass(this.data.className); + + // Add execute method + const executeMethod = cronClass.addMethod('execute'); + executeMethod.addComment('Execute the cron'); + executeMethod.addComment('\n@return void'); + executeMethod.setReturnType('void'); + executeMethod.setBody('// TODO: Implement execute() method.'); + + const printer = new PsrPrinter(); + + return new GeneratedFile( + Uri.joinPath(moduleDirectory, cronDir, `${this.data.className}.php`), + printer.printFile(phpFile) + ); + } +} diff --git a/src/generator/cronJob/CronJobXmlGenerator.ts b/src/generator/cronJob/CronJobXmlGenerator.ts new file mode 100644 index 0000000..4479a09 --- /dev/null +++ b/src/generator/cronJob/CronJobXmlGenerator.ts @@ -0,0 +1,104 @@ +import GeneratedFile from 'generator/GeneratedFile'; +import FileGenerator from 'generator/FileGenerator'; +import FindOrCreateCrontabXml from 'generator/util/FindOrCreateCrontabXml'; +import { Uri } from 'vscode'; +import { CronJobWizardData } from 'wizard/CronJobWizard'; +import indentString from 'indent-string'; +import Magento from 'util/Magento'; +import HandlebarsTemplateRenderer from 'generator/HandlebarsTemplateRenderer'; +import { MagentoScope } from 'types/global'; +import { TemplatePath } from 'types/handlebars'; + +export default class CronJobXmlGenerator extends FileGenerator { + public constructor(protected data: CronJobWizardData) { + super(); + } + + public async generate(workspaceUri: Uri): Promise { + const [vendor, module] = this.data.module.split('_'); + const moduleDirectory = Magento.getModuleDirectory(vendor, module, workspaceUri); + const etcDirectory = Uri.joinPath(moduleDirectory, 'etc'); + const crontabFile = Uri.joinPath(etcDirectory, 'crontab.xml'); + + // Prepare cron job data + const jobName = this.data.cronName; + const jobInstance = `${vendor}\\${module}\\Cron\\${this.data.className}`; + + // Get or create crontab.xml content + const crontabXml = await FindOrCreateCrontabXml.execute( + workspaceUri, + vendor, + module, + MagentoScope.Global + ); + + // Create template renderer + const renderer = new HandlebarsTemplateRenderer(); + + // Generate job XML using the template + const jobXml = await renderer.render(TemplatePath.XmlCronJob, { + jobName, + jobInstance, + cronSchedule: this.data.cronSchedule, + }); + + // Check if group exists + const groupExists = this.checkIfGroupExists(crontabXml, this.data.cronGroup); + + let insertXml: string; + + if (groupExists) { + // Group exists, just use the job XML + insertXml = indentString(jobXml, 4); + } else { + // Group doesn't exist, create it with the job XML + insertXml = await renderer.render( + TemplatePath.XmlCronGroup, + { + groupId: this.data.cronGroup, + }, + { + groupContent: jobXml, + } + ); + } + + // Find insertion position + const insertPosition = this.getInsertPosition(crontabXml, this.data.cronGroup); + + // Insert new group + const newCrontabXml = + crontabXml.slice(0, insertPosition) + + '\n' + + indentString(insertXml, 4) + + '\n' + + crontabXml.slice(insertPosition); + + return new GeneratedFile(crontabFile, newCrontabXml, false); + } + + /** + * Check if group with given ID already exists + */ + private checkIfGroupExists(crontabXml: string, groupId: string): boolean { + const groupRegex = new RegExp(`]*>`, 'i'); + const match = groupRegex.exec(crontabXml); + if (match) { + return match.index + match[0].length; + } + } + + // Otherwise insert before + return crontabXml.indexOf(''); + } +} diff --git a/src/generator/util/FindOrCreateCrontabXml.ts b/src/generator/util/FindOrCreateCrontabXml.ts new file mode 100644 index 0000000..f1cd38c --- /dev/null +++ b/src/generator/util/FindOrCreateCrontabXml.ts @@ -0,0 +1,31 @@ +import Magento from 'util/Magento'; +import { Uri } from 'vscode'; +import { MagentoScope } from 'types/global'; +import { TemplatePath } from 'types/handlebars'; +import HandlebarsTemplateRenderer from 'generator/HandlebarsTemplateRenderer'; +import FileSystem from 'util/FileSystem'; +import FileHeader from 'common/xml/FileHeader'; + +export default class FindOrCreateCrontabXml { + public static async execute( + workspaceUri: Uri, + vendor: string, + module: string, + area: MagentoScope = MagentoScope.Global + ): Promise { + const modulePath = Magento.getModuleDirectory(vendor, module, workspaceUri, 'etc'); + const crontabFile = Magento.getUriWithArea(modulePath, 'crontab.xml', area); + + if (await FileSystem.fileExists(crontabFile)) { + return await FileSystem.readFile(crontabFile); + } + + const fileHeader = FileHeader.getHeader(module); + + const renderer = new HandlebarsTemplateRenderer(); + + return await renderer.render(TemplatePath.XmlBlankCrontab, { + fileHeader, + }); + } +} diff --git a/src/test/generator/cronJob/CronJobClassGenerator.test.ts b/src/test/generator/cronJob/CronJobClassGenerator.test.ts new file mode 100644 index 0000000..b287fa4 --- /dev/null +++ b/src/test/generator/cronJob/CronJobClassGenerator.test.ts @@ -0,0 +1,63 @@ +import { CronJobWizardData } from 'wizard/CronJobWizard'; +import * as assert from 'assert'; +import { Uri } from 'vscode'; +import CronJobClassGenerator from 'generator/cronJob/CronJobClassGenerator'; +import { describe, it, before, afterEach } from 'mocha'; +import { setup } from 'test/setup'; +import { getReferenceFile, getTestWorkspaceUri } from 'test/util'; +import FileHeader from 'common/php/FileHeader'; +import sinon from 'sinon'; + +describe('CronJobClassGenerator Tests', () => { + const cronJobWizardData: CronJobWizardData = { + module: 'Foo_Bar', + className: 'TestCronJob', + cronName: 'foo_bar_test_cron_job', + cronGroup: 'default', + cronSchedule: '* * * * *', + }; + + before(async () => { + await setup(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should generate cron job class file', async () => { + // Mock the FileHeader.getHeader method to return a consistent header + sinon.stub(FileHeader, 'getHeader').returns('Foo_Bar'); + + // Create the generator with test data + const generator = new CronJobClassGenerator(cronJobWizardData); + + // Use a test workspace URI + const workspaceUri = getTestWorkspaceUri(); + + // Generate the file + const generatedFile = await generator.generate(workspaceUri); + + // Get the reference file content + const referenceContent = getReferenceFile('generator/cronJob/TestCronJob.php'); + + // Compare the generated content with reference + assert.strictEqual(generatedFile.content, referenceContent); + }); + + it('should generate file in correct location', async () => { + // Create the generator with test data + const generator = new CronJobClassGenerator(cronJobWizardData); + + // Use a test workspace URI + const workspaceUri = getTestWorkspaceUri(); + + // Generate the file + const generatedFile = await generator.generate(workspaceUri); + + // Expected path + const expectedPath = Uri.joinPath(workspaceUri, 'app/code/Foo/Bar/Cron/TestCronJob.php').fsPath; + + assert.strictEqual(generatedFile.uri.fsPath, expectedPath); + }); +}); diff --git a/src/test/generator/cronJob/CronJobXmlGenerator.test.ts b/src/test/generator/cronJob/CronJobXmlGenerator.test.ts new file mode 100644 index 0000000..da834ea --- /dev/null +++ b/src/test/generator/cronJob/CronJobXmlGenerator.test.ts @@ -0,0 +1,84 @@ +import { CronJobWizardData } from 'wizard/CronJobWizard'; +import * as assert from 'assert'; +import { Uri } from 'vscode'; +import CronJobXmlGenerator from 'generator/cronJob/CronJobXmlGenerator'; +import { describe, it, before } from 'mocha'; +import { setup } from 'test/setup'; +import { getReferenceFile, getTestWorkspaceUri } from 'test/util'; +import sinon from 'sinon'; +import FindOrCreateCrontabXml from 'generator/util/FindOrCreateCrontabXml'; + +describe('CronJobXmlGenerator Tests', () => { + const cronJobWizardData: CronJobWizardData = { + module: 'Foo_Bar', + className: 'Class', + cronName: 'foo_bar_class', + cronGroup: 'default', + cronSchedule: '* * * * *', + }; + + before(async () => { + await setup(); + }); + + it('should generate crontab.xml', async () => { + // Create the generator with test data + const generator = new CronJobXmlGenerator(cronJobWizardData); + + // Use a test workspace URI + const workspaceUri = getTestWorkspaceUri(); + + // Generate the file + const generatedFile = await generator.generate(workspaceUri); + + // Get the reference file content + const referenceContent = getReferenceFile('generator/cronJob/crontab.xml'); + + // Compare the generated content with reference + assert.strictEqual(generatedFile.content, referenceContent); + }); + + it('should generate file in correct location', async () => { + // Create the generator with test data + const generator = new CronJobXmlGenerator(cronJobWizardData); + + // Use a test workspace URI + const workspaceUri = getTestWorkspaceUri(); + + // Generate the file + const generatedFile = await generator.generate(workspaceUri); + + // Expected path + const expectedPath = Uri.joinPath(workspaceUri, 'app/code/Foo/Bar/etc/crontab.xml').fsPath; + + assert.strictEqual(generatedFile.uri.fsPath, expectedPath); + }); + + it('should add job to existing group', async () => { + const existingCrontabXml = getReferenceFile('generator/cronJob/crontab.xml'); + // Stub FindOrCreateCrontabXml.execute to return crontab.xml with existing group + const stub = sinon.stub(FindOrCreateCrontabXml, 'execute').resolves(existingCrontabXml); + + try { + // Create the generator with test data + const generator = new CronJobXmlGenerator({ + ...cronJobWizardData, + cronName: 'new_job', + }); + + // Use a test workspace URI + const workspaceUri = getTestWorkspaceUri(); + + // Generate the file + const generatedFile = await generator.generate(workspaceUri); + + const referenceContent = getReferenceFile('generator/cronJob/crontab-merged.xml'); + + // Compare the generated content with reference + assert.strictEqual(generatedFile.content, referenceContent); + } finally { + // Restore the stub + stub.restore(); + } + }); +}); diff --git a/src/test/generator/dataPatch/DataPatchGenerator.test.ts b/src/test/generator/dataPatch/DataPatchGenerator.test.ts index 8c53199..b2ef9fe 100644 --- a/src/test/generator/dataPatch/DataPatchGenerator.test.ts +++ b/src/test/generator/dataPatch/DataPatchGenerator.test.ts @@ -42,7 +42,7 @@ describe('DataPatchGenerator Tests', () => { const generatedFile = await generator.generate(workspaceUri); // Get the reference file content - const referenceContent = getReferenceFile('generator/dataPatch/patch.php'); + const referenceContent = getReferenceFile('generator/dataPatch/TestDataPatch.php'); // Compare the generated content with reference assert.strictEqual(generatedFile.content, referenceContent); @@ -62,7 +62,7 @@ describe('DataPatchGenerator Tests', () => { const generatedFile = await generator.generate(workspaceUri); // Get the reference file content - const referenceContent = getReferenceFile('generator/dataPatch/patchRevertable.php'); + const referenceContent = getReferenceFile('generator/dataPatch/TestDataPatchRevertable.php'); // Compare the generated content with reference assert.strictEqual(generatedFile.content, referenceContent); diff --git a/src/types/handlebars.ts b/src/types/handlebars.ts index 072d1be..28d187e 100644 --- a/src/types/handlebars.ts +++ b/src/types/handlebars.ts @@ -28,6 +28,8 @@ export enum TemplatePath { XmlBlankWidget = 'xml/blank-widget', XmlEventsObserver = 'xml/events/observer', XmlEventsEvent = 'xml/events/event', + XmlCronJob = 'xml/cron/job', + XmlCronGroup = 'xml/cron/group', } /** @@ -102,6 +104,22 @@ export interface PreferenceParams extends BaseTemplateParams { typeClass: string; } +/** + * Parameters for cron job templates + */ +export interface CronJobParams extends BaseTemplateParams { + jobName: string; + jobInstance: string; + cronSchedule: string; +} + +/** + * Parameters for cron group templates + */ +export interface CronGroupParams extends BaseTemplateParams { + groupId: string; +} + /** * Template parameters mapped by template path */ @@ -117,6 +135,8 @@ export interface TemplateParams { [TemplatePath.XmlDiType]: DiTypeParams; [TemplatePath.XmlDiPlugin]: DiPluginParams; [TemplatePath.XmlDiPreference]: PreferenceParams; + [TemplatePath.XmlCronJob]: CronJobParams; + [TemplatePath.XmlCronGroup]: CronGroupParams; [key: string]: BaseTemplateParams; } @@ -142,11 +162,19 @@ export interface DiTypeTemplatePartials extends BaseTemplatePartials { typeContent: string; } +/** + * Partials for cron group templates + */ +export interface CronGroupTemplatePartials extends BaseTemplatePartials { + groupContent: string; +} + /** * Template partials mapped by template path */ export interface TemplatePartials { [TemplatePath.XmlEventsEvent]: EventTemplatePartials; [TemplatePath.XmlDiType]: DiTypeTemplatePartials; + [TemplatePath.XmlCronGroup]: CronGroupTemplatePartials; [key: string]: BaseTemplatePartials; } diff --git a/src/wizard/CronJobWizard.ts b/src/wizard/CronJobWizard.ts new file mode 100644 index 0000000..f7589ce --- /dev/null +++ b/src/wizard/CronJobWizard.ts @@ -0,0 +1,89 @@ +import IndexManager from 'indexer/IndexManager'; +import ModuleIndexer from 'indexer/module/ModuleIndexer'; +import Validation from 'common/Validation'; +import { GeneratorWizard } from 'webview/GeneratorWizard'; +import { WizardFieldBuilder } from 'webview/WizardFieldBuilder'; +import { WizardFormBuilder } from 'webview/WizardFormBuilder'; +import { WizardTabBuilder } from 'webview/WizardTabBuilder'; + +export interface CronJobWizardData { + module: string; + className: string; + cronName: string; + cronGroup: string; + cronSchedule: string; +} + +export default class CronJobWizard extends GeneratorWizard { + public async show(contextModule?: string): Promise { + const moduleIndexData = IndexManager.getIndexData(ModuleIndexer.KEY); + + if (!moduleIndexData) { + throw new Error('Module index data not found'); + } + + const modules = moduleIndexData.getModuleOptions(module => module.location === 'app'); + + const builder = new WizardFormBuilder(); + + builder.setTitle('Generate a new Cron Job'); + builder.setDescription('Generates a new cron job class and crontab.xml configuration.'); + + const tab = new WizardTabBuilder(); + tab.setId('cronJob'); + tab.setTitle('Cron Job'); + + tab.addField( + WizardFieldBuilder.select('module', 'Module') + .setDescription(['Module where cron job will be generated in']) + .setOptions(modules) + .setInitialValue(contextModule || modules[0].value) + .build() + ); + + tab.addField( + WizardFieldBuilder.text('className', 'Class Name') + .setDescription(['The class name for the cron job']) + .setPlaceholder('SomeCronJob') + .build() + ); + + tab.addField( + WizardFieldBuilder.text('cronName', 'Cron Name') + .setDescription(['The name identifier for this cron job']) + .setPlaceholder('module_name_cron_job') + .build() + ); + + tab.addField( + WizardFieldBuilder.text('cronGroup', 'Cron Group') + .setDescription(['The cron group for this job']) + .setPlaceholder('default') + .setInitialValue('default') + .build() + ); + + tab.addField( + WizardFieldBuilder.text('cronSchedule', 'Cron Schedule') + .setDescription(['The cron schedule expression (e.g. * * * * *)']) + .setPlaceholder('* * * * *') + .setInitialValue('* * * * *') + .build() + ); + + builder.addTab(tab.build()); + + builder.addValidation('module', 'required'); + builder.addValidation('className', [ + 'required', + `regex:/${Validation.CLASS_NAME_REGEX.source}/`, + ]); + builder.addValidation('cronName', 'required'); + builder.addValidation('cronGroup', 'required'); + builder.addValidation('cronSchedule', 'required'); + + const data = await this.openWizard(builder.build()); + + return data; + } +} diff --git a/templates/handlebars/xml/cron/group.hbs b/templates/handlebars/xml/cron/group.hbs new file mode 100644 index 0000000..d6c798a --- /dev/null +++ b/templates/handlebars/xml/cron/group.hbs @@ -0,0 +1,3 @@ + + {{> groupContent}} + \ No newline at end of file diff --git a/templates/handlebars/xml/cron/job.hbs b/templates/handlebars/xml/cron/job.hbs new file mode 100644 index 0000000..aa22977 --- /dev/null +++ b/templates/handlebars/xml/cron/job.hbs @@ -0,0 +1,3 @@ + + {{cronSchedule}} + \ No newline at end of file diff --git a/test-resources/reference/generator/cronJob/TestCronJob.php b/test-resources/reference/generator/cronJob/TestCronJob.php new file mode 100644 index 0000000..00c9895 --- /dev/null +++ b/test-resources/reference/generator/cronJob/TestCronJob.php @@ -0,0 +1,22 @@ + + + + + + * * * * * + + + + * * * * * + + + diff --git a/test-resources/reference/generator/cronJob/crontab.xml b/test-resources/reference/generator/cronJob/crontab.xml new file mode 100644 index 0000000..3dc8771 --- /dev/null +++ b/test-resources/reference/generator/cronJob/crontab.xml @@ -0,0 +1,10 @@ + + + + + + * * * * * + + + diff --git a/test-resources/reference/generator/dataPatch/patch.php b/test-resources/reference/generator/dataPatch/TestDataPatch.php similarity index 100% rename from test-resources/reference/generator/dataPatch/patch.php rename to test-resources/reference/generator/dataPatch/TestDataPatch.php diff --git a/test-resources/reference/generator/dataPatch/patchRevertable.php b/test-resources/reference/generator/dataPatch/TestDataPatchRevertable.php similarity index 100% rename from test-resources/reference/generator/dataPatch/patchRevertable.php rename to test-resources/reference/generator/dataPatch/TestDataPatchRevertable.php