import fs from "node:fs"; import path from "node:path"; import { fileTypeFromFile } from "file-type"; import type { Db } from "../db.js"; import type { AppConfig } from "../config.js"; import { listShades, parseFabricParams, FABRIC_STRUCTURES, type FabricRow } from "./fabric.js"; interface CatalogSource { id: string; path: string; thumb_path: string | null; mime: string; original_name: string; sha256: string; source_url: string | null; fetched_at: string | null; note: string | null; } const IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/webp"]); export function catalogFile(base: string, relative: string | null): string | null { if (!relative || path.posix.isAbsolute(relative) || path.win32.isAbsolute(relative)) return null; const normalized = relative.replace(/\\/g, "/"); if (normalized.split("/").some((part) => part === ".." || part.includes(":"))) return null; try { const root = fs.realpathSync(base); const file = fs.realpathSync(path.resolve(root, normalized)); const rel = path.relative(root, file); if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null; return fs.statSync(file).isFile() ? file : null; } catch { return null; } } export function getCatalogFabric(db: Db, code: string): FabricRow | undefined { return db.prepare("SELECT * FROM fabrics WHERE code = ?").get(code) as unknown as FabricRow | undefined; } export function catalogCardSource(db: Db, fabric: FabricRow): CatalogSource | undefined { if (!fabric.card_path) return undefined; return db.prepare("SELECT * FROM source_assets WHERE replace(path, char(92), '/') = ? AND mime = 'application/pdf' ORDER BY id LIMIT 1") .get(fabric.card_path.replace(/\\/g, "/")) as unknown as CatalogSource | undefined; } function sourceById(db: Db, id: string): CatalogSource | undefined { return db.prepare("SELECT * FROM source_assets WHERE id = ?").get(id) as unknown as CatalogSource | undefined; } function publicSourceUrl(raw: string | null): string | null { if (!raw) return null; try { const url = new URL(raw); return ["https:", "http:"].includes(url.protocol) && !url.username && !url.password ? url.href : null; } catch { return null; } } function mediaDto(cfg: AppConfig, code: string, source: CatalogSource | undefined, pdf = false) { const available = source && (pdf ? source.mime === "application/pdf" : IMAGE_MIMES.has(source.mime)) && catalogFile(cfg.originalsDir, source.path); const url = available ? `/api/fabrics/${encodeURIComponent(code)}/media/${encodeURIComponent(source.id)}` : null; return { sourceId: source?.id ?? null, name: source?.original_name ?? null, sha256: source?.sha256 ?? null, sourceUrl: publicSourceUrl(source?.source_url ?? null), fetchedAt: source?.fetched_at ?? null, note: source?.note ?? null, status: available ? "ready" : "unavailable", url, thumbnailUrl: url && !pdf && catalogFile(cfg.derivedDir, source?.thumb_path ?? null) ? `${url}/thumb` : null, }; } export function listCatalogFabrics(db: Db) { return db.prepare(`SELECT f.id, f.code, f.name, f.producer, f.verification, (SELECT COUNT(*) FROM fabric_shades s WHERE s.fabric_id = f.id) AS shadeCount FROM fabrics f ORDER BY f.name COLLATE NOCASE, f.code`).all(); } export function catalogShades(db: Db, cfg: AppConfig, fabric: FabricRow) { return listShades(db, fabric.id).map((shade) => ({ id: shade.id, code: shade.code, name: shade.name, colorHex: shade.color_hex, verification: shade.verification, official: shade.official_image_path ? mediaDto(cfg, fabric.code, sourceById(db, shade.official_image_path)) : null, photo: shade.photo_path ? mediaDto(cfg, fabric.code, sourceById(db, shade.photo_path)) : null, })); } export function catalogDetail(db: Db, cfg: AppConfig, fabric: FabricRow) { const shades = catalogShades(db, cfg, fabric); return { fabric: { id: fabric.id, code: fabric.code, name: fabric.name, producer: fabric.producer, verification: fabric.verification, aiPrompt: fabric.ai_prompt, aiStructure: fabric.ai_structure, aiNotePl: fabric.ai_note_pl, params: parseFabricParams(fabric.params_json), shadeCount: shades.length }, shades, card: fabric.card_path ? mediaDto(cfg, fabric.code, catalogCardSource(db, fabric), true) : null, structures: FABRIC_STRUCTURES.map(({ key, label }) => ({ key, label })), products: db.prepare(`SELECT DISTINCT p.id, p.slug, p.name FROM products p JOIN variants v ON v.product_id = p.id WHERE v.fabric_id = ? ORDER BY p.name`).all(fabric.id), }; } export async function catalogMedia(db: Db, cfg: AppConfig, fabric: FabricRow, sourceId: string, thumbnail = false) { const isCard = catalogCardSource(db, fabric)?.id === sourceId; const isSwatch = db.prepare("SELECT 1 FROM fabric_shades WHERE fabric_id = ? AND (official_image_path = ? OR photo_path = ?) LIMIT 1") .get(fabric.id, sourceId, sourceId); if ((!isCard && !isSwatch) || (thumbnail && !isSwatch)) return null; const source = sourceById(db, sourceId); if (!source || !(isCard ? source.mime === "application/pdf" : IMAGE_MIMES.has(source.mime))) return null; const original = catalogFile(cfg.originalsDir, source.path); if (!original) return null; const file = thumbnail ? catalogFile(cfg.derivedDir, source.thumb_path) : original; if (!file) return null; const mime = thumbnail ? "image/webp" : source.mime; try { if ((await fileTypeFromFile(file))?.mime !== mime) return null; } catch { return null; } return { file, mime }; }