/** * Validation utilities for metadata fields */ // ISO 639-1 language codes (2-letter codes) // biome-ignore format: keep the ISO codes grouped by first letter const ISO_639_1_CODES = new Set([ 'aa', 'ab', 'ae', 'af', 'ak', 'am', 'an', 'ar', 'as', 'av', 'ay', 'az', 'ba', 'be', 'bg', 'bh', 'bi', 'bm', 'bn', 'bo', 'br', 'bs', 'ca', 'ce', 'ch', 'co', 'cr', 'cs', 'cu', 'cv', 'cy', 'da', 'de', 'dv', 'dz', 'ee', 'el', 'en', 'eo', 'es', 'et', 'eu', 'fa', 'ff', 'fi', 'fj', 'fo', 'fr', 'fy', 'ga', 'gd', 'gl', 'gn', 'gu', 'gv', 'ha', 'he', 'hi', 'ho', 'hr', 'ht', 'hu', 'hy', 'hz', 'ia', 'id', 'ie', 'ig', 'ii', 'ik', 'io', 'is', 'it', 'iu', 'ja', 'jv', 'ka', 'kg', 'ki', 'kj', 'kk', 'kl', 'km', 'kn', 'ko', 'kr', 'ks', 'ku', 'kv', 'kw', 'ky', 'la', 'lb', 'lg', 'li', 'ln', 'lo', 'lt', 'lu', 'lv', 'mg', 'mh', 'mi', 'mk', 'ml', 'mn', 'mr', 'ms', 'mt', 'my', 'na', 'nb', 'nd', 'ne', 'ng', 'nl', 'nn', 'no', 'nr', 'nv', 'ny', 'oc', 'oj', 'om', 'or', 'os', 'pa', 'pi', 'pl', 'ps', 'pt', 'qu', 'rm', 'rn', 'ro', 'ru', 'rw', 'sa', 'sc', 'sd', 'se', 'sg', 'si', 'sk', 'sl', 'sm', 'sn', 'so', 'sq', 'sr', 'ss', 'st', 'su', 'sv', 'sw', 'ta', 'te', 'tg', 'th', 'ti', 'tk', 'tl', 'tn', 'to', 'tr', 'ts', 'tt', 'tw', 'ty', 'ug', 'uk', 'ur', 'uz', 've', 'vi', 'vo', 'wa', 'wo', 'xh', 'yi', 'yo', 'za', 'zh', 'zu' ]); export interface ValidationResult { isValid: boolean; value: T | null; error?: string; } /** * Validates and normalizes date input * Accepts YYYY, YYYY-MM, or YYYY-MM-DD formats * Returns ISO string if valid, null if invalid */ export const validateAndNormalizeDate = (dateInput: string): ValidationResult => { if (!dateInput) { return { isValid: true, value: '' }; } const cleaned = dateInput.trim(); // Pattern for YYYY, YYYY-MM, or YYYY-MM-DD const datePatterns = [ { pattern: /^\d{4}$/, format: 'YYYY' }, { pattern: /^\d{4}-\d{2}$/, format: 'YYYY-MM' }, { pattern: /^\d{4}-\d{2}-\d{2}$/, format: 'YYYY-MM-DD' }, ]; const matchingPattern = datePatterns.find(({ pattern }) => pattern.test(cleaned)); if (!matchingPattern) { return { isValid: false, value: null, error: 'Invalid date format. Use YYYY, YYYY-MM, or YYYY-MM-DD', }; } try { let date: Date; if (cleaned.length === 4) { // YYYY format - set to January 1st date = new Date(`${cleaned}-01-01`); } else if (cleaned.length === 7) { // YYYY-MM format - set to 1st day of month date = new Date(`${cleaned}-01`); } else { // YYYY-MM-DD format date = new Date(cleaned); } // Check if date is valid if (isNaN(date.getTime())) { return { isValid: false, value: null, error: 'Invalid date', }; } // Check if year is reasonable (between 1000 and current year + 10) const year = parseInt(cleaned.substring(0, 4)); const currentYear = new Date().getFullYear(); if (year < 1000 || year > currentYear + 10) { return { isValid: false, value: null, error: `Year must be between 1000 and ${currentYear + 10}`, }; } // Validate month and day if provided if (cleaned.length >= 7) { const month = parseInt(cleaned.substring(5, 7)); if (month < 1 || month > 12) { return { isValid: false, value: null, error: 'Month must be between 01 and 12', }; } } if (cleaned.length === 10) { const day = parseInt(cleaned.substring(8, 10)); if (day < 1 || day > 31) { return { isValid: false, value: null, error: 'Day must be between 01 and 31', }; } } return { isValid: true, value: date.toISOString(), }; } catch { return { isValid: false, value: null, error: 'Failed to parse date', }; } }; /** * Validates and normalizes language code input * Accepts ISO 639-1 codes with optional country codes (e.g., en, en-US, zh-CN) * Returns normalized language code if valid, null if invalid */ export const validateAndNormalizeLanguage = (languageInput: string): ValidationResult => { if (!languageInput) { return { isValid: true, value: 'unknown' }; } const cleaned = languageInput.trim().toLowerCase(); // Pattern for language codes with optional country codes (e.g., en-US, zh-CN) const languagePattern = /^[a-z]{2}(-[a-z]{2,4})?$/i; if (!languagePattern.test(cleaned)) { return { isValid: false, value: null, error: 'Invalid language format. Use ISO 639-1 codes (e.g., en, zh-CN)', }; } const languageCode = cleaned.substring(0, 2); if (!ISO_639_1_CODES.has(languageCode)) { return { isValid: false, value: null, error: `Invalid language code: ${languageCode}. Must be a valid ISO 639-1 code`, }; } return { isValid: true, value: cleaned, }; }; export const validateISBN = (isbn: string): ValidationResult => { if (!isbn) { return { isValid: true, value: '' }; } const cleaned = isbn.replace(/[-\s]/g, ''); if (cleaned.length !== 10 && cleaned.length !== 13) { return { isValid: false, value: null, error: 'ISBN must be 10 or 13 digits', }; } // Validate ISBN-10 if (cleaned.length === 10) { const isValid = validateISBN10(cleaned); return { isValid, value: isValid ? cleaned : null, error: isValid ? undefined : 'Invalid ISBN-10 checksum', }; } // Validate ISBN-13 if (cleaned.length === 13) { const isValid = validateISBN13(cleaned); return { isValid, value: isValid ? cleaned : null, error: isValid ? undefined : 'Invalid ISBN-13 checksum', }; } return { isValid: false, value: null, error: 'Invalid ISBN format' }; }; const validateISBN10 = (isbn: string): boolean => { let sum = 0; for (let i = 0; i < 9; i++) { const digit = parseInt(isbn[i]!); if (isNaN(digit)) return false; sum += digit * (10 - i); } const lastChar = isbn[9]!; const checkDigit = lastChar === 'X' ? 10 : parseInt(lastChar); if (isNaN(checkDigit)) return false; sum += checkDigit; return sum % 11 === 0; }; const validateISBN13 = (isbn: string): boolean => { let sum = 0; for (let i = 0; i < 12; i++) { const digit = parseInt(isbn[i]!); if (isNaN(digit)) return false; sum += digit * (i % 2 === 0 ? 1 : 3); } const checkDigit = parseInt(isbn[12]!); if (isNaN(checkDigit)) return false; const calculatedCheck = (10 - (sum % 10)) % 10; return checkDigit === calculatedCheck; }; /** * Validates subjects/genres input * Accepts comma-separated values and returns cleaned array */ export const validateAndNormalizeSubjects = (subjectsInput: string): ValidationResult => { if (!subjectsInput) { return { isValid: true, value: [] }; } const subjects = subjectsInput.split(',').map((s) => s.trim()); const maxSubjects = 20; const maxSubjectLength = 100; if (subjects.length > maxSubjects) { return { isValid: false, value: null, error: `Too many subjects (max ${maxSubjects})`, }; } const tooLongSubject = subjects.find((s) => s.length > maxSubjectLength); if (tooLongSubject) { return { isValid: false, value: null, error: `Subject too long (max ${maxSubjectLength} characters): ${tooLongSubject}`, }; } return { isValid: true, value: subjects, }; };