337 lines
12 KiB
JavaScript
337 lines
12 KiB
JavaScript
#!/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
|
|
)
|
|
);
|