@@ -55,6 +55,207 @@ const ajv = new Ajv();
5555ajv . addFormat ( 'date' , / ^ \d { 4 } - \d { 2 } - \d { 2 } $ / ) ;
5656const validateOsf = ajv . compile ( schema ) ;
5757
58+ // Formula evaluator for spreadsheet calculations
59+ class FormulaEvaluator {
60+ private data : Record < string , any > ;
61+ private formulas : Map < string , string > ;
62+ private computed : Map < string , any > ;
63+ private evaluating : Set < string > ; // For circular reference detection
64+
65+ constructor ( data : Record < string , any > , formulas : { cell : [ number , number ] ; expr : string } [ ] ) {
66+ this . data = { ...data } ;
67+ this . formulas = new Map ( ) ;
68+ this . computed = new Map ( ) ;
69+ this . evaluating = new Set ( ) ;
70+
71+ // Convert formulas to map with string keys
72+ for ( const formula of formulas ) {
73+ const key = `${ formula . cell [ 0 ] } ,${ formula . cell [ 1 ] } ` ;
74+ this . formulas . set ( key , formula . expr ) ;
75+ }
76+ }
77+
78+ // Convert cell reference (like "A1", "B2") to row,col coordinates
79+ private cellRefToCoords ( cellRef : string ) : [ number , number ] {
80+ const match = cellRef . match ( / ^ ( [ A - Z ] + ) ( \d + ) $ / ) ;
81+ if ( ! match ) {
82+ throw new Error ( `Invalid cell reference: ${ cellRef } ` ) ;
83+ }
84+
85+ const colStr = match [ 1 ] ! ;
86+ const rowStr = match [ 2 ] ! ;
87+
88+ // Convert column letters to number (A=1, B=2, ..., Z=26, AA=27, etc.)
89+ let col = 0 ;
90+ for ( let i = 0 ; i < colStr . length ; i ++ ) {
91+ col = col * 26 + ( colStr . charCodeAt ( i ) - 64 ) ; // A=1, B=2, etc.
92+ }
93+
94+ const row = parseInt ( rowStr , 10 ) ;
95+ return [ row , col ] ;
96+ }
97+
98+ // Convert row,col coordinates to cell reference
99+ private coordsToCellRef ( row : number , col : number ) : string {
100+ let colStr = '' ;
101+ let temp = col ;
102+ while ( temp > 0 ) {
103+ temp -- ;
104+ colStr = String . fromCharCode ( 65 + ( temp % 26 ) ) + colStr ;
105+ temp = Math . floor ( temp / 26 ) ;
106+ }
107+ return `${ colStr } ${ row } ` ;
108+ }
109+
110+ // Get cell value, evaluating formulas if needed
111+ getCellValue ( row : number , col : number ) : any {
112+ const key = `${ row } ,${ col } ` ;
113+
114+ // Check for circular reference
115+ if ( this . evaluating . has ( key ) ) {
116+ throw new Error ( `Circular reference detected at cell ${ this . coordsToCellRef ( row , col ) } ` ) ;
117+ }
118+
119+ // Return cached computed value if available
120+ if ( this . computed . has ( key ) ) {
121+ return this . computed . get ( key ) ;
122+ }
123+
124+ // Check if there's a formula for this cell
125+ if ( this . formulas . has ( key ) ) {
126+ this . evaluating . add ( key ) ;
127+ try {
128+ const formula = this . formulas . get ( key ) ! ;
129+ const result = this . evaluateFormula ( formula ) ;
130+ this . computed . set ( key , result ) ;
131+ this . evaluating . delete ( key ) ;
132+ return result ;
133+ } catch ( error ) {
134+ this . evaluating . delete ( key ) ;
135+ throw error ;
136+ }
137+ }
138+
139+ // Return raw data value or empty string
140+ return this . data [ key ] ?? '' ;
141+ }
142+
143+ // Evaluate a formula expression
144+ private evaluateFormula ( expr : string ) : any {
145+ // Remove leading = if present
146+ if ( expr . startsWith ( '=' ) ) {
147+ expr = expr . slice ( 1 ) ;
148+ }
149+
150+ // Replace cell references with actual values
151+ const cellRefRegex = / \b ( [ A - Z ] + \d + ) \b / g;
152+ const processedExpr = expr . replace ( cellRefRegex , ( match ) => {
153+ const [ row , col ] = this . cellRefToCoords ( match ) ;
154+ const value = this . getCellValue ( row , col ) ;
155+
156+ // Convert to number if possible, otherwise use as string
157+ if ( typeof value === 'number' ) {
158+ return value . toString ( ) ;
159+ } else if ( typeof value === 'string' && ! isNaN ( Number ( value ) ) ) {
160+ return value ;
161+ } else {
162+ // For string values in formulas, wrap in quotes
163+ return `"${ value } "` ;
164+ }
165+ } ) ;
166+
167+ try {
168+ // Evaluate the mathematical expression
169+ return this . evaluateExpression ( processedExpr ) ;
170+ } catch ( error ) {
171+ throw new Error ( `Formula evaluation error: ${ ( error as Error ) . message } ` ) ;
172+ }
173+ }
174+
175+ // Safe expression evaluator supporting basic arithmetic
176+ private evaluateExpression ( expr : string ) : number {
177+ // Remove whitespace
178+ expr = expr . replace ( / \s + / g, '' ) ;
179+
180+ // Handle parentheses first
181+ while ( expr . includes ( '(' ) ) {
182+ const innerMatch = expr . match ( / \( ( [ ^ ( ) ] + ) \) / ) ;
183+ if ( ! innerMatch ) break ;
184+
185+ const innerResult = this . evaluateExpression ( innerMatch [ 1 ] ! ) ;
186+ expr = expr . replace ( innerMatch [ 0 ] ! , innerResult . toString ( ) ) ;
187+ }
188+
189+ // Handle multiplication and division (left to right, same precedence)
190+ expr = this . evaluateOperatorsLeftToRight ( expr , [ '*' , '/' ] ) ;
191+
192+ // Handle addition and subtraction (left to right, same precedence)
193+ expr = this . evaluateOperatorsLeftToRight ( expr , [ '+' , '-' ] ) ;
194+
195+ // Parse final result
196+ const result = parseFloat ( expr ) ;
197+ if ( isNaN ( result ) ) {
198+ throw new Error ( `Invalid expression: ${ expr } ` ) ;
199+ }
200+
201+ return result ;
202+ }
203+
204+ // Evaluate operators with left-to-right precedence
205+ private evaluateOperatorsLeftToRight ( expr : string , operators : string [ ] ) : string {
206+ // Create a regex that matches any of the operators
207+ const opPattern = operators . map ( op => `\\${ op } ` ) . join ( '|' ) ;
208+ const regex = new RegExp ( `(-?\\d+(?:\\.\\d+)?)(${ opPattern } )(-?\\d+(?:\\.\\d+)?)` , 'g' ) ;
209+
210+ // Keep evaluating until no more matches
211+ while ( regex . test ( expr ) ) {
212+ regex . lastIndex = 0 ; // Reset regex
213+ expr = expr . replace ( regex , ( _ , left , op , right ) => {
214+ const leftNum = parseFloat ( left ) ;
215+ const rightNum = parseFloat ( right ) ;
216+ let result : number ;
217+
218+ switch ( op ) {
219+ case '+' : result = leftNum + rightNum ; break ;
220+ case '-' : result = leftNum - rightNum ; break ;
221+ case '*' : result = leftNum * rightNum ; break ;
222+ case '/' :
223+ if ( rightNum === 0 ) throw new Error ( 'Division by zero' ) ;
224+ result = leftNum / rightNum ;
225+ break ;
226+ default : throw new Error ( `Unknown operator: ${ op } ` ) ;
227+ }
228+
229+ return result . toString ( ) ;
230+ } ) ;
231+ }
232+
233+ return expr ;
234+ }
235+
236+ // Get all computed values for a sheet
237+ getAllComputedValues ( maxRow : number , maxCol : number ) : Record < string , any > {
238+ const result : Record < string , any > = { } ;
239+
240+ for ( let r = 1 ; r <= maxRow ; r ++ ) {
241+ for ( let c = 1 ; c <= maxCol ; c ++ ) {
242+ const key = `${ r } ,${ c } ` ;
243+ try {
244+ const value = this . getCellValue ( r , c ) ;
245+ if ( value !== '' ) {
246+ result [ key ] = value ;
247+ }
248+ } catch ( error ) {
249+ // Store error message for cells that can't be computed
250+ result [ key ] = `#ERROR: ${ ( error as Error ) . message } ` ;
251+ }
252+ }
253+ }
254+
255+ return result ;
256+ }
257+ }
258+
58259function showHelp ( ) : void {
59260 console . log ( 'OmniScript Format (OSF) CLI v0.5.0' ) ;
60261 console . log ( 'Universal document DSL for LLMs and Git-native workflows\n' ) ;
@@ -130,6 +331,8 @@ function renderHtml(doc: OSFDocument): string {
130331 ' table { border-collapse: collapse; width: 100%; margin: 20px 0; }' ,
131332 ' th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }' ,
132333 ' th { background-color: #f5f5f5; font-weight: 600; }' ,
334+ ' .computed { background-color: #f0f8ff; font-style: italic; }' ,
335+ ' .error { background-color: #ffe6e6; color: #d00; }' ,
133336 ' </style>' ,
134337 '</head>' ,
135338 '<body>' ,
@@ -197,18 +400,33 @@ function renderHtml(doc: OSFDocument): string {
197400 }
198401
199402 if ( sheet . data ) {
200- parts . push ( ' <tbody>' ) ;
201- const rows : Record < string , any > = sheet . data ;
202- const coords = Object . keys ( rows ) . map ( k => k . split ( ',' ) . map ( Number ) ) ;
203- const maxRow = Math . max ( ...coords . map ( c => c [ 0 ] ! ) ) ;
204- const maxCol = Math . max ( ...coords . map ( c => c [ 1 ] ! ) ) ;
403+ // Evaluate formulas
404+ const evaluator = new FormulaEvaluator ( sheet . data , sheet . formulas || [ ] ) ;
405+
406+ // Calculate dimensions including formula cells
407+ const dataCoords = Object . keys ( sheet . data ) . map ( k => k . split ( ',' ) . map ( Number ) ) ;
408+ const formulaCoords = ( sheet . formulas || [ ] ) . map ( ( f : any ) => f . cell ) ;
409+ const allCoords = [ ...dataCoords , ...formulaCoords ] ;
410+
411+ const maxRow = Math . max ( ...allCoords . map ( c => c [ 0 ] ! ) ) ;
412+ const maxCol = Math . max ( ...allCoords . map ( c => c [ 1 ] ! ) ) ;
413+
414+ // Get all computed values including formulas
415+ const allValues = evaluator . getAllComputedValues ( maxRow , maxCol ) ;
205416
417+ parts . push ( ' <tbody>' ) ;
206418 for ( let r = 1 ; r <= maxRow ; r ++ ) {
207419 parts . push ( ' <tr>' ) ;
208420 for ( let c = 1 ; c <= maxCol ; c ++ ) {
209421 const key = `${ r } ,${ c } ` ;
210- const val = rows [ key ] ?? '' ;
211- parts . push ( ` <td>${ val } </td>` ) ;
422+ const val = allValues [ key ] ?? '' ;
423+ const hasFormula = sheet . formulas ?. some ( ( f : any ) => f . cell [ 0 ] === r && f . cell [ 1 ] === c ) ;
424+ const isError = typeof val === 'string' && val . startsWith ( '#ERROR:' ) ;
425+
426+ const cssClass = isError ? 'error' : ( hasFormula ? 'computed' : '' ) ;
427+ const cellContent = isError ? val . replace ( '#ERROR: ' , '' ) : val ;
428+
429+ parts . push ( ` <td class="${ cssClass } ">${ cellContent } </td>` ) ;
212430 }
213431 parts . push ( ' </tr>' ) ;
214432 }
@@ -305,17 +523,33 @@ function exportMarkdown(doc: OSFDocument): string {
305523 }
306524
307525 if ( sheet . data ) {
308- const rows : Record < string , any > = sheet . data ;
309- const coords = Object . keys ( rows ) . map ( k => k . split ( ',' ) . map ( Number ) ) ;
310- const maxRow = Math . max ( ...coords . map ( c => c [ 0 ] ! ) ) ;
311- const maxCol = Math . max ( ...coords . map ( c => c [ 1 ] ! ) ) ;
526+ // Evaluate formulas
527+ const evaluator = new FormulaEvaluator ( sheet . data , sheet . formulas || [ ] ) ;
528+
529+ // Calculate dimensions including formula cells
530+ const dataCoords = Object . keys ( sheet . data ) . map ( k => k . split ( ',' ) . map ( Number ) ) ;
531+ const formulaCoords = ( sheet . formulas || [ ] ) . map ( ( f : any ) => f . cell ) ;
532+ const allCoords = [ ...dataCoords , ...formulaCoords ] ;
533+
534+ const maxRow = Math . max ( ...allCoords . map ( c => c [ 0 ] ! ) ) ;
535+ const maxCol = Math . max ( ...allCoords . map ( c => c [ 1 ] ! ) ) ;
536+
537+ // Get all computed values including formulas
538+ const allValues = evaluator . getAllComputedValues ( maxRow , maxCol ) ;
312539
313540 for ( let r = 1 ; r <= maxRow ; r ++ ) {
314541 const cells : string [ ] = [ ] ;
315542 for ( let c = 1 ; c <= maxCol ; c ++ ) {
316543 const key = `${ r } ,${ c } ` ;
317- const val = rows [ key ] ?? '' ;
318- cells . push ( String ( val ) ) ;
544+ const val = allValues [ key ] ?? '' ;
545+ const hasFormula = sheet . formulas ?. some ( ( f : any ) => f . cell [ 0 ] === r && f . cell [ 1 ] === c ) ;
546+
547+ if ( hasFormula && typeof val === 'number' ) {
548+ // Show computed value with indication it's calculated
549+ cells . push ( `${ val } *(calc)*` ) ;
550+ } else {
551+ cells . push ( String ( val ) ) ;
552+ }
319553 }
320554 out . push ( '| ' + cells . join ( ' | ' ) + ' |' ) ;
321555 }
@@ -349,12 +583,35 @@ function exportJson(doc: OSFDocument): string {
349583 case 'sheet' : {
350584 const sheet : any = { ...block } ;
351585 delete sheet . type ;
586+
352587 if ( sheet . data ) {
353- sheet . data = Object . entries ( sheet . data ) . map ( ( [ cell , value ] ) => {
588+ // Evaluate formulas and include computed values
589+ const evaluator = new FormulaEvaluator ( sheet . data , sheet . formulas || [ ] ) ;
590+
591+ // Calculate dimensions including formula cells
592+ const dataCoords = Object . keys ( sheet . data ) . map ( ( k : string ) => k . split ( ',' ) . map ( Number ) ) ;
593+ const formulaCoords = ( sheet . formulas || [ ] ) . map ( ( f : any ) => f . cell ) ;
594+ const allCoords = [ ...dataCoords , ...formulaCoords ] ;
595+
596+ const maxRow = Math . max ( ...allCoords . map ( c => c [ 0 ] ! ) ) ;
597+ const maxCol = Math . max ( ...allCoords . map ( c => c [ 1 ] ! ) ) ;
598+
599+ // Get all computed values including formulas
600+ const allValues = evaluator . getAllComputedValues ( maxRow , maxCol ) ;
601+
602+ // Convert to array format with computed values
603+ sheet . data = Object . entries ( allValues ) . map ( ( [ cell , value ] ) => {
354604 const [ r , c ] = cell . split ( ',' ) . map ( Number ) ;
355- return { row : r , col : c , value } ;
605+ const hasFormula = sheet . formulas ?. some ( ( f : any ) => f . cell [ 0 ] === r && f . cell [ 1 ] === c ) ;
606+ return {
607+ row : r ,
608+ col : c ,
609+ value,
610+ computed : hasFormula
611+ } ;
356612 } ) ;
357613 }
614+
358615 if ( sheet . formulas ) {
359616 sheet . formulas = sheet . formulas . map ( ( f : any ) => ( {
360617 row : f . cell [ 0 ] ,
0 commit comments