Files
Spendlight/scripts/profile-samples.mjs
Chris Olson c3cf0f3eb8 Milestones 0 and 1: foundation and money in (20261007.10)
Foundation: single arm64 container (Hono + SQLite via Drizzle, React + Vite),
local sign-in with long-lived sessions and passkeys, roles (read-only, full
access, admin), nightly and pre-upgrade backups, light and dark themes,
date-based version numbers, and the ideas lightbulb behind IDEAS_ENABLED.

Money in: accounts with balances, CSV import with bank detection, a column
mapping pop-up for unknown layouts, duplicate checks, transfer pairing and
undo; one-tap review with learned rules; activity with search, splits, tags,
notes and cash entries. Statement files are blocked from commits by a
pre-commit hook; tests use synthetic fixtures only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:52:07 -04:00

156 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// Describe statement files without revealing their contents.
//
// node scripts/profile-samples.mjs [folder] (default: samples)
//
// Prints, for each file: its layout, header labels, and value *shapes* only.
// Every letter becomes A/a and every digit 9, so "KROGER #123" reads
// "AAAAAA #999" and "-54.20" reads "-99.99". Rows made only of labels (no
// digits at all, such as a CSV header) are shown as they are. This is what
// Claude reads to build importers; the raw files never leave this server.
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { basename, extname, join, relative } from 'node:path';
const root = process.argv[2] ?? 'samples';
const SKIP = new Set(['README.md', '.DS_Store']);
const mask = (s) => s.replace(/[A-Z]/g, 'A').replace(/[a-z]/g, 'a').replace(/[0-9]/g, '9').replace(/[^\x20-\x7e]/g, '?');
const shape = (s) =>
s.trim() === ''
? '(empty)'
: s.trim().replace(/[0-9]+/g, '9').replace(/[A-Z]+/g, 'A').replace(/[a-z]+/g, 'a').replace(/[^\x20-\x7e]/g, '?');
const maskName = (name) => name.replace(/[0-9]{3,}/g, (d) => '9'.repeat(d.length));
function files(dir) {
return readdirSync(dir).flatMap((name) => {
if (SKIP.has(name) || name.startsWith('_') || name.startsWith('.')) return [];
const path = join(dir, name);
return statSync(path).isDirectory() ? files(path) : [path];
});
}
/** A small CSV reader that understands quoted fields and embedded commas or newlines. */
function parseCsv(text, delimiter) {
const rows = [];
let row = [];
let field = '';
let quoted = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
field += '"';
i++;
} else if (ch === '"') quoted = false;
else field += ch;
} else if (ch === '"') quoted = true;
else if (ch === delimiter) {
row.push(field);
field = '';
} else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i++;
row.push(field);
rows.push(row);
row = [];
field = '';
} else field += ch;
}
if (field !== '' || row.length) {
row.push(field);
rows.push(row);
}
return rows;
}
const isLabelRow = (cells) => cells.filter((c) => c.trim()).length >= 2 && cells.every((c) => !/[0-9]/.test(c));
const showRow = (cells) => (isLabelRow(cells) ? cells.map((c) => c.trim()) : cells.map(mask));
const numeric = (s) => /^\s*[-+(]?\s*\$?\s*[0-9][0-9,]*(\.[0-9]+)?\s*\)?\s*(CR|DR)?\s*$/i.test(s);
function profileCsv(text) {
const firstLines = text.split(/\r?\n/).slice(0, 20).join('\n');
const counts = { ',': 0, '\t': 0, ';': 0, '|': 0 };
for (const ch of firstLines) if (ch in counts) counts[ch]++;
const delimiter = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
const rows = parseCsv(text, delimiter).filter((r) => r.some((c) => c.trim() !== ''));
const out = [];
out.push(`- Delimiter: ${JSON.stringify(delimiter)} · rows (non-blank): ${rows.length}`);
const widths = {};
for (const r of rows) widths[r.length] = (widths[r.length] ?? 0) + 1;
out.push(`- Columns per row: ${Object.entries(widths).map(([w, n]) => `${w} cols × ${n}`).join(', ')}`);
const headerIndex = rows.findIndex((r, i) => isLabelRow(r) && rows.slice(i + 1, i + 4).some((n) => n.length === r.length));
out.push(`- Header row: ${headerIndex >= 0 ? `row ${headerIndex + 1}` : 'none found'}`);
out.push('', '```text');
rows.slice(0, 12).forEach((r, i) => out.push(`${String(i + 1).padStart(3)}: ${showRow(r).join(' | ')}`));
if (rows.length > 15) out.push(' …');
rows.slice(Math.max(12, rows.length - 3)).forEach((r, i, arr) => out.push(`${String(rows.length - arr.length + i + 1).padStart(3)}: ${showRow(r).join(' | ')}`));
out.push('```');
if (headerIndex >= 0) {
const header = rows[headerIndex];
const data = rows.slice(headerIndex + 1).filter((r) => r.length === header.length);
out.push('', `Columns (${data.length} data rows):`, '');
header.forEach((name, col) => {
const values = data.map((r) => r[col] ?? '');
const shapes = {};
for (const v of values) shapes[shape(v)] = (shapes[shape(v)] ?? 0) + 1;
const top = Object.entries(shapes)
.sort((a, b) => b[1] - a[1])
.slice(0, 4)
.map(([s, n]) => `\`${s}\` ×${n}`)
.join(', ');
let extra = '';
const nums = values.filter((v) => numeric(v));
if (nums.length >= values.length * 0.6 && nums.length > 0) {
const neg = nums.filter((v) => /^\s*-|\(|DR/i.test(v)).length;
extra = ` · numeric: ${nums.length - neg} positive, ${neg} negative`;
} else {
const distinct = new Set(values.map((v) => v.trim()).filter(Boolean)).size;
extra = ` · ${distinct} distinct values`;
if (/^\d{1,2}\/\d{1,2}\/\d{2,4}$/.test(values.find((v) => v.trim()) ?? '')) {
const firsts = values.map((v) => Number(v.split('/')[0])).filter(Number.isFinite);
const seconds = values.map((v) => Number(v.split('/')[1])).filter(Number.isFinite);
extra += ` · first part max ${Math.max(...firsts)}, second part max ${Math.max(...seconds)}`;
}
}
out.push(`- **${name.trim() || `(column ${col + 1})`}**: ${top}${extra}`);
});
}
return out;
}
function profileOfx(text) {
const out = [];
const headerLines = text.split(/\r?\n/).filter((l) => /^[A-Z]+:[A-Z0-9]+$/.test(l.trim()));
if (headerLines.length) out.push(`- OFX header: ${headerLines.join(' ')}`);
const tags = {};
const examples = {};
for (const m of text.matchAll(/<([A-Z0-9.]+)>([^<\r\n]*)/g)) {
tags[m[1]] = (tags[m[1]] ?? 0) + 1;
if (m[2].trim() && !examples[m[1]]) examples[m[1]] = mask(m[2].trim());
}
out.push(`- Transactions (STMTTRN): ${tags.STMTTRN ?? 0}`);
out.push('', 'Tags (count, masked example):', '');
for (const [tag, n] of Object.entries(tags)) out.push(`- ${tag} ×${n}${examples[tag] ? ` · \`${examples[tag]}\`` : ''}`);
return out;
}
const list = files(root);
console.log(`# Sample profiles\n\n${list.length} file(s) under ${root}/. Values are masked: letters → A/a, digits → 9.\n`);
for (const path of list) {
const buffer = readFileSync(path);
const bom = buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf;
const text = buffer.toString('utf8').replace(/^/, '');
const ext = extname(path).toLowerCase();
const rel = relative(root, path);
console.log(`## ${maskName(rel)}\n`);
console.log(`- Size: ${buffer.length} bytes · extension: ${ext || '(none)'} · BOM: ${bom ? 'yes' : 'no'} · line endings: ${text.includes('\r\n') ? 'CRLF' : 'LF'} · non-ASCII bytes: ${/[^\x00-\x7f]/.test(text) ? 'yes' : 'no'}`);
const looksOfx = /<OFX>|OFXHEADER/i.test(text.slice(0, 2000));
const lines = looksOfx ? profileOfx(text) : profileCsv(text);
console.log(lines.join('\n') + '\n');
}
void basename;