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
+1 -1
View File
@@ -73,7 +73,7 @@
<form class="pagination-search" role="search" onsubmit={(event) => { event.preventDefault(); submitSearch(); }}>
{#each retainedParams as [key, value]}<input type="hidden" name={key} {value}/>{/each}
<input type="search" name={searchKey} value={searchValue} maxlength="80" placeholder={searchPlaceholder} aria-label={`${searchPlaceholder} — results update as you type`} oninput={(event) => scheduleSearch(event.currentTarget.value)}/>
<span class="pagination-search-state" class:pending={searchPending} aria-live="polite">{searchPending ? 'SEARCHING…' : 'LIVE SEARCH'}</span>
{#if searchPending}<span class="pagination-search-state pending" aria-live="polite">SEARCHING…</span>{/if}
{#if search}<a href={clearSearchHref}>CLEAR</a>{/if}
</form>
{/if}
+1 -3
View File
@@ -20,7 +20,6 @@
selected = $bindable([]),
columns = options.length,
required = false,
showIndicator = true,
onchange
}: {
legend: string;
@@ -31,7 +30,6 @@
selected?: string[];
columns?: number;
required?: boolean;
showIndicator?: boolean;
onchange?: (values: string[]) => void;
} = $props();
@@ -61,7 +59,7 @@
onchange={(event) => change(option.value, event.currentTarget.checked)}
/>
<span class:icon-only={option.iconOnly} class={option.className ?? ''} title={option.iconOnly ? option.label : undefined}>
{#if option.icon}<GameIcon name={option.icon} decorative/>{:else if showIndicator}<i aria-hidden="true"></i>{/if}
{#if option.icon}<GameIcon name={option.icon} decorative/>{/if}
<b class:sr-only={option.iconOnly}>{option.label}</b>
</span>
</label>
@@ -72,7 +72,6 @@
options={effectTypes}
bind:value={effect.type}
onchange={(values) => changeType(index, values[0])}
showIndicator={false}
required
/>
{#if effect.type === 'Attribute'}
@@ -92,7 +91,7 @@
/>
</label>
<label class="field">VALUE<input type="number" step="any" bind:value={effect.value} placeholder="OPTIONAL"/></label>
<SegmentedChoice legend="UNIT" name={`${idPrefix}-unit-${index}`} options={units} bind:value={effect.unit} showIndicator={false}/>
<SegmentedChoice legend="UNIT" name={`${idPrefix}-unit-${index}`} options={units} bind:value={effect.unit}/>
</div>
</section>
{:else}
+157 -4
View File
@@ -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;
}
+1 -1
View File
@@ -11,7 +11,7 @@ export const MANAGED_IMAGE_CATEGORIES = [
export const MAX_MANAGED_IMAGE_BYTES = 5 * 1024 * 1024;
const MAX_PIXELS = 24_000_000;
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
const KEY_PATTERN = /^library-[0-9a-f-]{36}\.webp$/i;
const KEY_PATTERN = /^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
export class ManagedImageValidationError extends Error {}