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:
1 parent
88373176f2
commit
62fcf6b45d
7 files changed
+151
-72
No files matched your search
+66
-38
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user