Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

4.3 kB Ruw bestand
/**
 * Self-numbering text columns.
 *
 * A pattern like `INV-{yyyy}-{nnn}` fills the column whenever a row arrives
 * with the cell empty. The counter is never stored anywhere: the next number
 * is read off the rows already in the column, among those that match the rest
 * of the pattern as it renders today. That is also what makes the numbering
 * controllable — put `{yyyy}` (or `{q}`, or `{mm}`) in the pattern and the
 * count starts over when the year turns, because nothing older matches any
 * more. Typing a value by hand always wins; the pattern only speaks for cells
 * left empty.
 */

export const SEQUENCE_MAX_LENGTH = 60;

/** The tokens a pattern may carry; everything else in it is literal text. */
const TOKEN = /\{(yyyy|yy|mm|q|n{1,6})\}/g;

type Part =
  | { kind: 'literal'; text: string }
  | { kind: 'date'; token: 'yyyy' | 'yy' | 'mm' | 'q' }
  | { kind: 'counter'; width: number };

function parts(pattern: string): Part[] {
  const out: Part[] = [];
  let last = 0;
  for (const m of pattern.matchAll(TOKEN)) {
    if (m.index! > last) out.push({ kind: 'literal', text: pattern.slice(last, m.index) });
    const token = m[1]!;
    if (token.startsWith('n')) out.push({ kind: 'counter', width: token.length });
    else out.push({ kind: 'date', token: token as 'yyyy' | 'yy' | 'mm' | 'q' });
    last = m.index! + m[0].length;
  }
  if (last < pattern.length) out.push({ kind: 'literal', text: pattern.slice(last) });
  return out;
}

/** Why a pattern cannot be a numbering, or null when it can. */
export function validateSequence(pattern: string): string | null {
  if (!pattern.trim()) return 'A numbering pattern cannot be empty.';
  if (pattern.length > SEQUENCE_MAX_LENGTH) {
    return `Numbering patterns are limited to ${SEQUENCE_MAX_LENGTH} characters.`;
  }
  // A brace that is not a known token is a typo, not a literal — accepting it
  // would number nothing and say nothing.
  const leftover = pattern.replace(TOKEN, '').match(/\{[^}]*\}|[{}]/);
  if (leftover) {
    return `"${leftover[0]}" is not a token. The counter is {n} ({nnn} to pad); {yyyy} {yy} {mm} {q} are today's date.`;
  }
  const counters = parts(pattern).filter((p) => p.kind === 'counter');
  if (counters.length === 0) {
    return 'The pattern needs a counter: {n}, or {nnn} to pad it to three digits.';
  }
  if (counters.length > 1) {
    return 'One counter only — a second {n} would have to hold the same number.';
  }
  return null;
}

function renderDate(token: 'yyyy' | 'yy' | 'mm' | 'q', date: Date): string {
  switch (token) {
    case 'yyyy':
      return String(date.getFullYear());
    case 'yy':
      return String(date.getFullYear() % 100).padStart(2, '0');
    case 'mm':
      return String(date.getMonth() + 1).padStart(2, '0');
    case 'q':
      return String(Math.floor(date.getMonth() / 3) + 1);
  }
}

/** The pattern with its counter at `n` — what the cell would actually hold. */
export function renderSequence(pattern: string, n: number, date: Date): string {
  return parts(pattern)
    .map((p) => {
      if (p.kind === 'literal') return p.text;
      if (p.kind === 'date') return renderDate(p.token, date);
      // Padding is a floor, not a ceiling: {nnn} writes 001 and, when the
      // column outgrows it, 1000 — a counter must never wrap back to 000.
      return String(n).padStart(p.width, '0');
    })
    .join('');
}

/**
 * The next value the pattern would write, given what the column holds now.
 *
 * Only values matching the pattern as rendered for `date` take part — that is
 * the whole scoping mechanism. Hand-typed values that do not match are simply
 * outside the numbering, and the highest matching counter plus one keeps the
 * sequence monotonic even when rows in the middle have been deleted.
 */
export function nextSequenceValue(
  pattern: string,
  existing: readonly string[],
  date: Date,
): string {
  const regex = new RegExp(
    '^' +
      parts(pattern)
        .map((p) => {
          if (p.kind === 'literal') return p.text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
          if (p.kind === 'date') return renderDate(p.token, date).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
          return '(\\d+)';
        })
        .join('') +
      '$',
  );

  let highest = 0;
  for (const value of existing) {
    const m = regex.exec(value);
    if (m) highest = Math.max(highest, parseInt(m[1]!, 10));
  }
  return renderSequence(pattern, highest + 1, date);
}