diff --git a/package.json b/package.json index a8d3514..902f84e 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test": "bun test", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "generate-example": "bun run src/scripts/generate-example-data.ts" }, "dependencies": { "@google/genai": "^1.40.0", diff --git a/src/index.ts b/src/index.ts index 03a6da7..424759a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,9 @@ import outfit from './api/outfit' import pose from './api/pose' import restore from './api/restore' import setting from './api/setting' +import { prisma } from './db' +import { exampleDataService } from './service/example-data' +import { settingService } from './service/setting' import index from './ui/index.html' // Ensure data directories exist @@ -62,6 +65,22 @@ if (process.env.NODE_ENV === 'production') { // Export type for RPC export type AppType = typeof route +// Check and seed example data +const initialized = await settingService.get('system_initialized') +if (!initialized) { + try { + const hasData = (await prisma.character.count()) > 0 + if (!hasData) { + console.log('🌱 Seeding example data...') + await exampleDataService.import() + console.log('✅ Example data seeded') + } + await settingService.set('system_initialized', 'true') + } catch (error) { + console.error('Failed to seed example data:', error) + } +} + // Serve the frontend const server = Bun.serve({ port: 3000, diff --git a/src/scripts/generate-example-data.ts b/src/scripts/generate-example-data.ts new file mode 100644 index 0000000..f4cb898 --- /dev/null +++ b/src/scripts/generate-example-data.ts @@ -0,0 +1,17 @@ +import { exampleDataService } from '../service/example-data' +import { prisma } from '../db' + +async function main() { + console.log('Starting example data generation...') + try { + await exampleDataService.export() + console.log('Example data generation completed successfully.') + } catch (error) { + console.error('Error generating example data:', error) + process.exit(1) + } finally { + await prisma.$disconnect() + } +} + +main() diff --git a/src/service/example-data.test.ts b/src/service/example-data.test.ts new file mode 100644 index 0000000..a7d3d61 --- /dev/null +++ b/src/service/example-data.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, mock, beforeEach } from 'bun:test' + +// 1. Mock node:fs for mkdirSync and existsSync +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const mockExistsSync = mock((path: string) => false) +const mockMkdirSync = mock(() => undefined) + +mock.module('node:fs', () => ({ + existsSync: mockExistsSync, + mkdirSync: mockMkdirSync, +})) + +// 2. Mock Bun globals +const mockBunWrite = mock(() => Promise.resolve(0)) +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const mockBunFile = mock((path: string) => ({ + exists: mock(() => Promise.resolve(false)), + // @ts-ignore + json: mock(() => Promise.resolve([])), + arrayBuffer: mock(() => Promise.resolve(new ArrayBuffer(0))), + text: mock(() => Promise.resolve('')), +})) + +// @ts-ignore +global.Bun.write = mockBunWrite +// @ts-ignore +global.Bun.file = mockBunFile + +// Mock prisma client +const mockPrisma = { + character: { + // @ts-ignore + findMany: mock(() => Promise.resolve([])), + // @ts-ignore + create: mock(() => Promise.resolve({})), + // @ts-ignore + findUnique: mock(() => Promise.resolve({})), + }, + equipment: { + // @ts-ignore + findMany: mock(() => Promise.resolve([])), + // @ts-ignore + create: mock(() => Promise.resolve({})), + }, + pose: { + // @ts-ignore + findMany: mock(() => Promise.resolve([])), + // @ts-ignore + create: mock(() => Promise.resolve({})), + }, + expression: { + // @ts-ignore + findMany: mock(() => Promise.resolve([])), + // @ts-ignore + create: mock(() => Promise.resolve({})), + }, + generation: { + // @ts-ignore + findMany: mock(() => Promise.resolve([])), + // @ts-ignore + create: mock(() => Promise.resolve({})), + }, + // @ts-ignore + asset: { create: mock(() => Promise.resolve({ id: 'mock-asset-id' })) }, + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-explicit-any + $transaction: mock((cb: any) => cb(mockPrisma)), +} + +mock.module('../db', () => ({ + prisma: mockPrisma, +})) + +// Mock asset service +const mockAssetService = { + createAssetFromBuffer: mock(() => Promise.resolve({ id: 'new-asset-id', path: 'new-path.webp' })), +} + +mock.module('./asset', () => ({ + assetService: mockAssetService, +})) + +// Import the service to test +import { exampleDataService } from './example-data' + +describe('ExampleDataService', () => { + beforeEach(() => { + // Reset mocks before each test + mockExistsSync.mockClear() + mockMkdirSync.mockClear() + mockBunWrite.mockClear() + mockBunFile.mockClear() + + mockPrisma.character.findMany.mockClear() + mockPrisma.equipment.findMany.mockClear() + mockPrisma.pose.findMany.mockClear() + mockPrisma.expression.findMany.mockClear() + mockPrisma.generation.findMany.mockClear() + + mockPrisma.character.create.mockClear() + mockPrisma.character.findUnique.mockClear() + mockPrisma.equipment.create.mockClear() + mockPrisma.pose.create.mockClear() + mockPrisma.expression.create.mockClear() + mockPrisma.generation.create.mockClear() + + mockAssetService.createAssetFromBuffer.mockClear() + }) + + describe('export', () => { + it('should export all data types to JSON files', async () => { + // Setup mock return values for findMany + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockPrisma.character.findMany.mockResolvedValue([ + { + id: 'c1', + name: 'Char 1', + description: 'Desc 1', + image: { name: 'img1.png', type: 'image/png', path: 'img1.webp' }, + }, + ] as any) + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockPrisma.equipment.findMany.mockResolvedValue([ + { + id: 'e1', + name: 'Eq 1', + description: 'Desc E1', + category: 'Cat1', + subCategory: null, + image: { name: 'imgE1.png', type: 'image/png', path: 'imgE1.webp' }, + }, + ] as any) + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockPrisma.pose.findMany.mockResolvedValue([ + { + id: 'p1', + name: 'Pose 1', + image: { name: 'imgP1.png', type: 'image/png', path: 'imgP1.webp' }, + }, + ] as any) + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockPrisma.expression.findMany.mockResolvedValue([ + { + id: 'ex1', + name: 'Expr 1', + image: { name: 'imgEx1.png', type: 'image/png', path: 'imgEx1.webp' }, + }, + ] as any) + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockPrisma.generation.findMany.mockResolvedValue([ + { + id: 'g1', + characterId: 'c1', + userPrompt: 'prompt', + pose: 'pose', + expression: 'expr', + image: { name: 'imgG1.png', type: 'image/png', path: 'imgG1.webp' }, + equipments: [{ equipmentId: 'e1' }], + }, + ] as any) + + // Mock Bun.file to exist so copyAsset works + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockBunFile.mockImplementation((path: string) => ({ + exists: mock(() => Promise.resolve(path.startsWith('data/files'))), + json: mock(() => Promise.resolve([])), + arrayBuffer: mock(() => Promise.resolve(new ArrayBuffer(0))), + text: mock(() => Promise.resolve('')), + })) + + await exampleDataService.export() + + // Verify directories created + expect(mockMkdirSync).toHaveBeenCalledTimes(2) // example-data and example-data/assets + + // Verify data fetching + expect(mockPrisma.character.findMany).toHaveBeenCalled() + expect(mockPrisma.equipment.findMany).toHaveBeenCalled() + expect(mockPrisma.pose.findMany).toHaveBeenCalled() + expect(mockPrisma.expression.findMany).toHaveBeenCalled() + expect(mockPrisma.generation.findMany).toHaveBeenCalled() + + // Verify JSON writing + // 5 JSON files + 5 Asset copies = 10 calls to Bun.write + expect(mockBunWrite).toHaveBeenCalledTimes(10) + + const calls = mockBunWrite.mock.calls + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const writtenPaths = calls.map((c: any) => c[0]) + expect(writtenPaths).toContain('example-data/characters.json') + expect(writtenPaths).toContain('example-data/equipments.json') + expect(writtenPaths).toContain('example-data/poses.json') + expect(writtenPaths).toContain('example-data/expressions.json') + expect(writtenPaths).toContain('example-data/looks.json') + }) + }) + + describe('import', () => { + it('should import data from JSON files', async () => { + // Mock directory existence + mockExistsSync.mockReturnValue(true) + + // Mock Bun.file for reading JSONs + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockBunFile.mockImplementation((path: string) => { + const mockExists = mock(() => Promise.resolve(true)) + const mockJson = mock(() => { + if (path.includes('characters.json')) + return Promise.resolve([ + { + id: 'c1', + name: 'Char 1', + description: 'Desc 1', + asset: { name: 'img1', type: 'image/png', path: 'img1.webp' }, + }, + ]) + if (path.includes('equipments.json')) + return Promise.resolve([ + { + id: 'e1', + name: 'Eq 1', + description: 'Desc E1', + category: 'Cat1', + subCategory: null, + asset: { name: 'imgE1', type: 'image/png', path: 'imgE1.webp' }, + }, + ]) + if (path.includes('poses.json')) + return Promise.resolve([ + { + id: 'p1', + name: 'Pose 1', + asset: { name: 'imgP1', type: 'image/png', path: 'imgP1.webp' }, + }, + ]) + if (path.includes('expressions.json')) + return Promise.resolve([ + { + id: 'ex1', + name: 'Expr 1', + asset: { name: 'imgEx1', type: 'image/png', path: 'imgEx1.webp' }, + }, + ]) + if (path.includes('looks.json')) + return Promise.resolve([ + { + id: 'g1', + characterId: 'c1', + userPrompt: 'prompt', + pose: 'pose', + expression: 'expr', + asset: { name: 'imgG1', type: 'image/png', path: 'imgG1.webp' }, + equipmentIds: ['e1'], + }, + ]) + return Promise.resolve([]) + }) + const mockArrayBuffer = mock(() => Promise.resolve(new ArrayBuffer(10))) + + return { + exists: mockExists, + // @ts-ignore + json: mockJson, + arrayBuffer: mockArrayBuffer, + text: mock(() => Promise.resolve('')), + } + }) + + // Mock Prisma finds needed for validation + // @ts-ignore + mockPrisma.character.findUnique.mockResolvedValue({ id: 'c1' }) + + await exampleDataService.import() + + // Verify asset creation (5 assets) + expect(mockAssetService.createAssetFromBuffer).toHaveBeenCalledTimes(5) + + // Verify DB creation + expect(mockPrisma.character.create).toHaveBeenCalledTimes(1) + expect(mockPrisma.equipment.create).toHaveBeenCalledTimes(1) + expect(mockPrisma.pose.create).toHaveBeenCalledTimes(1) + expect(mockPrisma.expression.create).toHaveBeenCalledTimes(1) + expect(mockPrisma.generation.create).toHaveBeenCalledTimes(1) + }) + + it('should skip import if example data folder does not exist', async () => { + mockExistsSync.mockReturnValue(false) + await exampleDataService.import() + expect(mockBunFile).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/service/example-data.ts b/src/service/example-data.ts new file mode 100644 index 0000000..1595fd7 --- /dev/null +++ b/src/service/example-data.ts @@ -0,0 +1,314 @@ +import { existsSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { prisma } from '../db' +import { assetService } from './asset' + +interface AssetData { + name: string + type: string + path: string // filename in example-data/assets +} + +interface CharacterData { + id: string + name: string + description: string | null + asset: AssetData +} + +interface EquipmentData { + id: string + name: string + description: string + category: string + subCategory: string | null + asset: AssetData +} + +interface PoseData { + id: string + name: string + asset: AssetData +} + +interface ExpressionData { + id: string + name: string + asset: AssetData +} + +interface GenerationData { + id: string + characterId: string + userPrompt: string | null + pose: string | null + expression: string | null + asset: AssetData + equipmentIds: string[] +} + +export class ExampleDataService { + private baseDir = 'example-data' + private assetsDir = join(this.baseDir, 'assets') + + constructor() { + // ensure base directories exist on instantiation (or method call) + } + + private ensureDirs() { + if (!existsSync(this.baseDir)) { + mkdirSync(this.baseDir) + } + if (!existsSync(this.assetsDir)) { + mkdirSync(this.assetsDir) + } + } + + async export() { + this.ensureDirs() + + // 1. Characters + const characters = await prisma.character.findMany({ include: { image: true } }) + const characterData: CharacterData[] = [] + for (const c of characters) { + await this.copyAsset(c.image.path) + characterData.push({ + id: c.id, + name: c.name, + description: c.description, + asset: { + name: c.image.name, + type: c.image.type, + path: c.image.path, + }, + }) + } + await Bun.write(join(this.baseDir, 'characters.json'), JSON.stringify(characterData, null, 2)) + + // 2. Equipments + const equipments = await prisma.equipment.findMany({ include: { image: true } }) + const equipmentData: EquipmentData[] = [] + for (const e of equipments) { + await this.copyAsset(e.image.path) + equipmentData.push({ + id: e.id, + name: e.name, + description: e.description, + category: e.category, + subCategory: e.subCategory, + asset: { + name: e.image.name, + type: e.image.type, + path: e.image.path, + }, + }) + } + await Bun.write(join(this.baseDir, 'equipments.json'), JSON.stringify(equipmentData, null, 2)) + + // 3. Poses + const poses = await prisma.pose.findMany({ include: { image: true } }) + const poseData: PoseData[] = [] + for (const p of poses) { + await this.copyAsset(p.image.path) + poseData.push({ + id: p.id, + name: p.name, + asset: { + name: p.image.name, + type: p.image.type, + path: p.image.path, + }, + }) + } + await Bun.write(join(this.baseDir, 'poses.json'), JSON.stringify(poseData, null, 2)) + + // 4. Expressions + const expressions = await prisma.expression.findMany({ include: { image: true } }) + const expressionData: ExpressionData[] = [] + for (const e of expressions) { + await this.copyAsset(e.image.path) + expressionData.push({ + id: e.id, + name: e.name, + asset: { + name: e.image.name, + type: e.image.type, + path: e.image.path, + }, + }) + } + await Bun.write(join(this.baseDir, 'expressions.json'), JSON.stringify(expressionData, null, 2)) + + // 5. Generations (Looks) + const generations = await prisma.generation.findMany({ + include: { + image: true, + equipments: true, + }, + }) + const generationData: GenerationData[] = [] + for (const g of generations) { + await this.copyAsset(g.image.path) + generationData.push({ + id: g.id, + characterId: g.characterId, + userPrompt: g.userPrompt, + pose: g.pose, + expression: g.expression, + asset: { + name: g.image.name, + type: g.image.type, + path: g.image.path, + }, + equipmentIds: g.equipments.map((e) => e.equipmentId), + }) + } + await Bun.write(join(this.baseDir, 'looks.json'), JSON.stringify(generationData, null, 2)) + + console.log(`Exported ${characters.length} characters`) + console.log(`Exported ${equipments.length} equipments`) + console.log(`Exported ${poses.length} poses`) + console.log(`Exported ${expressions.length} expressions`) + console.log(`Exported ${generations.length} looks`) + } + + async import() { + if (!existsSync(this.baseDir)) { + console.log('No example data found, skipping import.') + return + } + + // 1. Characters + const charFile = Bun.file(join(this.baseDir, 'characters.json')) + if (await charFile.exists()) { + const data: CharacterData[] = await charFile.json() + for (const item of data) { + const asset = await this.createAssetFromExample(item.asset) + await prisma.character.create({ + data: { + id: item.id, + name: item.name, + description: item.description, + imageId: asset.id, + }, + }) + } + console.log(`Imported ${data.length} characters`) + } + + // 2. Equipments + const equipFile = Bun.file(join(this.baseDir, 'equipments.json')) + if (await equipFile.exists()) { + const data: EquipmentData[] = await equipFile.json() + for (const item of data) { + const asset = await this.createAssetFromExample(item.asset) + await prisma.equipment.create({ + data: { + id: item.id, + name: item.name, + description: item.description, + category: item.category, + subCategory: item.subCategory, + imageId: asset.id, + }, + }) + } + console.log(`Imported ${data.length} equipments`) + } + + // 3. Poses + const poseFile = Bun.file(join(this.baseDir, 'poses.json')) + if (await poseFile.exists()) { + const data: PoseData[] = await poseFile.json() + for (const item of data) { + const asset = await this.createAssetFromExample(item.asset) + await prisma.pose.create({ + data: { + id: item.id, + name: item.name, + imageId: asset.id, + }, + }) + } + console.log(`Imported ${data.length} poses`) + } + + // 4. Expressions + const exprFile = Bun.file(join(this.baseDir, 'expressions.json')) + if (await exprFile.exists()) { + const data: ExpressionData[] = await exprFile.json() + for (const item of data) { + const asset = await this.createAssetFromExample(item.asset) + await prisma.expression.create({ + data: { + id: item.id, + name: item.name, + imageId: asset.id, + }, + }) + } + console.log(`Imported ${data.length} expressions`) + } + + // 5. Generations (Looks) + const lookFile = Bun.file(join(this.baseDir, 'looks.json')) + if (await lookFile.exists()) { + const data: GenerationData[] = await lookFile.json() + for (const item of data) { + // Ensure character exists + const characterExists = await prisma.character.findUnique({ + where: { id: item.characterId }, + }) + if (!characterExists) { + console.warn(`Skipping look ${item.id}: Character ${item.characterId} not found`) + continue + } + + const asset = await this.createAssetFromExample(item.asset) + await prisma.generation.create({ + data: { + id: item.id, + characterId: item.characterId, + userPrompt: item.userPrompt, + pose: item.pose, + expression: item.expression, + imageId: asset.id, + equipments: { + create: item.equipmentIds.map((id) => ({ + equipment: { connect: { id } }, + })), + }, + }, + }) + } + console.log(`Imported ${data.length} looks`) + } + } + + private async copyAsset(filename: string) { + const src = join('data/files', filename) + const dest = join(this.assetsDir, filename) + const file = Bun.file(src) + if (await file.exists()) { + await Bun.write(dest, file) + } else { + console.warn(`Asset file not found: ${src}`) + } + } + + private async createAssetFromExample(assetData: AssetData) { + const filePath = join(this.assetsDir, assetData.path) + const file = Bun.file(filePath) + if (!(await file.exists())) { + throw new Error(`Example asset file not found: ${filePath}`) + } + const buffer = await file.arrayBuffer() + // We use createAssetFromBuffer which handles saving to data/files and DB creation + return assetService.createAssetFromBuffer(buffer, { + name: assetData.name, + type: assetData.type, + }) + } +} + +export const exampleDataService = new ExampleDataService()