aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--README.md27
-rw-r--r--db.go188
-rw-r--r--db_test.go186
-rw-r--r--go.mod19
-rw-r--r--go.sum51
-rw-r--r--justfile41
-rw-r--r--main.go67
-rw-r--r--scrape.go175
-rw-r--r--server.go251
-rw-r--r--server_test.go406
-rw-r--r--templates/index.html211
12 files changed, 1625 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bfad6b8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+abvjt
+data/abvjt.db
+data/*
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b17fdb3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,27 @@
+# abvjt
+
+Journal abbreviation lookup. Scrapes [Web of Science](https://wos-help.webofscience.com/WOKRS535R111/help/WOS/0-9_abrvjt.html) journal title abbreviation pages, stores them in SQLite with FTS5, serves a search UI and a JSON API.
+
+Abbreviation data sourced from Web of Science (Clarivate).
+
+## Usage
+
+```
+go build -o abvjt .
+./abvjt scrape # fetch WOS pages, build DB
+./abvjt serve # start server on :8080
+./abvjt serve --port 3000 --db data/abvjt.db
+```
+
+| Command | Description |
+|---------|-------------|
+| `scrape` | Fetch WOS abbreviation pages, parse HTML, write SQLite |
+| `serve` | HTTP server (web UI + JSON API) |
+| `help` | Usage info |
+
+## API
+
+```
+GET /api/search?q=nature&limit=50
+GET /api/health
+```
diff --git a/db.go b/db.go
new file mode 100644
index 0000000..0861b24
--- /dev/null
+++ b/db.go
@@ -0,0 +1,188 @@
+package main
+
+import (
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ _ "modernc.org/sqlite"
+)
+
+var nonFTS5 = regexp.MustCompile(`[^a-zA-Z0-9\s\*]+`)
+
+// Journal represents a single journal entry.
+type Journal struct {
+ ID int64 `json:"id"`
+ FullName string `json:"full_name"`
+ Abbreviation string `json:"abbreviation"`
+}
+
+// DB wraps a SQLite connection.
+type DB struct {
+ conn *sql.DB
+}
+
+// OpenDB opens or creates the SQLite database at the given path.
+func OpenDB(path string) (*DB, error) {
+ // Ensure parent directory exists
+ dir := filepath.Dir(path)
+ if dir != "." && dir != "/" {
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return nil, fmt.Errorf("failed to create data directory: %w", err)
+ }
+ }
+
+ conn, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open database: %w", err)
+ }
+
+ conn.SetMaxOpenConns(1) // SQLite prefers single writer
+ conn.SetMaxIdleConns(1)
+
+ if err := conn.Ping(); err != nil {
+ return nil, fmt.Errorf("failed to ping database: %w", err)
+ }
+
+ db := &DB{conn: conn}
+ if err := db.initSchema(); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("failed to init schema: %w", err)
+ }
+
+ return db, nil
+}
+
+// initSchema creates the journals table and FTS5 virtual table.
+func (db *DB) initSchema() error {
+ stmts := []string{
+ `CREATE TABLE IF NOT EXISTS journals (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ full_name TEXT NOT NULL,
+ abbreviation TEXT NOT NULL
+ );`,
+ `CREATE VIRTUAL TABLE IF NOT EXISTS journals_fts USING fts5(
+ full_name, abbreviation,
+ content='journals',
+ content_rowid='id'
+ );`,
+ }
+
+ for _, stmt := range stmts {
+ if _, err := db.conn.Exec(stmt); err != nil {
+ return fmt.Errorf("schema init failed: %w", err)
+ }
+ }
+
+ return nil
+}
+
+// Close closes the database connection.
+func (db *DB) Close() error {
+ return db.conn.Close()
+}
+
+// InsertJournals inserts a batch of journals in a single transaction.
+func (db *DB) InsertJournals(journals []Journal) error {
+ if len(journals) == 0 {
+ return nil
+ }
+
+ tx, err := db.conn.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ insertStmt, err := tx.Prepare("INSERT INTO journals (full_name, abbreviation) VALUES (?, ?)")
+ if err != nil {
+ return err
+ }
+ defer insertStmt.Close()
+
+ ftsStmt, err := tx.Prepare("INSERT INTO journals_fts (rowid, full_name, abbreviation) VALUES (?, ?, ?)")
+ if err != nil {
+ return err
+ }
+ defer ftsStmt.Close()
+
+ for _, j := range journals {
+ res, err := insertStmt.Exec(j.FullName, j.Abbreviation)
+ if err != nil {
+ return fmt.Errorf("insert failed for %q: %w", j.FullName, err)
+ }
+
+ rowid, err := res.LastInsertId()
+ if err != nil {
+ return fmt.Errorf("last insert id failed: %w", err)
+ }
+
+ if _, err := ftsStmt.Exec(rowid, j.FullName, j.Abbreviation); err != nil {
+ return fmt.Errorf("fts insert failed for %q: %w", j.FullName, err)
+ }
+ }
+
+ return tx.Commit()
+}
+
+func sanitizeFTS5(query string) string {
+ clean := nonFTS5.ReplaceAllString(query, " ")
+ tokens := strings.Fields(clean)
+ for i, t := range tokens {
+ t = strings.ToLower(t)
+ if t != "*" && !strings.HasSuffix(t, "*") {
+ t = t + "*"
+ }
+ tokens[i] = t
+ }
+ return strings.Join(tokens, " ")
+}
+
+// SearchJournals searches for journals matching the query using FTS5.
+func (db *DB) SearchJournals(query string, limit int) ([]Journal, error) {
+ if limit <= 0 {
+ limit = 50
+ }
+ if limit > 200 {
+ limit = 200
+ }
+
+ clean := sanitizeFTS5(query)
+ if clean == "" {
+ return []Journal{}, nil
+ }
+
+ // FTS5 content tables don't store original data, so we must join back to the
+ // main journals table to retrieve full_name and abbreviation columns.
+ rows, err := db.conn.Query(`
+ SELECT j.id, j.full_name, j.abbreviation
+ FROM journals j
+ JOIN (
+ SELECT rowid FROM journals_fts WHERE journals_fts MATCH ? ORDER BY rank LIMIT ?
+ ) fts ON j.id = fts.rowid
+ `, clean, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ results := []Journal{}
+ for rows.Next() {
+ var j Journal
+ if err := rows.Scan(&j.ID, &j.FullName, &j.Abbreviation); err != nil {
+ return nil, err
+ }
+ results = append(results, j)
+ }
+
+ return results, rows.Err()
+}
+
+func (db *DB) Count() (int64, error) {
+ var count int64
+ err := db.conn.QueryRow("SELECT COUNT(*) FROM journals").Scan(&count)
+ return count, err
+}
diff --git a/db_test.go b/db_test.go
new file mode 100644
index 0000000..8e507ff
--- /dev/null
+++ b/db_test.go
@@ -0,0 +1,186 @@
+package main
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestOpenDB(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "test.db")
+
+ db, err := OpenDB(dbPath)
+ if err != nil {
+ t.Fatalf("OpenDB failed: %v", err)
+ }
+ defer db.Close()
+
+ count, err := db.Count()
+ if err != nil {
+ t.Fatalf("Count failed: %v", err)
+ }
+ if count != 0 {
+ t.Errorf("expected 0 journals, got %d", count)
+ }
+}
+
+func TestInsertAndSearch(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "test.db")
+
+ db, err := OpenDB(dbPath)
+ if err != nil {
+ t.Fatalf("OpenDB failed: %v", err)
+ }
+ defer db.Close()
+
+ journals := []Journal{
+ {FullName: "Journal of Applied Physics", Abbreviation: "J APPL PHYS"},
+ {FullName: "Nature Medicine", Abbreviation: "NAT MED"},
+ {FullName: "Physical Review Letters", Abbreviation: "PHYS REV LETT"},
+ }
+
+ if err := db.InsertJournals(journals); err != nil {
+ t.Fatalf("InsertJournals failed: %v", err)
+ }
+
+ count, err := db.Count()
+ if err != nil {
+ t.Fatalf("Count failed: %v", err)
+ }
+ if count != 3 {
+ t.Errorf("expected 3 journals, got %d", count)
+ }
+
+ results, err := db.SearchJournals("physics", 10)
+ if err != nil {
+ t.Fatalf("SearchJournals failed: %v", err)
+ }
+ if len(results) == 0 {
+ t.Error("expected at least one result for 'physics'")
+ }
+
+ found := false
+ for _, r := range results {
+ if r.FullName == "Journal of Applied Physics" {
+ found = true
+ if r.Abbreviation != "J APPL PHYS" {
+ t.Errorf("expected abbreviation 'J APPL PHYS', got %q", r.Abbreviation)
+ }
+ }
+ }
+ if !found {
+ t.Error("expected to find 'Journal of Applied Physics' in results")
+ }
+}
+
+func TestSearchPrefix(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "test.db")
+
+ db, err := OpenDB(dbPath)
+ if err != nil {
+ t.Fatalf("OpenDB failed: %v", err)
+ }
+ defer db.Close()
+
+ journals := []Journal{
+ {FullName: "Journal of Applied Physics", Abbreviation: "J APPL PHYS"},
+ {FullName: "Nature Medicine", Abbreviation: "NAT MED"},
+ }
+ if err := db.InsertJournals(journals); err != nil {
+ t.Fatalf("InsertJournals failed: %v", err)
+ }
+
+ results, err := db.SearchJournals("nat med", 10)
+ if err != nil {
+ t.Fatalf("SearchJournals failed: %v", err)
+ }
+ if len(results) == 0 {
+ t.Error("expected results for prefix query 'nat med', got none")
+ }
+}
+
+func TestSearchLimit(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "test.db")
+
+ db, err := OpenDB(dbPath)
+ if err != nil {
+ t.Fatalf("OpenDB failed: %v", err)
+ }
+ defer db.Close()
+
+ journals := []Journal{
+ {FullName: "Journal One", Abbreviation: "J ONE"},
+ {FullName: "Journal Two", Abbreviation: "J TWO"},
+ {FullName: "Journal Three", Abbreviation: "J THREE"},
+ }
+ if err := db.InsertJournals(journals); err != nil {
+ t.Fatalf("InsertJournals failed: %v", err)
+ }
+
+ results, err := db.SearchJournals("journal", 2)
+ if err != nil {
+ t.Fatalf("SearchJournals failed: %v", err)
+ }
+ if len(results) > 2 {
+ t.Errorf("expected at most 2 results, got %d", len(results))
+ }
+}
+
+func TestSanitizeFTS5(t *testing.T) {
+ cases := []struct {
+ input string
+ expected string
+ }{
+ {"hello world", "hello* world*"},
+ {"hello world", "hello* world*"},
+ {"hello-world", "hello* world*"},
+ {"hello&world", "hello* world*"},
+ {"hello(world)", "hello* world*"},
+ {"C++", "c*"},
+ {"Nature & Science", "nature* science*"},
+ {"phys*", "phys*"},
+ {"a", "a*"},
+ {"", ""},
+ {"!!!", ""},
+ }
+
+ for _, c := range cases {
+ got := sanitizeFTS5(c.input)
+ if got != c.expected {
+ t.Errorf("sanitizeFTS5(%q) = %q, want %q", c.input, got, c.expected)
+ }
+ }
+}
+
+func TestSearchWithSpecialChars(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "test.db")
+
+ db, err := OpenDB(dbPath)
+ if err != nil {
+ t.Fatalf("OpenDB failed: %v", err)
+ }
+ defer db.Close()
+
+ journals := []Journal{
+ {FullName: "Nature & Science", Abbreviation: "NAT SCI"},
+ {FullName: "C++ Weekly", Abbreviation: "C WEEKLY"},
+ {FullName: "Physical Review (Letters)", Abbreviation: "PHYS REV LETT"},
+ }
+ if err := db.InsertJournals(journals); err != nil {
+ t.Fatalf("InsertJournals failed: %v", err)
+ }
+
+ for _, q := range []string{"Nature & Science", "C++", "Physical Review (Letters)"} {
+ results, err := db.SearchJournals(q, 10)
+ if err != nil {
+ t.Fatalf("SearchJournals(%q) failed: %v", q, err)
+ }
+ if len(results) == 0 {
+ t.Errorf("expected results for %q, got none", q)
+ }
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..f628434
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,19 @@
+module abvjt
+
+go 1.25.11
+
+require modernc.org/sqlite v1.37.0
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
+ golang.org/x/net v0.56.0 // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ modernc.org/libc v1.62.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.9.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..45f58eb
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,51 @@
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
+golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
+golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
+golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
+golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
+golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
+modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
+modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
+modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
+modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
+modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
+modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
+modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
+modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..74bf926
--- /dev/null
+++ b/justfile
@@ -0,0 +1,41 @@
+# abvjt — Journal Abbreviation Lookup Service
+#
+# USAGE:
+# just build # Build the binary
+# just scrape # Build and scrape WOS data into SQLite
+# just serve # Build and start the server
+# just dev # Build, scrape, and serve
+# just clean # Clean build artifacts and database
+
+default:
+ @echo "abvjt — Journal Abbreviation Lookup"
+ @echo ""
+ @echo " just build # Build the binary"
+ @echo " just scrape # Build + scrape WOS data"
+ @echo " just serve # Build + start server"
+ @echo " just dev # Build + scrape + serve"
+ @echo " just clean # Clean artifacts"
+ @just --list
+
+# Build the Go binary
+build:
+ go build -o abvjt .
+
+# Build and scrape WOS data into data/abvjt.db
+scrape: build
+ ./abvjt scrape
+
+# Build and start the server
+serve: build
+ ./abvjt serve
+
+# Full development cycle: build, scrape, serve
+dev: build
+ ./abvjt scrape
+ ./abvjt serve
+
+# Clean artifacts
+clean:
+ go clean
+ rm -f abvjt
+ rm -f data/abvjt.db
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..91779ed
--- /dev/null
+++ b/main.go
@@ -0,0 +1,67 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+)
+
+type Command interface {
+ Name() string
+ Init(args []string) error
+ Run(stdin io.Reader, stdout io.Writer) error
+}
+
+func main() {
+ if len(os.Args) < 2 {
+ printHelp()
+ os.Exit(1)
+ }
+
+ cmdName := os.Args[1]
+ args := os.Args[2:]
+
+ if cmdName == "help" || cmdName == "--help" || cmdName == "-h" {
+ printHelp()
+ os.Exit(0)
+ }
+
+ var cmd Command
+ switch cmdName {
+ case "scrape":
+ cmd = &ScrapeCommand{}
+ case "serve":
+ cmd = &ServeCommand{}
+ default:
+ fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", cmdName)
+ printHelp()
+ os.Exit(1)
+ }
+
+ if err := cmd.Init(args); err != nil {
+ fmt.Fprintf(os.Stderr, "Error initializing %s: %v\n", cmdName, err)
+ os.Exit(1)
+ }
+
+ if err := cmd.Run(os.Stdin, os.Stdout); err != nil {
+ fmt.Fprintf(os.Stderr, "Error running %s: %v\n", cmdName, err)
+ os.Exit(1)
+ }
+}
+
+func printHelp() {
+ fmt.Printf(`abvjt <command> [arguments]
+
+Journal abbreviation lookup service.
+
+Commands:
+ scrape Scrape WOS journal abbreviations into SQLite DB
+ serve Start HTTP server for web UI and API
+ help Show this help message
+
+Usage:
+ abvjt scrape --db data/abvjt.db
+ abvjt serve --port 8080 --db data/abvjt.db
+
+`)
+}
diff --git a/scrape.go b/scrape.go
new file mode 100644
index 0000000..7b61667
--- /dev/null
+++ b/scrape.go
@@ -0,0 +1,175 @@
+package main
+
+import (
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "golang.org/x/net/html"
+)
+
+const wosBaseURL = "https://wos-help.webofscience.com/WOKRS535R111/help/WOS"
+
+var pageList = []string{
+ "0-9", "A", "B", "C", "D", "E", "F", "G", "H", "I",
+ "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S",
+ "T", "U", "V", "W", "X", "Y", "Z",
+}
+
+type ScrapeCommand struct {
+ DBPath string
+}
+
+func (c *ScrapeCommand) Name() string { return "scrape" }
+
+func (c *ScrapeCommand) Init(args []string) error {
+ fs := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
+ fs.StringVar(&c.DBPath, "db", "data/abvjt.db", "Path to SQLite database")
+ if err := fs.Parse(args); err != nil {
+ if errors.Is(err, flag.ErrHelp) {
+ fs.Usage()
+ return nil
+ }
+ return err
+ }
+ return nil
+}
+
+func (c *ScrapeCommand) Run(stdin io.Reader, stdout io.Writer) error {
+ db, err := OpenDB(c.DBPath)
+ if err != nil {
+ return err
+ }
+ defer db.Close()
+
+ client := &http.Client{
+ Timeout: 30 * time.Second,
+ }
+
+ var totalInserted int
+ for _, page := range pageList {
+ url := fmt.Sprintf("%s/%s_abrvjt.html", wosBaseURL, page)
+ fmt.Fprintf(stdout, "Fetching %s...\n", url)
+
+ journals, err := fetchPage(client, url)
+ if err != nil {
+ return fmt.Errorf("failed to fetch page %s: %w", page, err)
+ }
+
+ if len(journals) == 0 {
+ fmt.Fprintf(stdout, " Warning: no journals found on page %s\n", page)
+ continue
+ }
+
+ if err := db.InsertJournals(journals); err != nil {
+ return fmt.Errorf("failed to insert page %s: %w", page, err)
+ }
+
+ totalInserted += len(journals)
+ fmt.Fprintf(stdout, " Inserted %d journals\n", len(journals))
+
+ time.Sleep(200 * time.Millisecond)
+ }
+
+ count, err := db.Count()
+ if err != nil {
+ return fmt.Errorf("failed to get final count: %w", err)
+ }
+
+ fmt.Fprintf(stdout, "\nDone. Total journals inserted: %d (DB count: %d)\n", totalInserted, count)
+ return nil
+}
+
+func fetchPage(client *http.Client, url string) ([]Journal, error) {
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", "abvjt/1.0")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+
+ return parseWOSPage(resp.Body)
+}
+
+func parseWOSPage(r io.Reader) ([]Journal, error) {
+ tokenizer := html.NewTokenizer(r)
+ var journals []Journal
+ var nameBuf strings.Builder
+ var abbrevBuf strings.Builder
+
+ const (
+ stateDefault = iota
+ stateInName
+ stateExpectDD
+ stateInAbbrev
+ )
+ state := stateDefault
+
+ for {
+ tokenType := tokenizer.Next()
+ if tokenType == html.ErrorToken {
+ err := tokenizer.Err()
+ if err == io.EOF {
+ break
+ }
+ return nil, err
+ }
+
+ switch tokenType {
+ case html.StartTagToken:
+ token := tokenizer.Token()
+ switch token.Data {
+ case "dt":
+ state = stateInName
+ nameBuf.Reset()
+ case "b":
+ if state == stateInName {
+ state = stateExpectDD
+ }
+ case "dd":
+ if state == stateExpectDD {
+ state = stateInAbbrev
+ abbrevBuf.Reset()
+ }
+ }
+
+ case html.EndTagToken:
+ token := tokenizer.Token()
+ if token.Data == "b" && state == stateInAbbrev {
+ name := strings.TrimSpace(nameBuf.String())
+ abbrev := strings.TrimSpace(abbrevBuf.String())
+ if name != "" {
+ journals = append(journals, Journal{
+ FullName: name,
+ Abbreviation: abbrev,
+ })
+ }
+ state = stateDefault
+ }
+
+ case html.TextToken:
+ text := tokenizer.Token().Data
+ switch state {
+ case stateInName:
+ nameBuf.WriteString(text)
+ case stateInAbbrev:
+ abbrevBuf.WriteString(text)
+ }
+ }
+ }
+
+ return journals, nil
+}
diff --git a/server.go b/server.go
new file mode 100644
index 0000000..eeaa42e
--- /dev/null
+++ b/server.go
@@ -0,0 +1,251 @@
+package main
+
+import (
+ "context"
+ _ "embed"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "html/template"
+)
+
+//go:embed templates/index.html
+var indexHTML string
+
+type ServeCommand struct {
+ Port int
+ DBPath string
+ RateLimit int
+}
+
+func (c *ServeCommand) Name() string { return "serve" }
+
+func (c *ServeCommand) Init(args []string) error {
+ fs := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
+ fs.IntVar(&c.Port, "port", 8080, "Port to listen on")
+ fs.StringVar(&c.DBPath, "db", "data/abvjt.db", "Path to SQLite database")
+ fs.IntVar(&c.RateLimit, "rate-limit", 30, "Max requests per minute per IP")
+ if err := fs.Parse(args); err != nil {
+ if errors.Is(err, flag.ErrHelp) {
+ fs.Usage()
+ return nil
+ }
+ return err
+ }
+ return nil
+}
+
+func (c *ServeCommand) Run(stdin io.Reader, stdout io.Writer) error {
+ db, err := OpenDB(c.DBPath)
+ if err != nil {
+ return fmt.Errorf("failed to open database: %w", err)
+ }
+ defer db.Close()
+
+ http.HandleFunc("/", handleRoot())
+ http.HandleFunc("/api/search", handleSearch(db, c.RateLimit))
+ http.HandleFunc("/api/health", handleHealth(db, c.RateLimit))
+
+ addr := fmt.Sprintf(":%d", c.Port)
+ server := &http.Server{
+ Addr: addr,
+ ReadTimeout: 5 * time.Second,
+ WriteTimeout: 10 * time.Second,
+ IdleTimeout: 120 * time.Second,
+ }
+
+ listener, err := net.Listen("tcp", addr)
+ if err != nil {
+ return fmt.Errorf("failed to listen on %s: %w", addr, err)
+ }
+
+ fmt.Fprintf(stdout, "Server listening on %s (DB: %s, rate limit: %d/min)\n", addr, c.DBPath, c.RateLimit)
+
+ // Graceful shutdown
+ idleConnsClosed := make(chan struct{})
+ go func() {
+ sigint := make(chan os.Signal, 1)
+ signal.Notify(sigint, os.Interrupt, syscall.SIGTERM)
+ <-sigint
+ fmt.Fprintln(stdout, "\nShutting down...")
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := server.Shutdown(ctx); err != nil {
+ fmt.Fprintf(os.Stderr, "Shutdown error: %v\n", err)
+ }
+ close(idleConnsClosed)
+ }()
+
+ go func() {
+ if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
+ fmt.Fprintf(os.Stderr, "Server error: %v\n", err)
+ os.Exit(1)
+ }
+ }()
+
+ <-idleConnsClosed
+ return nil
+}
+
+func handleRoot() http.HandlerFunc {
+ tmpl, err := template.New("index").Parse(indexHTML)
+ if err != nil {
+ panic(fmt.Sprintf("failed to parse template: %v", err))
+ }
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ tmpl.Execute(w, nil)
+ }
+}
+
+func handleSearch(db *DB, rateLimit int) http.HandlerFunc {
+ rl := newRateLimiter(rateLimit)
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ip := clientIP(r)
+ if !rl.allow(ip) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusTooManyRequests)
+ json.NewEncoder(w).Encode(map[string]string{"error": "rate limit exceeded"})
+ return
+ }
+
+ query := strings.TrimSpace(r.URL.Query().Get("q"))
+ if query == "" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{"error": "missing query parameter 'q'"})
+ return
+ }
+
+ if len(query) > 200 {
+ query = query[:200]
+ }
+
+ limitStr := r.URL.Query().Get("limit")
+ limit, err := strconv.Atoi(limitStr)
+ if err != nil || limit <= 0 {
+ limit = 50
+ }
+
+ results, err := db.SearchJournals(query, limit)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ json.NewEncoder(w).Encode(map[string]string{"error": "search failed"})
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(results)
+ }
+}
+
+func handleHealth(db *DB, rateLimit int) http.HandlerFunc {
+ rl := newRateLimiter(rateLimit)
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ip := clientIP(r)
+ if !rl.allow(ip) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusTooManyRequests)
+ json.NewEncoder(w).Encode(map[string]string{"error": "rate limit exceeded"})
+ return
+ }
+
+ count, err := db.Count()
+ status := "ok"
+ if err != nil {
+ status = "error"
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "status": status,
+ "db_loaded": err == nil,
+ "total_journals": count,
+ })
+ }
+}
+
+func clientIP(r *http.Request) string {
+ ip := r.Header.Get("X-Forwarded-For")
+ if ip != "" {
+ parts := strings.Split(ip, ",")
+ return strings.TrimSpace(parts[0])
+ }
+ ip = r.Header.Get("X-Real-Ip")
+ if ip != "" {
+ return strings.TrimSpace(ip)
+ }
+ host, _, _ := net.SplitHostPort(r.RemoteAddr)
+ return host
+}
+
+type rateLimiter struct {
+ mu sync.RWMutex
+ buckets map[string]*bucket
+ maxPerMin int
+}
+
+type bucket struct {
+ tokens float64
+ lastCheck time.Time
+}
+
+func newRateLimiter(maxPerMin int) *rateLimiter {
+ return &rateLimiter{
+ buckets: make(map[string]*bucket),
+ maxPerMin: maxPerMin,
+ }
+}
+
+func (rl *rateLimiter) allow(ip string) bool {
+ now := time.Now()
+
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+
+ b, ok := rl.buckets[ip]
+ if !ok {
+ rl.buckets[ip] = &bucket{
+ tokens: float64(rl.maxPerMin) - 1,
+ lastCheck: now,
+ }
+ return true
+ }
+
+ elapsed := now.Sub(b.lastCheck).Minutes()
+ b.tokens = min(float64(rl.maxPerMin), b.tokens+elapsed*float64(rl.maxPerMin))
+ b.lastCheck = now
+
+ if b.tokens >= 1 {
+ b.tokens--
+ return true
+ }
+ return false
+}
diff --git a/server_test.go b/server_test.go
new file mode 100644
index 0000000..2dfd1fc
--- /dev/null
+++ b/server_test.go
@@ -0,0 +1,406 @@
+package main
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func setupTestServer(t *testing.T) (*DB, *httptest.Server) {
+ t.Helper()
+
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "test.db")
+
+ db, err := OpenDB(dbPath)
+ if err != nil {
+ t.Fatalf("OpenDB failed: %v", err)
+ }
+
+ journals := []Journal{
+ {FullName: "Journal of Applied Physics", Abbreviation: "J APPL PHYS"},
+ {FullName: "Nature Medicine", Abbreviation: "NAT MED"},
+ {FullName: "Physical Review Letters", Abbreviation: "PHYS REV LETT"},
+ {FullName: "Nature Biotechnology", Abbreviation: "NAT BIOTECHNOL"},
+ {FullName: "Nature & Science", Abbreviation: "NAT SCI"},
+ {FullName: "C++ Weekly", Abbreviation: "C WEEKLY"},
+ {FullName: "Physical Review (Letters)", Abbreviation: "PHYS REV LETT"},
+ {FullName: "Proceedings of the National Academy of Sciences", Abbreviation: "PROC NAT ACAD SCI"},
+ }
+
+ if err := db.InsertJournals(journals); err != nil {
+ t.Fatalf("InsertJournals failed: %v", err)
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", handleRoot())
+ mux.HandleFunc("/api/search", handleSearch(db, 1000))
+ mux.HandleFunc("/api/health", handleHealth(db, 1000))
+
+ server := httptest.NewServer(mux)
+ t.Cleanup(func() {
+ server.Close()
+ db.Close()
+ })
+
+ return db, server
+}
+
+func get(t *testing.T, url string) *http.Response {
+ t.Helper()
+ resp, err := http.Get(url)
+ if err != nil {
+ t.Fatalf("GET %s failed: %v", url, err)
+ }
+ return resp
+}
+
+func readBody(t *testing.T, resp *http.Response) string {
+ t.Helper()
+ defer resp.Body.Close()
+ b, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("reading body: %v", err)
+ }
+ return string(b)
+}
+
+func decodeJournals(t *testing.T, body string) []Journal {
+ t.Helper()
+ var results []Journal
+ if err := json.Unmarshal([]byte(body), &results); err != nil {
+ t.Fatalf("json decode failed: %v\nbody: %s", err, body)
+ }
+ return results
+}
+
+func TestGetRoot(t *testing.T) {
+ _, server := setupTestServer(t)
+
+ resp := get(t, server.URL+"/")
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ t.Errorf("expected 200, got %d", resp.StatusCode)
+ }
+
+ ct := resp.Header.Get("Content-Type")
+ if !strings.Contains(ct, "text/html") {
+ t.Errorf("expected text/html content type, got %s", ct)
+ }
+
+ body := readBody(t, resp)
+ if !strings.Contains(body, "Journal Abbreviations") {
+ t.Error("expected page title in HTML body")
+ }
+}
+
+func TestGetRootNotFound(t *testing.T) {
+ _, server := setupTestServer(t)
+
+ resp := get(t, server.URL+"/nonexistent")
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 404 {
+ t.Errorf("expected 404 for /nonexistent, got %d", resp.StatusCode)
+ }
+}
+
+func TestSearchByFullName(t *testing.T) {
+ _, server := setupTestServer(t)
+
+ resp := get(t, server.URL+"/api/search?q=physics")
+ body := readBody(t, resp)
+
+ results := decodeJournals(t, body)
+
+ found := false
+ for _, j := range results {
+ if j.FullName == "Journal of Applied Physics" {
+ found = true
+ if j.Abbreviation != "J APPL PHYS" {
+ t.Errorf("expected abbreviation 'J APPL PHYS', got %q", j.Abbreviation)
+ }
+ }
+ }
+ if !found {
+ t.Error("expected to find 'Journal of Applied Physics' when searching 'physics'")
+ }
+}
+
+func TestSearchByAbbreviation(t *testing.T) {
+ _, server := setupTestServer(t)
+
+ resp := get(t, server.URL+"/api/search?q=NAT+MED")
+ body := readBody(t, resp)
+
+ results := decodeJournals(t, body)
+
+ found := false
+ for _, j := range results {
+ if j.FullName == "Nature Medicine" {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("expected to find 'Nature Medicine' when searching abbreviation 'NAT MED', got %d results", len(results))
+ }
+}
+
+func TestSearchCaseInsensitive(t *testing.T) {
+ _, server := setupTestServer(t)
+
+ lowerResp := get(t, server.URL+"/api/search?q=nature+medicine")
+ lowerBody := readBody(t, lowerResp)
+ lowerResults := decodeJournals(t, lowerBody)
+
+ upperResp := get(t, server.URL+"/api/search?q=NATURE+MEDICINE")
+ upperBody := readBody(t, upperResp)
+ upperResults := decodeJournals(t, upperBody)
+
+ if len(lowerResults) == 0 {
+ t.Error("lowercase search returned no results")
+ }
+ if len(upperResults) == 0 {
+ t.Error("uppercase search returned no results")
+ }
+ if len(lowerResults) != len(upperResults) {
+ t.Errorf("case insensitive: lower=%d results, upper=%d results", len(lowerResults), len(upperResults))
+ }
+}
+
+func TestSearchMultipleMatches(t *testing.T) {
+ _, server := setupTestServer(t)
+
+ resp := get(t, server.URL+"/api/search?q=nature")
+ body := readBody(t, resp)
+
+ results := decodeJournals(t, body)
+
+ if len(results) < 2 {
+ t.Errorf("expected at least 2 results for 'nature', got %d", len(results))
+ }
+
+ names := map[string]bool{}
+ for _, j := range results {
+ names[j.FullName] = true
+ }
+ if !names["Nature Medicine"] {
+ t.Error("expected 'Nature Medicine' in results")
+ }
+ if !names["Nature Biotechnology"] {
+ t.Error("expected 'Nature Biotechnology' in results")
+ }
+}
+
+func TestSearchNoResults(t *testing.T) {
+ _, server := setupTestServer(t)
+
+ resp := get(t, server.URL+"/api/search?q=zzzzznonexistent")
+ body := readBody(t, resp)
+
+ if resp.StatusCode != 200 {
+ t.Errorf("expected 200 for no-results query, got %d", resp.StatusCode)
+ }
+
+ results := decodeJournals(t, body)
+ if len(results) != 0 {
+ t.Errorf("expected 0 results for nonexistent query, got %d", len(results))
+ }
+}
+
+func TestSearchMissingQuery(t *testing.T) {
+ _, server := setupTestServer(t)
+
+ resp := get(t, server.URL+"/api/search")
+ body := readBody(t, resp)
+
+ if resp.StatusCode != 400 {
+ t.Errorf("expected 400 for missing q param, got %d", resp.StatusCode)
+ }
+
+ if !strings.Contains(body, "missing query") {
+ t.Errorf("expected error message about missing query, got: %s", body)
+ }
+}
+
+func TestSearchWrongMethod(t *testing.T) {
+ _, server := setupTestServer(t)
+
+ resp, err := http.Post(server.URL+"/api/search?q=test", "text/plain", nil)
+ if err != nil {
+ t.Fatalf("POST request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 405 {
+ t.Errorf("expected 405 for POST, got %d", resp.StatusCode)
+ }
+}
+
+func TestSearchLimitParameter(t *testing.T) {
+ _, server := setupTestServer(t)
+
+ resp := get(t, server.URL+"/api/search?q=nature&limit=1")
+ body := readBody(t, resp)
+
+ results := decodeJournals(t, body)
+ if len(results) > 1 {
+ t.Errorf("expected at most 1 result with limit=1, got %d", len(results))
+ }
+}
+
+func TestSearchInvalidLimit(t *testing.T) {
+ _, server := setupTestServer(t)
+
+ resp := get(t, server.URL+"/api/search?q=nature&limit=abc")
+ body := readBody(t, resp)
+
+ if resp.StatusCode != 200 {
+ t.Errorf("expected 200 with invalid limit (should default), got %d", resp.StatusCode)
+ }
+
+ results := decodeJournals(t, body)
+ if len(results) == 0 {
+ t.Error("invalid limit should default to 50 and still return results")
+ }
+}
+
+func TestSearchSpecialChars(t *testing.T) {
+ _, server := setupTestServer(t)
+
+ for _, q := range []string{"Nature & Science", "C++"} {
+ resp := get(t, server.URL+"/api/search?q="+url.QueryEscape(q))
+ body := readBody(t, resp)
+
+ if resp.StatusCode != 200 {
+ t.Errorf("search for %q returned %d", q, resp.StatusCode)
+ }
+
+ results := decodeJournals(t, body)
+ if len(results) == 0 {
+ t.Errorf("expected results for %q", q)
+ }
+ }
+}
+
+func TestHealthEndpoint(t *testing.T) {
+ _, server := setupTestServer(t)
+
+ resp := get(t, server.URL+"/api/health")
+ body := readBody(t, resp)
+
+ if resp.StatusCode != 200 {
+ t.Errorf("expected 200, got %d", resp.StatusCode)
+ }
+
+ var result map[string]interface{}
+ if err := json.Unmarshal([]byte(body), &result); err != nil {
+ t.Fatalf("json decode failed: %v", err)
+ }
+
+ if result["status"] != "ok" {
+ t.Errorf("expected status 'ok', got %v", result["status"])
+ }
+ if result["db_loaded"] != true {
+ t.Errorf("expected db_loaded true, got %v", result["db_loaded"])
+ }
+ count, ok := result["total_journals"].(float64)
+ if !ok || count < 1 {
+ t.Errorf("expected total_journals >= 1, got %v", result["total_journals"])
+ }
+}
+
+func TestHealthWrongMethod(t *testing.T) {
+ _, server := setupTestServer(t)
+
+ resp, err := http.Post(server.URL+"/api/health", "text/plain", nil)
+ if err != nil {
+ t.Fatalf("POST request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 405 {
+ t.Errorf("expected 405 for POST to health, got %d", resp.StatusCode)
+ }
+}
+
+func TestSearchResponseIsJSONArray(t *testing.T) {
+ _, server := setupTestServer(t)
+
+ resp := get(t, server.URL+"/api/search?q=nature")
+ body := readBody(t, resp)
+
+ ct := resp.Header.Get("Content-Type")
+ if !strings.Contains(ct, "application/json") {
+ t.Errorf("expected application/json, got %s", ct)
+ }
+
+ if !strings.HasPrefix(strings.TrimSpace(body), "[") {
+ t.Errorf("expected JSON array response, got: %s", body[:min(len(body), 50)])
+ }
+}
+
+func TestSearchResultStructure(t *testing.T) {
+ _, server := setupTestServer(t)
+
+ resp := get(t, server.URL+"/api/search?q=Nature+Medicine")
+ body := readBody(t, resp)
+
+ results := decodeJournals(t, body)
+ if len(results) == 0 {
+ t.Fatal("expected at least one result")
+ }
+
+ j := results[0]
+ if j.ID == 0 {
+ t.Error("expected non-zero ID")
+ }
+ if j.FullName == "" {
+ t.Error("expected non-empty FullName")
+ }
+ if j.Abbreviation == "" {
+ t.Error("expected non-empty Abbreviation")
+ }
+}
+
+func TestRateLimiting(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "test.db")
+ db, err := OpenDB(dbPath)
+ if err != nil {
+ t.Fatalf("OpenDB failed: %v", err)
+ }
+ journals := []Journal{
+ {FullName: "Test Journal", Abbreviation: "TEST J"},
+ }
+ if err := db.InsertJournals(journals); err != nil {
+ t.Fatalf("InsertJournals failed: %v", err)
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/search", handleSearch(db, 3))
+ srv := httptest.NewServer(mux)
+ t.Cleanup(func() {
+ srv.Close()
+ db.Close()
+ })
+
+ tooManyCount := 0
+ for i := 0; i < 10; i++ {
+ resp := get(t, srv.URL+"/api/search?q=test")
+ resp.Body.Close()
+ if resp.StatusCode == 429 {
+ tooManyCount++
+ }
+ }
+
+ if tooManyCount == 0 {
+ t.Error("expected at least one 429 response when rate limit is 3/min and we made 10 rapid requests")
+ }
+}
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..cccd378
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,211 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Journal Abbreviations Tool</title>
+ <style>
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ body {
+ font-family: monospace;
+ background: #fff;
+ color: #000;
+ padding: 20px;
+ line-height: 1.6;
+ }
+ h1 {
+ font-size: 1.2em;
+ font-weight: bold;
+ margin-bottom: 20px;
+ border-bottom: 1px solid #000;
+ padding-bottom: 10px;
+ }
+ .search-form {
+ border: 1px solid #000;
+ padding: 20px;
+ margin-bottom: 20px;
+ }
+ input[type="text"] {
+ font-family: monospace;
+ font-size: 1em;
+ border: 1px solid #000;
+ padding: 5px;
+ width: 100%;
+ }
+ button {
+ font-family: monospace;
+ font-size: 1em;
+ background: #fff;
+ color: #000;
+ border: 1px solid #000;
+ padding: 5px 15px;
+ cursor: pointer;
+ margin-top: 15px;
+ }
+ button:hover {
+ background: #000;
+ color: #fff;
+ }
+ .results {
+ border: 1px solid #000;
+ padding: 0;
+ }
+ .result {
+ border-bottom: 1px solid #ccc;
+ padding: 10px;
+ }
+ .result:last-child {
+ border-bottom: none;
+ }
+ .full-name {
+ font-weight: bold;
+ margin-bottom: 5px;
+ }
+ .abbrev {
+ color: #666;
+ font-size: 0.9em;
+ }
+ .copy-buttons {
+ margin-top: 8px;
+ }
+ .copy-buttons button {
+ font-size: 0.85em;
+ padding: 3px 10px;
+ margin-right: 10px;
+ }
+ .no-results, .error {
+ padding: 10px;
+ border: 1px solid #000;
+ }
+ .error {
+ border-color: #f00;
+ color: #f00;
+ }
+ footer {
+ margin-top: 30px;
+ border-top: 1px solid #000;
+ padding-top: 10px;
+ font-size: 0.9em;
+ }
+ </style>
+</head>
+<body>
+ <h1>Journal Abbreviations Tool</h1>
+ <form class="search-form" id="searchForm">
+ <input type="text" id="query" name="q" placeholder="search journal name..." autofocus autocomplete="off">
+ <br>
+ <button type="submit">search</button>
+ </form>
+ <div id="results"></div>
+ <footer>
+ <a href="/api/health">health</a> &mdash;
+ data from <a href="https://wos-help.webofscience.com/WOKRS535R111/help/WOS/A_abrvjt.html">Web of Science</a>
+ </footer>
+ <script>
+ const form = document.getElementById('searchForm');
+ const queryInput = document.getElementById('query');
+ const resultsDiv = document.getElementById('results');
+
+ function toSentenceCase(str) {
+ return str.toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
+ }
+
+ function makeResultElement(journal) {
+ const sent = toSentenceCase(journal.abbreviation);
+
+ const container = document.createElement('div');
+ container.className = 'result';
+
+ const nameDiv = document.createElement('div');
+ nameDiv.className = 'full-name';
+ nameDiv.textContent = journal.full_name;
+ container.appendChild(nameDiv);
+
+ const abbrevDiv = document.createElement('div');
+ abbrevDiv.className = 'abbrev';
+ abbrevDiv.textContent = journal.abbreviation;
+ container.appendChild(abbrevDiv);
+
+ const buttonsDiv = document.createElement('div');
+ buttonsDiv.className = 'copy-buttons';
+
+ const btnSent = document.createElement('button');
+ btnSent.textContent = 'copy sentence case';
+ btnSent.addEventListener('click', () => copyText(sent));
+ buttonsDiv.appendChild(btnSent);
+
+ const btnCaps = document.createElement('button');
+ btnCaps.textContent = 'copy ALL CAPS';
+ btnCaps.addEventListener('click', () => copyText(journal.abbreviation));
+ buttonsDiv.appendChild(btnCaps);
+
+ container.appendChild(buttonsDiv);
+ return container;
+ }
+
+ function renderResults(journals) {
+ if (journals.length === 0) {
+ resultsDiv.innerHTML = '<div class="no-results">no results</div>';
+ return;
+ }
+
+ const wrapper = document.createElement('div');
+ wrapper.className = 'results';
+ for (const j of journals) {
+ wrapper.appendChild(makeResultElement(j));
+ }
+ resultsDiv.innerHTML = '';
+ resultsDiv.appendChild(wrapper);
+ }
+
+ async function copyText(text) {
+ try {
+ await navigator.clipboard.writeText(text);
+ } catch (e) {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ }
+ }
+
+ async function doSearch() {
+ const q = queryInput.value.trim();
+ if (!q) {
+ resultsDiv.innerHTML = '';
+ return;
+ }
+ if (q.length < 2) {
+ return;
+ }
+ resultsDiv.innerHTML = '<div class="no-results">loading...</div>';
+ try {
+ const resp = await fetch('/api/search?q=' + encodeURIComponent(q));
+ if (!resp.ok) throw new Error(resp.status + ' ' + resp.statusText);
+ const data = await resp.json();
+ renderResults(data);
+ } catch (err) {
+ const errorDiv = document.createElement('div');
+ errorDiv.className = 'error';
+ errorDiv.textContent = err.message;
+ resultsDiv.innerHTML = '';
+ resultsDiv.appendChild(errorDiv);
+ }
+ }
+
+ let debounceTimer;
+ queryInput.addEventListener('input', () => {
+ clearTimeout(debounceTimer);
+ debounceTimer = setTimeout(doSearch, 200);
+ });
+
+ form.addEventListener('submit', (e) => {
+ e.preventDefault();
+ clearTimeout(debounceTimer);
+ doSearch();
+ });
+ </script>
+</body>
+</html>