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 }; }