217 lines
7.1 KiB
TypeScript
217 lines
7.1 KiB
TypeScript
import { promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
import type { RowDataPacket } from "mysql2";
|
|
import { db } from "@/lib/db";
|
|
import { getEffectMapPath } from "@/lib/services/effectmap";
|
|
import { getFigureDataPath } from "@/lib/services/figuredata";
|
|
import { getFigureMapPath } from "@/lib/services/figuremap";
|
|
import {
|
|
getFurniAssetDirs,
|
|
getGamedataRoot,
|
|
} from "@/lib/services/furni-asset-dirs";
|
|
import { getFurnitureDataPath } from "@/lib/services/furni-data";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
import {
|
|
CATALOG_LANGUAGE_ROOT,
|
|
CATALOG_SQL_ROOT,
|
|
type CatalogFile,
|
|
sqlValue,
|
|
} from "./catalog-git-core";
|
|
|
|
const CONFIG_FILES = [
|
|
"FurnitureData.json",
|
|
"EffectMap.json",
|
|
"ExternalTexts.json",
|
|
"FigureData.json",
|
|
"FigureMap.json",
|
|
"HabboAvatarActions.json",
|
|
"ProductData.json",
|
|
];
|
|
const TABLES = ["items_base", "catalog_pages", "catalog_items"] as const;
|
|
const identifier = (name: string) => `\`${name.replaceAll("`", "``")}\``;
|
|
|
|
export async function createCatalogSnapshot(
|
|
directory: string,
|
|
): Promise<CatalogFile[]> {
|
|
const sources = new Map<string, string>();
|
|
const addTree = async (root: string, target: string, extensions: RegExp) => {
|
|
async function walk(source: string, relative = "") {
|
|
const entries = await fs
|
|
.readdir(source, { withFileTypes: true })
|
|
.catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === "ENOENT") return [];
|
|
throw error;
|
|
});
|
|
for (const entry of entries) {
|
|
if (entry.isSymbolicLink())
|
|
throw new Error("Symlink in catalog source");
|
|
const local = path.join(source, entry.name);
|
|
const dest = relative ? `${relative}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory()) await walk(local, dest);
|
|
else if (entry.isFile() && extensions.test(entry.name))
|
|
sources.set(`${target}/${dest}`, local);
|
|
}
|
|
}
|
|
await walk(root);
|
|
};
|
|
const gamedata = await getGamedataRoot();
|
|
const furni = await getFurniAssetDirs();
|
|
const furnitureData = await getFurnitureDataPath();
|
|
// Default files first; configured live locations take precedence.
|
|
await addTree(
|
|
path.join(process.cwd(), "public/swf/c_images"),
|
|
"Gamedata/c_images",
|
|
/\.(png|gif|webp|jpe?g)$/i,
|
|
);
|
|
await addTree(
|
|
path.join(process.cwd(), "public/nitro-assets/bundled"),
|
|
"Gamedata/bundled",
|
|
/\.nitro$/i,
|
|
);
|
|
if (gamedata) {
|
|
await addTree(
|
|
path.join(gamedata, "bundled"),
|
|
"Gamedata/bundled",
|
|
/\.nitro$/i,
|
|
);
|
|
await addTree(
|
|
path.join(gamedata, "icons"),
|
|
"Gamedata/icons",
|
|
/\.(png|gif|webp|jpe?g)$/i,
|
|
);
|
|
await addTree(
|
|
path.join(gamedata, "c_images"),
|
|
"Gamedata/c_images",
|
|
/\.(png|gif|webp|jpe?g)$/i,
|
|
);
|
|
}
|
|
await addTree(furni.nitroDir, "Gamedata/bundled/furniture", /\.nitro$/i);
|
|
await addTree(furni.iconDir, "Gamedata/icons", /\.(png|gif|webp|jpe?g)$/i);
|
|
for (const type of ["figure", "effect"]) {
|
|
const configured = await siteSettings.get(`${type}_nitro_dir`, "");
|
|
if (configured?.trim())
|
|
await addTree(configured, `Gamedata/bundled/${type}`, /\.nitro$/i);
|
|
}
|
|
// Pets currently write directly to the CMS directory.
|
|
await addTree(
|
|
path.join(process.cwd(), "public/nitro-assets/bundled/pet"),
|
|
"Gamedata/bundled/pet",
|
|
/\.nitro$/i,
|
|
);
|
|
const configRoot = gamedata
|
|
? path.join(gamedata, "config")
|
|
: path.dirname(furnitureData);
|
|
const configuredJson: Record<string, string> = {
|
|
FurnitureData: furnitureData,
|
|
FigureData: await getFigureDataPath(),
|
|
FigureMap: await getFigureMapPath(),
|
|
EffectMap: await getEffectMapPath(),
|
|
};
|
|
for (const name of CONFIG_FILES) {
|
|
const source =
|
|
configuredJson[name.replace(/\.json$/, "")] ??
|
|
path.join(configRoot, name);
|
|
try {
|
|
await fs.access(source);
|
|
sources.set(`Gamedata/config/${name}`, source);
|
|
} catch (error) {
|
|
if (
|
|
name === "FurnitureData.json" ||
|
|
(error as NodeJS.ErrnoException).code !== "ENOENT"
|
|
)
|
|
throw error;
|
|
}
|
|
}
|
|
for (const name of await fs.readdir(path.dirname(furnitureData))) {
|
|
if (/^FurnitureData_[a-z]{2}\.json$/.test(name)) {
|
|
sources.set(
|
|
`${CATALOG_LANGUAGE_ROOT}/${name}`,
|
|
path.join(path.dirname(furnitureData), name),
|
|
);
|
|
}
|
|
}
|
|
const files: CatalogFile[] = [];
|
|
const fingerprints = new Map<string, string>();
|
|
const fingerprint = async (source: string) => {
|
|
const stat = await fs.lstat(source);
|
|
if (!stat.isFile() || stat.isSymbolicLink())
|
|
throw new Error("Invalid catalog source");
|
|
return `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
|
|
};
|
|
for (const [target, source] of sources) {
|
|
fingerprints.set(source, await fingerprint(source));
|
|
const copy = path.join(directory, String(files.length));
|
|
await fs.copyFile(source, copy);
|
|
if (target.endsWith(".json")) JSON.parse(await fs.readFile(copy, "utf8"));
|
|
files.push({ source: copy, target });
|
|
}
|
|
const connection = await db.$client.getConnection();
|
|
try {
|
|
await connection.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");
|
|
await connection.query(
|
|
"START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY",
|
|
);
|
|
const [available] = await connection.query<RowDataPacket[]>("SHOW TABLES");
|
|
const tableNames = new Set(
|
|
available.map((row) => String(Object.values(row)[0])),
|
|
);
|
|
const tables = [
|
|
...TABLES,
|
|
...["catalog_pages_bc", "catalog_items_bc"].filter((name) =>
|
|
tableNames.has(name),
|
|
),
|
|
];
|
|
for (const table of tables) {
|
|
const [schemaRows] = await connection.query<RowDataPacket[]>(
|
|
`SHOW CREATE TABLE ${identifier(table)}`,
|
|
);
|
|
const definition = String(schemaRows[0]["Create Table"]);
|
|
if (!/ENGINE=InnoDB\b/i.test(definition))
|
|
throw new Error("Catalog SQL snapshots require InnoDB tables");
|
|
const [columns] = await connection.query<RowDataPacket[]>(
|
|
`SHOW COLUMNS FROM ${identifier(table)}`,
|
|
);
|
|
const names = columns
|
|
.filter(
|
|
(c) => !/\b(?:VIRTUAL|STORED) GENERATED\b/.test(String(c.Extra)),
|
|
)
|
|
.map((c) => String(c.Field));
|
|
const quoted = names.map(identifier).join(", ");
|
|
const [rows] = await connection.query<RowDataPacket[]>(
|
|
`SELECT ${quoted} FROM ${identifier(table)} ORDER BY id`,
|
|
);
|
|
const source = path.join(directory, `${table}.sql`);
|
|
const output = await fs.open(source, "wx");
|
|
try {
|
|
await output.writeFile(
|
|
`-- Catalog Studio snapshot. Existing rows are updated; absent rows are not deleted.\nSET NAMES utf8mb4;\n${definition.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS")};\nSTART TRANSACTION;\n`,
|
|
);
|
|
const update = names
|
|
.filter((n) => n !== "id")
|
|
.map((n) => `${identifier(n)}=VALUES(${identifier(n)})`)
|
|
.join(", ");
|
|
for (const row of rows) {
|
|
await output.writeFile(
|
|
`INSERT INTO ${identifier(table)} (${quoted}) VALUES (${names.map((n) => sqlValue(row[n])).join(", ")}) ON DUPLICATE KEY UPDATE ${update};\n`,
|
|
);
|
|
}
|
|
await output.writeFile("COMMIT;\n");
|
|
} finally {
|
|
await output.close();
|
|
}
|
|
files.push({ source, target: `${CATALOG_SQL_ROOT}/${table}.sql` });
|
|
}
|
|
await connection.commit();
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
for (const [source, stamp] of fingerprints) {
|
|
if ((await fingerprint(source)) !== stamp)
|
|
throw new Error("Assets changed during export; retry required");
|
|
}
|
|
return files;
|
|
}
|