Update generate-choice-labels.cjs
This commit is contained in:
@@ -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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
// 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 = {};
|
||||
|
||||
for (const comment of comments) {
|
||||
let labelMatch;
|
||||
while ((labelMatch = labelPattern.exec(comment)) !== null) {
|
||||
const [, value, label] = labelMatch;
|
||||
labels[value] = label.trim();
|
||||
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);
|
||||
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(` ⚠️ ${result.enumName}: Different labels found in ${dirName}, keeping first version`);
|
||||
console.warn(` ⚠️ ${enumName}: Conflict in ${appName}, keeping first version`);
|
||||
}
|
||||
} else {
|
||||
enums[result.enumName] = result.labels;
|
||||
console.log(` ✓ ${result.enumName}: ${Object.keys(result.labels).length} labels`);
|
||||
}
|
||||
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]) => {
|
||||
|
||||
Reference in New Issue
Block a user