diff --git a/plugins/voyage/playground/voyage-playground.html b/plugins/voyage/playground/voyage-playground.html index 6ad4fcb..dcf0e33 100644 --- a/plugins/voyage/playground/voyage-playground.html +++ b/plugins/voyage/playground/voyage-playground.html @@ -964,6 +964,114 @@ playground first-run shows a complete round-trip-able artifact. }); } + // ---- v4.3 Step 13 — loadProjectDirectory pipeline ----------------- + // Validate → classify → read → parse-frontmatter → build + // ProjectArtifacts. Mirrors lib/parsers/project-discovery.mjs:15-24 + // typedef on the browser side; storage-key derived deterministically + // from basePath so per-project draft state cannot leak across projects. + async function loadProjectDirectory(files, basePath) { + if (!files || !files.length) return null; + var prefix = basePath ? basePath + '/' : ''; + var artifacts = { + basePath: basePath || '', + storageKey: '', + brief: null, + plan: null, + review: null, + progress: null, + research: [], + architecture: { overview: null, gaps: null, looseFiles: [] }, + looseFiles: [] + }; + + // Phase 1 — validate + classify (sync). + var classified = []; + for (var i = 0; i < files.length; i++) { + var f = files[i]; + var rel = f.webkitRelativePath || ''; + // Validate: path must start with basePath prefix; reject parent traversal. + if (basePath && rel.indexOf(prefix) !== 0) continue; + if (rel.indexOf('..') !== -1) continue; + var inside = rel.slice(prefix.length); + if (!inside) continue; + var parts = inside.split('/'); + var name = parts[parts.length - 1]; + var bucket = null; + if (parts.length === 1 && name === 'brief.md') bucket = 'brief'; + else if (parts.length === 1 && name === 'plan.md') bucket = 'plan'; + else if (parts.length === 1 && name === 'review.md') bucket = 'review'; + else if (parts.length === 1 && name === 'progress.json') bucket = 'progress'; + else if (parts.length === 2 && parts[0] === 'research' && /\.md$/.test(name)) bucket = 'research'; + else if (parts.length === 2 && parts[0] === 'architecture' && name === 'overview.md') bucket = 'arch_overview'; + else if (parts.length === 2 && parts[0] === 'architecture' && name === 'gaps.md') bucket = 'arch_gaps'; + else if (parts.length === 2 && parts[0] === 'architecture' && /\.md$/.test(name)) bucket = 'arch_loose'; + else bucket = 'loose'; + classified.push({ file: f, rel: inside, bucket: bucket }); + } + + // Phase 2 — read content + parse frontmatter (async). + for (var j = 0; j < classified.length; j++) { + var c = classified[j]; + var text = ''; + try { text = await c.file.text(); } catch (_) { text = ''; } + var fm = quickParseFrontmatter(text); + var entry = { path: c.rel, content: text, frontmatter: fm }; + switch (c.bucket) { + case 'brief': artifacts.brief = entry; break; + case 'plan': artifacts.plan = entry; break; + case 'review': artifacts.review = entry; break; + case 'progress': artifacts.progress = entry; break; + case 'research': artifacts.research.push(entry); break; + case 'arch_overview': artifacts.architecture.overview = entry; break; + case 'arch_gaps': artifacts.architecture.gaps = entry; break; + case 'arch_loose': artifacts.architecture.looseFiles.push(entry); break; + default: artifacts.looseFiles.push(entry); break; + } + } + artifacts.research.sort(function (a, b) { return a.path.localeCompare(b.path); }); + + // Phase 3 — deterministic storage-key from basePath (NOT URL params) + // so draft-annotation state is isolated per project. + artifacts.storageKey = 'voyage_proj_' + djb2Hash(artifacts.basePath || '(unnamed)'); + + // Phase 4 — warn if brief missing (operator may still drop a partial dir). + if (!artifacts.brief) { + announce('Advarsel: brief.md mangler i prosjektmappen.'); + } + + // Phase 5 — render dashboard if available, else log. + if (typeof renderDashboard === 'function') { + renderDashboard(artifacts); + } else { + try { + console.log('[voyage] projectArtifacts loaded', { + basePath: artifacts.basePath, + brief: !!artifacts.brief, + plan: !!artifacts.plan, + review: !!artifacts.review, + research: artifacts.research.length, + architecture: { + overview: !!artifacts.architecture.overview, + gaps: !!artifacts.architecture.gaps, + loose: artifacts.architecture.looseFiles.length + }, + progress: !!artifacts.progress, + loose: artifacts.looseFiles.length, + storageKey: artifacts.storageKey + }); + } catch (_) {} + } + return artifacts; + } + + // djb2 — small deterministic non-cryptographic hash for storage-keys. + // Not security-sensitive; only needs to map basePath → stable string. + function djb2Hash(s) { + var h = 5381; + for (var i = 0; i < s.length; i++) h = ((h << 5) + h) + s.charCodeAt(i); + return (h >>> 0).toString(36); + } + // ---- v4.3 Step 12 — drag-drop with webkitGetAsEntry --------------- // Document-level dragenter shows the overlay; drop iterates // dataTransfer.items, walks DirectoryEntry.readEntries() recursively