/** * Project Hierarchy Utilities * * Functions for extracting language from ATLAS-ID, detecting content category, * calculating hierarchy depth, and generating job IDs. */ import type { Project, ContentCategory, HierarchyNode, Episode } from "../types/project"; /** * Extracts target language code from ATLAS-ID * Format: "1234567-DE" → "DE" */ export function extractLanguageFromAtlasId(atlasId: string | undefined): string | null { if (!atlasId) return null; const match = atlasId.match(/-([A-Z]{2})$/i); return match ? match[1].toUpperCase() : null; } /** * Extracts the base ID from ATLAS-ID (without language suffix) * Format: "1234567-DE" → "1234567" */ export function extractBaseAtlasId(atlasId: string | undefined): string | null { if (!atlasId) return null; const match = atlasId.match(/^(\d+)/); return match ? match[1] : null; } /** * Generates episode job ID from project ATLAS-ID and episode sequence * Format: "1234567-DE" + episode 1 → "1234567_1" */ export function generateEpisodeJobId(atlasId: string | undefined, episodeNumber: number): string | null { const baseId = extractBaseAtlasId(atlasId); if (!baseId) return null; return `${baseId}_${episodeNumber}`; } /** * Parses episode job ID to extract base ID and sequence number * Format: "1234567_1" → { baseId: "1234567", sequence: 1 } */ export function parseEpisodeJobId(jobId: string | undefined): { baseId: string; sequence: number } | null { if (!jobId) return null; const match = jobId.match(/^(\d+)_(\d+)$/); if (!match) return null; return { baseId: match[1], sequence: parseInt(match[2], 10) }; } /** * A flexible project type that accepts both the strict Project type * and the inferred type from mockProjects */ type ProjectLike = { content?: string; type?: string; season?: string | null; batch?: string | null; episodesList?: unknown[]; parentGroupId?: string | null; projectGroupTitle?: string | null; atlasId?: string; bcId?: string; language?: string; }; /** * Determines content category from project fields * Uses 'content' field primarily (Feature, Episode, Trailer, Promo, etc.) */ export function getContentCategory(project: ProjectLike): ContentCategory { const content = project.content?.toLowerCase() || ""; const type = project.type?.toLowerCase() || ""; if (content === "feature" || type.includes("film") || type.includes("movie")) { return "movie"; } if (content === "episode" || type.includes("series")) { return "series"; } if (content === "trailer") { return "trailer"; } if (content === "promo" || content === "advertisement") { return "promo"; } return "other"; } /** * Returns display icon identifier for content category */ export function getContentCategoryIcon(category: ContentCategory): string { switch (category) { case "series": return "tv"; case "movie": return "film"; case "trailer": return "clapperboard"; case "promo": return "megaphone"; default: return "file"; } } /** * Returns human-readable label for content category */ export function getContentCategoryLabel(category: ContentCategory): string { switch (category) { case "series": return "Series"; case "movie": return "Movie"; case "trailer": return "Trailer"; case "promo": return "Promo"; default: return "Other"; } } /** * Calculates hierarchy depth for a project (levels below title) * Returns number of levels below title: Season(0-1) + Batch(0-1) + Episodes(0-1) * * Note: This counts levels in the PROJECT's internal hierarchy, not including * external grouping. Group level is handled separately at the ProjectGroup level. * * Examples: * - Movie (flat): depth = 0 (no seasons, batches, or episodes list) * - Series S01: depth = 1 (season only) * - Series S01/B01: depth = 2 (season + batch) * - Series S01/B01/EP1-10: depth = 3 (season + batch + episodes) */ export function getHierarchyDepth(project: Project): number { let depth = 0; if (project.season) { depth += 1; } if (project.batch) { depth += 1; } if (project.episodesList && project.episodesList.length > 0) { depth += 1; } return depth; } /** * Determines which hierarchy levels are present for a project * * Note: `belongsToGroup` indicates whether this project is part of a ProjectGroup, * not a structural hierarchy level within the project itself. */ export function getHierarchyLevels(project: ProjectLike): { belongsToGroup: boolean; hasSeason: boolean; hasBatch: boolean; hasEpisodes: boolean; } { return { belongsToGroup: !!(project.parentGroupId || project.projectGroupTitle), hasSeason: !!project.season, hasBatch: !!project.batch, hasEpisodes: !!(project.episodesList && project.episodesList.length > 0) }; } /** * Converts a flat project to a hierarchy tree node with children */ export function projectToHierarchyNode(project: Project, parentId?: string): HierarchyNode { const atlasId = project.atlasId || project.bcId; const language = project.language || extractLanguageFromAtlasId(atlasId) || undefined; const contentCategory = getContentCategory(project); const children: HierarchyNode[] = []; let currentDepth = 1; // Add season level if present if (project.season) { const seasonNode: HierarchyNode = { id: `${project.id}-season-${project.season}`, type: "season", label: project.season, language, depth: currentDepth + 1, parentId: project.id, projectId: project.id, children: [] }; // Add batch level if present if (project.batch) { const batchNode: HierarchyNode = { id: `${project.id}-batch-${project.batch}`, type: "batch", label: project.batch, language, depth: currentDepth + 2, parentId: seasonNode.id, projectId: project.id, children: [] }; // Add episodes under batch if (project.episodesList && project.episodesList.length > 0) { batchNode.children = project.episodesList.map((ep, idx) => episodeToHierarchyNode(ep, project, batchNode.id, currentDepth + 3) ); } seasonNode.children = [batchNode]; } else if (project.episodesList && project.episodesList.length > 0) { // Episodes directly under season (no batch) seasonNode.children = project.episodesList.map((ep, idx) => episodeToHierarchyNode(ep, project, seasonNode.id, currentDepth + 2) ); } children.push(seasonNode); } else if (project.batch) { // Batch without season const batchNode: HierarchyNode = { id: `${project.id}-batch-${project.batch}`, type: "batch", label: project.batch, language, depth: currentDepth + 1, parentId: project.id, projectId: project.id, children: [] }; if (project.episodesList && project.episodesList.length > 0) { batchNode.children = project.episodesList.map((ep, idx) => episodeToHierarchyNode(ep, project, batchNode.id, currentDepth + 2) ); } children.push(batchNode); } else if (project.episodesList && project.episodesList.length > 0) { // Episodes directly under title (flat structure) children.push(...project.episodesList.map((ep, idx) => episodeToHierarchyNode(ep, project, project.id, currentDepth + 1) )); } return { id: project.id, type: "title", label: project.title, atlasId, language, contentCategory, status: project.status, depth: currentDepth, parentId, projectId: project.id, children: children.length > 0 ? children : undefined }; } /** * Converts an episode to a hierarchy node */ function episodeToHierarchyNode( episode: Episode, project: Project, parentId: string, depth: number ): HierarchyNode { const atlasId = project.atlasId || project.bcId; const jobId = episode.jobId || generateEpisodeJobId(atlasId, episode.number) || undefined; const language = project.language || extractLanguageFromAtlasId(atlasId) || undefined; return { id: `${project.id}-ep-${episode.number}`, type: "episode", label: `${episode.prefix || "EP"}${episode.number} - ${episode.title}`, jobId, language, status: episode.milestones?.delivery?.status, depth, parentId, projectId: project.id, episodeNumber: episode.number }; } /** * Groups projects by their ATLAS-ID to identify shared orders */ export function groupProjectsByAtlasId(projects: Project[]): Map { const groups = new Map(); for (const project of projects) { const baseId = extractBaseAtlasId(project.atlasId || project.bcId); if (!baseId) continue; const existing = groups.get(baseId) || []; existing.push(project); groups.set(baseId, existing); } return groups; } /** * Groups projects by their target language */ export function groupProjectsByLanguage(projects: Project[]): Map { const groups = new Map(); for (const project of projects) { const atlasId = project.atlasId || project.bcId; const language = project.language || extractLanguageFromAtlasId(atlasId) || "Unknown"; const existing = groups.get(language) || []; existing.push(project); groups.set(language, existing); } return groups; } /** * Gets unique languages from a list of projects */ export function getUniqueLanguages(projects: Project[]): string[] { const languages = new Set(); for (const project of projects) { const atlasId = project.atlasId || project.bcId; const language = project.language || extractLanguageFromAtlasId(atlasId); if (language) { languages.add(language); } } return Array.from(languages).sort(); } /** * Filters projects by content category * Uses generics to preserve the input array type */ export function filterByContentCategory(projects: T[], categories: ContentCategory | ContentCategory[]): T[] { const categoryArray = Array.isArray(categories) ? categories : [categories]; if (categoryArray.length === 0) return projects; return projects.filter(project => { const category = getContentCategory(project); return categoryArray.includes(category); }); } /** * Filters projects by language * Uses generics to preserve the input array type */ export function filterByLanguage( projects: T[], languages: string | string[] ): T[] { const langArray = Array.isArray(languages) ? languages : [languages]; if (langArray.length === 0) return projects; return projects.filter(project => { const atlasId = project.atlasId || project.bcId || ""; const projectLanguage = project.language || extractLanguageFromAtlasId(atlasId); return projectLanguage && langArray.includes(projectLanguage); }); } /** * Filters projects by hierarchy depth (number of levels: 0-3) * Uses generics to preserve the input array type */ export function filterByHierarchyDepth( projects: T[], depth: number | { hasSeasons?: boolean; hasBatches?: boolean; hasEpisodes?: boolean; isFlat?: boolean; belongsToGroup?: boolean; } ): T[] { // Simple depth filter if (typeof depth === "number") { return projects.filter(project => { let projectDepth = 0; if (project.season) projectDepth += 1; if (project.batch) projectDepth += 1; if (project.episodesList && project.episodesList.length > 0) projectDepth += 1; return projectDepth === depth; }); } // Options-based filter const options = depth; return projects.filter(project => { const levels = getHierarchyLevels(project); if (options.hasSeasons !== undefined && levels.hasSeason !== options.hasSeasons) { return false; } if (options.hasBatches !== undefined && levels.hasBatch !== options.hasBatches) { return false; } if (options.hasEpisodes !== undefined && levels.hasEpisodes !== options.hasEpisodes) { return false; } if (options.belongsToGroup !== undefined && levels.belongsToGroup !== options.belongsToGroup) { return false; } if (options.isFlat !== undefined) { const isFlat = !levels.hasSeason && !levels.hasBatch && !levels.hasEpisodes; if (isFlat !== options.isFlat) { return false; } } return true; }); }