AI Implementation feature(917): Category Icons (#65)
This commit was merged in pull request #65.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user