feat(voyage): implement loadProjectDirectory pipeline (validate + classify + read)

This commit is contained in:
Kjell Tore Guttormsen 2026-05-10 16:19:28 +02:00
commit 2bf766673d

View file

@ -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 --------------- // ---- v4.3 Step 12 — drag-drop with webkitGetAsEntry ---------------
// Document-level dragenter shows the overlay; drop iterates // Document-level dragenter shows the overlay; drop iterates
// dataTransfer.items, walks DirectoryEntry.readEntries() recursively // dataTransfer.items, walks DirectoryEntry.readEntries() recursively