#!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; const dataDir = path.resolve(process.argv[2] || 'data'); const makeAliases = !process.argv.includes('--no-aliases'); const generatedAt = new Date().toISOString(); function readJson(filePath, fallback = null) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return fallback; } } function listFiles(dir) { if (!fs.existsSync(dir)) return []; const out = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) out.push(...listFiles(full)); else if (entry.isFile()) out.push(full); } return out; } function safeSlug(input, fallback = 'item') { const raw = String(input || '').trim().toLowerCase(); const ascii = raw .replace(/[\u2018\u2019]/g, "'") .replace(/[\u201c\u201d]/g, '"') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 72); return ascii || fallback; } function publicUrlToFile(url) { if (!url || typeof url !== 'string') return null; if (!url.startsWith('/api/img/')) return null; const parts = url.split('/').filter(Boolean); if (parts.length < 4) return null; return path.join(dataDir, parts[2], parts.slice(3).join('/')); } function extFromUrl(url) { const clean = String(url || '').split('?')[0]; const ext = path.extname(clean); return ext || '.png'; } function pngSize(buffer) { if (buffer.length < 24) return null; if (buffer.toString('ascii', 1, 4) !== 'PNG') return null; return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; } function jpegSize(buffer) { if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null; let offset = 2; while (offset < buffer.length) { if (buffer[offset] !== 0xff) return null; const marker = buffer[offset + 1]; const length = buffer.readUInt16BE(offset + 2); if ([0xc0, 0xc1, 0xc2, 0xc3].includes(marker)) { return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5) }; } offset += 2 + length; } return null; } function svgSize(text) { const openTag = text.match(/]*>/i)?.[0] || ''; const width = openTag.match(/\bwidth=["']?([0-9.]+)/i)?.[1]; const height = openTag.match(/\bheight=["']?([0-9.]+)/i)?.[1]; const viewBox = openTag.match(/\bviewBox=["']\s*[-0-9.]+\s+[-0-9.]+\s+([0-9.]+)\s+([0-9.]+)/i); if (width && height) return { width: Number(width), height: Number(height) }; if (viewBox) return { width: Number(viewBox[1]), height: Number(viewBox[2]) }; return null; } function imageMeta(filePath) { if (!filePath || !fs.existsSync(filePath)) return { exists: false }; const stat = fs.statSync(filePath); const ext = path.extname(filePath).toLowerCase(); const buffer = fs.readFileSync(filePath); const size = ext === '.png' ? pngSize(buffer) : ['.jpg', '.jpeg'].includes(ext) ? jpegSize(buffer) : ext === '.svg' ? svgSize(buffer.toString('utf8')) : null; return { exists: true, sizeBytes: stat.size, mtime: stat.mtime.toISOString(), width: size?.width || null, height: size?.height || null, ratio: size?.width && size?.height ? Number((size.width / size.height).toFixed(4)) : null, }; } function packValues(packs) { if (Array.isArray(packs)) return packs; if (packs && typeof packs === 'object') return Object.values(packs); return []; } function fileRecordFromUrl({ session, section, title, id, url, kind, view, templateId, source }) { const filePath = publicUrlToFile(url); const relativePath = filePath ? path.relative(dataDir, filePath) : null; return { sessionId: session.id, sessionPrompt: session.prompt || '', section, source, id, title: title || templateId || view || id, kind: kind || null, view: view || null, templateId: templateId || null, url: url || null, path: relativePath, displayName: `${safeSlug(kind || section, 'asset')}_${safeSlug(templateId || view || title || id, 'asset')}_${safeSlug(id, 'id')}${extFromUrl(url)}`, ...imageMeta(filePath), }; } function sessionSlug(session, index) { const name = session.characterSpec?.name || session.prompt || session.id; return `${String(index + 1).padStart(2, '0')}-${safeSlug(name, session.id)}`; } function ensureAlias(asset, sessionAliasDir) { if (!asset.path || !asset.exists) return null; const section = safeSlug(asset.section || 'asset'); const aliasDir = path.join(sessionAliasDir, section); fs.mkdirSync(aliasDir, { recursive: true }); const aliasPath = path.join(aliasDir, asset.displayName); const target = path.relative(aliasDir, path.join(dataDir, asset.path)); try { const current = fs.lstatSync(aliasPath); if (current.isSymbolicLink()) fs.unlinkSync(aliasPath); else return { path: path.relative(dataDir, aliasPath), skipped: 'existing non-symlink' }; } catch { // no existing alias } fs.symlinkSync(target, aliasPath); return { path: path.relative(dataDir, aliasPath), target: asset.path }; } const sessionFiles = fs.existsSync(path.join(dataDir, 'sessions')) ? fs.readdirSync(path.join(dataDir, 'sessions')).filter(name => name.endsWith('.json')).sort() : []; const sessions = sessionFiles .map(name => ({ file: path.join(dataDir, 'sessions', name), data: readJson(path.join(dataDir, 'sessions', name), null) })) .filter(item => item.data?.id); const allAssets = []; const sessionSummaries = []; sessions.forEach(({ file, data: session }, index) => { const assets = []; const add = asset => { if (asset?.url) { assets.push(asset); allAssets.push(asset); } }; for (const upload of session.uploadedImages || []) { add(fileRecordFromUrl({ session, section: 'uploads', source: 'uploaded_image', id: upload.id, title: upload.originalFilename || upload.filename || upload.role, url: upload.url, kind: upload.role, view: upload.role, templateId: upload.role, })); } for (const image of session.images || []) { add(fileRecordFromUrl({ session, section: image.status === 'selected' ? 'selected_candidates' : 'candidates', source: image.meta?.provider || 'candidate', id: image.id, title: image.prompt, url: image.url, kind: 'candidate', view: String(image.meta?.index ?? ''), templateId: `candidate_${image.meta?.index ?? image.id}`, })); if (image.meta?.selectedUrl) { add(fileRecordFromUrl({ session, section: 'selected', source: 'selected_copy', id: `${image.id}_selected`, title: image.prompt, url: image.meta.selectedUrl, kind: 'selected', view: String(image.meta?.index ?? ''), templateId: `selected_${image.meta?.index ?? image.id}`, })); } } if (session.characterSpec?.cleanReferenceImageUrl) { add(fileRecordFromUrl({ session, section: 'anchors', source: 'clean_reference', id: `${session.id}_clean_anchor`, title: session.characterSpec.name || 'clean anchor', url: session.characterSpec.cleanReferenceImageUrl, kind: 'anchor', view: 'clean', templateId: 'l1_clean_reference', })); } for (const pack of packValues(session.packs)) { for (const asset of pack?.assets || []) { add(fileRecordFromUrl({ session, section: `pack_${asset.kind || pack.kind || 'unknown'}`, source: 'pack_asset', id: asset.id || asset.assetId, title: asset.title, url: asset.url, kind: asset.kind || pack.kind, view: asset.view, templateId: asset.templateId, })); } } sessionSummaries.push({ index: index + 1, id: session.id, file: path.relative(dataDir, file), aliasFolder: sessionSlug(session, index), prompt: session.prompt || '', inputMode: session.inputMode || null, createdAt: session.createdAt || null, characterName: session.characterSpec?.name || null, uploadedCount: session.uploadedImages?.length || 0, candidateCount: session.images?.length || 0, packCount: packValues(session.packs).length, assetCount: assets.length, }); }); const referenced = new Set(allAssets.map(asset => asset.path).filter(Boolean)); const ignored = new Set(['app.db', 'resource-index.json', 'resource-index.md']); const files = listFiles(dataDir) .map(file => path.relative(dataDir, file)) .filter(file => !file.startsWith(`named${path.sep}`)) .filter(file => !file.startsWith(`sessions${path.sep}`)) .filter(file => !ignored.has(file)); const unreferencedFiles = files.filter(file => !referenced.has(file)); let aliases = []; if (makeAliases) { const namedDir = path.join(dataDir, 'named'); fs.mkdirSync(namedDir, { recursive: true }); allAssets.forEach(asset => { const summary = sessionSummaries.find(item => item.id === asset.sessionId); const alias = ensureAlias(asset, path.join(namedDir, summary?.aliasFolder || asset.sessionId)); if (alias) aliases.push({ ...alias, sessionId: asset.sessionId, assetId: asset.id }); }); const unindexedDir = path.join(namedDir, '_unindexed'); fs.mkdirSync(unindexedDir, { recursive: true }); for (const file of unreferencedFiles) { const source = path.join(dataDir, file); const ext = path.extname(file); const basename = safeSlug(file.replace(ext, ''), 'unindexed'); const aliasPath = path.join(unindexedDir, `${basename}${ext}`); const target = path.relative(unindexedDir, source); try { const current = fs.lstatSync(aliasPath); if (current.isSymbolicLink()) fs.unlinkSync(aliasPath); else { aliases.push({ path: path.relative(dataDir, aliasPath), target: file, skipped: 'existing non-symlink' }); continue; } } catch { // no existing alias } fs.symlinkSync(target, aliasPath); aliases.push({ path: path.relative(dataDir, aliasPath), target: file, section: 'unindexed' }); } } const index = { generatedAt, dataDir, totals: { sessions: sessionSummaries.length, assets: allAssets.length, referencedFiles: referenced.size, unreferencedFiles: unreferencedFiles.length, aliases: aliases.length, }, sessions: sessionSummaries, assets: allAssets, unreferencedFiles, aliases, }; fs.writeFileSync(path.join(dataDir, 'resource-index.json'), JSON.stringify(index, null, 2) + '\n'); const md = [ '# Resource Index', '', `Generated at: ${generatedAt}`, `Data directory: ${dataDir}`, '', `- Sessions: ${index.totals.sessions}`, `- Assets in sessions: ${index.totals.assets}`, `- Referenced files: ${index.totals.referencedFiles}`, `- Unreferenced files: ${index.totals.unreferencedFiles}`, `- Named aliases: ${index.totals.aliases}`, '', '## Sessions', '', ...sessionSummaries.flatMap(session => [ `### ${session.index}. ${session.characterName || session.prompt || session.id}`, '', `- id: \`${session.id}\``, `- mode: \`${session.inputMode || 'unknown'}\``, `- alias folder: \`data/named/${session.aliasFolder}/\``, `- assets: ${session.assetCount}`, `- packs: ${session.packCount}`, '', ]), '## Missing Or Unindexed Files', '', ...(unreferencedFiles.length ? unreferencedFiles.map(file => `- \`${file}\``) : ['None']), '', ].join('\n'); fs.writeFileSync(path.join(dataDir, 'resource-index.md'), md); console.log(`Resource index written to ${path.join(dataDir, 'resource-index.json')}`); console.log(`Markdown index written to ${path.join(dataDir, 'resource-index.md')}`); if (makeAliases) console.log(`Named aliases written under ${path.join(dataDir, 'named')}`);