Re-sort the export and every line goes red. Nothing changed.
Redline diffs two spreadsheets by matching rows on their contents instead of their line numbers, so a re-sort reports zero changes. Go compiled to WASM, running in your tab, no upload.
Pull the same report twice, a month apart, and diff the two CSVs. Four thousand lines are red. Somewhere in there are the five edits you actually care about, but between the exports someone clicked a column header to sort, and that was enough. Every line moved, so every line reads as rewritten.
That is not a bug in diff. A text diff compares line 1 to line 1, line 2 to line 2. Its unit of
identity is position in the file, which is a completely reasonable thing to believe about source
code and a completely wrong thing to believe about a spreadsheet. In a sheet, the row is the
thing. Where it sits is an accident of whoever last clicked sort.
Redline takes the other position: a row's identity is what it says, not where it sits. Drop in a before file and an after file, and it hands back the grid you already know, marked up in place, with moved rows sitting quietly and only real edits lit up.
Fingerprint the row, then align
The mechanism is not exotic. Hash each row's cell values into a fingerprint, then run a Myers
longest-common-subsequence pass over the sequence of fingerprints instead of over characters.
Same algorithm family as git diff, pointed at rows.
LCS finds the longest run of rows the two files agree on, in order. That run is the spine of unchanged data. Everything that falls outside it is a genuine insert or delete, and inserts and deletes that land in the same gap get paired up and compared cell by cell, which is where changed cells come from. So the order of operations is: establish that two rows are the same row at two points in time, then and only then look at their cells. A row that just moved never reaches the cell comparison at all, because its fingerprint matched something on the other side and the alignment absorbed it.
The part that surprised me during the build is that columns have to be matched first, before any
row is fingerprinted. Say someone upstream inserts a region field in the middle of the header
row. If you fingerprint rows on raw cell values, every single fingerprint on the after side is now
different from every fingerprint on the before side, the LCS finds nothing in common, and the tool
confidently reports that the entire sheet was deleted and a new one inserted. It is the same
failure as the text diff, one layer up. So headers get matched by name first, columns get lined up
into a shared schema, and only then does a row become a fingerprint. Get that order backwards and
the whole thing is worse than useless, because it is wrong with a nice grid UI.
Go, WASM, and no server
The diff engine is Go compiled to WebAssembly. LCS over tens of thousands of rows is real work,
and it also happens to be a clean, testable core that has no idea the DOM exists. Parsing is
SheetJS in TypeScript, because rewriting .xlsx parsing would be effort
spent nowhere near the thing that makes this tool different.
Everything runs client-side. There is no server, which means "your spreadsheet is not uploaded anywhere" is true by construction rather than by policy, which is the only version of that sentence worth anything when the file is a vendor price list or a payroll snapshot. You can check it: open the network tab, drop two files, and watch nothing fire.
The honest limits. Fingerprinting is exact match, so a row where every cell changed cannot be recognized as the same row, and it comes back as one delete plus one insert. Duplicate rows are handled but ambiguous by nature: if a sheet has three identical rows and one goes away, no algorithm can tell you which one. And column matching leans on header names, so a renamed column reads as one dropped and one added.
Try it
Open Redline, drop last month's export on Before and this month's on After. If you want the demo in fifteen seconds instead: take one CSV, save a copy, sort it by a different column, change exactly one cell, and drop both in. You should get 1 changed cell and a lot of quiet. The source is on GitHub.
This post is part of the build log: every app my automated factory ships gets written up here, honestly. Browse everything at apps.charliekrug.com. Comments are open below.
Loading comments…