diff options
| author | Sam Scholten | 2026-06-14 20:00:15 +1000 |
|---|---|---|
| committer | Sam Scholten | 2026-06-14 20:00:15 +1000 |
| commit | decc46c876e7b5552f5f5ecac4ee4f1a64ad1d62 (patch) | |
| tree | 46875e236a062189115c0cd8ed8f1d82980c16b7 | |
| download | abvjt-decc46c876e7b5552f5f5ecac4ee4f1a64ad1d62.tar.gz abvjt-decc46c876e7b5552f5f5ecac4ee4f1a64ad1d62.zip | |
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | README.md | 27 | ||||
| -rw-r--r-- | db.go | 188 | ||||
| -rw-r--r-- | db_test.go | 186 | ||||
| -rw-r--r-- | go.mod | 19 | ||||
| -rw-r--r-- | go.sum | 51 | ||||
| -rw-r--r-- | justfile | 41 | ||||
| -rw-r--r-- | main.go | 67 | ||||
| -rw-r--r-- | scrape.go | 175 | ||||
| -rw-r--r-- | server.go | 251 | ||||
| -rw-r--r-- | server_test.go | 406 | ||||
| -rw-r--r-- | templates/index.html | 211 |
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 +``` @@ -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) + } + } +} @@ -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 +) @@ -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 @@ -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> — + 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> |
