feat: add managed ISAC image library

This commit is contained in:
JB
2026-07-26 13:46:44 -04:00
parent e8ed35e35f
commit 63b04d26a9
25 changed files with 705 additions and 57 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ try {
};
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
console.log(`${target} bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} game-data icons.`);
console.log(`${target} bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} managed image assets.`);
console.log(`Bootstrap bundle: ${outputPath}`);
} finally {
await rm(temporaryBackupPath, { force: true });
+1 -1
View File
@@ -111,5 +111,5 @@ for (const file of bundle.files.gameIcons) {
await writeFile(join(gameIconDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 });
}
console.log(`Bootstrapped ${target} with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} game-data icons.`);
console.log(`Bootstrapped ${target} with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} managed image assets.`);
console.log(`Source bundle: ${bundlePath}`);
+4 -3
View File
@@ -24,9 +24,10 @@ for (const name of ISAC_DATA_TABLES) {
const icons = [];
const iconTable = tables.find((table) => table.name === 'game_data_icons');
for (const row of iconTable?.rows ?? []) {
const managedImageTable = tables.find((table) => table.name === 'managed_images');
for (const row of [...(iconTable?.rows ?? []), ...(managedImageTable?.rows ?? [])]) {
if (typeof row.storage_key !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)) {
throw new Error(`Invalid game-data icon storage key "${String(row.storage_key)}".`);
throw new Error(`Invalid managed asset storage key "${String(row.storage_key)}".`);
}
const content = await readFile(join(iconDirectory, row.storage_key));
icons.push({ storageKey: row.storage_key, contentBase64: content.toString('base64') });
@@ -44,5 +45,5 @@ const snapshot = {
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
console.log(`Exported ${totalRows} ISAC rows and ${icons.length} uploaded icons.`);
console.log(`Exported ${totalRows} ISAC rows and ${icons.length} uploaded image assets.`);
console.log(`ISAC snapshot: ${outputPath}`);
+1 -1
View File
@@ -74,6 +74,6 @@ for (const icon of snapshot.icons) {
await writeFile(join(iconDirectory, icon.storageKey), Buffer.from(icon.contentBase64, 'base64'), { mode: 0o600 });
}
console.log(`Imported ${importedRows} ISAC rows and ${snapshot.icons.length} uploaded icons from ${snapshotPath}.`);
console.log(`Imported ${importedRows} ISAC rows and ${snapshot.icons.length} uploaded image assets from ${snapshotPath}.`);
console.log(`Full pre-import backup: ${backupPath}`);
console.log(`ISAC pre-import snapshot: ${isacBackupPath}`);
+2 -1
View File
@@ -48,7 +48,8 @@ export const ISAC_DATA_TABLES = [
'gear_mod_quality_values',
'gear_mod_secondary_options',
'gear_mod_secondary_quality_values',
'game_data_icons'
'game_data_icons',
'managed_images'
];
export const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
+28
View File
@@ -0,0 +1,28 @@
import { connect } from '@tursodatabase/database';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, dirname, join, resolve } from 'node:path';
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
const storageDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
const outputPath = resolve(process.argv[2] || `./data/backups/managed-images-${timestamp}.json`);
const database = await connect(databasePath);
const rows = await (await database.prepare('SELECT * FROM managed_images ORDER BY created_at')).all();
const images = [];
for (const row of rows) {
if (typeof row.storage_key !== 'string' || !/^library-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)) {
throw new Error(`Invalid managed-image storage key "${String(row.storage_key)}".`);
}
images.push({ row, contentBase64: (await readFile(join(storageDirectory, row.storage_key))).toString('base64') });
}
const bundle = {
format: 'shd-build-managed-images',
version: 1,
createdAt: new Date().toISOString(),
sourceDatabase: basename(databasePath),
images
};
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
console.log(`Exported ${images.length} managed image assets.`);
console.log(`Image bundle: ${outputPath}`);
+99
View File
@@ -0,0 +1,99 @@
import { connect } from '@tursodatabase/database';
import { spawnSync } from 'node:child_process';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { randomUUID } from 'node:crypto';
const bundleArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--'));
if (!bundleArgument || !process.argv.includes('--confirm-merge')) {
console.error('Usage: node scripts/managed-images-import.mjs <bundle.json> --confirm-merge [--update-existing]');
process.exit(2);
}
const updateExisting = process.argv.includes('--update-existing');
const bundlePath = resolve(bundleArgument);
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
if (bundle?.format !== 'shd-build-managed-images' || bundle?.version !== 1 || !Array.isArray(bundle.images)) {
throw new Error('The selected file is not a supported SHD.build managed-image bundle.');
}
for (const image of bundle.images) {
if (typeof image?.row?.id !== 'string' || !/^[0-9a-f-]{36}$/i.test(image.row.id)
|| typeof image.row.storage_key !== 'string' || !/^library-[0-9a-f-]{36}\.webp$/i.test(image.row.storage_key)
|| typeof image.contentBase64 !== 'string') {
throw new Error('The bundle contains an invalid managed image.');
}
}
const backup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
cwd: process.cwd(), stdio: 'inherit', env: process.env
});
if (backup.error) throw backup.error;
if (backup.status !== 0) throw new Error('Safety backup failed; no managed images were imported.');
const assetBackup = spawnSync(process.execPath, ['scripts/isac-data-export.mjs'], {
cwd: process.cwd(), stdio: 'inherit', env: process.env
});
if (assetBackup.error) throw assetBackup.error;
if (assetBackup.status !== 0) throw new Error('Uploaded-asset safety snapshot failed; no managed images were imported.');
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
const storageDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
const database = await connect(databasePath);
await mkdir(storageDirectory, { recursive: true });
let inserted = 0;
let updated = 0;
let unchanged = 0;
const preflight = [];
for (const image of bundle.images) {
const row = image.row;
const existing = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(row.id);
if (existing && !updateExisting) {
const same = ['name','category','tags_json','is_icon','force_white','storage_key','byte_size','width','height']
.every((key) => String(existing[key]) === String(row[key]));
if (!same) throw new Error(`Image ${row.id} already exists with different metadata. Re-run with --update-existing after review.`);
unchanged++;
continue;
}
const creator = await (await database.prepare('SELECT id FROM users WHERE id=?')).get(row.created_by);
if (!creator) throw new Error(`Image "${row.name}" references a creator that does not exist in the target database.`);
const nameConflict = await (await database.prepare('SELECT id FROM managed_images WHERE name=? COLLATE NOCASE AND id<>?')).get(row.name, row.id);
if (nameConflict) throw new Error(`Image name "${row.name}" is already assigned to a different target asset.`);
preflight.push({ image, existing });
}
await database.exec('BEGIN');
try {
for (const { image, existing } of preflight) {
const row = image.row;
await writeFile(join(storageDirectory, row.storage_key), Buffer.from(image.contentBase64, 'base64'), {
mode: 0o600, flag: existing ? 'w' : 'wx'
});
if (existing) {
await (await database.prepare(`UPDATE managed_images SET
name=?,category=?,tags_json=?,is_icon=?,force_white=?,storage_key=?,original_filename=?,
byte_size=?,width=?,height=?,updated_at=? WHERE id=?`)).run(
row.name,row.category,row.tags_json,row.is_icon,row.force_white,row.storage_key,row.original_filename,
row.byte_size,row.width,row.height,row.updated_at,row.id
);
updated++;
} else {
await (await database.prepare(`INSERT INTO managed_images
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
row.id,row.name,row.category,row.tags_json,row.is_icon,row.force_white,row.storage_key,row.original_filename,
row.byte_size,row.width,row.height,row.created_by,row.created_at,row.updated_at
);
inserted++;
}
await (await database.prepare(`INSERT INTO audit_log
(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)`)).run(
randomUUID(), row.created_by, 'system_configuration', row.id,
existing ? 'image.import_updated' : 'image.imported',
existing ? JSON.stringify(existing) : null, JSON.stringify(row), new Date().toISOString()
);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
console.log(`Managed-image merge complete: ${inserted} inserted, ${updated} updated, ${unchanged} unchanged.`);