293 lines
8.8 KiB
TypeScript
293 lines
8.8 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { inferSourceChangeScreenTitle } from '../src/services/chat-room-service.js';
|
|
|
|
const APPLY_FLAG = '--apply';
|
|
const repoRootPath = path.resolve(process.cwd(), '../../..');
|
|
const codexLiveRootPath = path.join(repoRootPath, 'resource', 'Codex Live');
|
|
const genericScreenRootPath = path.join(codexLiveRootPath, 'Codex Live');
|
|
|
|
type FeaturePlan = {
|
|
featureName: string;
|
|
sourcePath: string;
|
|
targetLabel: string;
|
|
targetPath: string;
|
|
filePaths: string[];
|
|
};
|
|
|
|
function normalizeWhitespace(value: string) {
|
|
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function getScreenLabelFromTitle(title: string) {
|
|
const segments = String(title ?? '')
|
|
.split('/')
|
|
.map((segment) => segment.trim())
|
|
.filter(Boolean);
|
|
|
|
return segments.at(-1) ?? '';
|
|
}
|
|
|
|
function extractSourcePathsFromSpec(text: string) {
|
|
return Array.from(
|
|
new Set(
|
|
Array.from(text.matchAll(/`((?:src|etc|docs|public|scripts)\/[^`]+)`/g), (match) => normalizeWhitespace(match[1])),
|
|
),
|
|
).filter(Boolean);
|
|
}
|
|
|
|
function containsPattern(values: string[], pattern: RegExp) {
|
|
return values.some((value) => pattern.test(normalizeWhitespace(value)));
|
|
}
|
|
|
|
function inferScreenLabelFromFeatureMetadata(args: {
|
|
featureName: string;
|
|
filePaths: string[];
|
|
specTexts: string[];
|
|
}) {
|
|
const featureName = normalizeWhitespace(args.featureName);
|
|
const filePaths = args.filePaths.map((value) => normalizeWhitespace(value));
|
|
const specTexts = args.specTexts.map((value) => normalizeWhitespace(value));
|
|
|
|
if (
|
|
containsPattern(filePaths, /(?:resourceManagerApi|resource-manager-service|resource-manager|ResourceManagementPage)/iu) ||
|
|
containsPattern([featureName], /(?:resource manager|리소스 관리|리소스 경로|리소스 가이드|이미지 생성 CLI)/iu)
|
|
) {
|
|
return '리소스 관리';
|
|
}
|
|
|
|
if (
|
|
containsPattern(filePaths, /(?:ChatSourceChangesPage|chat-room-service)/iu) ||
|
|
containsPattern([featureName], /(?:변경 이력|source change|source-changes)/iu)
|
|
) {
|
|
return '변경 이력';
|
|
}
|
|
|
|
if (
|
|
containsPattern(filePaths, /(?:PreviewAppOverlay|PreviewAppWindow|previewRuntime|appUpdate)/iu) ||
|
|
containsPattern([featureName], /(?:모바일 앱 열기|Preview App)/iu)
|
|
) {
|
|
return '모바일 앱 열기';
|
|
}
|
|
|
|
if (
|
|
containsPattern(filePaths, /(?:MainHeader|HeaderMessageCenter|MainLayout\.css)/iu) ||
|
|
containsPattern([featureName], /(?:헤더)/iu)
|
|
) {
|
|
return '헤더 표시';
|
|
}
|
|
|
|
if (
|
|
containsPattern(
|
|
filePaths,
|
|
/(?:MainChatPanel|ChatConversationView|mainChatPanel|ChatActivityChecklist|chatUtils)/iu,
|
|
) ||
|
|
containsPattern(
|
|
[featureName],
|
|
/(?:채팅 말풍선|시스템 카드|말풍선|prompt|즉시전송|즉시 접수|답변 이동|활동 로그|첨부 파일|채팅방|MainChatPanel|ChatConversationView|mainChatPanel)/iu,
|
|
)
|
|
) {
|
|
return '채팅 말풍선';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function inferScreenLabelFromSpec(args: {
|
|
featureName: string;
|
|
filePaths: string[];
|
|
specTexts: string[];
|
|
}) {
|
|
const inferredTitle = inferSourceChangeScreenTitle(args.filePaths, 'Codex Live / Codex Live');
|
|
const inferredLabel = getScreenLabelFromTitle(inferredTitle);
|
|
|
|
if (inferredLabel && inferredLabel !== 'Codex Live' && inferredLabel !== '새 대화') {
|
|
return inferredLabel;
|
|
}
|
|
|
|
const metadataLabel = inferScreenLabelFromFeatureMetadata(args);
|
|
|
|
if (metadataLabel) {
|
|
return metadataLabel;
|
|
}
|
|
|
|
const normalizedFeatureName = normalizeWhitespace(args.featureName);
|
|
const hasHeaderSpecificFile = args.filePaths.some((filePath) =>
|
|
/^(?:src\/app\/main\/MainHeader\.(?:ts|tsx)|src\/app\/main\/HeaderMessageCenter\.(?:ts|tsx|css))$/u.test(filePath),
|
|
);
|
|
const hasOnlyHeaderLayoutFiles =
|
|
args.filePaths.length > 0 &&
|
|
args.filePaths.every((filePath) =>
|
|
/^(?:src\/app\/main\/MainLayout\.css|src\/app\/main\/HeaderMessageCenter\.css)$/u.test(filePath),
|
|
);
|
|
const hasPreviewSpecificFile = args.filePaths.some((filePath) =>
|
|
/^(?:src\/app\/main\/PreviewAppOverlay\.(?:ts|tsx)|src\/app\/main\/PreviewAppWindow\.(?:ts|tsx)|src\/app\/main\/previewRuntime\.(?:ts|tsx|js)|src\/app\/main\/appUpdate\.(?:ts|tsx|js))$/u.test(
|
|
filePath,
|
|
),
|
|
);
|
|
|
|
if (/^(?:preview\b|동영상 preview\b)/iu.test(normalizedFeatureName) || hasPreviewSpecificFile) {
|
|
return '모바일 앱 열기';
|
|
}
|
|
|
|
if (
|
|
/(?:헤더|테마|앱 설정|알림 뱃지|헤더 표시)/u.test(normalizedFeatureName) &&
|
|
(hasHeaderSpecificFile || hasOnlyHeaderLayoutFiles || args.filePaths.length === 0)
|
|
) {
|
|
return '헤더 표시';
|
|
}
|
|
|
|
return 'Codex Live';
|
|
}
|
|
|
|
async function exists(targetPath: string) {
|
|
try {
|
|
await fs.access(targetPath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function readFeaturePlans() {
|
|
if (!(await exists(genericScreenRootPath))) {
|
|
return [] as FeaturePlan[];
|
|
}
|
|
|
|
const featureEntries = await fs.readdir(genericScreenRootPath, { withFileTypes: true });
|
|
const plans: FeaturePlan[] = [];
|
|
|
|
for (const featureEntry of featureEntries) {
|
|
if (!featureEntry.isDirectory()) {
|
|
continue;
|
|
}
|
|
|
|
const featureName = featureEntry.name;
|
|
const featurePath = path.join(genericScreenRootPath, featureName);
|
|
const datedEntries = (await fs.readdir(featurePath, { withFileTypes: true }))
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name)
|
|
.sort();
|
|
|
|
const specTexts: string[] = [];
|
|
const filePaths = new Set<string>();
|
|
|
|
for (const datedEntry of datedEntries) {
|
|
const specPath = path.join(featurePath, datedEntry, 'docs', 'feature-spec.md');
|
|
|
|
if (!(await exists(specPath))) {
|
|
continue;
|
|
}
|
|
|
|
const specText = await fs.readFile(specPath, 'utf8');
|
|
specTexts.push(specText);
|
|
|
|
extractSourcePathsFromSpec(specText).forEach((filePath) => {
|
|
filePaths.add(filePath);
|
|
});
|
|
}
|
|
|
|
const targetLabel = inferScreenLabelFromSpec({
|
|
featureName,
|
|
filePaths: Array.from(filePaths),
|
|
specTexts,
|
|
});
|
|
|
|
plans.push({
|
|
featureName,
|
|
sourcePath: featurePath,
|
|
targetLabel,
|
|
targetPath: path.join(codexLiveRootPath, targetLabel, featureName),
|
|
filePaths: Array.from(filePaths),
|
|
});
|
|
}
|
|
|
|
return plans.sort((left, right) => left.featureName.localeCompare(right.featureName, 'ko'));
|
|
}
|
|
|
|
async function moveDirectoryContents(sourcePath: string, targetPath: string): Promise<void> {
|
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
|
|
if (!(await exists(targetPath))) {
|
|
await fs.rename(sourcePath, targetPath);
|
|
return;
|
|
}
|
|
|
|
const sourceEntries = await fs.readdir(sourcePath, { withFileTypes: true });
|
|
|
|
for (const sourceEntry of sourceEntries) {
|
|
const nextSourcePath = path.join(sourcePath, sourceEntry.name);
|
|
const nextTargetPath = path.join(targetPath, sourceEntry.name);
|
|
|
|
if (sourceEntry.isDirectory()) {
|
|
await moveDirectoryContents(nextSourcePath, nextTargetPath);
|
|
continue;
|
|
}
|
|
|
|
if (await exists(nextTargetPath)) {
|
|
throw new Error(`대상 파일이 이미 존재합니다: ${path.relative(repoRootPath, nextTargetPath)}`);
|
|
}
|
|
|
|
await fs.mkdir(path.dirname(nextTargetPath), { recursive: true });
|
|
await fs.rename(nextSourcePath, nextTargetPath);
|
|
}
|
|
|
|
await fs.rm(sourcePath, { recursive: false });
|
|
}
|
|
|
|
async function applyMoves(plans: FeaturePlan[]) {
|
|
const applied: Array<{ featureName: string; from: string; to: string }> = [];
|
|
|
|
for (const plan of plans) {
|
|
if (plan.targetLabel === 'Codex Live') {
|
|
continue;
|
|
}
|
|
|
|
await moveDirectoryContents(plan.sourcePath, plan.targetPath);
|
|
applied.push({
|
|
featureName: plan.featureName,
|
|
from: path.relative(repoRootPath, plan.sourcePath),
|
|
to: path.relative(repoRootPath, plan.targetPath),
|
|
});
|
|
}
|
|
|
|
return applied;
|
|
}
|
|
|
|
try {
|
|
const plans = await readFeaturePlans();
|
|
const movablePlans = plans.filter((plan) => plan.targetLabel !== 'Codex Live');
|
|
|
|
const summary = {
|
|
mode: process.argv.includes(APPLY_FLAG) ? 'apply' : 'dry-run',
|
|
totalFeatureCount: plans.length,
|
|
movableFeatureCount: movablePlans.length,
|
|
groupedTargets: movablePlans.reduce<Record<string, number>>((accumulator, plan) => {
|
|
accumulator[plan.targetLabel] = (accumulator[plan.targetLabel] ?? 0) + 1;
|
|
return accumulator;
|
|
}, {}),
|
|
moves: movablePlans.map((plan) => ({
|
|
featureName: plan.featureName,
|
|
from: path.relative(repoRootPath, plan.sourcePath),
|
|
to: path.relative(repoRootPath, plan.targetPath),
|
|
filePaths: plan.filePaths,
|
|
})),
|
|
};
|
|
|
|
if (!process.argv.includes(APPLY_FLAG)) {
|
|
console.log(JSON.stringify(summary, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
const applied = await applyMoves(plans);
|
|
|
|
console.log(JSON.stringify({
|
|
...summary,
|
|
appliedCount: applied.length,
|
|
applied,
|
|
}, null, 2));
|
|
} catch (error) {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
}
|