Files
ai-code-app/etc/servers/work-server/scripts/backfill-codex-live-resource-paths.ts

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;
}