Files
thptqg2017/parser/internal/reader/reader.go
T
tiennm99 c359a0b444 refactor: one directory per pipeline stage, and an assembler to drive them
The repository now reads as the pipeline it is: crawler fetches, parser
converts, assembler verifies and publishes, with data/ and web/ as the stores
they hand work through. go-parser is renamed parser now that there is no other.

The assembler replaces build-db.js and assemble-site.js. It compiles the
parser, builds and verifies each database, compresses it, runs the Vite build
and assembles _site — one command, and the only place that knows the order.

It also closes a real hole: nothing previously asserted that a database reached
the site. An empty staging directory assembled happily, so every page rendered,
every query 404d and CI stayed green. The row-count and size guards could not
catch that, since they only run when a database was built at all.

Removing Node from the root forced the dataset list out of web/src/datasets.js,
which the assembler cannot import. datasets.json is now the registry both sides
read — JSON because Go and the browser both parse it without a dependency —
while presentation stays in the web app, keyed by id and cross-checked against
the registry so a half-added dataset fails instead of half-working.

Guards verified by making each one fail: a missing database, and an expected
row count one higher than the truth.
2026-08-13 22:50:05 +07:00

80 lines
2.4 KiB
Go

// Package reader wraps the two spreadsheet libraries behind one streaming
// contract, mirroring parser/src/reader.rs (which wraps calamine's
// open_workbook_auto for both formats).
//
// The contract is deliberately row-streaming rather than whole-workbook
// materialising: data/2017/ha-noi.xls alone holds 72k rows across two sheets,
// and the Rust original keeps at most one sheet range live at a time.
package reader
import (
"fmt"
"path/filepath"
"strings"
)
// Cell is one spreadsheet cell rendered the way calamine's Data::to_string()
// renders it.
//
// IsEmpty tracks calamine's Data::Empty variant separately from a string cell
// that happens to be empty. Rust distinguishes them at reader.rs:42, but both
// render "" and both count as blank in is_all_blank and in transform, so the
// flag is diagnostic only — never compare on it.
type Cell struct {
Str string
IsEmpty bool
}
// Sheet carries a sheet's identity and geometry. Height and Width describe the
// used range, matching calamine's Range::height()/width(); every used range in
// the corpus starts at (0,0), verified across all 299 files.
type Sheet struct {
Index int
Name string
Height int
Width int
}
// RowFunc receives each row of each sheet. Rows are padded to the sheet's used
// width — width is load-bearing because every column read downstream is
// positional with an unwrap_or_default() equivalent, so a short row silently
// NULLs its tail columns.
type RowFunc func(sheet Sheet, rowIdx int, row []Cell) error
// Workbook is one opened spreadsheet.
type Workbook interface {
// Sheets returns sheet identity and geometry in workbook order.
Sheets() []Sheet
// EachRow streams every row of the given sheet in order.
EachRow(sheetIdx int, fn RowFunc) error
Close() error
}
// Open dispatches on file extension, mirroring calamine's open_workbook_auto.
func Open(path string) (Workbook, error) {
switch strings.ToLower(filepath.Ext(path)) {
case ".xls":
return openXLS(path)
case ".xlsx", ".xlsm":
return openXLSX(path)
default:
return nil, fmt.Errorf("unsupported extension: %s", path)
}
}
// padRow extends row to width with empty cells, and truncates if longer.
func padRow(row []Cell, width int) []Cell {
if len(row) == width {
return row
}
if len(row) > width {
return row[:width]
}
out := make([]Cell, width)
copy(out, row)
for i := len(row); i < width; i++ {
out[i] = Cell{IsEmpty: true}
}
return out
}