/ scripts / sort-canvas.cjs
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'));