feat(attachments): attach external notes, screenshots, photos, and image URLs to sparks

This commit is contained in:
airent106 committed 2026-10-29 11:55:02 -06:00
1 parent 8a25771839
commit 4c5560a5a1
6 files changed
+888 -5

No files matched your search

+164
View File
@@ -435,6 +435,170 @@ export function createAppServer({
}
}
// 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({});