Files

939 lines
37 KiB
JavaScript

import * as http from "node:http";
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { createDatabase } from "./db.js";
import {
WHACK_CARDS,
SUIT_META,
getCardByNumber,
drawCards,
drawFourRoleSpread
} from "./whack_cards.js";
import {
NANO_BANANA_MODELS,
SURFACE_PRESETS,
buildNanoBananaPrompt,
generateWithNanoBanana,
isCodexConfigured
} from "./nano_banana.js";
import {
DEFAULT_USER_BACKGROUND,
DEV_HANDOFF_TARGETS,
generatePersonalizedRandomIdea,
generateNameSuggestions,
buildDevelopmentSpecMarkdown,
executeDevelopmentHandoff
} from "./idea_generator.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT_DIR = path.resolve(__dirname, "..");
const PUBLIC_DIR = path.join(ROOT_DIR, "public");
const BUNDLED_DATA_DIR = path.join(ROOT_DIR, "data");
const ACTIVE_DATA_DIR = process.env.DATA_DIR
? path.resolve(process.env.DATA_DIR)
: BUNDLED_DATA_DIR;
const DEFAULT_DB_PATH = path.join(ACTIVE_DATA_DIR, "ideas.db");
const DEFAULT_MOCKUPS_DIR = path.join(ACTIVE_DATA_DIR, "mockups");
const MIME_TYPES = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
".ico": "image/x-icon"
};
function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload);
res.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store"
});
res.end(body);
}
function readJsonBody(req, maxBytes = 25 * 1024 * 1024) {
return new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
req.on("data", (chunk) => {
total += chunk.length;
if (total > maxBytes) {
reject(new Error("Payload too large"));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
const raw = Buffer.concat(chunks).toString("utf-8");
resolve(raw ? JSON.parse(raw) : {});
} catch (err) {
reject(new Error("Invalid JSON body"));
}
});
req.on("error", reject);
});
}
function serveStaticFile(res, filePath) {
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
sendJson(res, 404, { error: "File not found" });
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || "application/octet-stream";
res.writeHead(200, { "Content-Type": contentType });
fs.createReadStream(filePath).pipe(res);
}
function ensureSeedMockupsCopied(targetMockupsDir) {
fs.mkdirSync(targetMockupsDir, { recursive: true });
const bundledMockupsDir = path.join(BUNDLED_DATA_DIR, "mockups");
if (!fs.existsSync(bundledMockupsDir) || path.resolve(bundledMockupsDir) === path.resolve(targetMockupsDir)) {
return;
}
for (const file of fs.readdirSync(bundledMockupsDir)) {
const src = path.join(bundledMockupsDir, file);
const dest = path.join(targetMockupsDir, file);
if (!fs.existsSync(dest) && fs.statSync(src).isFile()) {
fs.copyFileSync(src, dest);
}
}
}
export function createAppServer({
dbPath = DEFAULT_DB_PATH,
mockupsDir = DEFAULT_MOCKUPS_DIR
} = {}) {
ensureSeedMockupsCopied(mockupsDir);
const store = createDatabase(dbPath);
const server = http.createServer(async (req, res) => {
try {
const parsedUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`);
const pathname = decodeURIComponent(parsedUrl.pathname);
const method = req.method.toUpperCase();
// --- HEALTHCHECK ---
if (method === "GET" && pathname === "/api/health") {
sendJson(res, 200, {
status: "ok",
service: "sparkvault",
timestamp: new Date().toISOString()
});
return;
}
// --- API ROUTES ---
// 1. GET /api/ideas
if (method === "GET" && pathname === "/api/ideas") {
const status = parsedUrl.searchParams.get("status") || "all";
const category = parsedUrl.searchParams.get("category") || "all";
const tag = parsedUrl.searchParams.get("tag") || "all";
const search = parsedUrl.searchParams.get("search") || "";
const hasMockup = parsedUrl.searchParams.get("hasMockup") || "";
const sort = parsedUrl.searchParams.get("sort") || "score";
const ideas = store.listIdeas({ status, category, tag, search, hasMockup, sort });
const allIdeas = store.listIdeas({});
const tagCounts = {};
const statusCounts = { spark: 0, exploring: 0, building: 0, shipped: 0, archived: 0 };
let totalMockups = 0;
let totalWhacks = 0;
for (const item of allIdeas) {
if (statusCounts[item.status] !== undefined) statusCounts[item.status]++;
totalMockups += item.mockups.length;
totalWhacks += item.whack_history.length;
for (const t of item.tags) {
tagCounts[t] = (tagCounts[t] || 0) + 1;
}
}
sendJson(res, 200, {
ideas,
stats: {
total: allIdeas.length,
statusCounts,
totalMockups,
totalWhacks,
tags: Object.entries(tagCounts)
.sort((a, b) => b[1] - a[1])
.map(([name, count]) => ({ name, count }))
}
});
return;
}
// 2. POST /api/ideas
if (method === "POST" && pathname === "/api/ideas") {
const body = await readJsonBody(req);
if (!body.title || !String(body.title).trim()) {
sendJson(res, 400, { error: "Idea title is required" });
return;
}
const created = store.createIdea(body);
sendJson(res, 201, { idea: created });
return;
}
// 2b. POST /api/ideas/generate-random (Personalized Random Idea Generator)
if (method === "POST" && pathname === "/api/ideas/generate-random") {
const body = await readJsonBody(req);
const userBackground =
body.userBackground ||
store.getSetting("user_background", DEFAULT_USER_BACKGROUND) ||
DEFAULT_USER_BACKGROUND;
const categoryFocus = body.category || "any";
const existingTitles = store.listIdeas({}).map((i) => i.title);
const savedKey = store.getSetting("gemini_api_key", "") || process.env.GEMINI_API_KEY || "";
const result = await generatePersonalizedRandomIdea({
userBackground,
categoryFocus,
existingTitles,
geminiApiKey: savedKey
});
const created = store.createIdea({
...result.ideaPayload,
preserveRawTitle: true
});
return sendJson(res, 201, {
idea: created,
card: result.card,
generatorUsed: result.generatorUsed
});
}
// 3. GET / PATCH / DELETE /api/ideas/:id
const ideaIdMatch = pathname.match(/^\/api\/ideas\/(\d+)$/);
if (ideaIdMatch) {
const id = Number(ideaIdMatch[1]);
if (method === "GET") {
const idea = store.getIdea(id);
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
return sendJson(res, 200, { idea });
}
if (method === "PATCH") {
const body = await readJsonBody(req);
const updated = store.updateIdea(id, body);
if (!updated) return sendJson(res, 404, { error: "Idea not found" });
return sendJson(res, 200, { idea: updated });
}
if (method === "DELETE") {
const deleted = store.deleteIdea(id);
if (!deleted) return sendJson(res, 404, { error: "Idea not found" });
return sendJson(res, 200, { deleted: true, id });
}
}
// 3b. POST /api/ideas/:id/suggest-names (AI Name & Tagline Brainstormer)
const suggestNamesMatch = pathname.match(/^\/api\/ideas\/(\d+)\/suggest-names$/);
if (method === "POST" && suggestNamesMatch) {
const id = Number(suggestNamesMatch[1]);
const idea = store.getIdea(id);
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
const userBackground =
store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND;
const savedKey = store.getSetting("gemini_api_key", "") || process.env.GEMINI_API_KEY || "";
const result = await generateNameSuggestions({
idea,
userBackground,
geminiApiKey: savedKey
});
return sendJson(res, 200, result);
}
// 3c. GET /api/ideas/:id/spec.md (Download / Fetch Compiled Development Spec)
const specMdMatch = pathname.match(/^\/api\/ideas\/(\d+)\/spec\.md$/);
if (method === "GET" && specMdMatch) {
const id = Number(specMdMatch[1]);
const idea = store.getIdea(id);
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
const targetApp = parsedUrl.searchParams.get("target") || "antigravity";
const model = parsedUrl.searchParams.get("model") || "";
const includeHomelab = parsedUrl.searchParams.get("includeHomelab") !== "false";
const userBackground =
store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND;
const host = req.headers.host || "sparkvault.vloser.us";
const proto = req.headers["x-forwarded-proto"] || "https";
const publicBaseUrl = `${proto}://${host}`;
const md = buildDevelopmentSpecMarkdown({
idea,
targetApp,
model,
includeHomelab,
publicBaseUrl,
userBackground
});
res.writeHead(200, {
"Content-Type": "text/markdown; charset=utf-8",
"Content-Disposition": `attachment; filename="spark-${idea.id}-spec.md"`
});
res.end(md);
return;
}
// 3d. POST /api/ideas/:id/dev-handoff (Submit Idea to Antigravity or Codex for Development)
const devHandoffMatch = pathname.match(/^\/api\/ideas\/(\d+)\/dev-handoff$/);
if (method === "POST" && devHandoffMatch) {
const id = Number(devHandoffMatch[1]);
const idea = store.getIdea(id);
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
const body = await readJsonBody(req);
const targetApp = body.targetApp === "codex" ? "codex" : "antigravity";
const model = String(body.model || "").trim();
const customPrompt = String(body.customPrompt || "").trim();
const extraInstructions = String(body.extraInstructions || "").trim();
const includeHomelab = body.includeHomelab !== false;
const moveStatusToBuilding = body.moveStatusToBuilding !== false;
const executeServerCodex = Boolean(body.executeServerCodex);
const userBackground =
store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND;
const host = req.headers.host || "sparkvault.vloser.us";
const proto = req.headers["x-forwarded-proto"] || "https";
const publicBaseUrl = `${proto}://${host}`;
const workspacesRootDir = path.join(path.dirname(mockupsDir), "dev_workspaces");
const handoff = await executeDevelopmentHandoff({
idea,
targetApp,
model,
customPrompt,
includeHomelab,
extraInstructions,
executeServerCodex,
publicBaseUrl,
userBackground,
workspacesRootDir
});
// Record the handoff on the Spark and optionally move to "building"
const handoffAttachment = {
id: `dev-${Date.now()}`,
type: "note",
title: `🚀 Submitted to ${handoff.targetLabel} (${handoff.model})`,
textContent: handoff.codexExecution?.output
? `Submitted to ${handoff.targetLabel} using model ${handoff.model}.\n\n--- CODEX IMPLEMENTATION PLAN ---\n${handoff.codexExecution.output}`
: `Submitted full Spark specification to ${handoff.targetLabel} using model \`${handoff.model}\`.`,
sourceUrl: handoff.specUrl,
imageUrl: "",
createdAt: handoff.submittedAt
};
const patch = {
attachments: [handoffAttachment, ...(idea.attachments || [])]
};
if (moveStatusToBuilding && (idea.status === "spark" || idea.status === "exploring")) {
patch.status = "building";
}
const updatedIdea = store.updateIdea(id, patch);
return sendJson(res, 200, {
idea: updatedIdea,
handoff,
devTargets: DEV_HANDOFF_TARGETS
});
}
// 4. GET /api/whack-cards
if (method === "GET" && pathname === "/api/whack-cards") {
const allIdeas = store.listIdeas({});
const usageByCard = {};
for (const idea of allIdeas) {
if (idea.inspired_by_card) {
usageByCard[idea.inspired_by_card] = (usageByCard[idea.inspired_by_card] || 0) + 1;
}
for (const w of idea.whack_history) {
usageByCard[w.cardNumber] = (usageByCard[w.cardNumber] || 0) + 1;
}
}
const cards = WHACK_CARDS.map((c) => ({
...c,
usageCount: usageByCard[c.number] || 0
}));
sendJson(res, 200, {
suits: SUIT_META,
cards
});
return;
}
// 5. GET /api/whack-cards/draw
if (method === "GET" && pathname === "/api/whack-cards/draw") {
const mode = parsedUrl.searchParams.get("mode") || "single";
const suit = parsedUrl.searchParams.get("suit") || "";
const status = parsedUrl.searchParams.get("status") || "";
const ideaId = parsedUrl.searchParams.get("ideaId");
if (mode === "spread") {
const spread = drawFourRoleSpread();
return sendJson(res, 200, { mode: "spread", cards: spread });
}
if (mode === "collider") {
const activeIdeas = store
.listIdeas({})
.filter((i) => i.status !== "archived" && i.status !== "shipped");
let targetIdea = null;
if (ideaId) {
targetIdea = store.getIdea(Number(ideaId));
}
if (!targetIdea && activeIdeas.length > 0) {
targetIdea = activeIdeas[Math.floor(Math.random() * activeIdeas.length)];
}
const drawn = drawCards({
suit: suit || undefined,
status: targetIdea?.status || undefined,
count: 1
})[0];
return sendJson(res, 200, {
mode: "collider",
card: drawn,
idea: targetIdea
});
}
const drawn = drawCards({
suit: suit || undefined,
status: status || undefined,
count: 1
})[0];
sendJson(res, 200, { mode: "single", card: drawn });
return;
}
// 6. POST /api/ideas/:id/whack
const whackMatch = pathname.match(/^\/api\/ideas\/(\d+)\/whack$/);
if (method === "POST" && whackMatch) {
const id = Number(whackMatch[1]);
const body = await readJsonBody(req);
if (!body.cardNumber) {
return sendJson(res, 400, { error: "cardNumber is required" });
}
const updated = store.logWhackOnIdea(id, {
cardNumber: Number(body.cardNumber),
reflection: body.reflection || "",
addChecklistItem: Boolean(body.addChecklistItem),
promoteStatus: body.promoteStatus || null
});
if (!updated) return sendJson(res, 404, { error: "Idea not found" });
return sendJson(res, 200, { idea: updated });
}
// 7. POST /api/ideas/:id/mockups/nano-banana
const nbMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/nano-banana$/);
if (method === "POST" && nbMatch) {
const id = Number(nbMatch[1]);
const idea = store.getIdea(id);
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
const body = await readJsonBody(req);
const template = body.template || "tshirt-front";
const garmentColor = body.garmentColor || "#F5E8D3";
const garmentColorName = body.garmentColorName || "Vintage Cream";
const placement = body.placement || (template === "tshirt-left-chest" ? "left-chest" : "center-chest");
const whackCard = body.whackCardNumber ? getCardByNumber(body.whackCardNumber) : null;
// Check if editing an existing mockup
let referenceImagePath = null;
let parentMockupId = body.parentMockupId || null;
if (parentMockupId) {
const parentMockup = idea.mockups.find((m) => m.id === parentMockupId);
if (parentMockup && parentMockup.imageUrl.startsWith("/mockups/")) {
const candidate = path.join(mockupsDir, path.basename(parentMockup.imageUrl));
if (fs.existsSync(candidate)) {
referenceImagePath = candidate;
}
}
}
const finalPrompt = buildNanoBananaPrompt({
rawPrompt: body.prompt,
ideaTitle: idea.title,
elevatorPitch: idea.elevator_pitch,
template,
garmentColorName,
garmentHex: garmentColor,
whackCard,
editInstruction: body.editInstruction
});
const storedKey = store.getSetting("gemini_api_key", "");
const selectedModel = body.model || store.getSetting("nano_banana_model", "codex-image-gen");
try {
const genResult = await generateWithNanoBanana({
apiKey: body.apiKey || storedKey,
model: selectedModel,
prompt: finalPrompt,
referenceImagePath,
referenceImageBase64: body.referenceImageBase64 || null,
outputDir: mockupsDir
});
const updated = store.addMockupToIdea(id, {
title: body.title || (body.editInstruction ? `Edited: ${body.editInstruction.slice(0, 40)}` : `${SURFACE_PRESETS[template]?.label || "Mockup"}`),
template,
garmentColor,
garmentColorName,
placement,
prompt: finalPrompt,
editInstruction: body.editInstruction || "",
parentMockupId,
modelUsed: genResult.modelUsed,
imageUrl: genResult.imageUrl
});
return sendJson(res, 201, {
idea: updated,
mockup: updated.mockups[0],
textNotes: genResult.textNotes
});
} catch (err) {
const status = err.code === "MISSING_API_KEY" ? 400 : 502;
return sendJson(res, status, {
error: err.message || "Nano Banana image generation failed",
code: err.code || "GENERATION_ERROR",
promptBuilt: finalPrompt
});
}
}
// 8. POST /api/ideas/:id/mockups/canvas
const canvasMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/canvas$/);
if (method === "POST" && canvasMatch) {
const id = Number(canvasMatch[1]);
const idea = store.getIdea(id);
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
const body = await readJsonBody(req);
if (!body.imageDataUrl || !String(body.imageDataUrl).startsWith("data:image/")) {
return sendJson(res, 400, { error: "Valid imageDataUrl (data:image/png;base64,...) is required" });
}
const base64Data = String(body.imageDataUrl).replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, "");
const filename = `mockup-${id}-${Date.now()}.png`;
const filePath = path.join(mockupsDir, filename);
fs.writeFileSync(filePath, Buffer.from(base64Data, "base64"));
const updated = store.addMockupToIdea(id, {
title: body.title || `${idea.title} (${body.placement === "left-chest" ? "Left-Chest" : "Mockup"})`,
template: body.template || "tshirt-front",
garmentColor: body.garmentColor || "#F5E8D3",
garmentColorName: body.garmentColorName || "Vintage Cream",
placement: body.placement || "center-chest",
prompt: body.prompt || "",
topText: body.topText || "",
bottomText: body.bottomText || "",
modelUsed: body.modelUsed || "Interactive Mockup Compositor",
imageUrl: `/mockups/${filename}`
});
return sendJson(res, 201, {
idea: updated,
mockup: updated.mockups[0]
});
}
// 9. PATCH /api/ideas/:id/mockups/:mockupId/cover & DELETE /api/ideas/:id/mockups/:mockupId
const mockupItemMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/([^/]+)(?:\/(cover))?$/);
if (mockupItemMatch) {
const id = Number(mockupItemMatch[1]);
const mockupId = mockupItemMatch[2];
const isCoverAction = mockupItemMatch[3] === "cover";
const idea = store.getIdea(id);
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
if (method === "PATCH" && isCoverAction) {
const target = idea.mockups.find((m) => m.id === mockupId);
if (!target) return sendJson(res, 404, { error: "Mockup not found" });
const updated = store.updateIdea(id, { cover_mockup_url: target.imageUrl });
return sendJson(res, 200, { idea: updated });
}
if (method === "DELETE" && !isCoverAction) {
const nextMockups = idea.mockups.filter((m) => m.id !== mockupId);
const nextCover =
idea.cover_mockup_url && nextMockups.some((m) => m.imageUrl === idea.cover_mockup_url)
? idea.cover_mockup_url
: nextMockups[0]?.imageUrl || "";
const updated = store.updateIdea(id, {
mockups: nextMockups,
cover_mockup_url: nextCover
});
return sendJson(res, 200, { idea: updated });
}
}
// 9b. POST /api/ideas/:id/attachments & DELETE/PATCH /api/ideas/:id/attachments/:attId
const attPostMatch = pathname.match(/^\/api\/ideas\/(\d+)\/attachments$/);
if (method === "POST" && attPostMatch) {
const id = Number(attPostMatch[1]);
const idea = store.getIdea(id);
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
const body = await readJsonBody(req);
let savedImageUrl = "";
// Option 1: Base64 / Data URL image (upload, drag-and-drop, or clipboard paste)
if (body.imageDataUrl && String(body.imageDataUrl).startsWith("data:image/")) {
const mimeMatch = String(body.imageDataUrl).match(/^data:image\/([a-zA-Z0-9+.-]+);base64,/);
const rawExt = mimeMatch ? mimeMatch[1].toLowerCase() : "png";
const ext = rawExt === "jpeg" ? "jpg" : rawExt === "svg+xml" ? "svg" : rawExt;
const base64Data = String(body.imageDataUrl).replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, "");
const filename = `att-${id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`;
const filePath = path.join(mockupsDir, filename);
fs.writeFileSync(filePath, Buffer.from(base64Data, "base64"));
savedImageUrl = `/mockups/${filename}`;
}
// Option 2: External image URL (fetch & cache locally so it never breaks, or store URL fallback)
else if (body.externalImageUrl && /^https?:\/\//i.test(String(body.externalImageUrl).trim())) {
const remoteUrl = String(body.externalImageUrl).trim();
try {
const resp = await fetch(remoteUrl, {
headers: { "User-Agent": "SparkVault/1.0" },
signal: AbortSignal.timeout(10000)
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const ct = (resp.headers.get("content-type") || "").toLowerCase();
const ext = ct.includes("jpeg") || ct.includes("jpg")
? "jpg"
: ct.includes("webp")
? "webp"
: ct.includes("svg")
? "svg"
: ct.includes("gif")
? "gif"
: "png";
const arrBuf = await resp.arrayBuffer();
const filename = `att-url-${id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`;
fs.writeFileSync(path.join(mockupsDir, filename), Buffer.from(arrBuf));
savedImageUrl = `/mockups/${filename}`;
} catch {
// Fallback to direct external URL if remote host blocks server-side fetch
savedImageUrl = remoteUrl;
}
}
const type = savedImageUrl ? "image" : "note";
if (type === "note" && !String(body.textContent || "").trim() && !String(body.sourceUrl || "").trim()) {
return sendJson(res, 400, { error: "Provide an image, image URL, or note text to attach" });
}
let updated = store.addAttachmentToIdea(id, {
type,
title: String(body.title || "").trim() || (type === "image" ? "Reference Image" : "External Note Clip"),
textContent: String(body.textContent || "").trim(),
sourceUrl: String(body.sourceUrl || body.externalImageUrl || "").trim(),
imageUrl: savedImageUrl,
setAsCover: Boolean(body.setAsCover),
appendToNotes: Boolean(body.appendToNotes)
});
// Also add sourceUrl to idea links if provided
if (body.sourceUrl && /^https?:\/\//i.test(String(body.sourceUrl).trim())) {
const cleanUrl = String(body.sourceUrl).trim();
if (!updated.links.some((l) => l.url === cleanUrl)) {
updated = store.updateIdea(id, {
links: [
...updated.links,
{
id: `lnk-${Date.now()}`,
title: String(body.title || cleanUrl).trim(),
url: cleanUrl
}
]
});
}
}
return sendJson(res, 201, {
idea: updated,
attachment: updated.attachments[0]
});
}
const attItemMatch = pathname.match(/^\/api\/ideas\/(\d+)\/attachments\/([^/]+)(?:\/(cover|promote-mockup))?$/);
if (attItemMatch) {
const id = Number(attItemMatch[1]);
const attId = attItemMatch[2];
const action = attItemMatch[3];
const idea = store.getIdea(id);
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
const target = (idea.attachments || []).find((a) => a.id === attId);
if (!target) return sendJson(res, 404, { error: "Attachment not found" });
if (method === "PATCH" && action === "cover") {
if (!target.imageUrl) return sendJson(res, 400, { error: "Attachment has no image" });
const updated = store.updateIdea(id, { cover_mockup_url: target.imageUrl });
return sendJson(res, 200, { idea: updated });
}
if (method === "POST" && action === "promote-mockup") {
if (!target.imageUrl) return sendJson(res, 400, { error: "Attachment has no image" });
const updated = store.addMockupToIdea(id, {
title: target.title || "Imported Reference Image",
template: "isolated-graphic",
prompt: target.textContent || target.title || "",
modelUsed: "External Attachment",
imageUrl: target.imageUrl
});
return sendJson(res, 201, { idea: updated });
}
if (method === "DELETE" && !action) {
const nextAtt = (idea.attachments || []).filter((a) => a.id !== attId);
const updated = store.updateIdea(id, { attachments: nextAtt });
return sendJson(res, 200, { idea: updated });
}
}
// 9c. POST /api/ingest (External Clipper / iOS Shortcut / Bookmarklet / Share Target)
if (method === "POST" && pathname === "/api/ingest") {
const body = await readJsonBody(req);
let targetIdeaId = body.ideaId ? Number(body.ideaId) : null;
let idea = targetIdeaId ? store.getIdea(targetIdeaId) : null;
if (!idea) {
idea = store.createIdea({
title: body.title || "External Spark Clip",
elevator_pitch: body.elevator_pitch || "",
category: body.category || "spark",
notes_markdown: body.notes_markdown || body.textContent || "",
tags: Array.isArray(body.tags) ? body.tags : ["clipped"]
});
}
let savedImageUrl = "";
if (body.imageDataUrl && String(body.imageDataUrl).startsWith("data:image/")) {
const base64Data = String(body.imageDataUrl).replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, "");
const filename = `clip-${idea.id}-${Date.now()}.png`;
fs.writeFileSync(path.join(mockupsDir, filename), Buffer.from(base64Data, "base64"));
savedImageUrl = `/mockups/${filename}`;
} else if (body.externalImageUrl && /^https?:\/\//i.test(String(body.externalImageUrl).trim())) {
savedImageUrl = String(body.externalImageUrl).trim();
}
if (savedImageUrl || body.textContent || body.sourceUrl) {
idea = store.addAttachmentToIdea(idea.id, {
type: savedImageUrl ? "image" : "note",
title: body.attachmentTitle || body.title || "External Clip",
textContent: body.textContent || "",
sourceUrl: body.sourceUrl || "",
imageUrl: savedImageUrl,
setAsCover: Boolean(savedImageUrl)
});
}
return sendJson(res, 201, { idea });
}
// 10. GET /api/review/next & POST /api/review/arena
if (method === "GET" && pathname === "/api/review/next") {
const allIdeas = store.listIdeas({});
const activeIdeas = allIdeas.filter((i) => i.status !== "archived" && i.status !== "shipped");
const triageQueue = [...activeIdeas].sort((a, b) => {
if (!a.last_reviewed_at && b.last_reviewed_at) return -1;
if (a.last_reviewed_at && !b.last_reviewed_at) return 1;
if (a.last_reviewed_at && b.last_reviewed_at) {
return a.last_reviewed_at.localeCompare(b.last_reviewed_at);
}
return a.created_at.localeCompare(b.created_at);
});
let arenaPair = [];
if (activeIdeas.length >= 2) {
const shuffled = [...activeIdeas].sort(() => Math.random() - 0.5);
arenaPair = [shuffled[0], shuffled[1]];
}
return sendJson(res, 200, {
triageQueue,
arenaPair
});
}
if (method === "POST" && pathname === "/api/review/arena") {
const body = await readJsonBody(req);
if (!body.winnerId || !body.loserId) {
return sendJson(res, 400, { error: "winnerId and loserId are required" });
}
const result = store.recordArenaMatch({
winnerId: Number(body.winnerId),
loserId: Number(body.loserId)
});
if (!result) return sendJson(res, 404, { error: "Ideas not found" });
return sendJson(res, 200, result);
}
// 11. GET / PUT /api/settings
if (pathname === "/api/settings") {
if (method === "GET") {
const savedKey = store.getSetting("gemini_api_key", "");
const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
const activeKey = savedKey || envKey;
const codexReady = isCodexConfigured();
const model = store.getSetting("nano_banana_model", "codex-image-gen");
return sendJson(res, 200, {
hasApiKey: Boolean(activeKey) || codexReady,
codexReady,
apiKeySource: codexReady
? "OpenAI Codex (OAuth)"
: savedKey
? "settings"
: envKey
? "environment"
: "none",
maskedKey: activeKey
? `${activeKey.slice(0, 4)}...${activeKey.slice(-4)}`
: codexReady
? "Authenticated via ~/.codex/auth.json"
: "",
nanoBananaModel: model,
userBackground: store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND,
availableModels: NANO_BANANA_MODELS,
surfacePresets: Object.entries(SURFACE_PRESETS).map(([id, val]) => ({
id,
label: val.label
}))
});
}
if (method === "PUT") {
const body = await readJsonBody(req);
if (body.geminiApiKey !== undefined) {
store.setSetting("gemini_api_key", String(body.geminiApiKey).trim());
}
if (body.nanoBananaModel !== undefined) {
store.setSetting("nano_banana_model", String(body.nanoBananaModel).trim());
}
if (body.userBackground !== undefined) {
store.setSetting("user_background", String(body.userBackground).trim());
}
const savedKey = store.getSetting("gemini_api_key", "");
const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
const activeKey = savedKey || envKey;
const codexReady = isCodexConfigured();
return sendJson(res, 200, {
saved: true,
hasApiKey: Boolean(activeKey) || codexReady,
codexReady,
nanoBananaModel: store.getSetting("nano_banana_model", "codex-image-gen"),
userBackground: store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND
});
}
}
// 12. GET /api/export
if (method === "GET" && pathname === "/api/export") {
const format = parsedUrl.searchParams.get("format") || "json";
const ideas = store.listIdeas({});
if (format === "markdown") {
const mdLines = [
`# SparkVault Idea Export (${new Date().toISOString().slice(0, 10)})`,
""
];
for (const idea of ideas) {
mdLines.push(`## [${idea.status.toUpperCase()}] ${idea.title} (Score: ${idea.composite_score})`);
if (idea.elevator_pitch) mdLines.push(`> ${idea.elevator_pitch}`);
mdLines.push(`- **Category**: ${idea.category} | **Tags**: ${idea.tags.map((t) => `#${t}`).join(" ") || "none"}`);
mdLines.push(`- **Ratings**: Excitement ${idea.rating_excitement}/5, Impact ${idea.rating_impact}/5, Ease ${idea.rating_ease}/5 | **Arena Wins**: ${idea.arena_wins}`);
if (idea.notes_markdown) {
mdLines.push("", idea.notes_markdown);
}
if (idea.checklist.length > 0) {
mdLines.push("", "### Checklist");
for (const item of idea.checklist) {
mdLines.push(`- [${item.done ? "x" : " "}] ${item.text}`);
}
}
if (idea.whack_history.length > 0) {
mdLines.push("", "### Creative Whack Pack Reflections");
for (const w of idea.whack_history) {
mdLines.push(`- **Card #${w.cardNumber} (${w.cardTitle})**: ${w.reflection}`);
}
}
mdLines.push("", "---", "");
}
res.writeHead(200, {
"Content-Type": "text/markdown; charset=utf-8",
"Content-Disposition": 'attachment; filename="sparkvault-ideas.md"'
});
res.end(mdLines.join("\n"));
return;
}
res.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": 'attachment; filename="sparkvault-ideas.json"'
});
res.end(JSON.stringify({ exportedAt: new Date().toISOString(), ideas }, null, 2));
return;
}
// --- STATIC ASSETS ---
if (pathname.startsWith("/mockups/")) {
const rel = pathname.slice("/mockups/".length);
const safePath = path.normalize(path.join(mockupsDir, rel));
if (!safePath.startsWith(mockupsDir)) {
return sendJson(res, 403, { error: "Forbidden" });
}
return serveStaticFile(res, safePath);
}
const relPath = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
const staticPath = path.normalize(path.join(PUBLIC_DIR, relPath));
if (!staticPath.startsWith(PUBLIC_DIR)) {
return sendJson(res, 403, { error: "Forbidden" });
}
if (fs.existsSync(staticPath) && fs.statSync(staticPath).isFile()) {
return serveStaticFile(res, staticPath);
}
// SPA Fallback
return serveStaticFile(res, path.join(PUBLIC_DIR, "index.html"));
} catch (err) {
console.error("Server error:", err);
sendJson(res, 500, { error: err.message || "Internal server error" });
}
});
return { server, store };
}
// Start server if invoked directly
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
const port = Number(process.env.PORT || 3456);
const { server } = createAppServer();
server.listen(port, () => {
console.log(`✨ SparkVault Idea Tracker running at http://localhost:${port}`);
});
}