sort-canvas.cjs
1 #!/usr/bin/env node 2 /** 3 * sort-canvas.js — Read a DreamSong.canvas file and output its content 4 * in topological order as clean, AI-readable text. 5 * 6 * Usage: node sort-canvas.js /path/to/DreamSong.canvas 7 * 8 * Output format: 9 * [image: SpringLaunch/AURYN/AURYN.jpg] 10 * AURYN — highest priority, the catalyst... 11 * 12 * [image: SpringLaunch/PRISM/PRISM.gif] 13 * PRISM — decentralized media distribution... 14 * 15 * Edges with toEnd:"none" are undirected (media-text pairs). 16 * All other edges are directed (define reading order via topo sort). 17 */ 18 19 const fs = require('fs'); 20 21 const canvasPath = process.argv[2]; 22 if (!canvasPath) { 23 process.stderr.write('Usage: node sort-canvas.js <canvas-path>\n'); 24 process.exit(1); 25 } 26 27 let raw; 28 try { 29 raw = fs.readFileSync(canvasPath, 'utf8'); 30 } catch (e) { 31 process.stderr.write(`Cannot read ${canvasPath}: ${e.message}\n`); 32 process.exit(1); 33 } 34 35 const canvas = JSON.parse(raw); 36 const nodes = canvas.nodes || []; 37 const edges = canvas.edges || []; 38 39 // Separate directed / undirected edges 40 const directed = []; 41 const undirected = []; 42 for (const e of edges) { 43 if (e.toEnd === 'none') { 44 undirected.push(e); 45 } else { 46 directed.push(e); 47 } 48 } 49 50 // Find media-text pairs (file + text connected by undirected edge) 51 const nodesById = new Map(nodes.map(n => [n.id, n])); 52 const pairByMediaId = new Map(); 53 const pairedTextIds = new Set(); 54 55 for (const e of undirected) { 56 const from = nodesById.get(e.fromNode); 57 const to = nodesById.get(e.toNode); 58 if (!from || !to) continue; 59 60 let media = null, text = null; 61 if (from.type === 'file' && to.type === 'text') { media = from; text = to; } 62 else if (from.type === 'text' && to.type === 'file') { media = to; text = from; } 63 64 if (media && text) { 65 pairByMediaId.set(media.id, text); 66 pairedTextIds.add(text.id); 67 } 68 } 69 70 // Topo sort (Kahn's) — exclude paired text nodes 71 const sortNodes = nodes.filter(n => !pairedTextIds.has(n.id)); 72 const nodeIds = new Set(sortNodes.map(n => n.id)); 73 const adj = new Map(); 74 const inDeg = new Map(); 75 for (const id of nodeIds) { adj.set(id, []); inDeg.set(id, 0); } 76 77 for (const e of directed) { 78 if (nodeIds.has(e.fromNode) && nodeIds.has(e.toNode)) { 79 adj.get(e.fromNode).push(e.toNode); 80 inDeg.set(e.toNode, inDeg.get(e.toNode) + 1); 81 } 82 } 83 84 const queue = []; 85 for (const n of sortNodes) { 86 if (inDeg.get(n.id) === 0) queue.push(n.id); 87 } 88 const sorted = []; 89 while (queue.length > 0) { 90 const cur = queue.shift(); 91 sorted.push(cur); 92 for (const nb of (adj.get(cur) || [])) { 93 inDeg.set(nb, inDeg.get(nb) - 1); 94 if (inDeg.get(nb) === 0) queue.push(nb); 95 } 96 } 97 98 // Build output — clean text, no JSON 99 const out = []; 100 const placed = new Set(); 101 102 for (const id of sorted) { 103 if (placed.has(id)) continue; 104 const node = nodesById.get(id); 105 if (!node) continue; 106 placed.add(id); 107 108 if (node.type === 'file') { 109 out.push(`[image: ${node.file}]`); 110 // If paired with text, output it right after 111 const paired = pairByMediaId.get(id); 112 if (paired && !placed.has(paired.id)) { 113 out.push(paired.text); 114 placed.add(paired.id); 115 } 116 } else if (node.type === 'text' && node.text) { 117 out.push(node.text); 118 } 119 120 out.push(''); // blank line separator 121 } 122 123 process.stdout.write(out.join('\n'));