diff --git a/src/decode.go b/src/decode.go index 53cebe4..b5cdf58 100644 --- a/src/decode.go +++ b/src/decode.go @@ -19,7 +19,7 @@ func decodeTitle(title string) string { {"'", "’"}, // U+2019 {`$\cdot$`, `·`}, // U+00B7. } { - title = strings.Replace(title, convert.from, convert.to, -1) + title = strings.ReplaceAll(title, convert.from, convert.to) } // Get rid of all curly brackets. We're displaying titles without changing @@ -34,7 +34,7 @@ func decodeAuthors(authors string) string { for _, convert := range []conversion{ {"'", "’"}, } { - authors = strings.Replace(authors, convert.from, convert.to, -1) + authors = strings.ReplaceAll(authors, convert.from, convert.to) } // For simplicity, we expect authors to be formatted as "John Doe" instead // of "Doe, John". @@ -47,9 +47,9 @@ func decodeAuthors(authors string) string { func decodeProceedings(proceedings string) string { for _, convert := range []conversion{ - {`\&`, "&"}, + {`\&`, "&"}, } { - proceedings = strings.Replace(proceedings, convert.from, convert.to, -1) + proceedings = strings.ReplaceAll(proceedings, convert.from, convert.to) } return proceedings } diff --git a/src/footer.go b/src/footer.go index 796c9e1..b9f32d8 100644 --- a/src/footer.go +++ b/src/footer.go @@ -1,11 +1,254 @@ package main func footer() string { - return `` +var headerTmpl = template.Must(template.New("header").Parse(headerTemplate)) + func header() string { - tmpl, err := template.New("header").Parse(headerTemplate) - if err != nil { - log.Fatal(err) - } i := struct { Date string }{ Date: time.Now().UTC().Format(time.DateOnly), } - buf := bytes.NewBufferString("") - if err = tmpl.Execute(buf, i); err != nil { + buf := new(bytes.Buffer) + if err := headerTmpl.Execute(buf, i); err != nil { log.Fatalf("Error executing template: %v", err) } return buf.String() diff --git a/src/html.go b/src/html.go index 7353de5..68bacba 100644 --- a/src/html.go +++ b/src/html.go @@ -1,7 +1,8 @@ package main import ( - "fmt" + "bytes" + "html/template" "io" "sort" "strings" @@ -9,118 +10,96 @@ import ( "github.com/nickng/bibtex" ) -func sortByYear(yearToEntries map[string][]string) []string { - keys := make([]string, 0, len(yearToEntries)) - for k := range yearToEntries { - keys = append(keys, k) - } - sort.Sort(sort.Reverse(sort.StringSlice(keys))) - return keys +type bibEntryView struct { + CiteName string + Title string + Authors string + Venue string + VenuePrefix string + HasVenue bool + Year string + HasYear bool + Publisher string + URL string + DiscussionURL string } -func appendIfNotEmpty(slice []string, s string) []string { - if s != "" { - return append(slice, s) - } - return slice -} +var bibEntryTemplate = template.Must(template.New("bib-entry").Parse(`
  • +
    +{{.Title}} + +{{if .DiscussionURL}}Discussion icon{{end}} +Download icon +Cached download icon +BibTeX icon +Paper link icon + +
    +
    +{{.Authors}} +
    +{{if .HasVenue}}{{.VenuePrefix}}{{.Venue}}{{end}}{{if .Year}}{{if .HasVenue}}, {{end}}{{.Year}}{{end}}{{if .Publisher}}{{if or .HasVenue .HasYear}}, {{end}}{{.Publisher}}{{end}} +
  • +`)) func makeBib(to io.Writer, bibEntries []bibEntry) { - yearToEntries := make(map[string][]string) - + previousYear := "" for _, entry := range bibEntries { - y := entry.Fields["year"].String() - yearToEntries[y] = append(yearToEntries[y], makeBibEntry(&entry)) - } - - sortedYears := sortByYear(yearToEntries) - for _, year := range sortedYears { - fmt.Fprintln(to, "") + } + mustFprintf(to, "") + mustFprint(to, makeBibEntry(&entry)) + } + if previousYear != "" { + mustFprintln(to, "") } } func makeBibEntry(entry *bibEntry) string { - s := []string{ - fmt.Sprintf("
  • ", entry.CiteName), - `
    `, - makeBibEntryTitle(entry), - `
    `, - `
    `, - makeBibEntryAuthors(entry), - `
    `, - ``, - makeBibEntryMisc(entry), - ``, - `
  • `, + buf := new(bytes.Buffer) + if err := bibEntryTemplate.Execute(buf, entryView(entry)); err != nil { + panic(err) } - return strings.Join(s, "\n") + return buf.String() } -func makeBibEntryTitle(entry *bibEntry) string { - // Paper title is on the left side. - title := []string{ - ``, - decodeTitle(entry.Fields["title"].String()), - ``, +func entryView(entry *bibEntry) bibEntryView { + prefix, venue := entryVenueParts(entry) + year := toStr(entry.Fields["year"]) + return bibEntryView{ + CiteName: entry.CiteName, + Title: entryTitle(entry), + Authors: entryAuthors(entry), + Venue: venue, + VenuePrefix: prefix, + HasVenue: venue != "", + Year: year, + HasYear: year != "", + Publisher: toStr(entry.Fields["publisher"]), + URL: toStr(entry.Fields["url"]), + DiscussionURL: toStr(entry.Fields["discussion_url"]), } - // Icons are on the right side. - icons := makeIcons(entry) - return strings.Join(append(title, icons...), "\n") } -func makeIcons(entry *bibEntry) []string { - var icons = []string{``} - - // Not all references have a corresponding discussion (e.g., on net4people) - // but if they do, add an icon. - if field, ok := entry.Fields["discussion_url"]; ok { - s := fmt.Sprintf("", field.String()) + - `Discussion icon` + - `` - icons = append(icons, s) - } - - // Add icons that are always present. - icons = append(icons, []string{ - fmt.Sprintf("", entry.Fields["url"].String()), - `Download icon`, - ``, - fmt.Sprintf("", entry.CiteName), - `Cached download icon`, - ``, - fmt.Sprintf("", entry.lineNum), - `BibTeX download icon`, - ``, - fmt.Sprintf("", entry.CiteName), - `Paper link icon`, - ``, - }...) - - return append(icons, ``) +func entryTitle(entry *bibEntry) string { + return decodeTitle(toStr(entry.Fields["title"])) } -func makeBibEntryAuthors(entry *bibEntry) string { - s := []string{ - ``, - decodeAuthors(entry.Fields["author"].String()), - ``, - } - return strings.Join(s, "\n") +func entryAuthors(entry *bibEntry) string { + return decodeAuthors(toStr(entry.Fields["author"])) } -func makeBibEntryMisc(entry *bibEntry) string { - s := []string{} - s = appendIfNotEmpty(s, makeBibEntryVenue(entry)) - s = appendIfNotEmpty(s, toStr(entry.Fields["year"])) - s = appendIfNotEmpty(s, toStr(entry.Fields["publisher"])) - return strings.Join(s, ", ") +func entryVenue(entry *bibEntry) string { + _, venue := entryVenueParts(entry) + return venue } -func makeBibEntryVenue(entry *bibEntry) string { +func entryVenueParts(entry *bibEntry) (string, string) { var ( prefix string bs bibtex.BibString @@ -132,15 +111,46 @@ func makeBibEntryVenue(entry *bibEntry) string { } else if bs, ok = entry.Fields["journal"]; ok { prefix = "In: " } else { - return "" // Some entries are self-published. + return "", "" // Some entries are self-published. } - s := []string{ - prefix, - ``, - decodeProceedings(toStr(bs)), - ``, - } + return prefix, decodeProceedings(toStr(bs)) +} + +func sortBibEntries(bibEntries []bibEntry) { + sort.SliceStable(bibEntries, func(i, j int) bool { + a := &bibEntries[i] + b := &bibEntries[j] + for _, cmp := range []struct { + left string + right string + descending bool + }{ + {toStr(a.Fields["year"]), toStr(b.Fields["year"]), true}, + {entryVenue(a), entryVenue(b), false}, + {entryTitle(a), entryTitle(b), false}, + {a.CiteName, b.CiteName, false}, + } { + left := strings.ToLower(cmp.left) + right := strings.ToLower(cmp.right) + if left == right { + continue + } + if cmp.descending { + return left > right + } + return left < right + } + return false + }) +} - return strings.Join(s, "") +func makeSearchBox(to io.Writer, count int) { + mustFprintf(to, ` + +`, count) } diff --git a/src/main.go b/src/main.go index 08ffd86..2223441 100644 --- a/src/main.go +++ b/src/main.go @@ -1,7 +1,8 @@ package main import ( - "bufio" + "bytes" + "encoding/json" "flag" "fmt" "io" @@ -14,17 +15,22 @@ import ( ) // Matches e.g.: @inproceedings{Müller2024a, -// \p{L}\p{M} matches any letter, including accented characters. -var re = regexp.MustCompile(`@[a-z]*\{([\"\p{L}\p{M}\-]*[0-9]{4}[a-z]),`) +var re = regexp.MustCompile(`(?i)^@[a-z]+\s*\{\s*([^,\s]+)\s*,`) -// Map a cite name (e.g., Doe2024a) to its line number in the .bib file. All -// cite names are unique. -type entryToLineFunc func(string) int - -// Augment bibtex.BibEntry with the entry's line number in the .bib file. +// Augment bibtex.BibEntry with the entry's raw record in the .bib file. type bibEntry struct { bibtex.BibEntry - lineNum int + rawBibtex string +} + +type searchEntry struct { + CiteName string `json:"citeName"` + Title string `json:"title"` + Authors string `json:"authors"` + Venue string `json:"venue"` + Year string `json:"year"` + Publisher string `json:"publisher"` + RawBibtex string `json:"rawBibtex"` } func toStr(b bibtex.BibString) string { @@ -35,58 +41,57 @@ func toStr(b bibtex.BibString) string { } func parseBibFile(path string) []bibEntry { - file, err := os.Open(path) + contents, err := os.ReadFile(path) if err != nil { log.Fatal(err) } + file := bytes.NewReader(contents) bib, err := bibtex.Parse(file) if err != nil { log.Fatal(err) } - // Augment our BibTeX entries with their respective line numbers in the .bib - // file. This is necessary to create the "Download BibTeX" links. - lineOf := buildEntryToLineFunc(path) + rawByCiteName := extractRawBibEntries(contents) bibEntries := []bibEntry{} for _, entry := range bib.Entries { + rawBibtex, ok := rawByCiteName[entry.CiteName] + if !ok { + log.Fatalf("could not find raw BibTeX for cite name: %s", entry.CiteName) + } bibEntries = append(bibEntries, bibEntry{ - BibEntry: *entry, - lineNum: lineOf(entry.CiteName), + BibEntry: *entry, + rawBibtex: rawBibtex, }) } return bibEntries } -func buildEntryToLineFunc(path string) entryToLineFunc { - file, err := os.Open(path) - if err != nil { - log.Fatal(err) - } - - sc := bufio.NewScanner(file) - entryToLine := make(map[string]int) - line := 0 - for sc.Scan() { - line++ - s := sc.Text() - if !strings.HasPrefix(s, "@") { +func extractRawBibEntries(contents []byte) map[string]string { + rawByCiteName := make(map[string]string) + for i := 0; i < len(contents); i++ { + if contents[i] != '@' { continue } - entry := parseCiteName(s) // E.g., Doe2024a - entryToLine[entry] = line - } - if err := sc.Err(); err != nil { - log.Fatalf("scan file error: %v", err) - } - return func(entry string) int { - if line, ok := entryToLine[entry]; ok { - return line + depth := 0 + for j := i; j < len(contents); j++ { + switch contents[j] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + raw := strings.TrimSpace(string(contents[i : j+1])) + rawByCiteName[parseCiteName(raw)] = raw + i = j + goto nextEntry + } + } } - log.Fatalf("could not find line number for cite name: %s", entry) - return -1 + nextEntry: } + return rawByCiteName } func parseCiteName(line string) string { @@ -97,12 +102,54 @@ func parseCiteName(line string) string { return matches[1] } +func mustFprint(w io.Writer, a ...any) { + if _, err := fmt.Fprint(w, a...); err != nil { + log.Fatalf("failed to write HTML: %v", err) + } +} + +func mustFprintln(w io.Writer, a ...any) { + if _, err := fmt.Fprintln(w, a...); err != nil { + log.Fatalf("failed to write HTML: %v", err) + } +} + +func mustFprintf(w io.Writer, format string, a ...any) { + if _, err := fmt.Fprintf(w, format, a...); err != nil { + log.Fatalf("failed to write HTML: %v", err) + } +} + func run(w io.Writer, bibEntries []bibEntry) { - fmt.Fprint(w, header()) - fmt.Fprintln(w, "
    ") + sortBibEntries(bibEntries) + mustFprint(w, header()) + makeSearchBox(w, len(bibEntries)) + mustFprintln(w, "
    ") makeBib(w, bibEntries) - fmt.Fprintln(w, "
    ") - fmt.Fprint(w, footer()) + mustFprintln(w, "
    ") + makeReferenceDataScript(w, bibEntries) + mustFprint(w, footer()) +} + +func makeReferenceDataScript(w io.Writer, bibEntries []bibEntry) { + searchEntries := []searchEntry{} + for _, entry := range bibEntries { + searchEntries = append(searchEntries, searchEntry{ + CiteName: entry.CiteName, + Title: entryTitle(&entry), + Authors: entryAuthors(&entry), + Venue: entryVenue(&entry), + Year: toStr(entry.Fields["year"]), + Publisher: toStr(entry.Fields["publisher"]), + RawBibtex: entry.rawBibtex, + }) + } + + mustFprintln(w, ``) } func main() { diff --git a/src/main_test.go b/src/main_test.go index bd4929f..e6604d7 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -15,8 +15,8 @@ func mustParse(t *testing.T, s string) bibEntry { t.Fatalf("failed to parse bibtex: %v", err) } return bibEntry{ - BibEntry: *bib.Entries[0], - lineNum: 0, + BibEntry: *bib.Entries[0], + rawBibtex: strings.TrimSpace(s), } } @@ -34,10 +34,85 @@ func TestRun(t *testing.T) { makeBib(buf, []bibEntry{entry}) bufStr := buf.String() - if !strings.HasPrefix(bufStr, "