/*
* 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 { Area } from 'treb-base-types';
import type { IArea } from 'treb-base-types';
import type { ExpressionUnit, Parser } from 'treb-parser';
interface NamedExpression {
type: 'expression';
expression: ExpressionUnit;
}
interface NamedRange {
type: 'range';
area: Area;
}
/** @internal */
export type Named = (NamedExpression | NamedRange) & {
name: string; // canonical name
scope?: number; // scope to sheet by ID
};
/**
* serialized type is a composite of expression/range. we determine
* what it is when parsing the expression. this simplifies passing these
* things around.
*
* (named expressions and ranges they have slightly different behavior,
* which is why we have a distinction at all).
*
*/
export interface SerializedNamed {
name: string;
/** expression or address/area */
expression: string;
/** scope is a sheet name (not ID) */
scope?: string;
/**
* adding type. this is optional, it's not used by tooling. it's
* just for informational purpopses for clients.
*/
type?: 'range'|'expression';
}
/**
* this is a replacement for the name manager, which handles
* operations relating to named ranges.
*/
export class NamedRangeManager {
/**
* this map is stored with normalized names. normalized names
* here means we call `toLowerCase`. the objects themselves
* contain canonical names.
*
* ...we've always had a map for this, for fast lookups. but
* with scoping, we can't necessarily look up by name. let's try
* using scope:name keys. that way we can search for scope:name
* and then name and return the first match, if any.
*
*/
protected named: Map = new Map();
public get list() {
return this.named.values();
}
constructor(public parser: Parser) {}
/** shorthand for setting named expression */
public SetNamedExpression(name: string, expression: ExpressionUnit, scope?: number) {
return this.SetName({
type: 'expression',
name,
expression,
scope,
});
}
/** shorthand for setting named range */
public SetNamedRange(name: string, area: IArea, scope?: number) {
return this.SetName({
type: 'range',
name,
area: new Area(area.start, area.end),
scope,
});
}
/**
* add name. names are case-insensitive. if the name already
* exists, it will be overwritten.
*
* update: returns success (FIXME: proper errors)
*/
private SetName(named: Named): boolean {
const name = named.name;
// console.info('set name', named.name, {named});
const validated = this.ValidateNamed(name);
if (!validated) {
console.warn('invalid name', {name});
return false;
}
/*
if (named.type === 'range') {
// why is this considered invalid here? I've seen it done.
// maybe something we're doing with these ranges doesn't
// collapse them? (...)
if (named.area.entire_column || named.area.entire_row) {
console.info({named});
console.warn('invalid range');
return false;
}
}
*/
// this.named.set(name.toLowerCase(), named);
this.named.set(this.ScopedName(name, named.scope), named);
return true;
}
private ScopedName(name: string, scope?: number) {
if (typeof scope === 'number') {
return scope + ':' + name.toLowerCase();
}
return name.toLowerCase();
}
public ClearName(name: string, scope?: number): void {
if (typeof scope === 'number') {
this.named.delete(this.ScopedName(name, scope));
}
else {
this.named.delete(name.toLowerCase());
}
}
/**
* if we delete a sheet, remove ranges in that sheet. also remove
* anything that's scoped to the sheet.
*/
public RemoveRangesForSheet(sheet_id: number) {
const remove: string[] = [];
for (const [name, entry] of this.named) {
if (entry.type === 'range' && entry.area.start.sheet_id === sheet_id) {
remove.push(name);
}
else if (entry.scope === sheet_id) {
remove.push(name);
}
}
for (const name of remove) {
this.named.delete(name)
}
}
public Reset() {
this.named.clear();
}
/**
* requiring scope to help propgogate changes. we check the scoped
* version first; if that's not found, we default to the global version.
* that implies that if there are both, we'll prefer the scoped name.
*
* now possible to require scope, for qualified scoped names
*
* Q: why require scope? what's the benefit of that? (...)
*
*/
public Get_(name: string, scope: number, require_scope = false) {
if (require_scope) {
return this.named.get(this.ScopedName(name, scope));
}
return this.named.get(this.ScopedName(name, scope)) ?? this.named.get(name.toLowerCase());
}
/**
* named range rules:
*
* - legal characters are alphanumeric, underscore and dot.
*
* - must start with letter or underscore (not a number or dot).
* UPDATE: also possibly a backslash? not sure if that's escaping or not.
*
* - cannot look like a spreadsheet address, which is 1-3 letters followed by numbers.
* UPDATE: actually this is legal if the number is "0". that's the only
* number. I don't think our parser will process this correctly, so until
* then keep it illegal.
*
* - apparently questuon marks are legal, but not in first position. atm
* our parser will reject.
*
* - FIXME: we should block R1C1-looking notation as well
*
* returns a normalized name (just caps, atm)
*/
public ValidateNamed(name: string): string|false {
// normalize
name = name.trim().toUpperCase();
// can't be empty
if (!name.length) return false;
// can only contain legal characters [FIXME: backslash is legal?]
if (/[^A-Z\d_.?]/.test(name)) return false;
// must start with ascii letter or underscore
if (/^[^A-Z_]/.test(name)) return false;
// these are not legal for some reason -- they look like R1C1?
if (name === 'R' || name === 'C') {
return false;
}
// FIXME: should we just use a parser? we're adding more and more regexes.
// can't look like a spreadsheet address, unless the row is === 0
if (/^[A-Z]{1,3}[1-9]\d*$/.test(name)) return false;
// can't look like R1C1 either (we already checked for R and C)
if (/^R[[\]\d]+C/.test(name)) {
return false;
}
return name;
}
/**
* match an area, optionally a target within a larger area (for selections).
* we don't use the selection directly, as we may need to adjust target for
* merge area. returns the name only if the area is an exact match.
*/
public MatchSelection(area: Area, target?: Area): string|undefined {
if (!area.start.sheet_id) {
throw new Error('match selection without sheet id');
}
let label: string|undefined;
for (const entry of this.named.values()) {
if (entry.type === 'range') {
if (entry.area.start.sheet_id === area.start.sheet_id) {
if (area.Equals(entry.area)) {
label = entry.name; // don't break, in case there's a match for target which takes precendence.
}
if (target?.Equals(entry.area)) {
return entry.name;
}
}
}
}
return label;
}
/**
* fix named range references after row/column insert/delete
*
* surely there's overlap between this function and what we do in
* grid when columns are added/removed. can we consolidate? (FIXME/TODO)
*
*/
public PatchNamedRanges(sheet_id: number, before_column: number, column_count: number, before_row: number, row_count: number) {
const copy = [...this.list];
for (const entry of copy) {
if (entry.type === 'expression') {
continue;
}
const key = entry.name;
const range = entry.area;
if (range.start.sheet_id !== sheet_id) {
console.info('skipping name', key);
continue;
}
if (column_count && before_column <= range.end.column) {
/*
// (1) we are before the insert point, not affected
if (before_column > range.end.column) {
continue;
}
*/
if (column_count > 0) {
// (2) it's an insert and we are past the insert point:
// increment [start] and [end] by [count]
if (before_column <= range.start.column) {
range.Shift(0, column_count);
}
// (3) it's an insert and we contain the insert point:
// increment [end] by [count]
else if (before_column > range.start.column && before_column <= range.end.column) {
range.ConsumeAddress({row: range.end.row, column: range.end.column + column_count});
}
else {
console.warn(`PNR X case 1`, before_column, column_count, JSON.stringify(range));
}
}
else if (column_count < 0) {
// (4) it's a delete and we are past the delete point (before+count):
// decrement [start] and [end] by [count]
if (before_column - column_count <= range.start.column) {
range.Shift(0, column_count);
}
// (5) it's a delete and contains the entire range
else if (before_column <= range.start.column && before_column - column_count > range.end.column) {
this.ClearName(key);
}
// (6) it's a delete and contains part of the range. clip the range.
else if (before_column <= range.start.column) {
const last_column = before_column - column_count - 1;
this.SetName({
type: 'range', area: new Area({
row: range.start.row, column: last_column + 1 + column_count, sheet_id }, {
row: range.end.row, column: range.end.column + column_count }), name: key, });
}
else if (before_column <= range.end.column) {
const last_column = before_column - column_count - 1;
if (last_column >= range.end.column) {
this.SetName({ type: 'range', area: new Area({
row: range.start.row, column: range.start.column, sheet_id }, {
row: range.end.row, column: before_column - 1 }), name: key });
}
else {
this.SetName({ type: 'range', name: key, area: new Area({
row: range.start.row, column: range.start.column, sheet_id }, {
row: range.end.row, column: range.start.column + range.columns + column_count - 1})});
}
}
else {
console.warn(`PNR X case 2`, before_column, column_count, JSON.stringify(range));
}
}
}
if (row_count && before_row <= range.end.row) {
/*
// (1) we are before the insert point, not affected
if (before_row > range.end.row) {
continue;
}
*/
if (row_count > 0) {
// (2) it's an insert and we are past the insert point:
// increment [start] and [end] by [count]
if (before_row <= range.start.row) {
range.Shift(row_count, 0);
}
// (3) it's an insert and we contain the insert point:
// increment [end] by [count]
else if (before_row > range.start.row && before_row <= range.end.row) {
range.ConsumeAddress({row: range.end.row + row_count, column: range.end.column});
}
else {
console.warn(`PNR X case 3`, before_row, row_count, JSON.stringify(range));
}
}
else if (row_count < 0) {
// (4) it's a delete and we are past the delete point (before+count):
// decrement [start] and [end] by [count]
if (before_row - row_count <= range.start.row) {
range.Shift(row_count, 0);
}
// (5) it's a delete and contains the entire range
else if (before_row <= range.start.row && before_row - row_count > range.end.row) {
this.ClearName(key);
}
// (6) it's a delete and contains part of the range. clip the range.
else if (before_row <= range.start.row) {
const last_row = before_row - row_count - 1;
this.SetNamedRange(key, new Area({
column: range.start.column, row: last_row + 1 + row_count, sheet_id }, {
column: range.end.column, row: range.end.row + row_count }));
}
else if (before_row <= range.end.row) {
const last_row = before_row - row_count - 1;
if (last_row >= range.end.row) {
this.SetNamedRange(key, new Area({
column: range.start.column, row: range.start.row, sheet_id }, {
column: range.end.column, row: before_row - 1 }));
}
else {
this.SetNamedRange(key, new Area({
column: range.start.column, row: range.start.row, sheet_id }, {
column: range.end.column, row: range.start.row + range.rows + row_count - 1 }));
}
}
else {
console.warn(`PNR X case 4`, before_row, row_count, JSON.stringify(range));
}
}
}
}
}
}