sdocs-dev 1.12.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/sdocs-dev.js CHANGED
@@ -31,6 +31,7 @@ const styles = require('../lib/styles');
31
31
  const io = require('../lib/io');
32
32
  const helpText = require('../lib/help-text');
33
33
  const commands = require('../lib/commands');
34
+ const cellsVerify = require('../lib/cells-verify');
34
35
  const bridgeCommands = require('../lib/bridge-commands');
35
36
  const libraryCommands = require('../lib/library-commands');
36
37
 
@@ -46,11 +47,17 @@ function buildRouter() {
46
47
  r.register('schema', { handler: () => { console.log(helpText.SCHEMA); process.exit(0); } });
47
48
  r.register('charts', { handler: () => { console.log(helpText.CHARTS_HELP); process.exit(0); } });
48
49
  r.register('diagrams', { handler: () => { console.log(helpText.DIAGRAMS_HELP); process.exit(0); } });
49
- r.register('cells', { handler: () => { console.log(helpText.CELLS_HELP); process.exit(0); } });
50
+ // `sdoc cells` prints the reference; `sdoc cells verify <file>` evaluates a
51
+ // document's tabs headlessly and prints the computed values (the handler
52
+ // calls process.exit with the 0/1/2 result code).
53
+ r.register('cells', { handler: (opts) => {
54
+ if ((opts.file || '').toLowerCase() === 'verify') return cellsVerify.cellsVerifyCommand(opts);
55
+ console.log(helpText.CELLS_HELP); process.exit(0);
56
+ } });
50
57
  r.register('comments', { handler: () => { console.log(helpText.COMMENTS_HELP); process.exit(0); } });
51
58
 
52
59
  // Setup / refresh / auto-update.
53
- r.register('setup', { handler: async (opts) => { await setup.runSetup({ force: true, yes: !!opts.yesFlag }); process.exit(0); } });
60
+ r.register('setup', { handler: async (opts) => { await setup.runSetup({ force: true, yes: !!opts.yesFlag, dryRun: !!opts.dryRunFlag }); process.exit(0); } });
54
61
  r.register('refresh', { handler: async () => { await setup.runRefresh(); process.exit(0); } });
55
62
  r.register('auto-update', { handler: (opts) => {
56
63
  // Sub-arg lives in opts.file (positional). Accept on/off/empty.
@@ -18,8 +18,8 @@ const fs = require('fs');
18
18
  const path = require('path');
19
19
  const { SETUP_CACHE } = require('./constants');
20
20
 
21
- const AGENT_BLOCK_VERSION = 10;
22
- const AGENT_BLOCK_REASON = 'Documents `sdoc bridge file.md`: a live editing session for iterating on a file with the user (browser edits autosave to disk, file edits push to the page). Notes that it parks the terminal so it should run in the background, and that the browser asks for local-process / "Apps on device" permission the user must accept. Reframes plain `sdoc file.md` as the default way to open a file for comfortable reading or quick sharing. Renames the block heading from SDocs to SmallDocs, the project\'s new name and home, and spells out that "sdoc it" / "sdoc me the plan" / "make me a smalldoc" all mean: write the .md and open it with sdoc.';
21
+ const AGENT_BLOCK_VERSION = 11;
22
+ const AGENT_BLOCK_REASON = 'Notes that cells blocks can be multi-tab: naming a block (```cells Expenses) builds a workbook of several sheets whose formulas reference each other across tabs (=Expenses!B4), and `sdoc cells verify file.md` computes the whole workbook headlessly so the agent can read the values back. The rest of the cells line is unchanged.';
23
23
 
24
24
  const AGENT_BLOCK_BODY = `## SmallDocs
25
25
 
@@ -48,7 +48,7 @@ Each command below prints its reference when run with no arguments - run it befo
48
48
  - \`sdoc charts\` - rendering inline charts (\`\`\`chart blocks)
49
49
  - \`sdoc diagrams\` - rendering inline Mermaid diagrams (\`\`\`mermaid blocks; has full-screen mode for zoom). Reach for this when drawing system or architectural diagrams (sequence, flow, component layout) - a diagram often communicates the shape of something faster than the equivalent prose.
50
50
  - \`sdoc slides\` - inline slide decks (\`\`\`slide / ~~~slide blocks; has full-screen presentation mode). Slides can be standalone exported as \`.pdf\` or \`.pptx\`. \`sdoc present file.md\` - open file directly in fullscreen presentation mode.
51
- - \`sdoc cells\` - rendering spreadsheets (\`\`\`cells blocks): CSV rows where plain values and =formulas (SUM, AVERAGE, IF, ROUND...) sit in the same grid and compute live. The reader can sort, select ranges for quick stats, edit a scratch copy fullscreen, and download the sheet as Excel (.xlsx) with the formulas still working. Reach for this when handing the user numbers they will want to check or play with - totals, budgets, projections. \`sdoc report.csv\` opens a CSV file directly as a sheet.
51
+ - \`sdoc cells\` - rendering spreadsheets (\`\`\`cells blocks): CSV rows where plain values and =formulas (SUM, AVERAGE, IF, ROUND...) sit in the same grid and compute live. The reader can sort, select ranges for quick stats, edit a scratch copy fullscreen, and download the sheet as Excel (.xlsx) with the formulas still working. Name a block (\`\`\`cells Expenses) to build a workbook of several tabs whose formulas reference each other across sheets (\`=Expenses!B4\`); run \`sdoc cells verify file.md\` to compute the whole workbook headlessly and read the values back. Reach for this when handing the user numbers they will want to check or play with - totals, budgets, projections. \`sdoc report.csv\` opens a CSV file directly as a sheet.
52
52
  - \`sdoc schema\` - styling Markdown (fonts, colors, spacing). The default styles are already comfortable to read; reach for this only when they aren't enough - client-facing polish or a bit of fun.
53
53
  - \`sdoc feedback\` - rendering interactive elements (\`\`\`form blocks) to receive structured input from the user. Run \`sdoc feedback file.md\` and the user's submission lands as a JSON line on stdout. Good for eliciting complex/subtle feedback. All standard interactive HTML elements with prefilled (but editable) content of your choosing.
54
54
  `;
@@ -22,9 +22,11 @@
22
22
  const fs = require('fs');
23
23
  const path = require('path');
24
24
 
25
- // A ```cells fenced block (captures leading boundary + body). Tilde fences and
26
- // inline-data blocks are left untouched.
27
- const CELLS_BLOCK = /(^|\n)```cells[ \t]*\n([\s\S]*?)\n```/g;
25
+ // A ```cells fenced block (captures leading boundary, optional fence name, and
26
+ // body). The name (```cells Sales) is preserved on the baked block so a tab
27
+ // loaded from a CSV keeps its identity. Tilde fences and inline-data blocks are
28
+ // left untouched.
29
+ const CELLS_BLOCK = /(^|\n)```cells[ \t]*([^\n]*)\n([\s\S]*?)\n```/g;
28
30
  const REFERENCE = /^\{\{\s*([^}]+?)\s*\}\}$/;
29
31
  // A trailing :range suffix like :B5:J32 or :B5 (a view hint; data is baked in
30
32
  // whole regardless, so we just strip it off the path for now).
@@ -34,12 +36,13 @@ function directiveValue(s) {
34
36
  return /\s|"/.test(s) ? JSON.stringify(s) : s;
35
37
  }
36
38
 
37
- function bakeBlock(boundary, ref, baseDir, readFile, preLines) {
39
+ function bakeBlock(boundary, name, ref, baseDir, readFile, preLines) {
38
40
  var range = '';
39
41
  var filePath = ref;
40
42
  var rm = ref.match(RANGE_SUFFIX);
41
43
  if (rm) { range = rm[1]; filePath = ref.slice(0, ref.length - rm[0].length); }
42
44
 
45
+ var fence = '```cells' + (name ? ' ' + name : '');
43
46
  var base = path.basename(filePath);
44
47
  // Author format: lines (e.g. `format: B=$`) sit before the reference and are
45
48
  // preserved verbatim above the baked data.
@@ -48,13 +51,13 @@ function bakeBlock(boundary, ref, baseDir, readFile, preLines) {
48
51
  try {
49
52
  csv = readFile(path.resolve(baseDir, filePath));
50
53
  } catch (e) {
51
- return boundary + '```cells\n' + head + 'sdoc-cells: error=' +
54
+ return boundary + fence + '\n' + head + 'sdoc-cells: error=' +
52
55
  directiveValue('Could not read ' + base) + '\n```';
53
56
  }
54
57
  csv = String(csv).replace(/\s+$/, '');
55
58
  var directive = 'sdoc-cells: source=' + directiveValue(base) +
56
59
  (range ? ' range=' + range : '');
57
- return boundary + '```cells\n' + head + directive + '\n' + csv + '\n```';
60
+ return boundary + fence + '\n' + head + directive + '\n' + csv + '\n```';
58
61
  }
59
62
 
60
63
  // Replace every {{file.csv}} cells block in `content` with the baked data.
@@ -62,7 +65,7 @@ function bakeBlock(boundary, ref, baseDir, readFile, preLines) {
62
65
  function transcludeCells(content, baseDir, readFile) {
63
66
  if (typeof content !== 'string' || content.indexOf('```cells') === -1) return content;
64
67
  var read = readFile || function (p) { return fs.readFileSync(p, 'utf-8'); };
65
- return content.replace(CELLS_BLOCK, function (whole, boundary, body) {
68
+ return content.replace(CELLS_BLOCK, function (whole, boundary, name, body) {
66
69
  // Peel any leading author `format:` lines, then require a sole {{ref}}.
67
70
  var lines = body.split('\n');
68
71
  var pre = [];
@@ -71,7 +74,7 @@ function transcludeCells(content, baseDir, readFile) {
71
74
  var rest = lines.slice(i).join('\n').trim();
72
75
  var m = rest.match(REFERENCE);
73
76
  if (!m) return whole; // inline data - leave alone
74
- return bakeBlock(boundary, m[1], baseDir, read, pre);
77
+ return bakeBlock(boundary, (name || '').trim(), m[1], baseDir, read, pre);
75
78
  });
76
79
  }
77
80
 
@@ -0,0 +1,165 @@
1
+ // cells-verify.js - headless evaluation of a document's ```cells tabs.
2
+ //
3
+ // `sdoc cells verify <file.md>` parses every ```cells block, names the tabs
4
+ // exactly as the browser does, recalculates them together as one workbook
5
+ // (so cross-tab references like Sales!A1 resolve), and prints the COMPUTED
6
+ // values. It is the agent's feedback loop: write formulas, run this, read the
7
+ // numbers back - no browser, same engine the page runs (cli/shared/), so what
8
+ // it prints is what ships.
9
+ //
10
+ // Output:
11
+ // default CSV per tab, each under a "# sheet: <name>" banner. Human view;
12
+ // a data row could itself start with "# sheet:", so this form is
13
+ // documented as not machine-round-trippable - agents use --json.
14
+ // --json { ok, sheets:[{name, values:[[...]]}], errors:[{sheet,cell,code}] }
15
+ // --sheet N only that tab (case-insensitive). Absent name -> exit 2.
16
+ //
17
+ // Exit: 0 = no cell errors in the emitted tabs; 1 = at least one cell error
18
+ // (#REF!/#CIRC!/#DIV0!/...); 2 = bad usage (no file, --sheet names no tab).
19
+
20
+ const path = require('path');
21
+ const io = require('./io');
22
+ const CELLS = require('../shared/sdocs-cells.js');
23
+ const FX = require('../shared/sdocs-cells-formula.js');
24
+
25
+ // Scan ```cells (or ~~~cells) fenced blocks, capturing the fence info string
26
+ // as the tab name and the block body. Approximate vs marked (no nested-fence
27
+ // awareness) but matches how agents author docs. The closing fence must be the
28
+ // same run of fence characters (backreference) at a line start.
29
+ function scanCellsBlocks(md) {
30
+ var re = /(?:^|\n)(```+|~~~+)cells[ \t]*([^\n]*)\n([\s\S]*?)\n\1[ \t]*(?=\n|$)/g;
31
+ var blocks = [];
32
+ var m;
33
+ while ((m = re.exec(md))) {
34
+ blocks.push({ name: (m[2] || '').trim().replace(/"/g, ''), body: m[3] });
35
+ }
36
+ return blocks;
37
+ }
38
+
39
+ // Parse + name every block into a workbook, mirroring the renderer: a fence
40
+ // name wins, then an explicit `name=` directive, then auto Sheet1/Sheet2... by
41
+ // order among the blocks that became real grids. Non-grid blocks (empty /
42
+ // unresolved) are skipped; a parse error is recorded so the exit code reflects it.
43
+ function buildWorkbook(md) {
44
+ var blocks = scanCellsBlocks(md);
45
+ var sheets = []; // { name, model }
46
+ var parseErrors = [];
47
+ var autoIdx = 0;
48
+ for (var i = 0; i < blocks.length; i++) {
49
+ var model;
50
+ try { model = CELLS.parseCells(blocks[i].body); }
51
+ catch (e) { parseErrors.push((e && e.message) || 'parse error'); continue; }
52
+ if (model.error) { parseErrors.push(model.error); continue; }
53
+ if (model.unresolved || model.empty) continue;
54
+ var name = blocks[i].name || (model.name && String(model.name).trim()) || ('Sheet' + (++autoIdx));
55
+ sheets.push({ name: name, model: model });
56
+ }
57
+ return { sheets: sheets, parseErrors: parseErrors };
58
+ }
59
+
60
+ // Render one sheet's computed grid to a 2D array of display strings: a formula
61
+ // cell shows its result (number, or the error code), every other cell shows its
62
+ // literal text verbatim. fxGrid is this sheet's recalcWorkbook results.
63
+ function valuesFor(model, fxGrid) {
64
+ var out = [];
65
+ for (var r = 0; r < model.cells.length; r++) {
66
+ var line = model.cells[r];
67
+ var row = [];
68
+ for (var c = 0; c < line.length; c++) {
69
+ var cell = line[c];
70
+ if (FX.isFormula(cell.raw)) {
71
+ var fx = (fxGrid[r] && fxGrid[r][c]) || { kind: 'empty' };
72
+ if (fx.kind === 'number') row.push(String(fx.value));
73
+ else if (fx.kind === 'error') row.push(fx.code);
74
+ else if (fx.kind === 'text') row.push(String(fx.value));
75
+ else row.push('');
76
+ } else {
77
+ row.push(cell.raw);
78
+ }
79
+ }
80
+ out.push(row);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ // Collect cell errors across a sheet for the exit code + --json errors list.
86
+ function errorsFor(name, model, fxGrid) {
87
+ var errs = [];
88
+ for (var r = 0; r < fxGrid.length; r++) {
89
+ var line = fxGrid[r] || [];
90
+ for (var c = 0; c < line.length; c++) {
91
+ var fx = line[c];
92
+ if (fx && fx.kind === 'error') {
93
+ errs.push({ sheet: name, cell: CELLS.colName(c) + (r + 1), code: fx.code });
94
+ }
95
+ }
96
+ }
97
+ return errs;
98
+ }
99
+
100
+ async function cellsVerifyCommand(opts) {
101
+ var file = opts.extra; // `sdoc cells verify <file>` -> file lands in extra
102
+ if (!file) {
103
+ console.error('sdoc: cells verify needs a file - usage: sdoc cells verify <file.md> [--json] [--sheet <name>]');
104
+ process.exit(2);
105
+ }
106
+
107
+ var content = await io.readContent(file); // same baking the browser receives
108
+ if (content == null) {
109
+ console.error('sdoc: nothing to read from ' + file);
110
+ process.exit(2);
111
+ }
112
+
113
+ var wb = buildWorkbook(content);
114
+ var fxGrids = FX.recalcWorkbook(wb.sheets.map(function (s) {
115
+ return { name: s.name, model: s.model };
116
+ }));
117
+
118
+ // Build the per-sheet view once; --sheet filters it afterwards.
119
+ var rendered = wb.sheets.map(function (s, i) {
120
+ return {
121
+ name: s.name,
122
+ values: valuesFor(s.model, fxGrids[i] || []),
123
+ errors: errorsFor(s.name, s.model, fxGrids[i] || []),
124
+ };
125
+ });
126
+
127
+ if (opts.sheetName) {
128
+ var want = String(opts.sheetName).toLowerCase();
129
+ var only = rendered.filter(function (r) { return r.name.toLowerCase() === want; });
130
+ if (!only.length) {
131
+ console.error('sdoc: no tab named "' + opts.sheetName + '" in ' + file);
132
+ process.exit(2);
133
+ }
134
+ rendered = only;
135
+ }
136
+
137
+ var allErrors = [];
138
+ rendered.forEach(function (r) { allErrors = allErrors.concat(r.errors); });
139
+ var ok = allErrors.length === 0 && wb.parseErrors.length === 0;
140
+
141
+ if (opts.jsonFlag) {
142
+ process.stdout.write(JSON.stringify({
143
+ ok: ok,
144
+ sheets: rendered.map(function (r) { return { name: r.name, values: r.values }; }),
145
+ errors: allErrors,
146
+ parseErrors: wb.parseErrors,
147
+ }, null, 2) + '\n');
148
+ } else {
149
+ if (!rendered.length) console.error('sdoc: no cells tabs found in ' + file);
150
+ var chunks = rendered.map(function (r) {
151
+ return '# sheet: ' + r.name + '\n' + CELLS.serializeCsv(r.values);
152
+ });
153
+ if (chunks.length) process.stdout.write(chunks.join('\n') + '\n');
154
+ wb.parseErrors.forEach(function (e) { console.error('sdoc: cells parse error - ' + e); });
155
+ }
156
+
157
+ process.exit(ok ? 0 : 1);
158
+ }
159
+
160
+ module.exports = {
161
+ cellsVerifyCommand: cellsVerifyCommand,
162
+ scanCellsBlocks: scanCellsBlocks,
163
+ buildWorkbook: buildWorkbook,
164
+ valuesFor: valuesFor,
165
+ };
package/lib/help-text.js CHANGED
@@ -1011,6 +1011,59 @@ FORMULAS
1011
1011
  name), #REF! (bad range), #VALUE! (e.g. text in arithmetic), #CIRC! (a
1012
1012
  circular reference). Formulas recalculate live while you edit.
1013
1013
 
1014
+ MULTIPLE TABS (SHEETS)
1015
+ A document can hold several tabs that work together. Each tab is its own
1016
+ \`\`\`cells block; name it in the fence, right after the word cells:
1017
+
1018
+ \`\`\`cells Expenses
1019
+ Category,Jan,Feb,Mar
1020
+ Rent,1200,1200,1200
1021
+ Food,350,400,380
1022
+ Total,=SUM(B2:B3),=SUM(C2:C3),=SUM(D2:D3)
1023
+ \`\`\`
1024
+
1025
+ \`\`\`cells Summary
1026
+ Metric,Value
1027
+ Grand Total,=SUM(Expenses!B4:D4)
1028
+ \`\`\`
1029
+
1030
+ A formula reads another tab with a Sheet!A1 reference: =Expenses!B4 reads
1031
+ cell B4 of the Expenses tab, =SUM(Expenses!B4:D4) sums a range on it. A
1032
+ bare reference (=B4) always means the current tab. Names are
1033
+ case-insensitive; an unnamed block is Sheet1, Sheet2... by order; if two
1034
+ tabs share a name the first one owns it for references.
1035
+
1036
+ Qualified ranges stay within one tab (Expenses!A1:C1). A range that names
1037
+ two different tabs, a reference to a tab that does not exist, and a cycle
1038
+ that runs between tabs are all reported as errors (#REF! / #CIRC!), never a
1039
+ wrong number or a hang.
1040
+
1041
+ Two ways to view the tabs:
1042
+ - Stacked (default): each tab renders where its block sits, named by a
1043
+ small caption - good for a document with prose between the tabs.
1044
+ - One pane (opt in): expanding ANY tab opens the whole workbook in one
1045
+ window with a tab strip to switch sheets. For an in-document tabbed
1046
+ pane, add cells-tabs: tabbed to the document's front matter and the
1047
+ tabs collapse into a single widget (a tab strip + one grid at a time),
1048
+ placed where the first tab sat.
1049
+
1050
+ VERIFYING (for agents)
1051
+ Check the computed values without a browser:
1052
+
1053
+ sdoc cells verify <file.md> # values of every tab as CSV
1054
+ sdoc cells verify <file.md> --json # structured, lossless
1055
+ sdoc cells verify <file.md> --sheet Summary # one tab only
1056
+
1057
+ It runs the SAME engine the page does, so the numbers it prints are the
1058
+ numbers the document will show. Formula cells print their result; an
1059
+ errored cell prints its code (#REF! etc.) in place. The exit code is 0 when
1060
+ every tab computes cleanly and 1 when any cell errors, so an agent can gate
1061
+ on it. Write formulas, run verify, read the values back, fix, repeat.
1062
+
1063
+ The default output banners each tab with "# sheet: <name>". A data row
1064
+ could itself start with that text, so for machine parsing use --json (its
1065
+ per-tab values array is unambiguous).
1066
+
1014
1067
  SORTING
1015
1068
  Hover a column letter: an arrow appears on its right showing what a click
1016
1069
  will do (up = sort ascending, down = descending, x = clear the sort). The
package/lib/io.js CHANGED
@@ -43,6 +43,8 @@ function parseArgs(argv) {
43
43
  let tagsFlag = false;
44
44
  let helpFlag = false;
45
45
  let yesFlag = false;
46
+ let dryRunFlag = false;
47
+ let sheetName = null;
46
48
  const addTags = [];
47
49
 
48
50
  for (let i = 0; i < args.length; i++) {
@@ -93,6 +95,8 @@ function parseArgs(argv) {
93
95
  if (arg === '--log-file') { logFile = args[++i]; continue; }
94
96
  if (arg === '--tags') { tagsFlag = true; continue; }
95
97
  if (arg === '--yes' || arg === '-y') { yesFlag = true; continue; }
98
+ if (arg === '--dry-run') { dryRunFlag = true; continue; }
99
+ if (arg === '--sheet') { sheetName = args[++i]; continue; }
96
100
 
97
101
  if (!subcommand && SUBCOMMANDS.has(arg)) {
98
102
  subcommand = arg;
@@ -115,7 +119,7 @@ function parseArgs(argv) {
115
119
  resetFlag, shortFlag, jsonFlag, auditFlag, waitFlag,
116
120
  messageText, connectTimeoutS, idleTimeoutS, reconnectGraceMs,
117
121
  keepOpenFlag, logFile,
118
- tagsFlag, helpFlag, yesFlag,
122
+ tagsFlag, helpFlag, yesFlag, dryRunFlag, sheetName,
119
123
  addTags,
120
124
  };
121
125
  }
@@ -22,8 +22,13 @@ const MAX_EXCERPT = 400;
22
22
 
23
23
  function deriveTitle(meta, body) {
24
24
  if (meta && typeof meta.title === 'string' && meta.title.trim()) return meta.title.trim();
25
- const m = (body || '').match(/^#\s+(.+?)\s*$/m);
26
- if (m) return m[1];
25
+ const lines = (body || '').split('\n');
26
+ for (let i = 0; i < lines.length; i++) {
27
+ if (!lines[i].trim()) continue;
28
+ const m = lines[i].match(/^#\s+(.+?)\s*$/);
29
+ if (m) return m[1];
30
+ break;
31
+ }
27
32
  return null;
28
33
  }
29
34
 
package/lib/setup.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // versions ship.
4
4
  //
5
5
  // runSetup: first-run interactive flow. Detects agent configs, writes
6
- // the block into the ones the user agrees to.
6
+ // the block into the ones the user agrees to. Pass dryRun:true to
7
+ // preview what would be written without touching any file or state.
7
8
  // runRefresh: unconditional refresh of every agent file that already
8
9
  // has a recognised block.
9
10
  // runAutoUpdateSubcommand: flips state.autoInstallUpdates.
@@ -17,6 +18,7 @@ const readline = require('readline');
17
18
  const {
18
19
  AGENT_BLOCK_VERSION,
19
20
  AGENT_BLOCK_BODY,
21
+ formatAgentBlock,
20
22
  compareVersions,
21
23
  readSetupState,
22
24
  writeSetupState,
@@ -69,7 +71,7 @@ async function askAutoRefreshConsent() {
69
71
  return !a || a === 'y' || a === 'yes';
70
72
  }
71
73
 
72
- async function runSetup({ force = false, yes = false } = {}) {
74
+ async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
73
75
  if (!force) {
74
76
  if (!process.stdout.isTTY || !process.stdin.isTTY) return;
75
77
  if (process.env.CI || process.env.SDOCS_NO_SETUP) return;
@@ -84,6 +86,23 @@ async function runSetup({ force = false, yes = false } = {}) {
84
86
  // any number of times and the result is a current block in every detected
85
87
  // config, or a clean "nothing to do".
86
88
  if (yes) {
89
+ // ── --dry-run (preview only) path ─────────────────────────────────────
90
+ // Prints each file path and the block that would be written, then exits
91
+ // without touching any file or mutating setup state. Must return before
92
+ // the write steps below.
93
+ if (dryRun) {
94
+ const toWrite = detectAgents().filter(t => !fileHasBlock(t.filePath));
95
+ if (toWrite.length === 0) {
96
+ console.log('All SDocs agent blocks already at current version. Nothing to do.');
97
+ return;
98
+ }
99
+ for (const t of toWrite) {
100
+ console.log(`--- ${t.filePath} ---`);
101
+ console.log(formatAgentBlock(AGENT_BLOCK_VERSION, AGENT_BLOCK_BODY));
102
+ }
103
+ return;
104
+ }
105
+
87
106
  // Step 1: refresh any existing outdated / legacy blocks. This is what
88
107
  // closes the gap where re-running setup --yes used to silently no-op
89
108
  // on a stale install.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdocs-dev",
3
- "version": "1.12.0",
3
+ "version": "1.13.1",
4
4
  "description": "Open, share, and style markdown files from the terminal",
5
5
  "main": "bin/sdocs-dev.js",
6
6
  "bin": {
@@ -0,0 +1,485 @@
1
+ // sdocs-cells-formula.js - a small spreadsheet formula engine.
2
+ //
3
+ // Pure, dependency-free, shared between the browser (window.SDocCellsFormula)
4
+ // and Node tests (module.exports) via the UMD pattern used by the other cells
5
+ // modules. It evaluates a single cell's formula string (anything whose raw
6
+ // text starts with "=") against a grid of other cells, and recalc() resolves
7
+ // a whole model at once with cycle detection.
8
+ //
9
+ // Supported, deliberately small but useful:
10
+ // numbers 12, 3.5, -2, 1e3
11
+ // operators + - * / ^ % and unary minus, with parentheses
12
+ // cell references A1, B12 (column letters + 1-based row, like the UI)
13
+ // ranges A1:B3 (only inside a function's arguments)
14
+ // functions SUM AVERAGE/AVG MIN MAX COUNT COUNTA PRODUCT
15
+ // ROUND ABS IF
16
+ // comparisons = <> < <= > >= (mainly for IF)
17
+ //
18
+ // Values: a referenced empty cell is 0 in arithmetic; referenced text is an
19
+ // error (#VALUE!) in arithmetic but is counted by COUNTA and ignored by SUM.
20
+ // Anything that goes wrong yields an error string (#VALUE!, #DIV/0!, #NAME?,
21
+ // #REF!, #CIRC!) which the renderer shows in the cell, just like a real sheet.
22
+ (function (exports) {
23
+ 'use strict';
24
+
25
+ // Column letters -> 0-based index (mirror sdocs-cells.js so refs line up).
26
+ function colIndex(letters) {
27
+ var n = 0;
28
+ for (var i = 0; i < letters.length; i++) {
29
+ n = n * 26 + (letters.charCodeAt(i) - 64); // 'A' = 65 -> 1
30
+ }
31
+ return n - 1;
32
+ }
33
+
34
+ // 0-based index -> column letters (inverse of colIndex): 0 -> A, 26 -> AA.
35
+ function colName(index) {
36
+ var name = '';
37
+ var n = index + 1;
38
+ while (n > 0) {
39
+ var rem = (n - 1) % 26;
40
+ name = String.fromCharCode(65 + rem) + name;
41
+ n = Math.floor((n - 1) / 26);
42
+ }
43
+ return name;
44
+ }
45
+
46
+ function isFormula(raw) {
47
+ return typeof raw === 'string' && raw.charAt(0) === '=' && raw.length > 1;
48
+ }
49
+
50
+ function mkErr(code) { var e = new Error(code); e.isFormulaError = true; e.code = code; return e; }
51
+
52
+ // ── Tokenizer ────────────────────────────────────────────
53
+ function tokenize(src) {
54
+ var toks = [];
55
+ var i = 0, n = src.length;
56
+ function isDigit(c) { return c >= '0' && c <= '9'; }
57
+ function isAlpha(c) { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); }
58
+ while (i < n) {
59
+ var c = src[i];
60
+ if (c === ' ' || c === '\t') { i++; continue; }
61
+ if (isDigit(c) || (c === '.' && isDigit(src[i + 1]))) {
62
+ var num = '';
63
+ while (i < n && (isDigit(src[i]) || src[i] === '.')) num += src[i++];
64
+ if (i < n && (src[i] === 'e' || src[i] === 'E')) {
65
+ num += src[i++];
66
+ if (src[i] === '+' || src[i] === '-') num += src[i++];
67
+ while (i < n && isDigit(src[i])) num += src[i++];
68
+ }
69
+ toks.push({ t: 'num', v: parseFloat(num) });
70
+ continue;
71
+ }
72
+ if (isAlpha(c)) {
73
+ var word = '';
74
+ while (i < n && (isAlpha(src[i]) || isDigit(src[i]))) word += src[i++];
75
+ // A sheet-qualified reference: Sheet!A1. The word before '!' is the
76
+ // sheet name (any letters/digits run - Sales, Summary, Sheet1, Q1);
77
+ // the part after '!' must be a plain cell ref. Emitted as ONE ref
78
+ // token carrying `sheet`, so the parser's range branch (the next ':'
79
+ // check) keeps the sheet on the qualified endpoint.
80
+ if (src[i] === '!') {
81
+ var sheet = word;
82
+ i++; // consume '!'
83
+ var cellWord = '';
84
+ while (i < n && (isAlpha(src[i]) || isDigit(src[i]))) cellWord += src[i++];
85
+ var cm = /^([A-Za-z]+)([0-9]+)$/.exec(cellWord);
86
+ if (!cm) throw mkErr('#REF!');
87
+ toks.push({ t: 'ref', sheet: sheet, col: colIndex(cm[1].toUpperCase()), row: parseInt(cm[2], 10) - 1 });
88
+ continue;
89
+ }
90
+ var m = /^([A-Za-z]+)([0-9]+)$/.exec(word);
91
+ if (m && src[i] !== '(') {
92
+ toks.push({ t: 'ref', col: colIndex(m[1].toUpperCase()), row: parseInt(m[2], 10) - 1 });
93
+ } else {
94
+ toks.push({ t: 'name', v: word.toUpperCase() });
95
+ }
96
+ continue;
97
+ }
98
+ if (c === '<' || c === '>') {
99
+ var op = c; i++;
100
+ if (src[i] === '=' || (c === '<' && src[i] === '>')) op += src[i++];
101
+ toks.push({ t: 'op', v: op });
102
+ continue;
103
+ }
104
+ if ('+-*/^%(),:='.indexOf(c) !== -1) { toks.push({ t: 'op', v: c }); i++; continue; }
105
+ // A literal #REF! (left behind by shiftFormula when a reference was
106
+ // pushed off the sheet) evaluates to that error.
107
+ if (c === '#') throw mkErr(src.slice(i, i + 5).toUpperCase() === '#REF!' ? '#REF!' : '#NAME?');
108
+ throw mkErr('#NAME?');
109
+ }
110
+ return toks;
111
+ }
112
+
113
+ // ── Recursive-descent parser -> AST ──────────────────────
114
+ function parse(toks) {
115
+ var pos = 0;
116
+ function peek() { return toks[pos]; }
117
+ function next() { return toks[pos++]; }
118
+ function expectOp(v) { var t = next(); if (!t || t.t !== 'op' || t.v !== v) throw mkErr('#VALUE!'); }
119
+
120
+ function parseCompare() {
121
+ var left = parseAdd();
122
+ var t = peek();
123
+ if (t && t.t === 'op' && ['=', '<>', '<', '<=', '>', '>='].indexOf(t.v) !== -1) {
124
+ next();
125
+ return { k: 'cmp', op: t.v, a: left, b: parseAdd() };
126
+ }
127
+ return left;
128
+ }
129
+ function parseAdd() {
130
+ var node = parseMul();
131
+ while (peek() && peek().t === 'op' && (peek().v === '+' || peek().v === '-')) {
132
+ var op = next().v; node = { k: 'bin', op: op, a: node, b: parseMul() };
133
+ }
134
+ return node;
135
+ }
136
+ function parseMul() {
137
+ var node = parsePow();
138
+ while (peek() && peek().t === 'op' && (peek().v === '*' || peek().v === '/')) {
139
+ var op = next().v; node = { k: 'bin', op: op, a: node, b: parsePow() };
140
+ }
141
+ return node;
142
+ }
143
+ function parsePow() {
144
+ var node = parseUnary();
145
+ if (peek() && peek().t === 'op' && peek().v === '^') {
146
+ next(); return { k: 'bin', op: '^', a: node, b: parsePow() };
147
+ }
148
+ return node;
149
+ }
150
+ function parseUnary() {
151
+ var t = peek();
152
+ if (t && t.t === 'op' && (t.v === '-' || t.v === '+')) {
153
+ next(); return { k: 'unary', op: t.v, a: parseUnary() };
154
+ }
155
+ return parsePostfix();
156
+ }
157
+ function parsePostfix() {
158
+ var node = parsePrimary();
159
+ if (peek() && peek().t === 'op' && peek().v === '%') { next(); node = { k: 'percent', a: node }; }
160
+ return node;
161
+ }
162
+ function parsePrimary() {
163
+ var t = next();
164
+ if (!t) throw mkErr('#VALUE!');
165
+ if (t.t === 'num') return { k: 'num', v: t.v };
166
+ if (t.t === 'ref') {
167
+ if (peek() && peek().t === 'op' && peek().v === ':') {
168
+ next(); var end = next();
169
+ if (!end || end.t !== 'ref') throw mkErr('#REF!');
170
+ // A range stays within one sheet. A qualified start (Sales!A1:B3)
171
+ // applies its sheet to both ends; a range that names two different
172
+ // sheets (Sheet1!A1:Sheet2!B2) has no coherent rectangle -> #REF!.
173
+ var startKey = (t.sheet || '').toLowerCase();
174
+ var endKey = (end.sheet || '').toLowerCase();
175
+ if (end.sheet != null && endKey !== startKey) throw mkErr('#REF!');
176
+ return { k: 'range', sheet: t.sheet, c0: t.col, r0: t.row, c1: end.col, r1: end.row };
177
+ }
178
+ return { k: 'ref', col: t.col, row: t.row, sheet: t.sheet };
179
+ }
180
+ if (t.t === 'name') {
181
+ if (peek() && peek().t === 'op' && peek().v === '(') {
182
+ next();
183
+ var args = [];
184
+ if (!(peek() && peek().t === 'op' && peek().v === ')')) {
185
+ args.push(parseCompare());
186
+ while (peek() && peek().t === 'op' && peek().v === ',') { next(); args.push(parseCompare()); }
187
+ }
188
+ expectOp(')');
189
+ return { k: 'call', name: t.v, args: args };
190
+ }
191
+ if (t.v === 'TRUE') return { k: 'num', v: 1 };
192
+ if (t.v === 'FALSE') return { k: 'num', v: 0 };
193
+ throw mkErr('#NAME?');
194
+ }
195
+ if (t.t === 'op' && t.v === '(') { var e = parseCompare(); expectOp(')'); return e; }
196
+ throw mkErr('#VALUE!');
197
+ }
198
+
199
+ var ast = parseCompare();
200
+ if (pos !== toks.length) throw mkErr('#VALUE!');
201
+ return ast;
202
+ }
203
+
204
+ // ── Evaluator ────────────────────────────────────────────
205
+ function evalAst(node, ctx) {
206
+ switch (node.k) {
207
+ case 'num': return node.v;
208
+ case 'unary': { var v = num(evalAst(node.a, ctx)); return node.op === '-' ? -v : v; }
209
+ case 'percent': return num(evalAst(node.a, ctx)) / 100;
210
+ case 'bin': {
211
+ var a = num(evalAst(node.a, ctx)), b = num(evalAst(node.b, ctx));
212
+ switch (node.op) {
213
+ case '+': return a + b;
214
+ case '-': return a - b;
215
+ case '*': return a * b;
216
+ case '/': if (b === 0) throw mkErr('#DIV/0!'); return a / b;
217
+ case '^': return Math.pow(a, b);
218
+ }
219
+ throw mkErr('#VALUE!');
220
+ }
221
+ case 'cmp': {
222
+ var x = num(evalAst(node.a, ctx)), y = num(evalAst(node.b, ctx)), r;
223
+ switch (node.op) {
224
+ case '=': r = x === y; break;
225
+ case '<>': r = x !== y; break;
226
+ case '<': r = x < y; break;
227
+ case '<=': r = x <= y; break;
228
+ case '>': r = x > y; break;
229
+ case '>=': r = x >= y; break;
230
+ default: throw mkErr('#VALUE!');
231
+ }
232
+ return r ? 1 : 0;
233
+ }
234
+ case 'ref': return refValue(ctx.cell(node.col, node.row, node.sheet));
235
+ case 'range': throw mkErr('#VALUE!');
236
+ case 'call': return callFn(node, ctx);
237
+ }
238
+ throw mkErr('#VALUE!');
239
+ }
240
+
241
+ function refValue(cell) {
242
+ if (!cell || cell.kind === 'empty') return 0;
243
+ if (cell.kind === 'number') return cell.value;
244
+ if (cell.kind === 'error') throw mkErr(cell.code || '#VALUE!');
245
+ throw mkErr('#VALUE!');
246
+ }
247
+
248
+ function num(v) { if (typeof v !== 'number' || !isFinite(v)) throw mkErr('#VALUE!'); return v; }
249
+
250
+ function argCells(arg, ctx) {
251
+ if (arg.k === 'range') {
252
+ var out = [];
253
+ var r0 = Math.min(arg.r0, arg.r1), r1 = Math.max(arg.r0, arg.r1);
254
+ var c0 = Math.min(arg.c0, arg.c1), c1 = Math.max(arg.c0, arg.c1);
255
+ for (var r = r0; r <= r1; r++) {
256
+ for (var c = c0; c <= c1; c++) out.push(ctx.cell(c, r, arg.sheet));
257
+ }
258
+ return out;
259
+ }
260
+ return [{ kind: 'number', value: num(evalAst(arg, ctx)) }];
261
+ }
262
+
263
+ function callFn(node, ctx) {
264
+ var name = node.name, args = node.args;
265
+ switch (name) {
266
+ case 'SUM': case 'PRODUCT': case 'MIN': case 'MAX':
267
+ case 'AVERAGE': case 'AVG': case 'COUNT': case 'COUNTA': {
268
+ var nums = [], counted = 0, errored = null;
269
+ for (var i = 0; i < args.length; i++) {
270
+ var cells = argCells(args[i], ctx);
271
+ for (var j = 0; j < cells.length; j++) {
272
+ var cl = cells[j];
273
+ if (cl.kind === 'error') { errored = cl.code || '#VALUE!'; }
274
+ else if (cl.kind === 'number') { nums.push(cl.value); counted++; }
275
+ else if (cl.kind === 'text') { counted++; }
276
+ }
277
+ }
278
+ if (errored && name !== 'COUNTA') throw mkErr(errored);
279
+ if (name === 'COUNT') return nums.length;
280
+ if (name === 'COUNTA') return counted;
281
+ if (!nums.length) {
282
+ if (name === 'SUM') return 0;
283
+ if (name === 'PRODUCT') return 0;
284
+ throw mkErr('#DIV/0!');
285
+ }
286
+ if (name === 'SUM') return nums.reduce(function (a, b) { return a + b; }, 0);
287
+ if (name === 'PRODUCT') return nums.reduce(function (a, b) { return a * b; }, 1);
288
+ if (name === 'MIN') return Math.min.apply(null, nums);
289
+ if (name === 'MAX') return Math.max.apply(null, nums);
290
+ return nums.reduce(function (a, b) { return a + b; }, 0) / nums.length; // AVERAGE
291
+ }
292
+ case 'ROUND': {
293
+ if (args.length < 1) throw mkErr('#VALUE!');
294
+ var x = num(evalAst(args[0], ctx));
295
+ var d = args.length > 1 ? num(evalAst(args[1], ctx)) : 0;
296
+ var f = Math.pow(10, d);
297
+ return Math.round(x * f) / f;
298
+ }
299
+ case 'ABS':
300
+ if (args.length !== 1) throw mkErr('#VALUE!');
301
+ return Math.abs(num(evalAst(args[0], ctx)));
302
+ case 'IF': {
303
+ if (args.length < 2) throw mkErr('#VALUE!');
304
+ var cond = num(evalAst(args[0], ctx));
305
+ return cond !== 0 ? evalAst(args[1], ctx)
306
+ : (args.length > 2 ? evalAst(args[2], ctx) : 0);
307
+ }
308
+ }
309
+ throw mkErr('#NAME?');
310
+ }
311
+
312
+ // ── Relative reference shifting (fill handle / copy-paste) ──
313
+ // Rewrite every cell reference in a formula by (dr, dc) rows/columns:
314
+ // shiftFormula('=B2*C2', 1, 0) -> '=B3*C3'. Function names (SUM, IF...) are
315
+ // left alone - a word is only a reference when it is letters+digits and not
316
+ // followed by '('. A reference pushed past row 1 / column A becomes the
317
+ // literal #REF!, which evaluates to a #REF! error. Non-formula strings pass
318
+ // through unchanged.
319
+ function shiftFormula(formula, dr, dc) {
320
+ if (!isFormula(formula)) return formula;
321
+ var src = formula.slice(1);
322
+ var out = '';
323
+ var i = 0, n = src.length;
324
+ function isAlpha(ch) { return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); }
325
+ function isDigit(ch) { return ch >= '0' && ch <= '9'; }
326
+ while (i < n) {
327
+ var c = src[i];
328
+ if (isAlpha(c)) {
329
+ var j = i;
330
+ while (j < n && (isAlpha(src[j]) || isDigit(src[j]))) j++;
331
+ var word = src.slice(i, j);
332
+ // A sheet qualifier (Sheet!A1) - emit the name + '!' untouched and let
333
+ // the next iteration shift the cell ref that follows. Without this a
334
+ // numeric-suffixed sheet name (Q1, Sheet1) would be shifted as if it
335
+ // were a cell reference.
336
+ if (src[j] === '!') {
337
+ out += word + '!';
338
+ i = j + 1;
339
+ continue;
340
+ }
341
+ var m = /^([A-Za-z]+)([0-9]+)$/.exec(word);
342
+ if (m && src[j] !== '(') {
343
+ var col = colIndex(m[1].toUpperCase()) + dc;
344
+ var row = parseInt(m[2], 10) - 1 + dr;
345
+ out += (col < 0 || row < 0) ? '#REF!' : colName(col) + (row + 1);
346
+ } else {
347
+ out += word;
348
+ }
349
+ i = j;
350
+ continue;
351
+ }
352
+ out += c;
353
+ i++;
354
+ }
355
+ return '=' + out;
356
+ }
357
+
358
+ // Evaluate one formula string against ctx. Returns { value } or { error }.
359
+ function evaluate(formula, ctx) {
360
+ try {
361
+ var src = formula.charAt(0) === '=' ? formula.slice(1) : formula;
362
+ var ast = parse(tokenize(src));
363
+ var v = evalAst(ast, ctx);
364
+ if (typeof v !== 'number' || !isFinite(v)) return { error: '#VALUE!' };
365
+ return { value: v };
366
+ } catch (e) {
367
+ return { error: e && e.isFormulaError ? e.code : '#VALUE!' };
368
+ }
369
+ }
370
+
371
+ // ── Whole-workbook recalc with cross-sheet cycle detection ──
372
+ //
373
+ // recalcWorkbook resolves a list of sheets at once so a formula in one
374
+ // sheet can read a cell in another via a qualified reference (Sheet!A1).
375
+ // One shared memo (`results`/`state`) is keyed by (sheetIndex, r, c): the
376
+ // in-progress guard that catches A1->A1 within a sheet then also catches a
377
+ // cycle that spans sheets. The cycle detector never learns about sheets,
378
+ // it just gets a wider address space.
379
+ //
380
+ // sheets: [{ name, model }, ...] name optional; '' for an anonymous
381
+ // single sheet (see recalc below).
382
+ //
383
+ // Returns one results grid per sheet, same shape and order as the input:
384
+ // [ results0, results1, ... ] resultsN[r][c] = {kind, value/code}
385
+ //
386
+ // Sheet names resolve to a stable index up front (case-insensitive; on a
387
+ // name collision the FIRST sheet with that name wins). A qualified ref to a
388
+ // name that does not exist is reported as #REF! by the ctx and never enters
389
+ // resolve - so a missing sheet can neither hang nor bypass the cycle guard.
390
+ function recalcWorkbook(sheets) {
391
+ var n = sheets.length;
392
+ var results = []; // results[s][r][c]
393
+ var state = []; // state[s][r][c]: 1 = in progress, 2 = done
394
+ var nameToIndex = {};
395
+ for (var s = 0; s < n; s++) {
396
+ var sres = [], sstate = [];
397
+ var model0 = sheets[s].model;
398
+ var srows = model0 && model0.cells ? model0.cells.length : 0;
399
+ for (var r0 = 0; r0 < srows; r0++) { sres.push([]); sstate.push([]); }
400
+ results.push(sres); state.push(sstate);
401
+ var nm = sheets[s].name;
402
+ if (nm) {
403
+ var nkey = String(nm).toLowerCase();
404
+ if (!(nkey in nameToIndex)) nameToIndex[nkey] = s; // first wins
405
+ }
406
+ }
407
+
408
+ function rowsOf(si) {
409
+ var model = sheets[si].model;
410
+ return model && model.cells ? model.cells.length : 0;
411
+ }
412
+ function rawAt(si, c, r) {
413
+ var line = sheets[si].model.cells[r];
414
+ var cell = line && line[c];
415
+ return cell ? cell.raw : '';
416
+ }
417
+ function baseKind(si, c, r) {
418
+ var line = sheets[si].model.cells[r];
419
+ var cell = line && line[c];
420
+ if (!cell || cell.type === 'empty') return { kind: 'empty' };
421
+ if (cell.type === 'number') return { kind: 'number', value: cell.value };
422
+ return { kind: 'text', value: cell.raw };
423
+ }
424
+
425
+ // Resolve cell (c, r) within sheet index `si`. Bounds are checked against
426
+ // THIS sheet's row count (rowsOf(si)), not any caller's - a cross-sheet
427
+ // ref to a short sheet reads empty, not the wrong row.
428
+ function resolve(si, c, r) {
429
+ if (r < 0 || r >= rowsOf(si)) return { kind: 'empty' };
430
+ var rRes = results[si][r], rState = state[si][r];
431
+ if (rRes[c]) return rRes[c];
432
+ if (rState && rState[c] === 1) {
433
+ return (rRes[c] = { kind: 'error', code: '#CIRC!' });
434
+ }
435
+ var raw = rawAt(si, c, r);
436
+ if (!isFormula(raw)) return (rRes[c] = baseKind(si, c, r));
437
+ rState[c] = 1;
438
+ // ctx.cell(col, row, sheet): an undefined `sheet` means the formula's
439
+ // own sheet (si); a named sheet is looked up in nameToIndex; a name with
440
+ // no matching sheet is #REF! and never enters resolve.
441
+ var ctx = {
442
+ cell: function (cc, rr, sheetName) {
443
+ if (sheetName == null) return resolve(si, cc, rr);
444
+ var key = String(sheetName).toLowerCase();
445
+ if (!(key in nameToIndex)) return { kind: 'error', code: '#REF!' };
446
+ return resolve(nameToIndex[key], cc, rr);
447
+ },
448
+ };
449
+ var out = evaluate(raw, ctx);
450
+ rState[c] = 2;
451
+ return (rRes[c] = out.error
452
+ ? { kind: 'error', code: out.error }
453
+ : { kind: 'number', value: out.value });
454
+ }
455
+
456
+ for (var si2 = 0; si2 < n; si2++) {
457
+ var rows = rowsOf(si2);
458
+ for (var rr = 0; rr < rows; rr++) {
459
+ var line = sheets[si2].model.cells[rr];
460
+ var cols = line ? line.length : 0;
461
+ for (var cc = 0; cc < cols; cc++) resolve(si2, cc, rr);
462
+ }
463
+ }
464
+ return results;
465
+ }
466
+
467
+ // Whole-model recalc for a single sheet. A thin adapter over
468
+ // recalcWorkbook so every existing caller (renderer, editor, xlsx exporter,
469
+ // tests) keeps the same `recalc(model) -> results[r][c]` contract. With one
470
+ // anonymous sheet there are no qualified references to resolve, so the
471
+ // output is identical to the pre-workbook recalc.
472
+ function recalc(model) {
473
+ return recalcWorkbook([{ name: '', model: model }])[0];
474
+ }
475
+
476
+ exports.colIndex = colIndex;
477
+ exports.colName = colName;
478
+ exports.isFormula = isFormula;
479
+ exports.tokenize = tokenize;
480
+ exports.parse = parse;
481
+ exports.evaluate = evaluate;
482
+ exports.recalc = recalc;
483
+ exports.recalcWorkbook = recalcWorkbook;
484
+ exports.shiftFormula = shiftFormula;
485
+ })(typeof module !== 'undefined' && module.exports ? module.exports : (window.SDocCellsFormula = {}));
@@ -0,0 +1,389 @@
1
+ // sdocs-cells.js - pure grid data model for ```cells fenced blocks.
2
+ //
3
+ // This is the BACKBONE of the cells/sheets feature. It is deliberately
4
+ // just the model: parse a block body into an addressable grid of cells.
5
+ // Every renderer (the inline grid, the future fullscreen sheet, the
6
+ // future editor) and the exporter read FROM this model; none of them is
7
+ // the source of truth. The DOM is always a view, never the data.
8
+ //
9
+ // A cell is two values from day one:
10
+ // { raw, value, type }
11
+ // raw - exactly what the source held (a literal today, a formula
12
+ // string like "=B2-B3" tomorrow). Never rewritten.
13
+ // value - the computed display value. In v1 (no formula engine) this
14
+ // equals the parsed literal. When formulas land, only the
15
+ // compute step changes; raw already carries what it needs.
16
+ // type - 'number' | 'text' | 'empty', drives alignment + display.
17
+ //
18
+ // The block body is CSV. That is on purpose: inline raw data and a future
19
+ // {{file.csv}} transclusion then share one parser and one model, so those
20
+ // two features are one code path rather than two with a seam between them.
21
+ //
22
+ // UMD so Node tests can require it directly; no DOM, no window.SDocs here.
23
+ (function (exports) {
24
+ 'use strict';
25
+
26
+ // Bijective base-26 spreadsheet column name. 0 -> A, 25 -> Z, 26 -> AA,
27
+ // 701 -> ZZ, 702 -> AAA. Matches how every spreadsheet labels columns.
28
+ function colName(index) {
29
+ var i = index + 1;
30
+ var s = '';
31
+ while (i > 0) {
32
+ var rem = (i - 1) % 26;
33
+ s = String.fromCharCode(65 + rem) + s;
34
+ i = Math.floor((i - 1) / 26);
35
+ }
36
+ return s;
37
+ }
38
+
39
+ // Inverse of colName: a column letter -> 0-based index ("A" -> 0, "AA" -> 26).
40
+ // Returns -1 for anything that isn't a run of A-Z letters.
41
+ function colIndex(letter) {
42
+ var s = String(letter).toUpperCase();
43
+ var n = 0;
44
+ for (var i = 0; i < s.length; i++) {
45
+ var c = s.charCodeAt(i) - 64; // A -> 1
46
+ if (c < 1 || c > 26) return -1;
47
+ n = n * 26 + c;
48
+ }
49
+ return s.length ? n - 1 : -1;
50
+ }
51
+
52
+ // Strict literal number: optional sign, digits, optional single decimal.
53
+ // No thousands separators in v1 - a bare "1,000" is two CSV fields, and a
54
+ // quoted "1,000" stays text rather than guessing the user's locale.
55
+ var NUMBER_RE = /^-?\d+(?:\.\d+)?$/;
56
+
57
+ // Classify a raw field string into a cell record. The trimmed text drives
58
+ // type detection; `raw` is preserved verbatim for display and round-trip.
59
+ function classify(raw) {
60
+ var t = String(raw).trim();
61
+ if (t === '') return { raw: raw, value: '', type: 'empty' };
62
+ if (NUMBER_RE.test(t)) return { raw: raw, value: Number(t), type: 'number' };
63
+ return { raw: raw, value: t, type: 'text' };
64
+ }
65
+
66
+ // Parse a CSV string into an array of row arrays of field strings.
67
+ // Handles quoted fields with embedded commas / newlines and "" escapes.
68
+ // Lenient by design: there is no such thing as a malformed sheet, only a
69
+ // ragged one (which parseCells pads).
70
+ function parseCsv(src) {
71
+ var rows = [];
72
+ var row = [];
73
+ var field = '';
74
+ var inQuotes = false;
75
+ var i = 0;
76
+ var n = src.length;
77
+ while (i < n) {
78
+ var ch = src.charAt(i);
79
+ if (inQuotes) {
80
+ if (ch === '"') {
81
+ if (src.charAt(i + 1) === '"') { field += '"'; i += 2; continue; }
82
+ inQuotes = false; i++; continue;
83
+ }
84
+ field += ch; i++; continue;
85
+ }
86
+ if (ch === '"') { inQuotes = true; i++; continue; }
87
+ if (ch === ',') { row.push(field); field = ''; i++; continue; }
88
+ if (ch === '\r') { i++; continue; }
89
+ if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; i++; continue; }
90
+ field += ch; i++;
91
+ }
92
+ row.push(field);
93
+ rows.push(row);
94
+ return rows;
95
+ }
96
+
97
+ // A bare {{path/to/file.csv}} reference (optionally with a :range suffix).
98
+ // Present only when a doc is opened WITHOUT the CLI (which otherwise bakes
99
+ // the data in); the renderer shows a "load it with sdoc" message.
100
+ var REFERENCE_RE = /^\{\{\s*([^}]+?)\s*\}\}$/;
101
+ // The machine-generated metadata line the CLI prepends to a baked block:
102
+ // sdoc-cells: source=report.csv range=B5:J32
103
+ // sdoc-cells: error="Could not read report.csv"
104
+ var DIRECTIVE_RE = /^sdoc-cells:\s*(.*)$/;
105
+
106
+ // Parse `key=value` / `key="quoted value"` pairs from a directive line.
107
+ function parseDirectives(str) {
108
+ var meta = {};
109
+ var re = /(\w+)=(?:"([^"]*)"|(\S+))/g;
110
+ var m;
111
+ while ((m = re.exec(str))) meta[m[1]] = m[2] !== undefined ? m[2] : m[3];
112
+ return meta;
113
+ }
114
+
115
+ // Build the grid model from a ```cells block body.
116
+ // Returns { rows, cols, cells, empty } where cells is row-major, every row
117
+ // padded to `cols` with empty cells so the grid is rectangular. May instead
118
+ // return { unresolved: <ref> } or { error: <msg> } for reference blocks.
119
+ function parseCells(src) {
120
+ var text = String(src == null ? '' : src);
121
+ // Drop leading blank lines and all trailing whitespace (incl. the final
122
+ // newline marked emits) but keep interior blank rows - a blank row is
123
+ // meaningful in a sheet.
124
+ var trimmed = text.replace(/^\n+/, '').replace(/\s+$/, '');
125
+ if (trimmed === '') return { rows: 0, cols: 0, cells: [], empty: true };
126
+
127
+ // Peel leading directive lines (in any order, machine or author):
128
+ // sdoc-cells: source=... range=... error=... name=... (baked metadata)
129
+ // format: A=$ B=% C=plain (author column formats)
130
+ // `name` is the tab name. Authored as the fence info string (```cells
131
+ // Sales); the renderer + CLI normalise that into this directive so the
132
+ // name has one home in the model.
133
+ var source, range, formats, name;
134
+ var lines = trimmed.split('\n');
135
+ var idx = 0;
136
+ var FORMAT_RE = /^format:\s*(.*)$/i;
137
+ while (idx < lines.length) {
138
+ var dm = lines[idx].match(DIRECTIVE_RE);
139
+ var fm = lines[idx].match(FORMAT_RE);
140
+ if (dm) {
141
+ var meta = parseDirectives(dm[1]);
142
+ source = meta.source; range = meta.range;
143
+ if (meta.name != null) name = meta.name;
144
+ if (meta.error) return { empty: false, error: meta.error, source: source, name: name };
145
+ idx++; continue;
146
+ }
147
+ if (fm) { formats = parseFormats(fm[1]); idx++; continue; }
148
+ break;
149
+ }
150
+ var body = lines.slice(idx).join('\n').replace(/\s+$/, '');
151
+
152
+ // Unresolved reference (after directives): the CLI never baked it.
153
+ var ref = body.match(REFERENCE_RE);
154
+ if (ref) return { empty: false, unresolved: ref[1], formats: formats, name: name };
155
+ if (body === '') return { rows: 0, cols: 0, cells: [], empty: true, source: source, range: range, formats: formats, name: name };
156
+
157
+ var raw = parseCsv(body);
158
+ var cols = 0;
159
+ for (var r = 0; r < raw.length; r++) {
160
+ if (raw[r].length > cols) cols = raw[r].length;
161
+ }
162
+ var cells = [];
163
+ for (var r2 = 0; r2 < raw.length; r2++) {
164
+ var line = raw[r2];
165
+ var out = [];
166
+ for (var c = 0; c < cols; c++) {
167
+ out.push(classify(c < line.length ? line[c] : ''));
168
+ }
169
+ cells.push(out);
170
+ }
171
+ return { rows: raw.length, cols: cols, cells: cells, empty: false, source: source, range: range, formats: formats, name: name };
172
+ }
173
+
174
+ // Serialize a 2D array of raw cell strings back to CSV (RFC 4180 quoting:
175
+ // wrap in quotes and double any embedded quote when a field contains a
176
+ // comma, quote, or newline). The inverse of parseCsv for the copy actions.
177
+ function serializeCsv(rows) {
178
+ return rows.map(function (row) {
179
+ return row.map(function (v) {
180
+ var s = String(v == null ? '' : v);
181
+ return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
182
+ }).join(',');
183
+ }).join('\n');
184
+ }
185
+
186
+ // Stats for a selected rectangle [r0..r1] x [c0..c1] of a model. Numbers
187
+ // drive sum/avg/min/max; count is every non-empty cell (Excel's "Count").
188
+ // Cells past the data (fullscreen padding) read as empty. fx (optional) is
189
+ // a recalc results grid aligned to this model's rows: a formula cell then
190
+ // contributes its computed value (errors count as non-empty, not numeric).
191
+ function selectionStats(model, r0, c0, r1, c1, fx) {
192
+ var count = 0, numericCount = 0, sum = 0, min = null, max = null;
193
+ for (var r = r0; r <= r1; r++) {
194
+ var line = model.cells[r];
195
+ for (var c = c0; c <= c1; c++) {
196
+ var cell = line && line[c];
197
+ if (!cell || cell.type === 'empty') continue;
198
+ count++;
199
+ var isNum = cell.type === 'number';
200
+ var v = cell.value;
201
+ // A formula cell's raw is "=..." text; its computed result decides.
202
+ if (fx && cell.raw && cell.raw.charAt(0) === '=') {
203
+ var fxCell = fx[r] && fx[r][c];
204
+ isNum = !!fxCell && fxCell.kind === 'number';
205
+ if (isNum) v = fxCell.value;
206
+ }
207
+ if (isNum) {
208
+ numericCount++;
209
+ sum += v;
210
+ if (min === null || v < min) min = v;
211
+ if (max === null || v > max) max = v;
212
+ }
213
+ }
214
+ }
215
+ return {
216
+ count: count,
217
+ numericCount: numericCount,
218
+ sum: sum,
219
+ avg: numericCount > 0 ? sum / numericCount : null,
220
+ min: min,
221
+ max: max,
222
+ };
223
+ }
224
+
225
+ // Heuristic: is row 0 a header? True when row 0 has no numbers and there is
226
+ // numeric data below it - so a sort can keep it pinned to the top.
227
+ function looksLikeHeader(model) {
228
+ if (!model || model.rows < 2) return false;
229
+ var row0 = model.cells[0];
230
+ for (var c = 0; c < model.cols; c++) {
231
+ if (row0[c] && row0[c].type === 'number') return false;
232
+ }
233
+ for (var r = 1; r < model.rows; r++) {
234
+ var line = model.cells[r];
235
+ for (var c2 = 0; c2 < model.cols; c2++) {
236
+ if (line[c2] && line[c2].type === 'number') return true;
237
+ }
238
+ }
239
+ return false;
240
+ }
241
+
242
+ // Sort key: numbers (rank 0) sort before text (rank 1); empty (rank 2) last.
243
+ // fxCell, when given, is the cell's computed formula result ({kind, value} /
244
+ // {kind: 'error', code}) and takes precedence: a formula sorts by the value
245
+ // the user sees, not by its "=..." source text. Errors sort with text.
246
+ function sortKey(cell, fxCell) {
247
+ if (fxCell && fxCell.kind === 'number') return { rank: 0, v: fxCell.value };
248
+ if (fxCell && fxCell.kind === 'error') return { rank: 1, v: String(fxCell.code || '').toLowerCase() };
249
+ if (!cell || cell.type === 'empty') return { rank: 2, v: 0 };
250
+ if (cell.type === 'number') return { rank: 0, v: cell.value };
251
+ return { rank: 1, v: String(cell.value).toLowerCase() };
252
+ }
253
+
254
+ // A summary row aggregates other rows: it holds at least one formula with a
255
+ // range spanning 2+ rows, e.g. =SUM(D2:D5). Per-row formulas (=B2*C2) refer
256
+ // to single cells and do not count. Used by sortRows to keep a trailing
257
+ // "Total" row pinned at the bottom instead of jumbling it into the data.
258
+ var ROW_RANGE_RE = /[A-Za-z]+([0-9]+)\s*:\s*[A-Za-z]+([0-9]+)/;
259
+ function isSummaryRow(row) {
260
+ if (!row) return false;
261
+ for (var i = 0; i < row.length; i++) {
262
+ var raw = row[i] && row[i].raw;
263
+ if (!raw || raw.charAt(0) !== '=') continue;
264
+ var m = ROW_RANGE_RE.exec(raw);
265
+ if (m && m[1] !== m[2]) return true; // the range spans rows
266
+ }
267
+ return false;
268
+ }
269
+
270
+ // Return the row order (array of original indices) sorting the model by a
271
+ // column. A view reorder - the model itself is not changed. Empty cells stay
272
+ // last either direction; a header row (when hasHeader) stays pinned to row 0;
273
+ // trailing summary rows (see isSummaryRow) stay pinned at the bottom.
274
+ // fx (optional) is a recalc results grid indexed by SOURCE row: formula
275
+ // cells then sort by their computed value instead of their source text.
276
+ function sortRows(model, col, dir, hasHeader, fx) {
277
+ var order = [];
278
+ for (var r = 0; r < model.rows; r++) order.push(r);
279
+ var start = hasHeader ? 1 : 0;
280
+ // Trailing summary rows (a Total / Average footer) sit outside the sort,
281
+ // in their original order.
282
+ var end = model.rows;
283
+ while (end > start && isSummaryRow(model.cells[end - 1])) end--;
284
+ var head = order.slice(0, start);
285
+ var tail = order.slice(end);
286
+ var body = order.slice(start, end);
287
+ var sign = dir === 'desc' ? -1 : 1;
288
+ function fxFor(r, cell) {
289
+ if (!fx || !cell || !cell.raw || cell.raw.charAt(0) !== '=') return null;
290
+ return (fx[r] && fx[r][col]) || null;
291
+ }
292
+ body.sort(function (ra, rb) {
293
+ var ca = model.cells[ra] && model.cells[ra][col];
294
+ var cb = model.cells[rb] && model.cells[rb][col];
295
+ var a = sortKey(ca, fxFor(ra, ca));
296
+ var b = sortKey(cb, fxFor(rb, cb));
297
+ if (a.rank === 2 || b.rank === 2) return a.rank - b.rank; // empty always last
298
+ if (a.rank !== b.rank) return (a.rank - b.rank) * sign;
299
+ if (a.v < b.v) return -1 * sign;
300
+ if (a.v > b.v) return 1 * sign;
301
+ return 0;
302
+ });
303
+ return head.concat(body).concat(tail);
304
+ }
305
+
306
+ // Display formatting for a numeric raw string: group the integer part with
307
+ // thousands separators, preserve the sign and the decimal part verbatim
308
+ // (so "1234.50" keeps its trailing zero). Display only - the model's raw is
309
+ // untouched, so copy / export still emit the original value.
310
+ function formatNumber(raw) {
311
+ var s = String(raw == null ? '' : raw).trim();
312
+ var neg = s.charAt(0) === '-';
313
+ if (neg) s = s.slice(1);
314
+ var dot = s.indexOf('.');
315
+ var intPart = dot === -1 ? s : s.slice(0, dot);
316
+ var rest = dot === -1 ? '' : s.slice(dot);
317
+ intPart = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
318
+ return (neg ? '-' : '') + intPart + rest;
319
+ }
320
+
321
+ // Parse one format token into a spec: { kind, symbol?, decimals? }.
322
+ // Tokens: $ / usd / currency, £ / gbp, € / eur, % / percent, , / comma /
323
+ // number, plain / text / raw, plus an optional trailing .N for decimals
324
+ // (e.g. "$.0", "%.1", ".2"). Unknown tokens return null.
325
+ function parseFmtToken(tok) {
326
+ var t = String(tok).trim();
327
+ var decimals = null;
328
+ var dm = t.match(/\.(\d+)$/);
329
+ if (dm) { decimals = parseInt(dm[1], 10); t = t.slice(0, t.length - dm[0].length); }
330
+ var lc = t.toLowerCase();
331
+ if (t === '$' || lc === 'usd' || lc === 'currency') return { kind: 'currency', symbol: '$', decimals: decimals == null ? 2 : decimals };
332
+ if (t === '£' || lc === 'gbp') return { kind: 'currency', symbol: '£', decimals: decimals == null ? 2 : decimals };
333
+ if (t === '€' || lc === 'eur') return { kind: 'currency', symbol: '€', decimals: decimals == null ? 2 : decimals };
334
+ if (t === '%' || lc === 'percent') { var p = { kind: 'percent' }; if (decimals != null) p.decimals = decimals; return p; }
335
+ if (t === ',' || lc === 'comma' || lc === 'number' || lc === 'num') { var nn = { kind: 'number' }; if (decimals != null) nn.decimals = decimals; return nn; }
336
+ if (lc === 'plain' || lc === 'text' || lc === 'raw') return { kind: 'plain' };
337
+ if (t === '' && decimals != null) return { kind: 'number', decimals: decimals };
338
+ return null;
339
+ }
340
+
341
+ // Parse a per-column format spec like "A=plain B=$ C=%.1" into a map
342
+ // { colIndex: fmt }. Keys are column letters; unknown tokens are skipped.
343
+ function parseFormats(spec) {
344
+ var out = {};
345
+ var re = /([A-Za-z]+)\s*=\s*(\S+)/g;
346
+ var m;
347
+ while ((m = re.exec(spec))) {
348
+ var col = colIndex(m[1]);
349
+ var fmt = parseFmtToken(m[2]);
350
+ if (col >= 0 && fmt) out[col] = fmt;
351
+ }
352
+ return out;
353
+ }
354
+
355
+ // Format a numeric cell's display per a column format. Returns null for
356
+ // non-number cells (the caller falls back to text rendering). Display only -
357
+ // the model's raw is untouched, so copy / export emit the original.
358
+ function formatValue(cell, fmt) {
359
+ if (!cell || cell.type !== 'number') return null;
360
+ var v = cell.value;
361
+ if (!fmt || fmt.kind === 'number') {
362
+ return (fmt && fmt.decimals != null) ? formatNumber(v.toFixed(fmt.decimals)) : formatNumber(cell.raw);
363
+ }
364
+ if (fmt.kind === 'plain') return cell.raw;
365
+ if (fmt.kind === 'currency') {
366
+ var d = fmt.decimals == null ? 2 : fmt.decimals;
367
+ return (v < 0 ? '-' : '') + fmt.symbol + formatNumber(Math.abs(v).toFixed(d));
368
+ }
369
+ if (fmt.kind === 'percent') {
370
+ var p = v * 100;
371
+ var str = fmt.decimals != null ? p.toFixed(fmt.decimals) : String(Math.round(p * 1e6) / 1e6);
372
+ return formatNumber(str) + '%';
373
+ }
374
+ return formatNumber(cell.raw);
375
+ }
376
+
377
+ exports.colName = colName;
378
+ exports.classify = classify;
379
+ exports.parseCsv = parseCsv;
380
+ exports.parseCells = parseCells;
381
+ exports.serializeCsv = serializeCsv;
382
+ exports.selectionStats = selectionStats;
383
+ exports.formatNumber = formatNumber;
384
+ exports.colIndex = colIndex;
385
+ exports.parseFormats = parseFormats;
386
+ exports.formatValue = formatValue;
387
+ exports.looksLikeHeader = looksLikeHeader;
388
+ exports.sortRows = sortRows;
389
+ })(typeof module !== 'undefined' && module.exports ? module.exports : (window.SDocCells = {}));