AI Implementation feature(917): Category Icons (#65)

This commit was merged in pull request #65.
This commit is contained in:
2026-07-23 16:14:08 +00:00
parent e81c568fca
commit 143ed3c538
10 changed files with 553 additions and 75 deletions
@@ -4,6 +4,7 @@ import { DataSource } from 'typeorm';
import * as path from 'path';
import { FilesystemTransactionService } from '../modules/admin/system/filesystem-transaction.service';
import { resolveSystemStagingDir } from '../common/utils/upload';
import { seedSystemCategoryIcons } from './system-category-icons';
@Injectable()
export class DatabaseInitService implements OnApplicationBootstrap {
@@ -59,6 +60,11 @@ export class DatabaseInitService implements OnApplicationBootstrap {
} catch (e) {
this.logger.warn(`verifySeed skipped: ${(e as Error).message}`);
}
try {
await this.ensureSystemCategoryIcons();
} catch (e) {
this.logger.warn(`ensureSystemCategoryIcons skipped: ${(e as Error).message}`);
}
this.initialized = true;
}
@@ -128,6 +134,19 @@ export class DatabaseInitService implements OnApplicationBootstrap {
* Persisted for file-backed databases; idempotent and safe on every
* startup.
*/
private async ensureSystemCategoryIcons(): Promise<void> {
const uploadDirRaw = this.config.get<string>('UPLOAD_DIR', './data/uploads');
if (!uploadDirRaw || uploadDirRaw.includes(':memory:')) {
this.logger.debug('ensureSystemCategoryIcons skipped: in-memory UPLOAD_DIR');
return;
}
const uploadDir = path.resolve(uploadDirRaw);
const result = await seedSystemCategoryIcons(uploadDir);
this.logger.log(
`System category icons: written=${result.written.length} skipped=${result.skipped.length}`,
);
}
private async ensureWalMode(): Promise<void> {
try {
const rows: any[] = await this.dataSource.query('PRAGMA journal_mode');
@@ -0,0 +1,117 @@
import * as fs from 'fs';
import * as path from 'path';
import sharp from 'sharp';
/**
* Canonical system-category icon seeder.
*
* The canonical six system categories (CRY / HW / MSC / PWN / REV / WEB) are
* declared as `CANONICAL_SYSTEM_CATEGORIES` in
* `1700000000400-RepairCategorySchemaAndSystemCategories.ts`, whose
* `icon_path` column references `/uploads/icons/<KEY>.png`. The migration
* creates the DB rows but never writes the PNG assets to disk, so a freshly
* bootstrapped instance serves 404s for every system category icon.
*
* This helper closes that gap by generating a deterministic 128×128 PNG for
* each canonical key on startup. It is intentionally idempotent: existing
* files (including admin-uploaded icons written through `UploadsController`)
* are never overwritten.
*
* IMPORTANT: keep this list in sync with
* `CANONICAL_SYSTEM_CATEGORIES` in
* `1700000000400-RepairCategorySchemaAndSystemCategories.ts` and with the
* hard-coded `iconPath` URLs in
* `1700000000300-UpdateSystemCategoryKeys.ts` and
* `1700000000400-RepairCategorySchemaAndSystemCategories.ts`.
*/
export const CANONICAL_SYSTEM_ICON_KEYS = ['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB'] as const;
export type CanonicalSystemIconKey = (typeof CANONICAL_SYSTEM_ICON_KEYS)[number];
export const SYSTEM_ICON_SIZE = 128;
const PALETTE: ReadonlyArray<{ r: number; g: number; b: number }> = [
{ r: 0x1f, g: 0x6f, b: 0xeb },
{ r: 0x16, g: 0xa3, b: 0x4a },
{ r: 0xea, g: 0x58, b: 0x0c },
{ r: 0x93, g: 0x3f, b: 0xea },
{ r: 0xdb, g: 0x27, b: 0x77 },
{ r: 0x06, g: 0xb6, b: 0xd4 },
{ r: 0xdc, g: 0x26, b: 0x26 },
{ r: 0x65, g: 0x73, b: 0x0d },
];
function colorForKey(key: string): { r: number; g: number; b: number } {
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
}
return PALETTE[hash % PALETTE.length];
}
function renderLabel(label: string): Buffer {
const width = SYSTEM_ICON_SIZE;
const height = SYSTEM_ICON_SIZE;
const cellW = Math.floor(width / Math.max(label.length, 1));
const cellH = Math.floor(height / 2);
const cells: Array<{ x: number; y: number; on: boolean }> = [];
for (let i = 0; i < label.length; i++) {
const cx = Math.floor(cellW * (i + 0.5));
const cy = cellH;
cells.push({ x: cx, y: cy, on: true });
}
const grid = Buffer.alloc(width * height * 4, 0);
for (const c of cells) {
if (c.x < 0 || c.x >= width || c.y < 0 || c.y >= height) continue;
const idx = (c.y * width + c.x) * 4;
grid[idx] = 255;
grid[idx + 1] = 255;
grid[idx + 2] = 255;
grid[idx + 3] = 220;
}
return grid;
}
export interface SystemCategoryIconSeedResult {
written: string[];
skipped: string[];
}
export async function seedSystemCategoryIcons(uploadDir: string): Promise<SystemCategoryIconSeedResult> {
const iconsDir = path.join(uploadDir, 'icons');
if (!fs.existsSync(iconsDir)) fs.mkdirSync(iconsDir, { recursive: true });
const result: SystemCategoryIconSeedResult = { written: [], skipped: [] };
for (const key of CANONICAL_SYSTEM_ICON_KEYS) {
const target = path.join(iconsDir, `${key}.png`);
try {
const stat = fs.existsSync(target) ? fs.statSync(target) : null;
if (stat && stat.size > 0) {
result.skipped.push(target);
continue;
}
} catch {
/* fall through and write */
}
const bg = colorForKey(key);
const overlay = renderLabel(key);
const buf = await sharp({
create: {
width: SYSTEM_ICON_SIZE,
height: SYSTEM_ICON_SIZE,
channels: 4,
background: { r: bg.r, g: bg.g, b: bg.b, alpha: 1 },
},
})
.composite([{ input: overlay, raw: { width: SYSTEM_ICON_SIZE, height: SYSTEM_ICON_SIZE, channels: 4 } }])
.png()
.toBuffer();
fs.writeFileSync(target, buf);
result.written.push(target);
}
return result;
}