/** * Path Template Engine Utility * Documentation: documentation/backend/services/file-organizer.md * * Provides template variable substitution, validation, and preview generation * for audiobook file organization paths. */ /** * Template variables for path substitution */ export interface TemplateVariables { author: string; title: string; narrator?: string; asin?: string; year?: number; series?: string; seriesPart?: string; } /** * Template validation result */ export interface ValidationResult { valid: boolean; error?: string; } /** * Supported template variable names */ const VALID_VARIABLES = ['author', 'title', 'narrator', 'asin', 'year', 'series', 'seriesPart']; /** * Invalid file path characters (outside of template variables) */ const INVALID_PATH_CHARS = /[<>:"|?*]/; /** * Placeholder characters for escaped braces during substitution. * Uses Unicode Private Use Area characters that won't appear in metadata * and won't be affected by path cleanup operations. */ const LBRACE_PLACEHOLDER = '\uE000'; const RBRACE_PLACEHOLDER = '\uE001'; /** * Sanitize a path component by removing invalid characters * Reuses logic from file-organizer.ts * * @param name - Path component to sanitize * @returns Sanitized path component */ function sanitizePath(name: string): string { return ( name // Remove invalid filename characters .replace(/[<>:"/\\|?*]/g, '') // Remove leading/trailing dots and spaces .trim() .replace(/^\.+/, '') .replace(/\.+$/, '') // Collapse multiple spaces .replace(/\s+/g, ' ') // Limit length (255 chars max for most filesystems) .slice(0, 200) ); } /** * Find valid template variable names within arbitrary content text. * Sorts by length descending to prevent substring false matches * (e.g., 'seriesPart' matched before 'series'). * Uses word-boundary detection to avoid matching variable names * that are substrings of other words. */ function findVariablesInContent(content: string): string[] { const sortedVars = [...VALID_VARIABLES].sort((a, b) => b.length - a.length); const found: string[] = []; for (const varName of sortedVars) { const regex = new RegExp(`(? { // If content is exactly a valid variable name, skip (leave for simple substitution) if (VALID_VARIABLES.includes(content)) { return match; } // Find variables in the content const foundVars = findVariablesInContent(content); // If no variables found, leave as-is (validation will catch it) if (foundVars.length === 0) { return match; } // Check if all found variables have non-empty values const allPresent = foundVars.every(varName => { const value = variables[varName as keyof TemplateVariables]; return value !== undefined && value !== null && String(value).trim() !== ''; }); if (!allPresent) { return ''; } // Substitute variables within the content, output rest as literal text // Sort by length descending to prevent substring false matches let result = content; const sortedVars = [...foundVars].sort((a, b) => b.length - a.length); for (const varName of sortedVars) { const value = variables[varName as keyof TemplateVariables]; const sanitizedValue = sanitizePath(String(value).trim()); const regex = new RegExp(`(? part.trim()) .filter(part => part.length > 0) .join('/'); // Resolve escaped brace placeholders as the final step, // after all variable substitution and path cleanup is complete result = result.replace(new RegExp(LBRACE_PLACEHOLDER, 'g'), '{'); result = result.replace(new RegExp(RBRACE_PLACEHOLDER, 'g'), '}'); return result; } /** * Validate a path template string * * Checks for: * - Valid variable names only (rejects unknown variables) * - No invalid file path characters outside of variables * - Non-empty template * - Relative paths only (no absolute paths) * * @param template - Path template string to validate * @returns Validation result with error message if invalid * * @example * ```typescript * const result = validateTemplate("{author}/{title}"); * // Returns: { valid: true } * * const invalid = validateTemplate("{invalid}/{title}"); * // Returns: { valid: false, error: "Unknown variable: {invalid}" } * ``` */ export function validateTemplate(template: string): ValidationResult { // Check for empty template if (!template || template.trim().length === 0) { return { valid: false, error: 'Template cannot be empty' }; } // Check for absolute paths (backslash followed by { or } is a brace escape, not a path) if (template.startsWith('/') || /^\\(?![{}])/.test(template) || /^[a-zA-Z]:/.test(template)) { return { valid: false, error: 'Template must be a relative path (no absolute paths like "/" or "C:\\")' }; } // Strip escaped braces (\{ and \}) before parsing so they don't interfere // with variable extraction or character validation const templateWithoutEscapedBraces = template.replace(/\\[{}]/g, ''); // Extract all variables from the stripped template const variableMatches = templateWithoutEscapedBraces.match(/\{[^}]+\}/g); if (variableMatches) { for (const match of variableMatches) { const content = match.slice(1, -1); // Remove { and } // Simple variable — exact match to a valid variable name if (VALID_VARIABLES.includes(content)) { continue; } // Conditional block — must contain at least one valid variable const foundVars = findVariablesInContent(content); if (foundVars.length === 0) { return { valid: false, error: `No valid variable found in conditional block: {${content}}. Valid variables are: ${VALID_VARIABLES.map(v => `{${v}}`).join(', ')}` }; } // Check literal text inside conditional block for invalid path chars let literalText = content; const sortedVars = [...foundVars].sort((a, b) => b.length - a.length); for (const varName of sortedVars) { literalText = literalText.replace( new RegExp(`(? substituteTemplate(template, variables)); } /** * Get list of valid template variable names * * @returns Array of valid variable names */ export function getValidVariables(): string[] { return [...VALID_VARIABLES]; } /** * Validate a filename template string * * Similar to validateTemplate but for filenames (not paths): * - Disallows forward slashes (no directory separators in filenames) * - Does not require relative path structure * - Must contain at least one variable * * @param template - Filename template string to validate * @returns Validation result with error message if invalid */ export function validateFilenameTemplate(template: string): ValidationResult { if (!template || template.trim().length === 0) { return { valid: false, error: 'Filename template cannot be empty', }; } // Disallow forward slashes — filenames cannot contain directory separators if (template.includes('/')) { return { valid: false, error: 'Filename template cannot contain "/" (directory separators). Use the organization template for directory structure.', }; } // Disallow backslashes that aren't brace escapes if (/\\(?![{}])/.test(template)) { return { valid: false, error: 'Filename template cannot contain backslashes. Use the organization template for directory structure.', }; } // Strip escaped braces before parsing const templateWithoutEscapedBraces = template.replace(/\\[{}]/g, ''); // Extract all variables from the stripped template const variableMatches = templateWithoutEscapedBraces.match(/\{[^}]+\}/g); if (variableMatches) { for (const match of variableMatches) { const content = match.slice(1, -1); // Simple variable if (VALID_VARIABLES.includes(content)) { continue; } // Conditional block — must contain at least one valid variable const foundVars = findVariablesInContent(content); if (foundVars.length === 0) { return { valid: false, error: `No valid variable found in: {${content}}. Valid variables are: ${VALID_VARIABLES.map(v => `{${v}}`).join(', ')}`, }; } // Check literal text inside conditional block for invalid filename chars let literalText = content; const sortedVars = [...foundVars].sort((a, b) => b.length - a.length); for (const varName of sortedVars) { literalText = literalText.replace( new RegExp(`(? { const name = substituteTemplate(template, variables); return `${name}.m4b`; }); // Show multi-file example with first mock data only const multiName = substituteTemplate(template, mockData[0]); const multi = [ `${multiName} - 1.mp3`, `${multiName} - 2.mp3`, `${multiName} - 3.mp3`, ]; return { single, multi }; } /** * Build a renamed filename from a template, metadata variables, and original extension. * Optionally appends a 1-based index for multi-file scenarios. * * @param template - Filename template string (e.g., "{title}") * @param variables - Template variables with metadata values * @param originalExtension - File extension including dot (e.g., ".m4b") * @param index - Optional 1-based index for multi-file scenarios * @returns Sanitized filename with extension */ export function buildRenamedFilename( template: string, variables: TemplateVariables, originalExtension: string, index?: number, ): string { let baseName = substituteTemplate(template, variables); // substituteTemplate cleans up slashes for paths — but since this is a filename, // remove any residual slashes that conditional blocks might have introduced baseName = baseName.replace(/[/\\]/g, ''); // Sanitize again for filename safety baseName = baseName .replace(/[<>:"/\\|?*]/g, '') .trim() .replace(/^\.+/, '') .replace(/\.+$/, '') .replace(/\s+/g, ' ') .slice(0, 200); if (index !== undefined) { baseName = `${baseName} - ${index}`; } // Ensure extension starts with a dot const ext = originalExtension.startsWith('.') ? originalExtension : `.${originalExtension}`; return `${baseName}${ext}`; }