Spreadsheet-UX op een echte relationele engine

main

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

5.5 kB Ruw bestand
import { isValidDate, type Reading } from '@wellformed/shared';

/**
 * Reading values the way they were written, not the way SQL spells them.
 *
 * Shared between the importer and the tightening dry run on purpose: a file
 * has to mean the same thing whether it is being imported today or tightened
 * next month, so there is exactly one answer to "is `1.234,50` a number" and
 * both ask it here.
 */

/**
 * Which mark separates the decimals, and which end of a slashed date the day is
 * on, guessed from the column as a whole.
 *
 * `1.234` is a thousand in Amsterdam and one-and-a-bit in Chicago; `03/04/2026`
 * is April in one and March in the other. Neither is decidable from a single
 * value, and both are decidable from a column often enough to be worth trying —
 * one `31/12/2025` settles a thousand ambiguous rows above it. Where the column
 * does not settle it, this says so and the operator does.
 */
export function inferReading(texts: string[]): { reading: Required<Reading>; ambiguousDates: boolean } {
  let commaVotes = 0;
  let dotVotes = 0;
  let dayVotes = 0;
  let monthVotes = 0;
  let slashed = 0;

  for (const raw of texts) {
    const s = raw.trim();
    if (!s) continue;

    // `\s` already covers the non-breaking space a spreadsheet groups with.
    const stripped = s.replace(/[\s$€£¥₹()-]/g, '');
    const lastDot = stripped.lastIndexOf('.');
    const lastComma = stripped.lastIndexOf(',');
    if (lastDot >= 0 && lastComma >= 0) {
      // Both marks present: the rightmost one is the decimal, always.
      if (lastComma > lastDot) commaVotes++;
      else dotVotes++;
    } else if (lastComma >= 0 && /^\d+,\d{1,2}$/.test(stripped)) {
      // One comma with one or two digits after it is money, not a thousand.
      commaVotes++;
    } else if (lastDot >= 0 && /^\d+\.\d{1,2}$/.test(stripped) && !/^\d{1,3}\.\d{3}$/.test(stripped)) {
      dotVotes++;
    }

    const parts = /^(\d{1,2})[-/.](\d{1,2})[-/.](\d{2}|\d{4})$/.exec(s);
    if (parts) {
      slashed++;
      const first = Number(parts[1]);
      const second = Number(parts[2]);
      if (first > 12 && second <= 12) dayVotes++;
      else if (second > 12 && first <= 12) monthVotes++;
    }
  }

  return {
    reading: {
      // A tie goes to the dot, which is what every other number in this product
      // is already written with.
      decimal: commaVotes > dotVotes ? ',' : '.',
      dayFirst: monthVotes > dayVotes ? false : true,
    },
    // Dates in the file, and nothing in the file that decides which way round.
    ambiguousDates: slashed > 0 && dayVotes === 0 && monthVotes === 0,
  };
}

/** Read a number out of however it was written. Null when it is not one. */
export function readNumber(text: string, decimal: '.' | ','): number | null {
  let s = text.trim();
  if (!s) return null;

  // `(1,200.00)` is how a ledger export writes a negative.
  let negative = false;
  if (/^\(.*\)$/.test(s)) {
    negative = true;
    s = s.slice(1, -1);
  }

  s = s.replace(/[\s$€£¥₹]/g, '');
  if (s.startsWith('-')) {
    negative = !negative;
    s = s.slice(1);
  } else if (s.startsWith('+')) {
    s = s.slice(1);
  }

  /**
   * The other mark is only allowed where a thousands separator is allowed —
   * every three digits — so reading the column the wrong way round fails
   * loudly instead of turning `1.234,50` into 1.2345 and saying nothing. A
   * silent wrong number is the one outcome this whole module exists to avoid.
   */
  const plain = decimal === ',' ? /^\d+(,\d+)?$/ : /^\d+(\.\d+)?$/;
  const grouped = decimal === ',' ? /^\d{1,3}(\.\d{3})+(,\d+)?$/ : /^\d{1,3}(,\d{3})+(\.\d+)?$/;
  if (!plain.test(s) && !grouped.test(s)) return null;

  s = decimal === ',' ? s.replace(/\./g, '').replace(',', '.') : s.replace(/,/g, '');

  const n = Number(s);
  if (!Number.isFinite(n)) return null;
  return negative ? -n : n;
}

/** Read a date out of however it was written. Null when it is not one. */
export function readDate(text: string, dayFirst: boolean): string | null {
  // An exported timestamp is a date with a midnight nobody meant.
  const s = text.trim().replace(/[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?Z?$/, '');

  const iso = /^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/.exec(s);
  if (iso) return build(Number(iso[1]), Number(iso[2]), Number(iso[3]));

  const parts = /^(\d{1,2})[-/.](\d{1,2})[-/.](\d{2}|\d{4})$/.exec(s);
  if (parts) {
    const a = Number(parts[1]);
    const b = Number(parts[2]);
    /**
     * One reading for the whole column, applied to every value in it.
     *
     * Letting a `22/04` quietly read day-first inside a month-first column
     * would convert every row and leave two different calendars in one column,
     * which is the silent wrong number this module exists to avoid. So a value
     * the chosen reading cannot take is reported as one that will not convert —
     * and since the reading was guessed from the unambiguous values in the
     * first place, that only happens when somebody overrules the guess, which
     * is exactly when they should hear about it.
     */
    return build(year(parts[3]!), dayFirst ? b : a, dayFirst ? a : b);
  }

  return null;

  function build(y: number, m: number, d: number): string | null {
    const value = `${String(y).padStart(4, '0')}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
    return isValidDate(value) ? value : null;
  }

  /** Two digits: the same window every spreadsheet uses. */
  function year(raw: string): number {
    const n = Number(raw);
    if (raw.length === 4) return n;
    return n < 70 ? 2000 + n : 1900 + n;
  }
}