Line numbers are places in import and export; fix stale scripts

Import: each row's place is its line number, so blank lines leave a
place empty; a fully numbered text file ("38. Title") or a Rank column
gives places directly, and repeated numbers take the next free place.
Entries are added at those places (existing ones there move down), the
review shows each row's #, and the room check covers the highest place.

Export writes a blank line for every empty place, so line N is #N and a
file round-trips.

Static files now carry an ETag and unversioned ones are revalidated on
every load. Scripts import each other without a ?v= tag, so browsers kept
an hour-old import.js (with the "null" bug) after an update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-08 14:43:53 -07:00
1 parent 88373176f2
commit 62fcf6b45d
7 files changed
+151 -72

No files matched your search

+30 -12
View File
@@ -1,6 +1,7 @@
package importer
import (
"fmt"
"reflect"
"testing"
@@ -8,17 +9,28 @@ import (
)
func TestParseText(t *testing.T) {
got, err := Parse([]byte("1. Heat (1995)\n2) 2001: A Space Odyssey\n\n3 - Alien [1979]\n"), "list.txt")
got, err := Parse([]byte("1. Heat (1995)\n2) 2001: A Space Odyssey\n\n4 - Alien [1979]\n"), "list.txt")
if err != nil {
t.Fatal(err)
}
want := []Row{{Title: "Heat", Year: "1995"}, {Title: "2001: A Space Odyssey"}, {Title: "Alien", Year: "1979"}}
want := []Row{{Place: 1, Title: "Heat", Year: "1995"}, {Place: 2, Title: "2001: A Space Odyssey"}, {Place: 4, Title: "Alien", Year: "1979"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %+v", got)
}
// Not every line is numbered, so nothing is stripped.
got, _ = Parse([]byte("2001: A Space Odyssey\nHeat\n1917\n"), "x.txt")
if got[0].Title != "2001: A Space Odyssey" || got[2].Title != "1917" {
// Not every line is numbered, so nothing is stripped; line numbers are
// the places, and a blank line leaves one empty.
got, _ = Parse([]byte("2001: A Space Odyssey\nHeat\n\n1917\n"), "x.txt")
if got[0].Title != "2001: A Space Odyssey" || got[2].Title != "1917" || got[2].Place != 4 {
t.Fatalf("got %+v", got)
}
// Numbers win over line numbers when every line has one.
got, _ = Parse([]byte("1. Super Mario Sunshine\n38. Super Mario World\n"), "games.txt")
if got[0].Place != 1 || got[1].Place != 38 || got[1].Title != "Super Mario World" {
t.Fatalf("got %+v", got)
}
// A repeated number takes the next free place, keeping the file's order.
got, _ = Parse([]byte("3. A\n3. B\n1. C\n"), "x.txt")
if fmt.Sprint(got) != fmt.Sprint([]Row{{Place: 1, Title: "C"}, {Place: 3, Title: "A"}, {Place: 4, Title: "B"}}) {
t.Fatalf("got %+v", got)
}
}
@@ -30,20 +42,26 @@ func TestParseCSV(t *testing.T) {
t.Fatal(err)
}
want := []Row{
{Title: "Heat", Year: "1995", Subtitle: "Michael Mann"},
{Title: "Alien", Year: "1979", Subtitle: "Ridley Scott", Note: "Tense, perfect"},
{Place: 1, Title: "Heat", Year: "1995", Subtitle: "Michael Mann"},
{Place: 2, Title: "Alien", Year: "1979", Subtitle: "Ridley Scott", Note: "Tense, perfect"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %+v", got)
}
// Canon's own export: one quoted column, no header.
got, _ = Parse([]byte("\xef\xbb\xbf\"Heat\"\r\n\"Crouching Tiger, Hidden Dragon\"\r\n"), "films.csv")
if len(got) != 2 || got[1].Title != "Crouching Tiger, Hidden Dragon" {
// Canon's own export: one column, no header, a blank line for an empty
// place.
got, _ = Parse([]byte("\xef\xbb\xbfHeat\r\n\r\n\"Crouching Tiger, Hidden Dragon\"\r\n"), "films.csv")
if len(got) != 2 || got[1].Title != "Crouching Tiger, Hidden Dragon" || got[1].Place != 3 {
t.Fatalf("got %+v", got)
}
// With a header, the first row after it is #1.
got, _ = Parse([]byte("Title\nHeat\n\nAlien\n"), "x.csv")
if got[0].Place != 1 || got[1].Place != 3 {
t.Fatalf("got %+v", got)
}
// Headerless rank,title,year.
got, _ = Parse([]byte("1,Heat,1995\n2,Alien,1979\n"), "x.csv")
if got[1] != (Row{Title: "Alien", Year: "1979"}) {
got, _ = Parse([]byte("1,Heat,1995\n5,Alien,1979\n"), "x.csv")
if got[1] != (Row{Place: 5, Title: "Alien", Year: "1979"}) {
t.Fatalf("got %+v", got)
}
if _, err := Parse([]byte("\n \n"), "x.txt"); err != ErrEmpty {
+66 -38
View File
@@ -6,6 +6,7 @@ import (
"bytes"
"encoding/csv"
"errors"
"io"
"regexp"
"slices"
"strconv"
@@ -16,8 +17,12 @@ import (
// MaxRows caps one import.
const MaxRows = 2000
// Row is one entry read from the file, in the file's order.
// Row is one entry read from the file.
type Row struct {
// Place is where it goes on the list: its line number (blank lines
// leave a place empty), or the number it was given ("38. Title" in a
// numbered list, or a Rank column).
Place int `json:"place"`
Title string `json:"title"`
Year string `json:"year,omitempty"`
Subtitle string `json:"subtitle,omitempty"` // artist, director, developer…
@@ -50,12 +55,27 @@ func Parse(data []byte, filename string) ([]Row, error) {
if len(rows) == 0 {
return nil, ErrEmpty
}
rows = orderPlaces(rows)
if len(rows) > MaxRows {
rows = rows[:MaxRows]
}
return rows, nil
}
// orderPlaces sorts rows by place and gives a repeated place (two rows both
// numbered 3) the next free one, keeping the file's order.
func orderPlaces(rows []Row) []Row {
slices.SortStableFunc(rows, func(a, b Row) int { return a.Place - b.Place })
prev := 0
for i := range rows {
if rows[i].Place <= prev {
rows[i].Place = prev + 1
}
prev = rows[i].Place
}
return rows
}
// latin1 decodes bytes as Windows-1252/Latin-1, which is what spreadsheet
// exports that aren't UTF-8 usually are.
func latin1(b []byte) string {
@@ -84,27 +104,35 @@ var numbered = regexp.MustCompile(`^\s*#?\d{1,5}\s*[.):\-–—]?\s+`)
var trailingYear = regexp.MustCompile(`\s*[(\[]((?:1[5-9]|20)\d\d)[)\]]\s*$`)
func parseLines(text string) []Row {
var lines []string
for _, l := range strings.Split(text, "\n") {
lines := strings.Split(text, "\n")
// Only use numbering when every non-blank line has it, so a list that
// merely contains "2001: A Space Odyssey" keeps its title.
numberedAll, any := true, false
for _, l := range lines {
if l = strings.TrimSpace(l); l != "" {
lines = append(lines, l)
any = true
if !numbered.MatchString(l) {
numberedAll = false
break
}
}
}
// Only strip numbering when every line has it, so a list that merely
// contains "2001: A Space Odyssey" keeps its title.
strip := len(lines) > 0
for _, l := range lines {
if !numbered.MatchString(l) {
strip = false
break
numberedAll = numberedAll && any
var rows []Row
for i, l := range lines {
l = strings.TrimSpace(l)
if l == "" {
continue // a blank line is an empty place
}
}
rows := make([]Row, 0, len(lines))
for _, l := range lines {
if strip {
place := i + 1
if numberedAll {
if n, err := strconv.Atoi(strings.Trim(numbered.FindString(l), " \t#.):-–—")); err == nil && n > 0 {
place = n
}
l = numbered.ReplaceAllString(l, "")
}
if r := rowFromTitle(l); r.Title != "" {
r.Place = place
rows = append(rows, r)
}
}
@@ -140,18 +168,28 @@ func parseDelimited(text string, delim rune) ([]Row, error) {
cr.FieldsPerRecord = -1
cr.LazyQuotes = true
cr.TrimLeadingSpace = true
records, err := cr.ReadAll()
if err != nil {
return nil, errors.New("that CSV file couldn't be read: " + err.Error())
// The reader skips blank lines, so keep each record's line number: a
// blank line is an empty place.
var records [][]string
var lines []int
for {
rec, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, errors.New("that CSV file couldn't be read: " + err.Error())
}
line, _ := cr.FieldPos(0)
records = append(records, rec)
lines = append(lines, line)
}
records = slices.DeleteFunc(records, func(rec []string) bool {
return strings.TrimSpace(strings.Join(rec, "")) == ""
})
if len(records) == 0 {
return nil, nil
}
cols := map[string]int{}
first := 1 // the line place #1 is on
if head := records[0]; isHeader(head) {
for i, name := range head {
if role, ok := headerNames[strings.ToLower(strings.TrimSpace(name))]; ok {
@@ -160,7 +198,8 @@ func parseDelimited(text string, delim rune) ([]Row, error) {
}
}
}
records = records[1:]
first = lines[0] + 1
records, lines = records[1:], lines[1:]
} else {
cols = guessColumns(records)
}
@@ -168,11 +207,6 @@ func parseDelimited(text string, delim rune) ([]Row, error) {
cols["title"] = 0
}
type ranked struct {
Row
rank int
}
var out []ranked
cell := func(rec []string, role string) string {
i, ok := cols[role]
if !ok || i >= len(rec) {
@@ -180,6 +214,7 @@ func parseDelimited(text string, delim rune) ([]Row, error) {
}
return strings.TrimSpace(rec[i])
}
var rows []Row
for i, rec := range records {
r := Row{Title: cell(rec, "title"), Subtitle: cell(rec, "subtitle"), Note: cell(rec, "note")}
if y := yearIn(cell(rec, "year")); y != "" {
@@ -191,18 +226,11 @@ func parseDelimited(text string, delim rune) ([]Row, error) {
if r.Title == "" {
continue
}
rank, err := strconv.Atoi(strings.TrimPrefix(cell(rec, "rank"), "#"))
if err != nil {
rank = 1 << 30
r.Place = lines[i] - first + 1
if n, err := strconv.Atoi(strings.TrimPrefix(cell(rec, "rank"), "#")); err == nil && n > 0 {
r.Place = n
}
out = append(out, ranked{r, rank*MaxRows*2 + i}) // stable within equal ranks
}
if _, ok := cols["rank"]; ok {
slices.SortStableFunc(out, func(a, b ranked) int { return a.rank - b.rank })
}
rows := make([]Row, len(out))
for i, r := range out {
rows[i] = r.Row
rows = append(rows, r)
}
return rows, nil
}