Use canonical name-based general icon paths
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const scenarioRoot = path.join(repositoryRoot, 'resources', 'scenario');
|
||||
const catalogPath = path.join(repositoryRoot, 'resources', 'general-icons.json');
|
||||
const canonicalDirectory = '장수';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const write = args.includes('--write');
|
||||
const imageRootIndex = args.indexOf('--image-root');
|
||||
if (imageRootIndex < 0 || !args[imageRootIndex + 1]) {
|
||||
throw new Error('Usage: node tools/manage-general-icons.mjs --image-root <image-repository> [--write]');
|
||||
}
|
||||
const imageRoot = path.resolve(args[imageRootIndex + 1]);
|
||||
const iconsRoot = path.join(imageRoot, 'icons');
|
||||
|
||||
const compareText = (left, right) => left.localeCompare(right, 'ko');
|
||||
const toPosix = (value) => value.split(path.sep).join('/');
|
||||
|
||||
const readJson = async (filePath) => JSON.parse(await fs.readFile(filePath, 'utf8'));
|
||||
|
||||
const exists = async (filePath) => {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const listFiles = async (root) => {
|
||||
const result = [];
|
||||
const visit = async (directory) => {
|
||||
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await visit(entryPath);
|
||||
} else if (entry.isFile()) {
|
||||
result.push(toPosix(path.relative(iconsRoot, entryPath)));
|
||||
}
|
||||
}
|
||||
};
|
||||
await visit(root);
|
||||
return result.sort(compareText);
|
||||
};
|
||||
|
||||
const scenarioFiles = (await fs.readdir(scenarioRoot))
|
||||
.filter((fileName) => /^scenario_\d+\.json$/.test(fileName))
|
||||
.sort(compareText);
|
||||
const scenarios = await Promise.all(
|
||||
scenarioFiles.map(async (fileName) => {
|
||||
const filePath = path.join(scenarioRoot, fileName);
|
||||
const source = await fs.readFile(filePath, 'utf8');
|
||||
return {
|
||||
fileName,
|
||||
filePath,
|
||||
source,
|
||||
data: JSON.parse(source),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const inventory = await listFiles(iconsRoot);
|
||||
const inventorySet = new Set(inventory);
|
||||
const filesByDirectoryAndStem = new Map();
|
||||
const filesByStem = new Map();
|
||||
for (const relativePath of inventory) {
|
||||
const parsed = path.posix.parse(relativePath);
|
||||
const directory = parsed.dir || '.';
|
||||
const directoryKey = `${directory}\0${parsed.name.normalize('NFC')}`;
|
||||
filesByDirectoryAndStem.set(directoryKey, relativePath);
|
||||
const stem = parsed.name.normalize('NFC');
|
||||
const values = filesByStem.get(stem) ?? [];
|
||||
values.push(relativePath);
|
||||
filesByStem.set(stem, values);
|
||||
}
|
||||
|
||||
const existingCatalog = (await exists(catalogPath))
|
||||
? await readJson(catalogPath)
|
||||
: { schemaVersion: 1, canonicalDirectory, icons: [], unresolved: [] };
|
||||
const entriesByName = new Map(existingCatalog.icons.map((entry) => [entry.name, entry]));
|
||||
|
||||
const numericSourcesByName = new Map();
|
||||
const namesByNumericId = new Map();
|
||||
for (const scenario of scenarios) {
|
||||
for (const collectionName of ['general', 'general_ex', 'general_neutral']) {
|
||||
for (const row of scenario.data[collectionName] ?? []) {
|
||||
const [name, picture] = [row[1], row[2]];
|
||||
if (typeof name !== 'string' || typeof picture !== 'number' || picture < 0) {
|
||||
continue;
|
||||
}
|
||||
const normalizedName = name.normalize('NFC');
|
||||
const ids = numericSourcesByName.get(normalizedName) ?? new Set();
|
||||
ids.add(picture);
|
||||
numericSourcesByName.set(normalizedName, ids);
|
||||
const names = namesByNumericId.get(picture) ?? new Set();
|
||||
names.add(normalizedName);
|
||||
namesByNumericId.set(picture, names);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ambiguousNames = [...numericSourcesByName]
|
||||
.filter(([, ids]) => ids.size > 1)
|
||||
.map(([name, ids]) => ({ name, legacyPictureIds: [...ids].sort((left, right) => left - right) }));
|
||||
if (ambiguousNames.length > 0) {
|
||||
throw new Error(`One general name maps to multiple numeric icons: ${JSON.stringify(ambiguousNames)}`);
|
||||
}
|
||||
|
||||
const unresolvedByName = new Map(
|
||||
(existingCatalog.unresolved ?? []).map((entry) => [entry.name, entry])
|
||||
);
|
||||
for (const [name, ids] of [...numericSourcesByName].sort(([left], [right]) => compareText(left, right))) {
|
||||
if (entriesByName.has(name)) {
|
||||
continue;
|
||||
}
|
||||
const legacyPictureIds = [...ids].sort((left, right) => left - right);
|
||||
const numericSource = `icons/${legacyPictureIds[0]}.jpg`;
|
||||
let source = numericSource;
|
||||
if (!inventorySet.has(numericSource.slice('icons/'.length))) {
|
||||
const namedCandidates = (filesByStem.get(name) ?? []).filter(
|
||||
(candidate) => !candidate.startsWith(`${canonicalDirectory}/`)
|
||||
);
|
||||
if (namedCandidates.length !== 1) {
|
||||
unresolvedByName.set(name, {
|
||||
name,
|
||||
legacyPictureIds,
|
||||
reason:
|
||||
namedCandidates.length === 0
|
||||
? 'missing numeric and unique named source'
|
||||
: 'multiple named source candidates',
|
||||
candidates: namedCandidates,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
source = `icons/${namedCandidates[0]}`;
|
||||
}
|
||||
const extension = path.posix.extname(source).toLowerCase();
|
||||
const fileName = `${name}${extension}`;
|
||||
entriesByName.set(name, {
|
||||
name,
|
||||
path: `${canonicalDirectory}/${fileName}`,
|
||||
legacyPictureIds,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
const catalog = {
|
||||
schemaVersion: 1,
|
||||
canonicalDirectory,
|
||||
icons: [...entriesByName.values()].sort((left, right) => compareText(left.name, right.name)),
|
||||
unresolved: [...unresolvedByName.values()].sort((left, right) => compareText(left.name, right.name)),
|
||||
};
|
||||
const catalogByName = new Map(catalog.icons.map((entry) => [entry.name, entry]));
|
||||
const catalogByNumericId = new Map();
|
||||
for (const entry of catalog.icons) {
|
||||
for (const numericId of entry.legacyPictureIds) {
|
||||
const values = catalogByNumericId.get(numericId) ?? [];
|
||||
values.push(entry);
|
||||
catalogByNumericId.set(numericId, values);
|
||||
}
|
||||
}
|
||||
|
||||
const stats = {
|
||||
scenarios: scenarios.length,
|
||||
rows: 0,
|
||||
changedRows: 0,
|
||||
themedMatches: 0,
|
||||
canonicalMatches: 0,
|
||||
unresolvedRows: 0,
|
||||
};
|
||||
|
||||
const resolvePicture = (name, picture, iconDirectory) => {
|
||||
const normalizedName = name.normalize('NFC');
|
||||
if (typeof picture === 'number') {
|
||||
if (picture < 0) {
|
||||
return 'default.jpg';
|
||||
}
|
||||
const candidates = catalogByNumericId.get(picture) ?? [];
|
||||
const exact = candidates.find((entry) => entry.name === normalizedName);
|
||||
const selected = exact ?? (candidates.length === 1 ? candidates[0] : undefined);
|
||||
if (selected) {
|
||||
stats.canonicalMatches += 1;
|
||||
return selected.path;
|
||||
}
|
||||
stats.unresolvedRows += 1;
|
||||
return 'default.jpg';
|
||||
}
|
||||
if (typeof picture === 'string' && picture.length > 0) {
|
||||
if (picture.includes('/')) {
|
||||
return picture;
|
||||
}
|
||||
if (iconDirectory !== '.' && inventorySet.has(`${iconDirectory}/${picture}`)) {
|
||||
return `${iconDirectory}/${picture}`;
|
||||
}
|
||||
return picture;
|
||||
}
|
||||
|
||||
if (iconDirectory !== '.') {
|
||||
const themed = filesByDirectoryAndStem.get(`${iconDirectory}\0${normalizedName}`);
|
||||
if (themed) {
|
||||
stats.themedMatches += 1;
|
||||
return themed;
|
||||
}
|
||||
}
|
||||
const canonical = catalogByName.get(normalizedName);
|
||||
if (canonical) {
|
||||
stats.canonicalMatches += 1;
|
||||
return canonical.path;
|
||||
}
|
||||
const rootNamed = filesByDirectoryAndStem.get(`.\0${normalizedName}`);
|
||||
if (rootNamed) {
|
||||
stats.themedMatches += 1;
|
||||
return rootNamed;
|
||||
}
|
||||
stats.unresolvedRows += 1;
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderRow = (row) => `[${row.map((value) => JSON.stringify(value)).join(', ')}]`;
|
||||
|
||||
const rewriteScenario = (scenario) => {
|
||||
const iconDirectory = typeof scenario.data.iconPath === 'string' ? scenario.data.iconPath : '.';
|
||||
const expectedCounts = new Map(
|
||||
['general', 'general_ex', 'general_neutral'].map((collectionName) => [
|
||||
collectionName,
|
||||
(scenario.data[collectionName] ?? []).length,
|
||||
])
|
||||
);
|
||||
const visitedCounts = new Map([...expectedCounts.keys()].map((collectionName) => [collectionName, 0]));
|
||||
let activeCollection = null;
|
||||
const output = scenario.source.split('\n').map((line) => {
|
||||
const collectionMatch = line.match(/^ {4}"(general|general_ex|general_neutral)": \[$/);
|
||||
if (collectionMatch) {
|
||||
activeCollection = collectionMatch[1];
|
||||
return line;
|
||||
}
|
||||
if (activeCollection === null) {
|
||||
return line;
|
||||
}
|
||||
if (/^ {4}\](?:,)?$/.test(line)) {
|
||||
activeCollection = null;
|
||||
return line;
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
return line;
|
||||
}
|
||||
const rowMatch = line.match(/^( {8})(\[.*\])(,?)$/);
|
||||
if (!rowMatch) {
|
||||
throw new Error(`${scenario.fileName}: unsupported multiline ${activeCollection} row: ${line}`);
|
||||
}
|
||||
const row = JSON.parse(rowMatch[2]);
|
||||
if (!Array.isArray(row) || typeof row[1] !== 'string') {
|
||||
throw new Error(`${scenario.fileName}: invalid ${activeCollection} row: ${line}`);
|
||||
}
|
||||
stats.rows += 1;
|
||||
visitedCounts.set(activeCollection, (visitedCounts.get(activeCollection) ?? 0) + 1);
|
||||
const resolved = resolvePicture(row[1], row[2], iconDirectory);
|
||||
if (row[2] !== resolved) {
|
||||
row[2] = resolved;
|
||||
stats.changedRows += 1;
|
||||
return `${rowMatch[1]}${renderRow(row)}${rowMatch[3]}`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
for (const [collectionName, expected] of expectedCounts) {
|
||||
const visited = visitedCounts.get(collectionName);
|
||||
if (visited !== expected) {
|
||||
throw new Error(
|
||||
`${scenario.fileName}: visited ${String(visited)} ${collectionName} rows, expected ${String(expected)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return output.join('\n');
|
||||
};
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
scenario.output = rewriteScenario(scenario);
|
||||
}
|
||||
|
||||
const catalogText = `${JSON.stringify(catalog, null, 4)}\n`;
|
||||
let drift = !(await exists(catalogPath)) || (await fs.readFile(catalogPath, 'utf8')) !== catalogText;
|
||||
for (const scenario of scenarios) {
|
||||
if (scenario.source !== scenario.output) {
|
||||
drift = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of catalog.icons) {
|
||||
const sourcePath = path.join(imageRoot, entry.source);
|
||||
const destinationPath = path.join(iconsRoot, entry.path);
|
||||
if (!(await exists(sourcePath))) {
|
||||
throw new Error(`Catalog source does not exist: ${entry.source}`);
|
||||
}
|
||||
if (!(await exists(destinationPath))) {
|
||||
drift = true;
|
||||
if (write) {
|
||||
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
|
||||
await fs.copyFile(sourcePath, destinationPath);
|
||||
}
|
||||
} else {
|
||||
const [sourceBytes, destinationBytes] = await Promise.all([
|
||||
fs.readFile(sourcePath),
|
||||
fs.readFile(destinationPath),
|
||||
]);
|
||||
if (!sourceBytes.equals(destinationBytes)) {
|
||||
throw new Error(`Canonical icon differs from source: icons/${entry.path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (write) {
|
||||
await fs.writeFile(catalogPath, catalogText);
|
||||
await Promise.all(scenarios.map((scenario) => fs.writeFile(scenario.filePath, scenario.output)));
|
||||
} else if (drift) {
|
||||
throw new Error('General icon catalog, aliases, or scenario paths are out of date. Re-run with --write.');
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: write ? 'write' : 'check',
|
||||
canonicalIcons: catalog.icons.length,
|
||||
unresolvedCatalogEntries: catalog.unresolved.length,
|
||||
sharedNumericIds: [...catalogByNumericId.values()].filter((entries) => entries.length > 1).length,
|
||||
...stats,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user