From 304194d7ec436984331e6077afe29a22771196ef Mon Sep 17 00:00:00 2001 From: David Bruno Vontor Date: Mon, 26 Jan 2026 09:54:56 +0100 Subject: [PATCH] Update generate-choice-labels.cjs --- frontend/scripts/generate-choice-labels.cjs | 188 +++++++++++--------- 1 file changed, 102 insertions(+), 86 deletions(-) diff --git a/frontend/scripts/generate-choice-labels.cjs b/frontend/scripts/generate-choice-labels.cjs index 50b15af..6bc1327 100644 --- a/frontend/scripts/generate-choice-labels.cjs +++ b/frontend/scripts/generate-choice-labels.cjs @@ -1,22 +1,29 @@ #!/usr/bin/env node /** - * Generates TypeScript file with choice labels from Orval-generated enum files + * Generates TypeScript file with choice labels from Django TextChoices in Python models * - * Usage: node scripts/generate-choice-labels.js + * Usage: node scripts/generate-choice-labels.cjs * - * This script reads the TypeScript enum files generated by Orval and extracts - * choice labels from JSDoc comments (format: * `value` - Label) and generates - * a TypeScript file with runtime-accessible label mappings. + * This script reads Django models.py files from backend apps and extracts + * TextChoices class definitions (format: VALUE = 'value', 'Czech Label') + * and generates a TypeScript file with runtime-accessible label mappings. + * + * This is more reliable than parsing OpenAPI schema since it reads directly + * from the source of truth (Python models). */ const fs = require('fs'); const path = require('path'); -// Configuration -const MODELS_DIRS = [ - path.join(__dirname, '../src/api/generated/private/models'), - path.join(__dirname, '../src/api/generated/public/models') +// Configuration - Backend Python apps to scan +const PYTHON_APPS = [ + path.join(__dirname, '../../backend/account'), + path.join(__dirname, '../../backend/booking'), + path.join(__dirname, '../../backend/commerce'), + path.join(__dirname, '../../backend/servicedesk'), + path.join(__dirname, '../../backend/product'), + path.join(__dirname, '../../backend/configuration'), ]; const OUTPUT_PATH = path.join(__dirname, '../src/constants/choiceLabels.ts'); @@ -28,112 +35,119 @@ function camelToSnakeCase(str) { .toUpperCase(); } -// Extract enum labels from TypeScript file comments -function parseEnumFile(filePath) { +// Extract TextChoices from Python models.py file +function parsePythonModels(filePath) { const content = fs.readFileSync(filePath, 'utf8'); - const filename = path.basename(filePath, '.ts'); + const results = {}; - // Extract all JSDoc comment blocks - // Match all: /** ... */ - const commentRegex = /\/\*\*\s*\n([\s\S]*?)\*\//g; - const comments = []; - let match; + // First, find all model class definitions to track context + // Match: class ModelName(SoftDeleteModel): or class ModelName(models.Model): + const modelRegex = /class\s+(\w+)\([^)]*(?:Model|SoftDeleteModel)[^)]*\):/g; + const modelPositions = []; + let modelMatch; - while ((match = commentRegex.exec(content)) !== null) { - comments.push(match[1]); + while ((modelMatch = modelRegex.exec(content)) !== null) { + modelPositions.push({ + name: modelMatch[1], + start: modelMatch.index, + }); } - if (comments.length === 0) { - return null; - } + // Match TextChoices class definitions: + // class RoleChoices(models.TextChoices): or class StatusChoices(models.TextChoices): + const classRegex = /class\s+(\w+Choices)\s*\([^)]*TextChoices[^)]*\):\s*\n((?:\s+\w+\s*=\s*[^\n]+\n?)+)/g; - // Try to find labels in all comment blocks - // Parse labels from comment lines like: * `value` - Label - // Note: The asterisk might have 0 or more spaces after it - const labelPattern = /\*\s*`([^`]+)`\s*-\s*(.+)/g; - const labels = {}; - - for (const comment of comments) { - let labelMatch; - while ((labelMatch = labelPattern.exec(comment)) !== null) { - const [, value, label] = labelMatch; - labels[value] = label.trim(); + let classMatch; + while ((classMatch = classRegex.exec(content)) !== null) { + const [, className, classBody] = classMatch; + const choicePosition = classMatch.index; + + // Find the parent model class (closest model before this choice) + let parentModel = null; + for (let i = modelPositions.length - 1; i >= 0; i--) { + if (modelPositions[i].start < choicePosition) { + parentModel = modelPositions[i].name; + break; + } + } + + // Generate enum name based on parent model + choice class name + // MarketSlot + StatusChoices -> MarketSlotStatusEnum + // Reservation + StatusChoices -> ReservationStatusEnum (but spec uses Status9e5Enum) + // Order + StatusChoices -> OrderStatusEnum + // For top-level choices (no parent), use just the choice name + let enumName; + if (parentModel && className === 'StatusChoices') { + // Use parent model name for nested StatusChoices + enumName = `${parentModel}${className.replace(/Choices$/, 'Enum')}`; + } else { + // Top-level choices like RoleChoices, AccountTypeChoices + enumName = className.replace(/Choices$/, 'Enum'); + } + + // Parse individual choice lines: ADMIN = 'admin', 'Administrรกtor' + const choiceRegex = /\w+\s*=\s*['"]([^'"]+)['"],\s*['"]([^'"]+)['"]/g; + const labels = {}; + + let choiceMatch; + while ((choiceMatch = choiceRegex.exec(classBody)) !== null) { + const [, value, label] = choiceMatch; + labels[value] = label; + } + + if (Object.keys(labels).length > 0) { + results[enumName] = labels; } } - // Only return if we found labels - if (Object.keys(labels).length === 0) { - return null; - } - - // Extract enum name from the type definition - // Match: export type EnumName = ... - const typeMatch = content.match(/export\s+type\s+(\w+Enum)\s*=/); - const enumName = typeMatch ? typeMatch[1] : null; - - if (!enumName) { - return null; - } - - return { enumName, labels }; + return results; } // Main function function generateLabels() { - console.log('๐Ÿ” Scanning Orval-generated enum files...'); + console.log('๐Ÿ” Scanning Python models for TextChoices...'); const enums = {}; - let totalEnumFiles = 0; + let totalFiles = 0; - // Check each models directory - for (const MODELS_DIR of MODELS_DIRS) { - const dirName = path.basename(path.dirname(MODELS_DIR)); + // Scan each backend app for models.py + for (const appDir of PYTHON_APPS) { + const appName = path.basename(appDir); + const modelsFile = path.join(appDir, 'models.py'); - if (!fs.existsSync(MODELS_DIR)) { - console.warn(`โš ๏ธ Models directory not found: ${MODELS_DIR}`); + if (!fs.existsSync(modelsFile)) { continue; } - console.log(`๐Ÿ“ Scanning ${dirName}/models...`); + console.log(`๐Ÿ“ Scanning ${appName}/models.py...`); + totalFiles++; - // Read all enum files - const files = fs.readdirSync(MODELS_DIR); - const enumFiles = files.filter(f => f.endsWith('Enum.ts')); + // Parse the models file + const results = parsePythonModels(modelsFile); - console.log(` Found ${enumFiles.length} enum files`); - totalEnumFiles += enumFiles.length; - - // Parse each enum file - for (const file of enumFiles) { - const filePath = path.join(MODELS_DIR, file); - const result = parseEnumFile(filePath); - - if (result) { - // Check if enum already exists from another directory - if (enums[result.enumName]) { - // Compare labels to see if they're the same - const existing = JSON.stringify(enums[result.enumName]); - const current = JSON.stringify(result.labels); - - if (existing !== current) { - console.warn(` โš ๏ธ ${result.enumName}: Different labels found in ${dirName}, keeping first version`); - } - } else { - enums[result.enumName] = result.labels; - console.log(` โœ“ ${result.enumName}: ${Object.keys(result.labels).length} labels`); + for (const [enumName, labels] of Object.entries(results)) { + if (enums[enumName]) { + // Check for conflicts + const existing = JSON.stringify(enums[enumName]); + const current = JSON.stringify(labels); + + if (existing !== current) { + console.warn(` โš ๏ธ ${enumName}: Conflict in ${appName}, keeping first version`); } + } else { + enums[enumName] = labels; + console.log(` โœ“ ${enumName}: ${Object.keys(labels).length} labels`); } } } - if (totalEnumFiles === 0) { - console.error('โŒ No enum files found in any models directory'); - console.error(' Run "npm run api:gen" first to generate API client'); + if (totalFiles === 0) { + console.error('โŒ No models.py files found in backend apps'); process.exit(1); } - + if (Object.keys(enums).length === 0) { - console.error('โŒ No enum labels found in generated files'); + console.error('โŒ No TextChoices found in Python models'); process.exit(1); } @@ -141,10 +155,12 @@ function generateLabels() { console.log('๐Ÿ“ Generating TypeScript file...'); const tsContent = `/** - * Auto-generated choice labels from Orval-generated enum files + * Auto-generated choice labels from Django TextChoices in Python models * Generated by: scripts/generate-choice-labels.cjs * * DO NOT EDIT MANUALLY - Run \`npm run api:gen\` to regenerate + * + * Source: backend (TextChoices classes) */ ${Object.entries(enums).map(([enumName, labels]) => {