229 lines
7.1 KiB
JavaScript
229 lines
7.1 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Generates TypeScript file with choice labels from Django TextChoices in Python models
|
|
*
|
|
* Usage: node scripts/generate-choice-labels.cjs
|
|
*
|
|
* 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 - 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');
|
|
|
|
// Convert camelCase to UPPER_SNAKE_CASE
|
|
function camelToSnakeCase(str) {
|
|
return str
|
|
.replace(/([A-Z])/g, '_$1')
|
|
.replace(/^_/, '') // Remove leading underscore
|
|
.toUpperCase();
|
|
}
|
|
|
|
// Extract TextChoices from Python models.py file
|
|
function parsePythonModels(filePath) {
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
const results = {};
|
|
|
|
// 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 ((modelMatch = modelRegex.exec(content)) !== null) {
|
|
modelPositions.push({
|
|
name: modelMatch[1],
|
|
start: modelMatch.index,
|
|
});
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// Main function
|
|
function generateLabels() {
|
|
console.log('🔍 Scanning Python models for TextChoices...');
|
|
|
|
const enums = {};
|
|
let totalFiles = 0;
|
|
|
|
// 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(modelsFile)) {
|
|
continue;
|
|
}
|
|
|
|
console.log(`📁 Scanning ${appName}/models.py...`);
|
|
totalFiles++;
|
|
|
|
// Parse the models file
|
|
const results = parsePythonModels(modelsFile);
|
|
|
|
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 (totalFiles === 0) {
|
|
console.error('❌ No models.py files found in backend apps');
|
|
process.exit(1);
|
|
}
|
|
|
|
if (Object.keys(enums).length === 0) {
|
|
console.error('❌ No TextChoices found in Python models');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Generate TypeScript file
|
|
console.log('📝 Generating TypeScript file...');
|
|
|
|
const tsContent = `/**
|
|
* 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]) => {
|
|
const enumBaseName = enumName.replace(/Enum$/, '');
|
|
const constantName = camelToSnakeCase(enumBaseName) + '_LABELS';
|
|
|
|
return `/**
|
|
* Labels for ${enumName}
|
|
* ${Object.entries(labels).map(([value, label]) => `* ${value}: ${label}`).join('\n * ')}
|
|
*/
|
|
export const ${constantName} = ${JSON.stringify(labels, null, 2)} as const;
|
|
`;
|
|
}).join('\n')}
|
|
/**
|
|
* Type-safe helper to get choice label
|
|
*/
|
|
export function getChoiceLabel<T extends string>(
|
|
labels: Record<string, string>,
|
|
value: T | undefined | null
|
|
): string {
|
|
if (!value) return '';
|
|
return labels[value] || value;
|
|
}
|
|
|
|
/**
|
|
* Get options array for select dropdowns
|
|
*/
|
|
export function getChoiceOptions<T extends string>(
|
|
labels: Record<T, string>
|
|
): Array<{ value: T; label: string }> {
|
|
return Object.entries(labels).map(([value, label]) => ({
|
|
value: value as T,
|
|
label: label as string,
|
|
}));
|
|
}
|
|
|
|
// Auto-generate all OPTIONS exports
|
|
${Object.entries(enums).map(([enumName]) => {
|
|
const enumBaseName = enumName.replace(/Enum$/, '');
|
|
const labelsConstName = camelToSnakeCase(enumBaseName) + '_LABELS';
|
|
const optionsConstName = camelToSnakeCase(enumBaseName) + '_OPTIONS';
|
|
return `export const ${optionsConstName} = getChoiceOptions(${labelsConstName});`;
|
|
}).join('\n')}
|
|
`;
|
|
|
|
// Ensure output directory exists
|
|
const outputDir = path.dirname(OUTPUT_PATH);
|
|
if (!fs.existsSync(outputDir)) {
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
}
|
|
|
|
// Write file
|
|
fs.writeFileSync(OUTPUT_PATH, tsContent, 'utf8');
|
|
|
|
console.log(`✅ Generated ${OUTPUT_PATH}`);
|
|
console.log(` Found ${Object.keys(enums).length} enum types with labels`);
|
|
}
|
|
|
|
// Run the script
|
|
try {
|
|
generateLabels();
|
|
} catch (error) {
|
|
console.error('❌ Error generating choice labels:', error.message);
|
|
process.exit(1);
|
|
}
|