/* * This file is part of TREB. * * TREB is free software: you can redistribute it and/or modify it under the * terms of the GNU General Public License as published by the Free Software * Foundation, either version 3 of the License, or (at your option) any * later version. * * TREB is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more * details. * * You should have received a copy of the GNU General Public License along * with TREB. If not, see . * * Copyright 2022-2026 trebco, llc. * info@treb.app * */ import type { Sheet } from './sheet'; import { SheetCollection } from './sheet_collection'; import { type UnitAddress, type UnitStructuredReference, type UnitRange, Parser, QuotedSheetNameRegex, DecimalMarkType, ArgumentSeparatorType } from 'treb-parser'; import type { IArea, ICellAddress, Table, CellStyle, CellValue } from 'treb-base-types'; import { Is2DArray } from 'treb-base-types'; import { Area, IsCellAddress, Style } from 'treb-base-types'; import type { SerializedNamed } from './named'; import { NamedRangeManager } from './named'; import type { ConnectedElementType, MacroFunction } from './types'; import type { LanguageModel } from './language-model'; /** * */ export class DataModel { public readonly parser: Parser = new Parser(); /** moved from embedded spreadsheet */ public language_model?: LanguageModel; /** document metadata */ public document_name?: string; /** * document metadata. this is opaque to everyone except the user, so * we're intentionally leaving it as unknown except where users have * direct access (embedded spreadsheet). */ public user_data?: unknown; /** * */ public readonly sheets = new SheetCollection(); /** new composite collection (TODO: add macro functions too?) */ public readonly named = new NamedRangeManager(this.parser); /** macro functions are functions written in spreadsheet language */ public readonly macro_functions: Map = new Map(); /** index for views */ public view_count = 0; /** * base style properties moved to model, so we can have a single * and consistent reference. */ public theme_style_properties: CellStyle = JSON.parse(JSON.stringify(Style.DefaultProperties)); /** * tables are global, because we need to reference them by name; and they * need unique names, so we need to keep track of names. name matching is * icase so we lc the names before inserting. */ public tables: Map = new Map(); /** * we're wrapping up the get name method so we can check for a sheet * name -- we have the list of sheet names. we could pass that to the * name list manager but it's easier for us to intercept the call. * I thought about wrapping up more API functions here, but that seems * unecessary atm. */ public GetName(name: string, scope: number) { // helpfully this is not a legal character in names or sheets, so // we don't need a full parser to handle the split. watch out for // quoted sheet names. const parts = name.split(/!/); if (parts.length === 1) { return this.named.Get_(name, scope); } let sheet_name = parts[0]; // can we just test with indexes? surely faster if (/^'.*?'$/.test(sheet_name)) { sheet_name = sheet_name.substring(1, sheet_name.length - 1); } const sheet = this.sheets.ID(sheet_name); // Q: why require scope in this case? if there _is_ a global name, // and no scoped name, why not return that? that seems to be the // behavior in excel, although I can't be sure // test: default false return this.named.Get_(parts[1], sheet || 0, false); // true); // require scope in this case } /** * @param force_locale - always parse assuming a locale like en-us (comma * argument separators). the current thinking is that this is required for * XLSX import, although that might be incorrect. */ public UnserializeNames(names: SerializedNamed[], active_sheet?: Sheet, force_locale = false) { this.parser.Save(); if (force_locale) { this.parser.SetLocaleSettings(DecimalMarkType.Period, ArgumentSeparatorType.Comma); } //const sorted = names.map(named => { for (const named of names) { if (!named.expression) { continue; } const parse_result = this.parser.Parse(named.expression); if (parse_result.expression) { const scope = (typeof named.scope === 'string') ? this.sheets.ID(named.scope) : undefined; if (parse_result.expression.type === 'address' || parse_result.expression.type === 'range') { const [start, end] = parse_result.expression.type === 'range' ? [ parse_result.expression.start, parse_result.expression.end, ] : [ parse_result.expression, parse_result.expression ]; if (start.sheet) { if (/^\[\d+\]/.test(start.sheet)) { console.warn('named range refers to an external file'); } else { const area = new Area({...start, sheet_id: this.sheets.ID(start.sheet), }, end); if (area.start.sheet_id) { this.named.SetNamedRange(named.name, area, scope); } else { console.warn("missing sheet ID?", start); } } } else { console.warn("missing sheet name?", start); } } else { this.parser.Walk(parse_result.expression, unit => { if (unit.type === 'address' || unit.type === 'range') { if (unit.type === 'range') { unit = unit.start; } if (!unit.sheet_id) { if (unit.sheet) { unit.sheet_id = this.sheets.ID(unit.sheet); } } if (!unit.sheet_id) { unit.sheet_id = active_sheet?.id; } return false; // don't continue in ranges } return true; }); this.named.SetNamedExpression(named.name, parse_result.expression, scope); } } } this.parser.Restore(); } /** * serialize names. ranges are easy, but make sure there's a sheet name * in each address (and remove the ID). expressions are a little more * complicated. */ public SerializeNames(active_sheet?: Sheet): SerializedNamed[] { const list: SerializedNamed[] = []; for (const entry of this.named.list) { const named: SerializedNamed = { name: entry.name, expression: '', type: entry.type, }; if (entry.scope) { named.scope = this.sheets.Name(entry.scope); } if (entry.type === 'expression') { this.parser.Walk(entry.expression, unit => { if (unit.type === 'address' || unit.type === 'range') { const test = unit.type === 'range' ? unit.start : unit; test.absolute_column = test.absolute_row = true; if (!test.sheet) { if (test.sheet_id) { test.sheet = this.sheets.Name(test.sheet_id); } if (!test.sheet) { test.sheet = active_sheet?.name; } } if (unit.type === 'range') { unit.end.absolute_column = unit.end.absolute_row = true; } return false; } /* // if we do the function export thing, we need to call that here // (exporting functions to fix missing arguments or add decorators). // we're not doing that, at least not yet. else if (unit.type === 'call' && options.export_functions) { // ... } */ return true; }); // this is using the current locale settings, but unserialize // assumes we are unserializing in US-style locale. I think we // do that because excel always uses that? not sure, but we need // to be consistent. named.expression = this.parser.Render(entry.expression, { missing: '' }); } else { const area = { start: { ...entry.area.start, absolute_column: true, absolute_row: true, }, end: { ...entry.area.end, absolute_column: true, absolute_row: true, }, }; named.expression = this.AddressToLabel(area); } list.push(named); } return list; } /** * putting this here temporarily. it should probably move into a table * manager class or something like that. */ public ResolveStructuredReference(ref: UnitStructuredReference, context: ICellAddress): UnitAddress|UnitRange|undefined { let table: Table|undefined; // if there's no table specified, it means "I am in the table". // in that case we need to find the table from the cell. if (ref.table) { table = this.tables.get(ref.table.toLowerCase()); } else { if (context.sheet_id) { const sheet = this.sheets.Find(context.sheet_id); const cell = sheet?.CellData(context); table = cell?.table; } } if (!table) { return undefined; // table not found } // resolve the column const reference_column = ref.column.toLowerCase(); let column = -1; if (table.columns) { // FIXME: make this required for (let i = 0; i < table.columns.length; i++) { if (reference_column === table.columns[i]) { column = table.area.start.column + i; break; } } } if (column < 0) { return undefined; // invalid column } // for row scope, make sure we're in a valid row. if (ref.scope === 'row') { const row = context.row; if (row < table.area.start.row || row > table.area.end.row) { return undefined; // invalid row for "this row" } // OK, we can use this return { label: ref.label, type: 'address', row, column, sheet_id: table.area.start.sheet_id, id: ref.id, position: ref.position, }; } else { // the difference between 'all' and 'column' is that 'all' includes // the first (header) row and the last (totals) row, if we have one. let start_row = table.area.start.row; let end_row = table.area.end.row; if (ref.scope === 'column') { start_row++; // skip headers if (table.totals_row) { end_row--; // skip totals } } return { label: ref.label, type: 'range', position: ref.position, id: ref.id, start: { type: 'address', row: start_row, column, sheet_id: table.area.start.sheet_id, label: ref.label, position: ref.position, id: 0, }, end: { type: 'address', row: end_row, column, label: ref.label, position: ref.position, id: 0, }, } } return undefined; } /** * return an address label for this address (single cell or range). * * this is doing something unexpected: for dynamic arrays, it seems * to be filling out the full array. this could be an issue with how * we're calling it in the specific case of external editors. * * there should be no case where this is actually intended, because * the resulting label is garbage. nevertheless, I don't want to change * any existing behavior so we will move it behind a flag and default to * prior behavior. if necessary later we can change the default/remove the * old behavior. * * @param address * @param active_sheet */ public AddressToLabel(address: ICellAddress|IArea, correct_dynamic_arrays = false) { let start: ICellAddress; const parts: string[] = []; if (IsCellAddress(address)) { start = address; parts.push(Area.CellAddressToLabel(address)); } else { start = address.start; parts.push(Area.CellAddressToLabel(address.start)); if (!(address.start.spill && correct_dynamic_arrays)) { parts.push(Area.CellAddressToLabel(address.end)); } } /* const start = is_address ? address : address.start; const parts = is_address ? [Area.CellAddressToLabel(address)] : [Area.CellAddressToLabel(address.start), Area.CellAddressToLabel(address.end)]; */ const sheet = this.sheets.Find(start.sheet_id || 0); const name = (sheet?.name) ? (QuotedSheetNameRegex.test(sheet.name) ? `'${sheet.name}'` : sheet.name) : ''; return name + (name ? '!' : '') + (parts[0] === parts[1] ? parts[0] : parts.join(':')); } // --- resolution api, moved from calculator --------------------------------- /** * returns false if the sheet cannot be resolved, which probably * means the name changed (that's the case we are working on with * this fix). */ public ResolveSheetID(expr: UnitAddress|UnitRange, context?: ICellAddress, active_sheet?: Sheet): boolean { const target = expr.type === 'address' ? expr : expr.start; if (target.sheet_id) { return true; } if (target.sheet) { const sheet = this.sheets.Find(target.sheet); if (sheet) { target.sheet_id = sheet.id; return true; } } else if (context?.sheet_id) { target.sheet_id = context.sheet_id; return true; } else if (active_sheet?.id) { target.sheet_id = active_sheet.id; return true; } return false; // the error } /** wrapper method ensures it always returns an Area (instance, not interface) */ public ResolveArea(address: string|ICellAddress|IArea, active_sheet: Sheet, options?: { r1c1?: boolean }): Area { const resolved = this.ResolveAddress(address, active_sheet, options); return IsCellAddress(resolved) ? new Area(resolved) : new Area(resolved.start, resolved.end); } /** * moved from embedded sheet. also modified to preserve ranges, so it * might return a range (area). if you are expecting the old behavior * you need to check (perhaps we could have a wrapper, or make it optional?) * * Q: why does this not go in grid? or model? (...) * Q: why are we not preserving absoute/relative? (...) * */ public ResolveAddress(address: string|ICellAddress|IArea, active_sheet: Sheet, options? : { r1c1?: boolean }): ICellAddress|IArea { if (typeof address === 'string') { if (options?.r1c1) { this.parser.Save(); this.parser.flags.r1c1 = true; } const parse_result = this.parser.Parse(address); if (options?.r1c1) { this.parser.Restore(); } if (parse_result.expression && parse_result.expression.type === 'address') { this.ResolveSheetID(parse_result.expression, undefined, active_sheet); return { row: parse_result.expression.row, column: parse_result.expression.column, sheet_id: parse_result.expression.sheet_id, }; } else if (parse_result.expression && parse_result.expression.type === 'range') { this.ResolveSheetID(parse_result.expression, undefined, active_sheet); return { start: { row: parse_result.expression.start.row, column: parse_result.expression.start.column, sheet_id: parse_result.expression.start.sheet_id, }, end: { row: parse_result.expression.end.row, column: parse_result.expression.end.column, } }; } else if (parse_result.expression && parse_result.expression.type === 'identifier') { const named = this.GetName(parse_result.expression.name, active_sheet.id); if (named?.type === 'range') { return named.area; } /* // is named range guaranteed to have a sheet ID? (I think yes...) const named_range = this.named_ranges.Get(parse_result.expression.name); if (named_range) { return named_range; } */ } return { row: 0, column: 0 }; // default for string types -- broken } return address; // already range or address } public AddConnectedElement(connected_element: ConnectedElementType): number { const id = this.connected_element_id++; this.connected_elements.set(id, connected_element); return id; } public RemoveConnectedElement(id: number) { const element = this.connected_elements.get(id); this.connected_elements.delete(id); return element; } /** * identifier for connected elements, used to manage. these need to be * unique in the lifetime of a model instance, but no more than that. */ protected connected_element_id = 100; /** * these are intentionally NOT serialized. they're ephemeral, created * at runtime and not persistent. * * @internal */ public connected_elements: Map = new Map(); // --- moving translation here ---------------------------------------------- /** * maps common language (english) -> local language. this should * be passed in (actually set via a function). */ private language_map?: Record; /** * maps local language -> common (english). this should be constructed * when the forward function is passed in, so there's a 1-1 correspondence. */ private reverse_language_map?: Record; /** * set the language translation map. this is a set of function names * (in english) -> the local equivalent. both should be in canonical form, * as that will be used when we translate one way or the other. */ public SetLanguageMap(language_map?: Record) { if (!language_map) { this.language_map = this.reverse_language_map = undefined; } else { const keys = Object.keys(language_map); // normalize forward this.language_map = {}; for (const key of keys) { this.language_map[key.toUpperCase()] = language_map[key]; } // normalize backward this.reverse_language_map = {}; for (const key of keys) { const value = language_map[key]; this.reverse_language_map[value.toUpperCase()] = key; } } /* // we might need to update the current displayed selection. depends // on when we expect languages to be set. if (!this.primary_selection.empty) { this.Select(this.primary_selection, this.primary_selection.area, this.primary_selection.target); } */ } /** * translate function from common (english) -> local language. this could * be inlined (assuming it's only called in one place), but we are breaking * it out so we can develop/test/manage it. */ public TranslateFunction(value: string, options?: { r1c1?: boolean }): string { if (this.language_map) { return this.TranslateInternal(value, this.language_map, this.language_model?.boolean_true, this.language_model?.boolean_false, options); } return value; } /** * translate from local language -> common (english). * @see TranslateFunction */ public UntranslateFunction(value: string, options?: { r1c1?: boolean }): string { if (this.reverse_language_map) { return this.TranslateInternal(value, this.reverse_language_map, 'TRUE', 'FALSE', options); } return value; } public UntranslateData(value: CellValue|CellValue[]|CellValue[][], options?: { r1c1?: boolean }): CellValue|CellValue[]|CellValue[][] { if (Array.isArray(value)) { // could be 1d or 2d. typescript is complaining, not sure why... if (Is2DArray(value)) { return value.map(row => row.map(entry => { if (entry && typeof entry === 'string' && entry[0] === '=') { return this.UntranslateFunction(entry, options); } return entry; })); } else { return value.map(entry => { if (entry && typeof entry === 'string' && entry[0] === '=') { return this.UntranslateFunction(entry, options); } return entry; }); } } else if (value && typeof value === 'string' && value[0] === '=') { // single value value = this.UntranslateFunction(value, options); } return value; } /** * translation back and forth is the same operation, with a different * (inverted) map. although it still might be worth inlining depending * on cost. * * FIXME: it's about time we started using proper maps, we dropped * support for IE11 some time ago. */ private TranslateInternal(value: string, map: Record, boolean_true?: string, boolean_false?: string, options?: { r1c1?: boolean }): string { this.parser.Save(); this.parser.flags.r1c1 = options?.r1c1; const parse_result = this.parser.Parse(value); if (parse_result.expression) { let modified = false; this.parser.Walk(parse_result.expression, unit => { if (unit.type === 'call') { const replacement = map[unit.name.toUpperCase()]; if (replacement) { modified = true; unit.name = replacement; } } else if (unit.type === 'literal' && typeof unit.value === 'boolean') { // to/from english/locale depends on the direction, but we're not // passing that in? FIXME, pass it as a parameter. it doesn't matter // here, but later when we render. modified = true; } return true; }); if (modified) { return '=' + this.parser.Render(parse_result.expression, { missing: '', boolean_true, boolean_false, r1c1: options?.r1c1, }); } } this.parser.Restore(); return value; } /** * this is not public _yet_ * * @internal */ public SetLanguage(model?: LanguageModel): void { this.language_model = model; if (!model) { this.SetLanguageMap(); // clear // set defaults for parsing. this.parser.flags.boolean_true = 'TRUE'; this.parser.flags.boolean_false = 'FALSE'; } else { // create a name map for grid const map: Record< string, string > = {}; if (model.functions) { for (const entry of model.functions || []) { map[entry.base.toUpperCase()] = entry.name; // toUpperCase because of a data error -- fix at the source } } this.SetLanguageMap(map); // console.info({map}); if (!model.boolean_false) { model.boolean_false = map['FALSE']; } if (!model.boolean_true) { model.boolean_true = map['TRUE']; } // set defaults for parsing. this.parser.flags.boolean_true = model.boolean_true || 'TRUE'; this.parser.flags.boolean_false = model.boolean_false || 'FALSE'; // console.info("booleans:", this.model.parser.flags.boolean_true, ",", this.model.parser.flags.boolean_false) } for (const sheet of this.sheets.list) { sheet.FlushCellStyles(); } } }