feat: refine ISAC media and notification workflows

This commit is contained in:
JB
2026-07-26 20:36:30 -04:00
parent 338ea8761d
commit 6ea45c05fa
37 changed files with 1002 additions and 138 deletions
+163
View File
@@ -39,6 +39,7 @@ const statements = schema
for (const statement of statements) {
// This index is deferred until the additive legacy-column migration below.
if (statement.startsWith('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx')) continue;
if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx')) continue;
await database.exec(statement);
}
@@ -290,6 +291,168 @@ if (typeof specializationIconDefinition?.sql === 'string' && !specializationIcon
await database.exec('ALTER TABLE game_data_icons_specializations RENAME TO game_data_icons');
}
const managedImageColumns = await (await database.prepare("PRAGMA table_info('managed_images')")).all();
for (const [name, definition] of [
['source_entity_type', 'TEXT'],
['source_entity_id', 'TEXT']
]) {
if (!managedImageColumns.some((column) => column.name === name)) {
console.log(`Linking Image Library assets to ISAC entities: ${name}.`);
await database.exec(`ALTER TABLE managed_images ADD COLUMN ${name} ${definition}`);
}
}
await database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx
ON managed_images(source_entity_type,source_entity_id)
WHERE source_entity_type IS NOT NULL AND source_entity_id IS NOT NULL`);
const iconLibraryCreator = await (await database.prepare(`SELECT users.id
FROM users
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
LEFT JOIN user_roles ON user_roles.user_id=users.id
LEFT JOIN roles ON roles.id=user_roles.role_id
LEFT JOIN role_permissions ON role_permissions.role_id=roles.id
LEFT JOIN permissions ON permissions.id=role_permissions.permission_id
WHERE deleted_accounts.user_id IS NULL
GROUP BY users.id,users.created_at
ORDER BY MAX(CASE WHEN roles.name='Admin' THEN 1
WHEN permissions.name='*' THEN 1 ELSE 0 END) DESC,
users.created_at ASC LIMIT 1`)).get();
const iconLibraryCategory = {
talent: 'Talents',
brand_set: 'Brands',
os_protocol: 'OS Protocols',
skill_chip: 'Skill Chips',
specialization: 'Specializations',
specialization_ability: 'Specializations',
specialization_node: 'Specializations'
};
const iconLibraryLabel = {
talent: 'Talent',
brand_set: 'Brand Set',
os_protocol: 'OS Protocol',
skill_chip: 'Skill Chip',
specialization: 'Specialization',
specialization_ability: 'Specialization Ability',
specialization_node: 'Specialization Tree Node'
};
const normalizeLibraryTags = (values) => [...new Set(values
.flatMap((value) => String(value ?? '').split(','))
.map((value) => value.normalize('NFKC').trim().toLocaleLowerCase())
.filter(Boolean))].slice(0, 20);
const linkedIconMetadata = async (entityType, entityId) => {
switch (entityType) {
case 'talent': {
const row = await (await database.prepare(`SELECT talent.name,talent.category,scope.scope
FROM talents talent LEFT JOIN talent_weapon_scopes scope ON scope.talent_id=talent.id
WHERE talent.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.category === 'Vest' ? 'Body Armor' : row?.category, row?.scope] };
}
case 'brand_set': {
const row = await (await database.prepare('SELECT name FROM brand_sets WHERE id=?')).get(entityId);
return { name: row?.name, tags: ['gear', 'set bonus'] };
}
case 'os_protocol': {
const row = await (await database.prepare('SELECT name,quality,primary_attribute_type FROM os_protocols WHERE id=?')).get(entityId);
return { name: row?.name, tags: [row?.quality, row?.primary_attribute_type] };
}
case 'skill_chip': {
const row = await (await database.prepare(`SELECT chip.name,ability.name AS ability_name,specialization.name AS specialization_name
FROM skill_chip_sets chip
LEFT JOIN specialization_abilities ability ON ability.id=chip.specialization_ability_id
LEFT JOIN specializations specialization ON specialization.id=ability.specialization_id
WHERE chip.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.ability_name, row?.specialization_name] };
}
case 'specialization': {
const row = await (await database.prepare('SELECT name FROM specializations WHERE id=?')).get(entityId);
return { name: row?.name, tags: ['class'] };
}
case 'specialization_ability': {
const row = await (await database.prepare(`SELECT ability.name,specialization.name AS specialization_name
FROM specialization_abilities ability
JOIN specializations specialization ON specialization.id=ability.specialization_id
WHERE ability.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.specialization_name, 'ability'] };
}
case 'specialization_node': {
const row = await (await database.prepare(`SELECT node.name,node.row_number,focus.name AS focus_name,
specialization.name AS specialization_name
FROM specialization_tree_nodes node
JOIN specialization_focuses focus ON focus.id=node.focus_id
JOIN specializations specialization ON specialization.id=focus.specialization_id
WHERE node.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.specialization_name, row?.focus_name, `row ${row?.row_number}`, 'tree node'] };
}
default:
return { name: null, tags: [] };
}
};
const uniqueLibraryName = async (preferred, entityType, entityId) => {
const candidates = [
preferred,
`${preferred} · ${iconLibraryLabel[entityType] ?? 'ISAC Asset'}`,
`${preferred} · ${entityId.slice(0, 8)}`
];
for (const candidate of candidates) {
const normalized = candidate.slice(0, 80);
const conflict = await (await database.prepare('SELECT id FROM managed_images WHERE name=? COLLATE NOCASE')).get(normalized);
if (!conflict) return normalized;
}
return `${preferred.slice(0, 64)} · ${entityId.slice(0, 8)}`;
};
if (iconLibraryCreator?.id) {
const unregisteredIcons = await (await database.prepare(`SELECT icon.*
FROM game_data_icons icon
LEFT JOIN managed_images image
ON image.source_entity_type=icon.entity_type AND image.source_entity_id=icon.entity_id
WHERE image.id IS NULL
ORDER BY icon.entity_type,icon.entity_id`)).all();
if (unregisteredIcons.length) console.log(`Registering ${unregisteredIcons.length} existing ISAC icons in the Image Library.`);
for (const icon of unregisteredIcons) {
const metadata = await linkedIconMetadata(String(icon.entity_type), String(icon.entity_id));
const label = iconLibraryLabel[icon.entity_type] ?? 'ISAC Asset';
const preferredName = String(metadata.name ?? `${label} ${String(icon.entity_id).slice(0, 8)}`);
const name = await uniqueLibraryName(preferredName, String(icon.entity_type), String(icon.entity_id));
const tags = normalizeLibraryTags([
'isac', 'game data', String(icon.entity_type).replaceAll('_', ' '), preferredName, ...metadata.tags
]);
const now = String(icon.updated_at ?? new Date().toISOString());
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,
source_entity_type,source_entity_id,created_by,created_at,updated_at)
VALUES(lower(hex(randomblob(4)))||'-'||lower(hex(randomblob(2)))||'-4'||substr(lower(hex(randomblob(2))),2)
||'-'||substr('89ab',abs(random())%4+1,1)||substr(lower(hex(randomblob(2))),2)||'-'||lower(hex(randomblob(6))),
?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
name, iconLibraryCategory[icon.entity_type] ?? 'General', JSON.stringify(tags), 1, 0,
icon.storage_key, icon.storage_key, icon.byte_size, 256, 256,
icon.entity_type, icon.entity_id, iconLibraryCreator.id, now, now
);
}
const linkedIcons = await (await database.prepare(`SELECT icon.*,image.id AS library_image_id,image.tags_json
FROM game_data_icons icon
JOIN managed_images image
ON image.source_entity_type=icon.entity_type AND image.source_entity_id=icon.entity_id
ORDER BY icon.entity_type,icon.entity_id`)).all();
for (const icon of linkedIcons) {
const metadata = await linkedIconMetadata(String(icon.entity_type), String(icon.entity_id));
const preferredName = String(metadata.name ?? `${iconLibraryLabel[icon.entity_type] ?? 'ISAC Asset'} ${String(icon.entity_id).slice(0, 8)}`);
let existingTags = [];
try { existingTags = JSON.parse(String(icon.tags_json)); } catch { /* replace malformed legacy tags below */ }
existingTags = existingTags.filter((tag) => String(tag).toLocaleLowerCase() !== 'vest');
const tags = normalizeLibraryTags([
...existingTags, 'isac', 'game data', String(icon.entity_type).replaceAll('_', ' '), preferredName, ...metadata.tags
]);
await (await database.prepare(`UPDATE managed_images SET
category=?,tags_json=?,storage_key=?,original_filename=?,byte_size=?,width=256,height=256
WHERE id=?`)).run(
iconLibraryCategory[icon.entity_type] ?? 'General', JSON.stringify(tags),
icon.storage_key, icon.storage_key, icon.byte_size, icon.library_image_id
);
}
}
const gearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all();
if (gearAugmentColumns.length && !gearAugmentColumns.some((column) => column.name === 'effect_type')) {
console.log('Separating custom Gear Tactical Augments from fixed base-item percentage upgrades.');
+3
View File
@@ -23,12 +23,15 @@ for (const name of ISAC_DATA_TABLES) {
}
const icons = [];
const exportedStorageKeys = new Set();
const iconTable = tables.find((table) => table.name === 'game_data_icons');
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 managed asset storage key "${String(row.storage_key)}".`);
}
if (exportedStorageKeys.has(row.storage_key)) continue;
exportedStorageKeys.add(row.storage_key);
const content = await readFile(join(iconDirectory, row.storage_key));
icons.push({ storageKey: row.storage_key, contentBase64: content.toString('base64') });
}
+2 -1
View File
@@ -10,7 +10,8 @@ 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)) {
if (typeof row.storage_key !== 'string'
|| !/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[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') });
+22 -8
View File
@@ -17,10 +17,16 @@ if (bundle?.format !== 'shd-build-managed-images' || bundle?.version !== 1 || !A
}
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.row.storage_key !== 'string'
|| !/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[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.');
}
if (image.row.source_entity_type && (
typeof image.row.source_entity_id !== 'string'
|| !/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id)
|| !image.row.storage_key.startsWith(`${image.row.source_entity_type}-`)
)) throw new Error('The bundle contains an invalid linked ISAC image.');
}
const backup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
@@ -47,7 +53,7 @@ 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']
const same = ['name','category','tags_json','is_icon','force_white','storage_key','byte_size','width','height','source_entity_type','source_entity_id']
.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++;
@@ -57,6 +63,12 @@ for (const image of bundle.images) {
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.`);
if (row.source_entity_type && row.source_entity_id) {
const sourceConflict = await (await database.prepare(
'SELECT id FROM managed_images WHERE source_entity_type=? AND source_entity_id=? AND id<>?'
)).get(row.source_entity_type, row.source_entity_id, row.id);
if (sourceConflict) throw new Error(`The ${row.source_entity_type} image ${row.source_entity_id} is already registered under a different library asset.`);
}
preflight.push({ image, existing });
}
@@ -65,22 +77,24 @@ 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'
mode: 0o600, flag: existing || row.source_entity_type ? '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(
byte_size=?,width=?,height=?,source_entity_type=?,source_entity_id=?,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
row.byte_size,row.width,row.height,row.source_entity_type ?? null,row.source_entity_id ?? null,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(
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
source_entity_type,source_entity_id,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
row.byte_size,row.width,row.height,row.source_entity_type ?? null,row.source_entity_id ?? null,
row.created_by,row.created_at,row.updated_at
);
inserted++;
}