Files
sparkvault/server/db.js
T

602 lines
24 KiB
JavaScript

import { DatabaseSync } from "node:sqlite";
import * as fs from "node:fs";
import * as path from "node:path";
import { getCardByNumber } from "./whack_cards.js";
export const VALID_STATUSES = ["spark", "exploring", "building", "shipped", "archived"];
export const VALID_CATEGORIES = ["project", "creative", "spark", "research"];
export function parseQuickCaptureInput(rawInput, defaultCategory = "spark") {
let text = String(rawInput || "").trim();
const tags = [];
let detectedCategory = null;
let boostExcitement = false;
// Extract #tags
text = text.replace(/(?:^|\s)#([a-zA-Z0-9_-]+)/g, (_, tag) => {
const clean = tag.toLowerCase();
if (!tags.includes(clean)) tags.push(clean);
return " ";
});
// Extract @category
text = text.replace(/(?:^|\s)@([a-zA-Z0-9_-]+)/g, (_, cat) => {
const lower = cat.toLowerCase();
if (["project", "product", "app", "saas", "startup"].includes(lower)) {
detectedCategory = "project";
} else if (["creative", "art", "tshirt", "merch", "design", "writing"].includes(lower)) {
detectedCategory = "creative";
if (["tshirt", "merch"].includes(lower) && !tags.includes(lower)) {
tags.push(lower);
}
} else if (["research", "book", "learn", "inspiration"].includes(lower)) {
detectedCategory = "research";
} else if (["spark", "thought", "quick"].includes(lower)) {
detectedCategory = "spark";
}
return " ";
});
// Extract !high or !!
if (/(?:^|\s)!(?:high|hot|!)/i.test(text)) {
boostExcitement = true;
text = text.replace(/(?:^|\s)!(?:high|hot|!)/gi, " ");
}
const cleanTitle = text.replace(/\s+/g, " ").trim() || "Untitled Spark";
const category = detectedCategory || (VALID_CATEGORIES.includes(defaultCategory) ? defaultCategory : "spark");
return {
title: cleanTitle,
category,
tags,
boostExcitement
};
}
export function computeCompositeScore(row) {
const excitement = Number(row.rating_excitement ?? 3);
const impact = Number(row.rating_impact ?? 3);
const ease = Number(row.rating_ease ?? 3);
// Weighted base 0..100
const base = ((excitement * 0.42 + impact * 0.35 + ease * 0.23) / 5) * 100;
const arenaBonus = Math.min(15, Number(row.arena_wins || 0) * 3);
const whackBonus = Math.min(6, (row.whack_history?.length || 0) * 2);
const mockupBonus = (row.mockups?.length || 0) > 0 ? 4 : 0;
return Math.min(100, Math.round(base + arenaBonus + whackBonus + mockupBonus));
}
export function createDatabase(dbFilePath) {
fs.mkdirSync(path.dirname(dbFilePath), { recursive: true });
const db = new DatabaseSync(dbFilePath);
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS ideas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
elevator_pitch TEXT DEFAULT '',
category TEXT DEFAULT 'spark',
status TEXT DEFAULT 'spark',
notes_markdown TEXT DEFAULT '',
tags_json TEXT DEFAULT '[]',
checklist_json TEXT DEFAULT '[]',
pros_json TEXT DEFAULT '[]',
cons_json TEXT DEFAULT '[]',
links_json TEXT DEFAULT '[]',
whack_history_json TEXT DEFAULT '[]',
inspired_by_card INTEGER DEFAULT NULL,
mockups_json TEXT DEFAULT '[]',
cover_mockup_url TEXT DEFAULT '',
rating_excitement INTEGER DEFAULT 3,
rating_impact INTEGER DEFAULT 3,
rating_ease INTEGER DEFAULT 3,
arena_wins INTEGER DEFAULT 0,
arena_matches INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
last_reviewed_at TEXT DEFAULT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
function safeParse(jsonStr, fallback = []) {
try {
return jsonStr ? JSON.parse(jsonStr) : fallback;
} catch {
return fallback;
}
}
function hydrateIdea(row) {
if (!row) return null;
const tags = safeParse(row.tags_json, []);
const checklist = safeParse(row.checklist_json, []);
const pros = safeParse(row.pros_json, []);
const cons = safeParse(row.cons_json, []);
const links = safeParse(row.links_json, []);
const whack_history = safeParse(row.whack_history_json, []);
const mockups = safeParse(row.mockups_json, []);
const inspiredCard = row.inspired_by_card ? getCardByNumber(row.inspired_by_card) : null;
const hydrated = {
id: row.id,
title: row.title,
elevator_pitch: row.elevator_pitch || "",
category: row.category || "spark",
status: row.status || "spark",
notes_markdown: row.notes_markdown || "",
tags,
checklist,
pros,
cons,
links,
whack_history,
inspired_by_card: row.inspired_by_card || null,
inspired_card_detail: inspiredCard,
mockups,
cover_mockup_url: row.cover_mockup_url || (mockups[0]?.imageUrl || ""),
rating_excitement: Number(row.rating_excitement ?? 3),
rating_impact: Number(row.rating_impact ?? 3),
rating_ease: Number(row.rating_ease ?? 3),
arena_wins: Number(row.arena_wins ?? 0),
arena_matches: Number(row.arena_matches ?? 0),
pinned: Boolean(row.pinned),
last_reviewed_at: row.last_reviewed_at,
created_at: row.created_at,
updated_at: row.updated_at
};
hydrated.composite_score = computeCompositeScore(hydrated);
return hydrated;
}
function listIdeas({ status, category, tag, search, hasMockup, sort = "score" } = {}) {
const rows = db.prepare(`SELECT * FROM ideas`).all();
let items = rows.map(hydrateIdea);
if (status && status !== "all") {
items = items.filter((i) => i.status === status);
}
if (category && category !== "all") {
items = items.filter((i) => i.category === category);
}
if (tag && tag !== "all") {
items = items.filter((i) => i.tags.includes(tag.toLowerCase()));
}
if (hasMockup === "true" || hasMockup === true) {
items = items.filter((i) => i.mockups.length > 0 || Boolean(i.cover_mockup_url));
}
if (search && search.trim()) {
const q = search.trim().toLowerCase();
items = items.filter(
(i) =>
i.title.toLowerCase().includes(q) ||
i.elevator_pitch.toLowerCase().includes(q) ||
i.notes_markdown.toLowerCase().includes(q) ||
i.tags.some((t) => t.toLowerCase().includes(q))
);
}
items.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
if (sort === "newest") return b.created_at.localeCompare(a.created_at) || b.id - a.id;
if (sort === "updated") return b.updated_at.localeCompare(a.updated_at) || b.id - a.id;
if (sort === "arena") return b.arena_wins - a.arena_wins || b.composite_score - a.composite_score;
if (sort === "excitement") return b.rating_excitement - a.rating_excitement || b.composite_score - a.composite_score;
return b.composite_score - a.composite_score || b.updated_at.localeCompare(a.updated_at);
});
return items;
}
function getIdea(id) {
const row = db.prepare(`SELECT * FROM ideas WHERE id = ?`).get(Number(id));
return hydrateIdea(row);
}
function createIdea(payload = {}) {
const now = new Date().toISOString();
const parsed = parseQuickCaptureInput(payload.title || "Untitled Spark", payload.category);
const finalTitle = payload.preserveRawTitle ? String(payload.title).trim() : parsed.title;
const finalCategory = VALID_CATEGORIES.includes(payload.category) ? payload.category : parsed.category;
const finalStatus = VALID_STATUSES.includes(payload.status) ? payload.status : "spark";
const mergedTags = Array.from(
new Set([...(Array.isArray(payload.tags) ? payload.tags.map((t) => String(t).toLowerCase()) : []), ...parsed.tags])
);
const excitement = payload.rating_excitement ?? (parsed.boostExcitement ? 5 : 3);
const impact = payload.rating_impact ?? 3;
const ease = payload.rating_ease ?? 3;
const mockups = Array.isArray(payload.mockups) ? payload.mockups : [];
const whackHistory = Array.isArray(payload.whack_history) ? payload.whack_history : [];
const coverMockupUrl = payload.cover_mockup_url || (mockups[0]?.imageUrl ?? "");
const stmt = db.prepare(`
INSERT INTO ideas (
title, elevator_pitch, category, status, notes_markdown,
tags_json, checklist_json, pros_json, cons_json, links_json,
whack_history_json, inspired_by_card, mockups_json, cover_mockup_url,
rating_excitement, rating_impact, rating_ease,
arena_wins, arena_matches, pinned, last_reviewed_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
finalTitle,
payload.elevator_pitch || "",
finalCategory,
finalStatus,
payload.notes_markdown || "",
JSON.stringify(mergedTags),
JSON.stringify(Array.isArray(payload.checklist) ? payload.checklist : []),
JSON.stringify(Array.isArray(payload.pros) ? payload.pros : []),
JSON.stringify(Array.isArray(payload.cons) ? payload.cons : []),
JSON.stringify(Array.isArray(payload.links) ? payload.links : []),
JSON.stringify(whackHistory),
payload.inspired_by_card ? Number(payload.inspired_by_card) : null,
JSON.stringify(mockups),
coverMockupUrl,
Number(excitement),
Number(impact),
Number(ease),
Number(payload.arena_wins || 0),
Number(payload.arena_matches || 0),
payload.pinned ? 1 : 0,
payload.last_reviewed_at || null,
now,
now
);
return getIdea(result.lastInsertRowid);
}
function updateIdea(id, patch = {}) {
const existing = getIdea(id);
if (!existing) return null;
const now = new Date().toISOString();
const updated = {
title: patch.title !== undefined ? String(patch.title).trim() || existing.title : existing.title,
elevator_pitch: patch.elevator_pitch !== undefined ? String(patch.elevator_pitch) : existing.elevator_pitch,
category: patch.category !== undefined && VALID_CATEGORIES.includes(patch.category) ? patch.category : existing.category,
status: patch.status !== undefined && VALID_STATUSES.includes(patch.status) ? patch.status : existing.status,
notes_markdown: patch.notes_markdown !== undefined ? String(patch.notes_markdown) : existing.notes_markdown,
tags: Array.isArray(patch.tags) ? patch.tags.map((t) => String(t).toLowerCase().trim()).filter(Boolean) : existing.tags,
checklist: Array.isArray(patch.checklist) ? patch.checklist : existing.checklist,
pros: Array.isArray(patch.pros) ? patch.pros : existing.pros,
cons: Array.isArray(patch.cons) ? patch.cons : existing.cons,
links: Array.isArray(patch.links) ? patch.links : existing.links,
whack_history: Array.isArray(patch.whack_history) ? patch.whack_history : existing.whack_history,
inspired_by_card: patch.inspired_by_card !== undefined ? (patch.inspired_by_card ? Number(patch.inspired_by_card) : null) : existing.inspired_by_card,
mockups: Array.isArray(patch.mockups) ? patch.mockups : existing.mockups,
cover_mockup_url: patch.cover_mockup_url !== undefined ? String(patch.cover_mockup_url) : existing.cover_mockup_url,
rating_excitement: patch.rating_excitement !== undefined ? Math.max(1, Math.min(5, Number(patch.rating_excitement))) : existing.rating_excitement,
rating_impact: patch.rating_impact !== undefined ? Math.max(1, Math.min(5, Number(patch.rating_impact))) : existing.rating_impact,
rating_ease: patch.rating_ease !== undefined ? Math.max(1, Math.min(5, Number(patch.rating_ease))) : existing.rating_ease,
arena_wins: patch.arena_wins !== undefined ? Number(patch.arena_wins) : existing.arena_wins,
arena_matches: patch.arena_matches !== undefined ? Number(patch.arena_matches) : existing.arena_matches,
pinned: patch.pinned !== undefined ? (patch.pinned ? 1 : 0) : (existing.pinned ? 1 : 0),
last_reviewed_at: patch.last_reviewed_at !== undefined ? patch.last_reviewed_at : existing.last_reviewed_at,
updated_at: now
};
db.prepare(`
UPDATE ideas SET
title = ?, elevator_pitch = ?, category = ?, status = ?, notes_markdown = ?,
tags_json = ?, checklist_json = ?, pros_json = ?, cons_json = ?, links_json = ?,
whack_history_json = ?, inspired_by_card = ?, mockups_json = ?, cover_mockup_url = ?,
rating_excitement = ?, rating_impact = ?, rating_ease = ?,
arena_wins = ?, arena_matches = ?, pinned = ?, last_reviewed_at = ?, updated_at = ?
WHERE id = ?
`).run(
updated.title,
updated.elevator_pitch,
updated.category,
updated.status,
updated.notes_markdown,
JSON.stringify(updated.tags),
JSON.stringify(updated.checklist),
JSON.stringify(updated.pros),
JSON.stringify(updated.cons),
JSON.stringify(updated.links),
JSON.stringify(updated.whack_history),
updated.inspired_by_card,
JSON.stringify(updated.mockups),
updated.cover_mockup_url,
updated.rating_excitement,
updated.rating_impact,
updated.rating_ease,
updated.arena_wins,
updated.arena_matches,
updated.pinned,
updated.last_reviewed_at,
updated.updated_at,
Number(id)
);
return getIdea(id);
}
function deleteIdea(id) {
const info = db.prepare(`DELETE FROM ideas WHERE id = ?`).run(Number(id));
return info.changes > 0;
}
function logWhackOnIdea(id, { cardNumber, reflection, addChecklistItem = false, promoteStatus = null }) {
const idea = getIdea(id);
if (!idea) return null;
const card = getCardByNumber(cardNumber);
if (!card) throw new Error(`Invalid Whack Pack card number: ${cardNumber}`);
const entry = {
id: `whack-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
cardNumber: card.number,
cardTitle: card.title,
suit: card.suit,
suitLabel: card.suitLabel,
promptQuestion: card.promptQuestion,
reflection: String(reflection || "").trim(),
createdAt: new Date().toISOString()
};
const nextWhackHistory = [entry, ...idea.whack_history];
const nextChecklist = [...idea.checklist];
if (addChecklistItem && entry.reflection) {
nextChecklist.push({
id: `chk-${Date.now()}`,
text: `[Card #${card.number} ${card.title}] ${entry.reflection}`,
done: false
});
}
const patch = {
whack_history: nextWhackHistory,
checklist: nextChecklist,
last_reviewed_at: new Date().toISOString()
};
if (promoteStatus && VALID_STATUSES.includes(promoteStatus)) {
patch.status = promoteStatus;
}
return updateIdea(id, patch);
}
function addMockupToIdea(id, mockupEntry) {
const idea = getIdea(id);
if (!idea) return null;
const entry = {
id: mockupEntry.id || `mk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
title: mockupEntry.title || `${idea.title} Mockup`,
template: mockupEntry.template || "tshirt-front",
garmentColor: mockupEntry.garmentColor || "#F5E8D3",
garmentColorName: mockupEntry.garmentColorName || "Vintage Cream",
placement: mockupEntry.placement || "center-chest",
prompt: mockupEntry.prompt || "",
editInstruction: mockupEntry.editInstruction || "",
parentMockupId: mockupEntry.parentMockupId || null,
modelUsed: mockupEntry.modelUsed || "canvas-compositor",
imageUrl: mockupEntry.imageUrl,
topText: mockupEntry.topText || "",
bottomText: mockupEntry.bottomText || "",
createdAt: new Date().toISOString()
};
const nextMockups = [entry, ...idea.mockups];
return updateIdea(id, {
mockups: nextMockups,
cover_mockup_url: entry.imageUrl
});
}
function recordArenaMatch({ winnerId, loserId }) {
const winner = getIdea(winnerId);
const loser = getIdea(loserId);
if (!winner || !loser) return null;
const now = new Date().toISOString();
const updatedWinner = updateIdea(winner.id, {
arena_wins: winner.arena_wins + 1,
arena_matches: winner.arena_matches + 1,
last_reviewed_at: now
});
const updatedLoser = updateIdea(loser.id, {
arena_matches: loser.arena_matches + 1,
last_reviewed_at: now
});
return { winner: updatedWinner, loser: updatedLoser };
}
function getSetting(key, defaultValue = "") {
const row = db.prepare(`SELECT value FROM settings WHERE key = ?`).get(String(key));
return row ? row.value : defaultValue;
}
function setSetting(key, value) {
db.prepare(`
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`).run(String(key), String(value ?? ""));
}
function seedIfEmpty() {
const countRow = db.prepare(`SELECT COUNT(*) AS count FROM ideas`).get();
if (countRow && countRow.count > 0) return;
createIdea({
title: "High Altitude Espresso Club — Vintage Screenprint Tee",
elevator_pitch: "Heavyweight cream crewneck t-shirt with a 3-color retro mountain & espresso emblem (5-inch left chest + full back print).",
category: "creative",
status: "building",
rating_excitement: 5,
rating_impact: 4,
rating_ease: 5,
pinned: true,
tags: ["tshirt", "merch", "screenprint", "apparel"],
notes_markdown: "### Garment & Ink Specs\n- **Blank**: Heavyweight 6.5oz combed ring-spun tee in **Vintage Cream (`#F5E8D3`)**.\n- **Ink Palette**: Espresso Brown (`#42200E`), Burnt Orange (`#BC4921`), Warm Gold (`#EFA51C`).\n- **Placement**: Test both **Left-Chest 5-inch emblem** and **Full-Front club crest** in the Nano Banana Mockup Studio.",
checklist: [
{ id: "c1", text: "Generate initial 3-color emblem mockup in Nano Banana Studio", done: true },
{ id: "c2", text: "Compare Left-Chest 5in placement vs Full-Front on Cream tee", done: true },
{ id: "c3", text: "Test Forest Green & Vintage Black colorway variations", done: false },
{ id: "c4", text: "Export 300 DPI print-ready PNG proof", done: false }
],
pros: [
"3 spot colors keep screen setup affordable",
"Cream fabric works as the 4th highlight tone without white underbase"
],
cons: [
"Small tagline text needs minimum 10pt stroke for clean screen mesh"
],
inspired_by_card: 14,
whack_history: [
{
id: "whack-seed-1",
cardNumber: 14,
cardTitle: "Look to the Past",
suit: "explorer",
suitLabel: "Explorer",
promptQuestion: "What vintage craft, historical design, or classic wisdom can you revive or remix here?",
reflection: "Modeled the typography and badge border after 1970s Alpine mountaineering club patches and classic Italian espresso tin labels.",
createdAt: new Date(Date.now() - 86400000 * 2).toISOString()
}
],
mockups: [
{
id: "mk-seed-espresso",
title: "Vintage Cream Tee — 3-Color Mountain Espresso Crest",
template: "tshirt-front",
garmentColor: "#F5E8D3",
garmentColorName: "Vintage Cream",
placement: "center-chest",
prompt: "Heavyweight vintage cream crewneck t-shirt featuring a 3-color retro screenprint emblem for HIGH ALTITUDE ESPRESSO CLUB - EST. 1984",
modelUsed: "gemini-nano-banana-2.1",
imageUrl: "/mockups/seed-tshirt-espresso.jpg",
createdAt: new Date(Date.now() - 3600000 * 12).toISOString()
}
],
cover_mockup_url: "/mockups/seed-tshirt-espresso.jpg",
arena_wins: 3,
arena_matches: 3
});
createIdea({
title: "Take a Whack At It — Die-Cut Studio Sticker & Desk Totem",
elevator_pitch: "Die-cut vinyl laptop & sketchbook sticker inspired by Card #49 (Warrior suit) to nudge daily shipping habits.",
category: "creative",
status: "exploring",
rating_excitement: 4,
rating_impact: 4,
rating_ease: 5,
tags: ["sticker", "merch", "whack-pack", "illustration"],
notes_markdown: "Inspired by Roger von Oech's **Warrior Card #49: Take a Whack at It**.\n\nCould be printed as a 3-inch matte vinyl sticker and included as a physical reward whenever a project in the vault moves to **Shipped**.",
checklist: [
{ id: "c21", text: "Mock up die-cut sticker contour with bold typography", done: true },
{ id: "c22", text: "Try a t-shirt pocket variation in the Mockup Studio", done: false }
],
pros: ["Instant tactile reminder to stop overthinking and build"],
cons: ["Needs high-contrast border for dark laptops"],
inspired_by_card: 49,
whack_history: [
{
id: "whack-seed-2",
cardNumber: 49,
cardTitle: "Take a Whack at It",
suit: "warrior",
suitLabel: "Warrior",
promptQuestion: "How can you take a concrete whack at your idea in the next 30 minutes?",
reflection: "Turned the lightbulb-boxer metaphor directly into a collectible studio sticker.",
createdAt: new Date(Date.now() - 86400000).toISOString()
}
],
mockups: [
{
id: "mk-seed-sticker",
title: "Die-Cut Vinyl Sticker — Lightbulb Boxer",
template: "sticker",
garmentColor: "#1C1B1A",
garmentColorName: "Studio Slate",
placement: "die-cut",
prompt: "Die-cut vinyl sticker of a glowing lightbulb wearing red boxing gloves with text TAKE A WHACK AT IT",
modelUsed: "gemini-nano-banana-2.1",
imageUrl: "/mockups/seed-sticker-whack.jpg",
createdAt: new Date(Date.now() - 3600000 * 6).toISOString()
}
],
cover_mockup_url: "/mockups/seed-sticker-whack.jpg",
arena_wins: 2,
arena_matches: 3
});
createIdea({
title: "E-Ink Kitchen Recipe & Brew Telemetry Frame",
elevator_pitch: "Low-power wall-mounted e-paper display that shows active fermentation stats by day and switches to dinner recipe cards at 5pm.",
category: "project",
status: "exploring",
rating_excitement: 5,
rating_impact: 5,
rating_ease: 3,
tags: ["hardware", "e-ink", "homelab", "brewing"],
notes_markdown: "Combine a 7.5-inch tri-color E-Ink panel with ESP32.\n- Morning/Afternoon: Ambient dashboard (brew tilt hydrometer gravity + cellar temp).\n- Evening: Pulls tonight's selected recipe card with step-by-step ingredients.",
checklist: [
{ id: "c31", text: "Design 800x480 1-bit wireframe layout", done: false },
{ id: "c32", text: "Test rotary encoder for scrolling recipe steps with floury hands", done: false }
],
pros: ["Zero screen glare in the kitchen", "Battery lasts months"],
cons: ["Slow refresh rate when switching pages"],
inspired_by_card: 25,
arena_wins: 1,
arena_matches: 2
});
createIdea({
title: "National Park Night-Sky Astrophotography Hoodie Series",
elevator_pitch: "Minimalist star-chart back print paired with elevation coordinates on the left chest for Maroon Bells & Mount Sneffels.",
category: "creative",
status: "spark",
rating_excitement: 4,
rating_impact: 4,
rating_ease: 4,
tags: ["tshirt", "apparel", "colorado", "astronomy"],
notes_markdown: "Use glow-in-the-dark or metallic silver puff ink on a deep midnight navy garment (`#1B2A4A`). Great candidate to test in the **Nano Banana Mockup Studio**!"
});
createIdea({
title: "Acoustic Vinyl Listening Log & Liner Notes Archive",
elevator_pitch: "Scan album jackets on the turntable and pair them with personal listening memories and pressing notes.",
category: "spark",
status: "spark",
rating_excitement: 3,
rating_impact: 3,
rating_ease: 4,
tags: ["music", "archive", "weekend-project"],
notes_markdown: "Quick spark—could use a Whack Pack card in the **Idea Collider** to find a unique twist."
});
}
seedIfEmpty();
return {
db,
listIdeas,
getIdea,
createIdea,
updateIdea,
deleteIdea,
logWhackOnIdea,
addMockupToIdea,
recordArenaMatch,
getSetting,
setSetting
};
}