feat: refine ISAC media and notification workflows
This commit is contained in:
@@ -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.');
|
||||
|
||||
@@ -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') });
|
||||
}
|
||||
|
||||
@@ -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') });
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user