gts-x / src /lib /utils /projectHierarchy.ts
JAMES HAN
Initial commit — GTS Modern subtitle converter
dff41a8
Raw
History Blame Contribute Delete
12.6 kB
/**
* 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<string, Project[]> {
const groups = new Map<string, Project[]>();
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<string, Project[]> {
const groups = new Map<string, Project[]>();
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<string>();
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<T extends ProjectLike>(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<T extends ProjectLike>(
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<T extends ProjectLike>(
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;
});
}