mirror of
https://github.com/tiennm99/thptqg.git
synced 2026-10-06 06:16:17 +00:00
Four of the 119 files in data/2016 publish one score column per subject instead of a DIEM_THI sentence, and none of them was being read correctly. The ĐH Công nghiệp Thực phẩm file puts a three-row ministry title block above its header, so no header was recognised and the positional fallback shifted every column by one: the serial number became so_bao_danh, the exam number became ho_ten, the name became ngay_sinh, and the national ID became the score cell. All 7,833 rows were unusable. The three ĐH Cần Thơ files name an SBD column but no DIEM_THI, so they fell to the same fallback: surname into ngay_sinh, given name into ten_cum_thi, birth date into the score cell, and 12,152 candidates with no scores at all. Both are now read by FormatSubjectColumns, which resolves identity and one column per subject from the header. The header is searched for in the first five rows, so a title block no longer hides it. The Cần Thơ score columns are numbered rather than named. They follow the order the exam was sat — each morning an essay paper, each afternoon a multiple-choice one — which is what identifies them: columns 1/3/5/7 quantise to 0.25 and 2/4/6/8 do not, and each column's mean lands within 0.5 of the same subject's mean across the rest of the dataset. The foreign language is filed under the subject its N1..N6 code names. Gender now accepts the 0/1 encoding those files use: of the rows marked 1, 53% carry "Thị" in the name against 1% of those marked 0. Birth dates in the compact ddmmyy form are expanded so the column holds one format. A score of 0 is stored rather than dropped, recovering 302 real scores that a JavaScript falsy check had been turning into NULL. Row count falls by one, to 877,460: the removed row is the title line "ĐƠN VỊ: / TRƯỜNG ĐẠI HỌC CÔNG NGHIỆP THỰC PHẨM TP. HỒ CHÍ MINH", which had been stored as a student. The dataset has no duplicate exam numbers; the three rows previously described as collapsing were that same file's title and header lines being counted and then rejected. Also drops behaviour that existed only to match the parser this one replaced: the inert "SINH " header token, the untrimmed diem_thi cell, an unreachable blank-row branch, and a cross-check test against a database that can no longer exist. None of them changes output. Verified by rebuilding both datasets: 877,460 and 861,068 rows, both artifacts through the assembler's row and size guards, and the reader fidelity suite unchanged across all 182 files.
592 lines
16 KiB
Go
592 lines
16 KiB
Go
package ingest
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/tiennm99/thptqg/parser/internal/config"
|
|
"github.com/tiennm99/thptqg/parser/internal/reader"
|
|
"github.com/tiennm99/thptqg/parser/internal/transform"
|
|
"github.com/tiennm99/thptqg/parser/internal/writer"
|
|
)
|
|
|
|
// The 2016 dataset's 119 files were produced by inconsistent tooling and use
|
|
// four different column layouts, chosen per sheet at runtime. The literals
|
|
// below are institutional knowledge about those files, with no rule to derive
|
|
// them from — extend them, but do not tidy them into something more regular.
|
|
|
|
// KnownHeaders are the upper-cased first-cell values that identify a header row.
|
|
var KnownHeaders = []string{
|
|
"SOBAODANH",
|
|
"SBD",
|
|
"HO_TEN",
|
|
"HOTEN",
|
|
"HỌ TÊN",
|
|
"NGAY_SINH",
|
|
"TEN_CUMTHI",
|
|
"GIOI_TINH",
|
|
"DIEM_THI",
|
|
"STT",
|
|
"TOAN",
|
|
"VAN",
|
|
"LY",
|
|
"HOA",
|
|
"SINH",
|
|
"SU",
|
|
"DIA",
|
|
}
|
|
|
|
func isKnownHeader(s string) bool {
|
|
for _, h := range KnownHeaders {
|
|
if h == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsHeaderRow2016 reports whether row[0] is a known header token.
|
|
//
|
|
// The guard here is len < 2, not the len < 3 used by the 2017 header check.
|
|
func IsHeaderRow2016(row []reader.Cell) bool {
|
|
if len(row) < 2 {
|
|
return false
|
|
}
|
|
return isKnownHeader(strings.ToUpper(strings.TrimSpace(row[0].Str)))
|
|
}
|
|
|
|
// FormatKind is one of the 2016 layouts.
|
|
type FormatKind int
|
|
|
|
const (
|
|
// FormatSeparateScores has one column per subject rather than a free-text
|
|
// DIEM_THI cell — the dhhanghai-style files.
|
|
FormatSeparateScores FormatKind = iota
|
|
// FormatMapped resolves column indices from the header by name.
|
|
FormatMapped
|
|
// FormatDefault is the positional 6-column layout used when no header is
|
|
// recognised. It is FormatMapped with fixed indices, not a separate path.
|
|
FormatDefault
|
|
// FormatSubjectColumns resolves BOTH identity and one score column per
|
|
// subject from the header by name — the university-cluster files, which
|
|
// publish scores in columns instead of a DIEM_THI sentence.
|
|
FormatSubjectColumns
|
|
)
|
|
|
|
// Format is a detected layout plus its resolved column indices.
|
|
type Format struct {
|
|
Kind FormatKind
|
|
Sbd int
|
|
HoTen int
|
|
NgaySinh *int
|
|
TenCumThi *int
|
|
GioiTinh *int
|
|
DiemThi int
|
|
|
|
// Subjects maps a schema score field to its column, for
|
|
// FormatSubjectColumns only.
|
|
Subjects map[string]int
|
|
// LangScore is the foreign-language score column; the subject it counts
|
|
// towards is named by the code in LangCode.
|
|
LangScore *int
|
|
LangCode *int
|
|
}
|
|
|
|
// defaultFormat is FormatMapped with the fixed positional indices.
|
|
func defaultFormat() Format {
|
|
two, three, four := 2, 3, 4
|
|
return Format{
|
|
Kind: FormatDefault, Sbd: 0, HoTen: 1,
|
|
NgaySinh: &two, TenCumThi: &three, GioiTinh: &four, DiemThi: 5,
|
|
}
|
|
}
|
|
|
|
// identity holds the identity columns resolved from a header row. A nil field
|
|
// means the header does not name that column.
|
|
type identity struct {
|
|
Sbd, HoTen, NgaySinh, TenCumThi, GioiTinh, DiemThi *int
|
|
}
|
|
|
|
// resolveIdentity maps header names to identity columns, order-independent.
|
|
// Each column has several spellings across the corpus, including the accented
|
|
// and unseparated ones the university files use.
|
|
func resolveIdentity(cols []string) identity {
|
|
var id identity
|
|
for i := range cols {
|
|
idx := i
|
|
switch cols[i] {
|
|
case "SOBAODANH", "SBD":
|
|
id.Sbd = &idx
|
|
case "HO_TEN", "HOTEN", "HỌ TÊN":
|
|
id.HoTen = &idx
|
|
case "NGAY_SINH", "NGAYSINH", "NGÀY SINH":
|
|
id.NgaySinh = &idx
|
|
case "TEN_CUMTHI":
|
|
id.TenCumThi = &idx
|
|
case "GIOI_TINH", "GIỚI TÍNH", "PHAI":
|
|
id.GioiTinh = &idx
|
|
case "DIEM_THI":
|
|
id.DiemThi = &idx
|
|
}
|
|
}
|
|
return id
|
|
}
|
|
|
|
// subjectColumnFamily is one university-cluster spelling of the per-subject
|
|
// score columns. The two families are told apart by which names resolve.
|
|
type subjectColumnFamily struct {
|
|
scores map[string]string // header name -> schema score field
|
|
langScore string // header of the foreign-language score column
|
|
langCode string // header of the N1..N6 language code column
|
|
}
|
|
|
|
// subjectColumnFamilies are tried in order. The Cần Thơ family goes first
|
|
// because its CDIEM<n> names are unambiguous, while the two-letter family
|
|
// shares "HO" with that layout's surname column.
|
|
var subjectColumnFamilies = []subjectColumnFamily{
|
|
{scores: canThoScoreHeaders, langScore: "CDIEM2", langCode: "NGOAINGU"},
|
|
{scores: subjectCodeHeaders, langScore: "NN", langCode: "MÔN NN"},
|
|
}
|
|
|
|
// canThoScoreHeaders maps the Cần Thơ cluster's published-score columns to
|
|
// schema fields. The columns are numbered in the order the 2016 exam was sat —
|
|
// each morning an essay paper (quarter-point scores), each afternoon a
|
|
// multiple-choice one — which is what identifies them: CDIEM1/3/5/7 quantise to
|
|
// 0.25 and CDIEM2/4/6/8 do not, and the per-column means match the published
|
|
// national averages for those subjects.
|
|
var canThoScoreHeaders = map[string]string{
|
|
"CDIEM1": "toan",
|
|
"CDIEM3": "ngu_van",
|
|
"CDIEM4": "vat_ly",
|
|
"CDIEM5": "dia_ly",
|
|
"CDIEM6": "hoa_hoc",
|
|
"CDIEM7": "lich_su",
|
|
"CDIEM8": "sinh_hoc",
|
|
}
|
|
|
|
// subjectCodeHeaders maps the two-letter subject columns to schema fields.
|
|
var subjectCodeHeaders = map[string]string{
|
|
"TO": "toan",
|
|
"VA": "ngu_van",
|
|
"LI": "vat_ly",
|
|
"HO": "hoa_hoc",
|
|
"SI": "sinh_hoc",
|
|
"SU": "lich_su",
|
|
"DI": "dia_ly",
|
|
}
|
|
|
|
// languageFields maps the ministry's foreign-language codes to schema fields.
|
|
var languageFields = map[string]string{
|
|
"N1": "tieng_anh",
|
|
"N2": "tieng_nga",
|
|
"N3": "tieng_phap",
|
|
"N4": "tieng_trung",
|
|
"N5": "tieng_duc",
|
|
"N6": "tieng_nhat",
|
|
}
|
|
|
|
// DetectFormat inspects a header row and decides which layout applies.
|
|
func DetectFormat(headerRow []reader.Cell) Format {
|
|
cols := make([]string, len(headerRow))
|
|
for i, c := range headerRow {
|
|
cols[i] = strings.ToUpper(strings.TrimSpace(c.Str))
|
|
}
|
|
|
|
// Format 1: SBD in col 0 AND TOAN in col 2.
|
|
if len(cols) > 2 && cols[0] == "SBD" && cols[2] == "TOAN" {
|
|
return Format{Kind: FormatSeparateScores}
|
|
}
|
|
|
|
id := resolveIdentity(cols)
|
|
|
|
// Format 2: a free-text DIEM_THI cell alongside an SBD.
|
|
if id.Sbd != nil && id.DiemThi != nil {
|
|
hoTen := 1 // fallback: col 1, present in all known files
|
|
if id.HoTen != nil {
|
|
hoTen = *id.HoTen
|
|
}
|
|
return Format{
|
|
Kind: FormatMapped, Sbd: *id.Sbd, HoTen: hoTen,
|
|
NgaySinh: id.NgaySinh, TenCumThi: id.TenCumThi, GioiTinh: id.GioiTinh,
|
|
DiemThi: *id.DiemThi,
|
|
}
|
|
}
|
|
|
|
// Format 3: one column per subject.
|
|
if f, ok := subjectColumnsFormat(cols, id); ok {
|
|
return f
|
|
}
|
|
|
|
// Unrecognised header, or none at all.
|
|
return defaultFormat()
|
|
}
|
|
|
|
// subjectColumnsFormat resolves a per-subject-column layout, if the header
|
|
// names one.
|
|
//
|
|
// Three resolved subjects are required: a stray one- or two-letter header in an
|
|
// unrelated file must not turn that file into this layout.
|
|
func subjectColumnsFormat(cols []string, id identity) (Format, bool) {
|
|
if id.Sbd == nil {
|
|
return Format{}, false
|
|
}
|
|
for _, fam := range subjectColumnFamilies {
|
|
subjects := make(map[string]int)
|
|
for i, name := range cols {
|
|
if field, ok := fam.scores[name]; ok {
|
|
subjects[field] = i
|
|
}
|
|
}
|
|
if len(subjects) < 3 {
|
|
continue
|
|
}
|
|
hoTen := 1
|
|
if id.HoTen != nil {
|
|
hoTen = *id.HoTen
|
|
}
|
|
return Format{
|
|
Kind: FormatSubjectColumns, Sbd: *id.Sbd, HoTen: hoTen,
|
|
NgaySinh: id.NgaySinh, TenCumThi: id.TenCumThi, GioiTinh: id.GioiTinh,
|
|
DiemThi: -1,
|
|
Subjects: subjects,
|
|
LangScore: indexOf(cols, fam.langScore),
|
|
LangCode: indexOf(cols, fam.langCode),
|
|
}, true
|
|
}
|
|
return Format{}, false
|
|
}
|
|
|
|
// indexOf returns the column holding name, or nil when the header lacks it.
|
|
func indexOf(cols []string, name string) *int {
|
|
for i := range cols {
|
|
if cols[i] == name {
|
|
idx := i
|
|
return &idx
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseScoreCell parses a per-subject score cell. A parsed 0 is a real score:
|
|
// the candidate sat the paper and scored nothing.
|
|
func parseScoreCell(row []reader.Cell, idx int) (float64, bool) {
|
|
s := cellAt(row, idx)
|
|
if s == "" {
|
|
return 0, false
|
|
}
|
|
v, err := strconv.ParseFloat(s, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
// processSeparateScoresRow handles the fixed 12-column layout:
|
|
//
|
|
// 0=SBD 1=HOTEN 2=TOAN 3=VAN 4=LY 5=HOA 6=SINH 7=SU 8=DIA
|
|
// 9=NGOAINGUTN 10=NGOAINGUTL 11=NGOAINGU(total -> tieng_anh)
|
|
//
|
|
// tieng_phap / tieng_duc / tieng_nhat / tieng_trung are structurally unreachable
|
|
// in this format, and ngay_sinh / ten_cum_thi / gioi_tinh are always nil.
|
|
// Scores are read as floats directly; this layout has no free-text score cell,
|
|
// so the subject regexes never run.
|
|
func processSeparateScoresRow(row []reader.Cell) *transform.ParsedRow {
|
|
sbd := cellAt(row, 0)
|
|
hoTen := cellAt(row, 1)
|
|
if sbd == "" || hoTen == "" {
|
|
return nil
|
|
}
|
|
|
|
scores := make(map[string]float64)
|
|
for field, idx := range map[string]int{
|
|
"toan": 2, "ngu_van": 3, "vat_ly": 4, "hoa_hoc": 5,
|
|
"sinh_hoc": 6, "lich_su": 7, "dia_ly": 8,
|
|
"tieng_anh": 11, // NGOAINGU total
|
|
} {
|
|
if v, ok := parseScoreCell(row, idx); ok {
|
|
scores[field] = v
|
|
}
|
|
}
|
|
|
|
return &transform.ParsedRow{
|
|
SoBaoDanh: sbd,
|
|
HoTen: hoTen,
|
|
HoTenAscii: transform.ToAscii(hoTen),
|
|
Scores: scores,
|
|
}
|
|
}
|
|
|
|
// processMappedRow handles header-derived column indices.
|
|
func processMappedRow(row []reader.Cell, f Format) *transform.ParsedRow {
|
|
sbd := cellAt(row, f.Sbd)
|
|
hoTen := cellAt(row, f.HoTen)
|
|
if sbd == "" || hoTen == "" {
|
|
return nil
|
|
}
|
|
|
|
// Leaked-header guard: a row whose SBD or name cell is itself a header token
|
|
// is a repeated header, not data.
|
|
if isKnownHeader(strings.ToUpper(sbd)) || isKnownHeader(strings.ToUpper(hoTen)) {
|
|
return nil
|
|
}
|
|
|
|
return &transform.ParsedRow{
|
|
SoBaoDanh: sbd,
|
|
HoTen: hoTen,
|
|
HoTenAscii: transform.ToAscii(hoTen),
|
|
NgaySinh: birthDate(row, f.NgaySinh),
|
|
TenCumThi: optionalCell(row, f.TenCumThi),
|
|
GioiTinh: gender(row, f.GioiTinh),
|
|
Scores: transform.ParseScores(cellAt(row, f.DiemThi)),
|
|
}
|
|
}
|
|
|
|
// processSubjectColumnsRow reads a row whose scores sit in one column per
|
|
// subject, with the foreign language filed under the subject its code names.
|
|
func processSubjectColumnsRow(row []reader.Cell, f Format) *transform.ParsedRow {
|
|
sbd := cellAt(row, f.Sbd)
|
|
hoTen := cellAt(row, f.HoTen)
|
|
if sbd == "" || hoTen == "" {
|
|
return nil
|
|
}
|
|
if isKnownHeader(strings.ToUpper(sbd)) || isKnownHeader(strings.ToUpper(hoTen)) {
|
|
return nil
|
|
}
|
|
|
|
scores := make(map[string]float64)
|
|
for field, idx := range f.Subjects {
|
|
if v, ok := parseScoreCell(row, idx); ok {
|
|
scores[field] = v
|
|
}
|
|
}
|
|
if f.LangScore != nil {
|
|
if v, ok := parseScoreCell(row, *f.LangScore); ok {
|
|
code := "N1" // an unlabelled language paper is English in this corpus
|
|
if f.LangCode != nil {
|
|
if c := strings.ToUpper(cellAt(row, *f.LangCode)); c != "" {
|
|
code = c
|
|
}
|
|
}
|
|
// An unknown code is left out rather than guessed at.
|
|
if field, ok := languageFields[code]; ok {
|
|
scores[field] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
return &transform.ParsedRow{
|
|
SoBaoDanh: sbd,
|
|
HoTen: hoTen,
|
|
HoTenAscii: transform.ToAscii(hoTen),
|
|
NgaySinh: birthDate(row, f.NgaySinh),
|
|
TenCumThi: optionalCell(row, f.TenCumThi),
|
|
GioiTinh: gender(row, f.GioiTinh),
|
|
Scores: scores,
|
|
}
|
|
}
|
|
|
|
// optionalCell returns the trimmed cell at idx, or nil when the column is
|
|
// absent or its cell empty.
|
|
func optionalCell(row []reader.Cell, idx *int) *string {
|
|
if idx == nil {
|
|
return nil
|
|
}
|
|
if s := cellAt(row, *idx); s != "" {
|
|
return &s
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// gender normalises the gender cell to "Nam"/"Nữ", or nil when it says neither.
|
|
//
|
|
// The Cần Thơ files encode it numerically instead: of the rows marked 1, 53%
|
|
// carry the female marker "Thị" in the name, against 1% of those marked 0.
|
|
func gender(row []reader.Cell, idx *int) *string {
|
|
s := optionalCell(row, idx)
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
switch *s {
|
|
case "Nam", "Nữ":
|
|
return s
|
|
case "0":
|
|
nam := "Nam"
|
|
return &nam
|
|
case "1":
|
|
nu := "Nữ"
|
|
return &nu
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// birthDate returns the birth-date cell as dd/mm/yyyy.
|
|
//
|
|
// Most files already write it that way; the Cần Thơ ones use a compact ddmmyy,
|
|
// expanded here so the column holds one format. The century is always 19xx: a
|
|
// 2016 candidate born after 1999 would have sat the exam under age.
|
|
func birthDate(row []reader.Cell, idx *int) *string {
|
|
s := optionalCell(row, idx)
|
|
if s == nil || len(*s) != 6 || !allDigits(*s) {
|
|
return s
|
|
}
|
|
out := (*s)[0:2] + "/" + (*s)[2:4] + "/19" + (*s)[4:6]
|
|
return &out
|
|
}
|
|
|
|
func allDigits(s string) bool {
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] < '0' || s[i] > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// headerScanRows is how far into a sheet the header may sit. One file opens
|
|
// with a three-row ministry title block above its header; a sheet with no
|
|
// header at all has data from row 0 and simply finds nothing in that window.
|
|
const headerScanRows = 5
|
|
|
|
// sheetFormat picks the layout for one sheet and returns the row its data
|
|
// starts on. Rows above the header are a title block and are not data.
|
|
func sheetFormat(rows [][]reader.Cell) (Format, int) {
|
|
limit := headerScanRows
|
|
if len(rows) < limit {
|
|
limit = len(rows)
|
|
}
|
|
for i := 0; i < limit; i++ {
|
|
if IsHeaderRow2016(rows[i]) {
|
|
return DetectFormat(rows[i]), i + 1
|
|
}
|
|
}
|
|
return defaultFormat(), 0
|
|
}
|
|
|
|
// ProcessRow2016 dispatches a data row through the detected layout. A nil return
|
|
// means the row is empty or invalid and should be skipped.
|
|
func ProcessRow2016(row []reader.Cell, f Format) *transform.ParsedRow {
|
|
switch f.Kind {
|
|
case FormatSeparateScores:
|
|
return processSeparateScoresRow(row)
|
|
case FormatSubjectColumns:
|
|
return processSubjectColumnsRow(row, f)
|
|
}
|
|
// FormatMapped and FormatDefault share one implementation; Default is just a
|
|
// fixed index tuple.
|
|
return processMappedRow(row, f)
|
|
}
|
|
|
|
// Detect2016 ingests the 2016 dataset.
|
|
func Detect2016(cfg *config.DatasetConfig, inputDir, outputPath string) error {
|
|
files, err := InputFiles(inputDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
label := DatasetLabel(inputDir)
|
|
fmt.Printf("[build:2016] %s/ → %s (%d files)\n", label, outputPath, len(files))
|
|
|
|
db, err := writer.OpenDB(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("begin: %w", err)
|
|
}
|
|
ins, err := writer.Prepare(tx)
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
var st writer.Stats // Skipped stays 0 on this path
|
|
for _, file := range files {
|
|
base := filepath.Base(file)
|
|
fileRows, err := processFile2016(file, cfg, ins, base, &st)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, " [error] %s: %v\n", base, err)
|
|
st.Errors++
|
|
continue
|
|
}
|
|
fmt.Printf(" %s: %d rows\n", base, fileRows)
|
|
}
|
|
|
|
if err := ins.Close(); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit: %w", err)
|
|
}
|
|
return writer.Finish(db, outputPath, st)
|
|
}
|
|
|
|
// processFile2016 detects the layout per SHEET and processes that sheet's rows.
|
|
//
|
|
// Detection is per sheet, not per file: within one workbook, sheet 2 may
|
|
// legitimately detect differently from sheet 1, which matters for the province
|
|
// files that overflow past Excel's row cap.
|
|
func processFile2016(path string, cfg *config.DatasetConfig, ins *writer.Inserter, base string, st *writer.Stats) (uint64, error) {
|
|
wb, err := reader.Open(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer wb.Close()
|
|
|
|
sheets := wb.Sheets()
|
|
if len(sheets) == 0 {
|
|
return 0, nil
|
|
}
|
|
if cfg.Reader.SheetMode == config.SheetModeFirst {
|
|
sheets = sheets[:1]
|
|
}
|
|
|
|
var fileRows uint64
|
|
for _, sh := range sheets {
|
|
var rows [][]reader.Cell
|
|
if err := wb.EachRow(sh.Index, func(_ reader.Sheet, _ int, row []reader.Cell) error {
|
|
rows = append(rows, row)
|
|
return nil
|
|
}); err != nil {
|
|
return fileRows, err
|
|
}
|
|
if len(rows) == 0 {
|
|
continue
|
|
}
|
|
|
|
f, startIdx := sheetFormat(rows)
|
|
|
|
for _, row := range rows[startIdx:] {
|
|
// Rows shorter than 2 cells are dropped BEFORE the counter, so they
|
|
// never appear in the source total.
|
|
if len(row) < 2 {
|
|
continue
|
|
}
|
|
st.SourceRows++
|
|
|
|
parsed := ProcessRow2016(row, f)
|
|
if parsed == nil {
|
|
// Empty or invalid. Note this is NOT counted as skipped — the
|
|
// 2016 path leaves that counter at zero, so the stats block
|
|
// reports insertable == source rows and the Audit line absorbs
|
|
// the difference.
|
|
continue
|
|
}
|
|
if err := ins.Insert(parsed); err != nil {
|
|
st.Errors++
|
|
if st.Errors <= 5 {
|
|
fmt.Fprintf(os.Stderr, " [warn] %s: %v\n", base, err)
|
|
}
|
|
continue
|
|
}
|
|
fileRows++
|
|
}
|
|
}
|
|
return fileRows, nil
|
|
}
|