|
|
|
@@ -1,4 +1,5 @@
|
|
|
|
|
import type { Database } from '@tursodatabase/database';
|
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
|
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
|
|
|
import path from 'node:path';
|
|
|
|
|
import sharp from 'sharp';
|
|
|
|
@@ -14,6 +15,24 @@ export const MAX_GAME_ICON_PIXELS = 12_000_000;
|
|
|
|
|
export const GAME_ICON_SIZE = 256;
|
|
|
|
|
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
|
|
|
|
const STORAGE_KEY_PATTERN = /^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
|
|
|
|
|
const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
|
|
|
|
|
talent: 'Talents',
|
|
|
|
|
brand_set: 'Brands',
|
|
|
|
|
os_protocol: 'OS Protocols',
|
|
|
|
|
skill_chip: 'Skill Chips',
|
|
|
|
|
specialization: 'Specializations',
|
|
|
|
|
specialization_ability: 'Specializations',
|
|
|
|
|
specialization_node: 'Specializations'
|
|
|
|
|
};
|
|
|
|
|
const LIBRARY_LABEL: Record<GameDataIconType, string> = {
|
|
|
|
|
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'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export class GameDataIconValidationError extends Error {}
|
|
|
|
|
|
|
|
|
@@ -95,7 +114,118 @@ export async function processGameDataIconUpload(file: File): Promise<Buffer> {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function saveGameDataIcon(database: Database, type: GameDataIconType, entityId: string, output: Buffer): Promise<string> {
|
|
|
|
|
const normalizeTags = (values: unknown[]) => [...new Set(values
|
|
|
|
|
.flatMap((value) => String(value ?? '').split(','))
|
|
|
|
|
.map((value) => value.normalize('NFKC').trim().toLocaleLowerCase())
|
|
|
|
|
.filter(Boolean))].slice(0, 20);
|
|
|
|
|
|
|
|
|
|
async function libraryMetadata(database: Database, type: GameDataIconType, entityId: string) {
|
|
|
|
|
switch (type) {
|
|
|
|
|
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'] };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { name: null, tags: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function uniqueLibraryName(database: Database, preferred: string, type: GameDataIconType, entityId: string) {
|
|
|
|
|
for (const candidate of [
|
|
|
|
|
preferred,
|
|
|
|
|
`${preferred} · ${LIBRARY_LABEL[type]}`,
|
|
|
|
|
`${preferred} · ${entityId.slice(0, 8)}`
|
|
|
|
|
]) {
|
|
|
|
|
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)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function registerLibraryIcon(
|
|
|
|
|
database: Database,
|
|
|
|
|
type: GameDataIconType,
|
|
|
|
|
entityId: string,
|
|
|
|
|
key: string,
|
|
|
|
|
byteSize: number,
|
|
|
|
|
updatedAt: string,
|
|
|
|
|
actorId: string
|
|
|
|
|
) {
|
|
|
|
|
const existing = await (await database.prepare(
|
|
|
|
|
'SELECT * FROM managed_images WHERE source_entity_type=? AND source_entity_id=?'
|
|
|
|
|
)).get(type, entityId);
|
|
|
|
|
const metadata = await libraryMetadata(database, type, entityId);
|
|
|
|
|
const preferredName = String(metadata.name ?? `${LIBRARY_LABEL[type]} ${entityId.slice(0, 8)}`);
|
|
|
|
|
const derivedTags = normalizeTags(['isac', 'game data', type.replaceAll('_', ' '), preferredName, ...metadata.tags]);
|
|
|
|
|
if (existing) {
|
|
|
|
|
const previousTags = (() => {
|
|
|
|
|
try { return JSON.parse(String(existing.tags_json)); } catch { return []; }
|
|
|
|
|
})().filter((tag: unknown) => String(tag).toLocaleLowerCase() !== 'vest');
|
|
|
|
|
await (await database.prepare(`UPDATE managed_images SET
|
|
|
|
|
category=?,tags_json=?,storage_key=?,original_filename=?,byte_size=?,width=256,height=256,updated_at=?
|
|
|
|
|
WHERE id=?`)).run(
|
|
|
|
|
LIBRARY_CATEGORY[type], JSON.stringify(normalizeTags([...previousTags, ...derivedTags])),
|
|
|
|
|
key, key, byteSize, updatedAt, existing.id
|
|
|
|
|
);
|
|
|
|
|
return String(existing.id);
|
|
|
|
|
}
|
|
|
|
|
const name = await uniqueLibraryName(database, preferredName, type, entityId);
|
|
|
|
|
const id = randomUUID();
|
|
|
|
|
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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
|
|
|
|
id, name, LIBRARY_CATEGORY[type], JSON.stringify(derivedTags), 1, 0, key, key, byteSize, 256, 256,
|
|
|
|
|
type, entityId, actorId, updatedAt, updatedAt
|
|
|
|
|
);
|
|
|
|
|
return id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function saveGameDataIcon(
|
|
|
|
|
database: Database,
|
|
|
|
|
type: GameDataIconType,
|
|
|
|
|
entityId: string,
|
|
|
|
|
output: Buffer,
|
|
|
|
|
actorId: string
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
const key = storageKey(type, entityId);
|
|
|
|
|
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
|
|
|
|
|
await writeFile(storagePath(key), output, { flag: 'w', mode: 0o600 });
|
|
|
|
@@ -104,12 +234,14 @@ export async function saveGameDataIcon(database: Database, type: GameDataIconTyp
|
|
|
|
|
VALUES(?,?,?,?,?) ON CONFLICT(entity_type,entity_id) DO UPDATE SET
|
|
|
|
|
storage_key=excluded.storage_key,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
|
|
|
|
|
.run(type, entityId, key, output.length, now);
|
|
|
|
|
await registerLibraryIcon(database, type, entityId, key, output.length, now, actorId);
|
|
|
|
|
return now;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function deleteGameDataIcon(database: Database, type: GameDataIconType, entityId: string): Promise<void> {
|
|
|
|
|
const icon = await (await database.prepare('SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(type, entityId);
|
|
|
|
|
await (await database.prepare('DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=?')).run(type, entityId);
|
|
|
|
|
await (await database.prepare('DELETE FROM managed_images WHERE source_entity_type=? AND source_entity_id=?')).run(type, entityId);
|
|
|
|
|
if (typeof icon?.storage_key === 'string') {
|
|
|
|
|
await unlink(storagePath(icon.storage_key)).catch((error: NodeJS.ErrnoException) => {
|
|
|
|
|
if (error.code !== 'ENOENT') throw error;
|
|
|
|
@@ -137,11 +269,32 @@ export async function readIconMutation(form: FormData): Promise<{ output: Buffer
|
|
|
|
|
return { output: null, remove };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function applyIconMutation(database: Database, type: GameDataIconType, entityId: string, mutation: { output: Buffer | null; remove: boolean }): Promise<void> {
|
|
|
|
|
if (mutation.output) await saveGameDataIcon(database, type, entityId, mutation.output);
|
|
|
|
|
export async function applyIconMutation(
|
|
|
|
|
database: Database,
|
|
|
|
|
type: GameDataIconType,
|
|
|
|
|
entityId: string,
|
|
|
|
|
mutation: { output: Buffer | null; remove: boolean },
|
|
|
|
|
actorId: string
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
if (mutation.output) await saveGameDataIcon(database, type, entityId, mutation.output, actorId);
|
|
|
|
|
else if (mutation.remove) await deleteGameDataIcon(database, type, entityId);
|
|
|
|
|
else {
|
|
|
|
|
const icon = await (await database.prepare(
|
|
|
|
|
'SELECT storage_key,byte_size,updated_at FROM game_data_icons WHERE entity_type=? AND entity_id=?'
|
|
|
|
|
)).get(type, entityId);
|
|
|
|
|
if (typeof icon?.storage_key === 'string') {
|
|
|
|
|
await registerLibraryIcon(
|
|
|
|
|
database, type, entityId, icon.storage_key, Number(icon.byte_size), String(icon.updated_at), actorId
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function gameDataIconMetadata(database: Database, type: GameDataIconType, entityId: string) {
|
|
|
|
|
return await (await database.prepare('SELECT byte_size,updated_at FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(type, entityId) ?? null;
|
|
|
|
|
return await (await database.prepare(`SELECT icon.byte_size,icon.updated_at,
|
|
|
|
|
image.id AS library_image_id,image.category AS library_category,image.tags_json AS library_tags_json
|
|
|
|
|
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 icon.entity_type=? AND icon.entity_id=?`)).get(type, entityId) ?? null;
|
|
|
|
|
}
|
|
|
|
|