commit 2be865c18df87156c40fcccbab952621cee370ac from: rohanverma2007 date: Mon Apr 20 20:23:44 2026 UTC init commit - /dev/null commit + 2be865c18df87156c40fcccbab952621cee370ac blob - /dev/null blob + df0ae3ed47f8a1ad597844bf5ef15491d102c6b2 (mode 644) --- /dev/null +++ Dockerfile @@ -0,0 +1,15 @@ +FROM docker.io/library/golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY main.go index.html ./ +COPY utils/ utils/ +RUN apk add --no-cache upx && \ + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOAMD64=v4 \ + go build -trimpath -tags nethttpomithttp2 \ + -ldflags="-s -w" -o /out/find . && \ + upx --best --lzma /out/find +FROM scratch +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=build /out/find /find +EXPOSE 11081 +ENTRYPOINT ["/find"] blob - /dev/null blob + b6a58120b5cec77b94a06c6ebe71d5cf48d567bf (mode 644) --- /dev/null +++ LICENSE @@ -0,0 +1,11 @@ +Copyright 2026 Rohan Verma + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. blob - /dev/null blob + 7fe729f0b741c05f28019a468d6faaeda8b566a9 (mode 644) --- /dev/null +++ Makefile @@ -0,0 +1,22 @@ +APP = find +BUILD_DIR = build +BIN = $(BUILD_DIR)/$(APP) + +build: + mkdir -p $(BUILD_DIR) + CGO_ENABLED=0 GOOS=linux \ + go build -trimpath -tags nethttpomithttp2 \ + -ldflags="-s -w" -o $(BIN) . + +compress: build + upx --best --lzma $(BIN) + +run: + go run . + +clean: + rm -rf $(BUILD_DIR) + +rebuild: clean build + +.PHONY: build compress run clean rebuild blob - /dev/null blob + db760418da9eeaac0053ab9c6ae79561d02d48ee (mode 644) --- /dev/null +++ README.md @@ -0,0 +1,24 @@ +# find + +a really basic search engine that uses [4get](https://git.joygnu.org/joy/4get) backend + +## usage +podman setup +``` +podman-compose build +podman-compose up -d +``` + +docker setup +``` +docker-compose build +docker-compose up -d +``` + +## build +``` +git clone https://git.sr.ht/~rohanverma2007/find +make build +cd build +./find +``` blob - /dev/null blob + f81ac337cbdd4c2907ecc6a7aed2c9adde34c79d (mode 644) --- /dev/null +++ compose.yaml @@ -0,0 +1,37 @@ +services: + fourget: + image: docker.io/luuul/4get:latest + container_name: fourget + restart: unless-stopped + environment: + - FOURGET_PROTO=http + - FOURGET_SERVER_NAME=find.rsap.sh + networks: + podman: + ipv4_address: 10.88.0.134 + find: + build: . + container_name: find + restart: unless-stopped + depends_on: + - fourget + environment: + - PORT=11081 + - FOURGET_URL=http://10.88.0.134 + - CACHE_DIR=/cache + - GOGC=off + - GOMEMLIMIT=256MiB + volumes: + - ./cache:/cache + labels: + - "traefik.enable=true" + - "traefik.http.routers.find.rule=Host(`find.rsap.sh`)" + - "traefik.http.routers.find.entrypoints=websecure" + - "traefik.http.routers.find.tls=true" + - "traefik.http.routers.find.tls.certresolver=lets-encrypt" + - "traefik.http.services.find.loadbalancer.server.port=11081" + networks: + - podman +networks: + podman: + external: true blob - /dev/null blob + 6f81894bf45332956aae01feb7c6c61767d8f3a0 (mode 644) --- /dev/null +++ go.mod @@ -0,0 +1,3 @@ +module find + +go 1.24 blob - /dev/null blob + ea8206cc00a2753d2255e120cace5dede4f082fe (mode 644) --- /dev/null +++ index.html @@ -0,0 +1,106 @@ +{{define "head"}} + + + {{if .Query}}{{.Query}} - {{end}}find + + + +

find

+
+ + + +
+ {{if .ShowTabs}}

+ {{if eq .Tab "web"}}Web{{else}}Web{{end}} + {{if eq .Tab "images"}}Images{{else}}Images{{end}} + {{if eq .Tab "news"}}News{{else}}News{{end}} +

{{end}}{{end}} + +{{define "tail"}}{{if .Calc.Show}} +
{{.Calc.Result}}
+ {{end}}{{if .Dict.Show}} +
+ {{.Dict.Word}}{{if .Dict.Phonetic}} {{.Dict.Phonetic}}{{end}} + {{range .Dict.Meanings}} +

{{.Speech}}

+ {{range .Defs}} +

{{.Num}}. {{.Def}}{{if .Example}}
"{{.Example}}"{{end}}{{if .Syns}}
Similar: {{.Syns}}{{end}}

+ {{end}} + {{end}} +
+ {{end}}{{if .Conv.Show}} +
+ {{.Conv.Result}} +
+ + +

+ + to +

+

+
+
+ {{end}}{{if and (eq .Tab "web") .Spelling}}

{{.Spelling}}

{{end}} + {{if .Error}}
Error

{{.Error}}
{{end}} + {{if and (eq .Tab "web") .Answer}}
{{if .AnswerTitle}}{{.AnswerTitle}}

{{end}}{{.Answer}}
{{end}} + {{if eq .Tab "web"}}{{range .Web}} +

{{.Title}}
{{.URL}}
{{.Desc}}

+ {{if .Sub}}{{end}} + {{end}}{{end}} + {{if eq .Tab "news"}}{{range .News}} +

{{.Title}}
{{.URL}}{{if .Date}}
{{.Date}}{{end}}
{{.Desc}}

+ {{end}}{{end}} + {{if eq .Tab "images"}}{{range .Images}} +
+ {{if .Thumb}}{{.Title}}{{else}}[image]{{end}} + {{.Title}} + {{if .Dims}}{{.Dims}}{{end}} +
+ {{end}}{{end}} + {{if or .HasPrev .HasNext}}

+ {{if .HasPrev}}{{else}}{{end}} + {{.Page}} + {{if .HasNext}}{{else}}{{end}} +

{{end}} + {{if and (eq .Error "") (eq .Count 0) (eq .Answer "") (ne .Query "")}}

No results.

{{end}} + +{{end}} blob - /dev/null blob + 9358b57d14e3d7cfd2aebe0516ad0c6f62ee39bb (mode 644) --- /dev/null +++ main.go @@ -0,0 +1,314 @@ +package main + +import ( + _ "embed" + "encoding/json" + "find/utils" + "html/template" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" +) + +const ( + cacheTTL = 24 * time.Hour + cacheMax = 5 << 30 +) + +//go:embed index.html +var indexHTML string + +type ( + Link struct{ Title, URL string } + WebRes struct{ Title, URL, Desc string; Sub []Link } + NewsRes struct{ Title, URL, Desc, Date string } + ImgRes struct{ Title, URL, Thumb, Dims string } +) + +type Page struct { + Tab, Query, Error string + EscQ, EscNPT template.URL + Spelling, AnswerTitle string + Answer template.HTML + Conv utils.Conv + Calc utils.Calc + Dict utils.Dict + Web []WebRes + News []NewsRes + Images []ImgRes + Count int + ShowTabs, HasPrev, HasNext bool + Page, PrevPage, NextPage int + PrevNPT, PrevH, NextH template.URL +} + +type apiResp struct { + Status string `json:"status"` + NPT string `json:"npt"` + Web []apiWeb `json:"web"` + News []apiNews `json:"news"` + Image []apiImg `json:"image"` + Spell *apiSpell `json:"spelling"` + Answer []apiAns `json:"answer"` +} +type apiWeb struct{ Title, URL, Description string; Sublink []Link } +type apiNews struct{ Title, URL, Description string; Date json.RawMessage } +type apiImg struct{ Title string; Source []struct{ URL string }; Width, Height *int } +type apiSpell struct{ Type, Using, Correction string } +type apiAns struct{ Title string; Description []struct{ Type, Value, URL string } } + +var ( + tpl = template.Must(template.New("").Funcs(template.FuncMap{ + "safe": func(s string) template.URL { return template.URL(s) }, + }).Parse(indexHTML)) + homeHTML []byte + client = &http.Client{Timeout: 12 * time.Second, Transport: &http.Transport{ + MaxIdleConns: 64, MaxIdleConnsPerHost: 64, IdleConnTimeout: 90 * time.Second, + }} + ramMu sync.RWMutex + ram = map[string]Page{} + cdir, base string +) + +// --- helpers --- + +func env(k, d string) string { if v := trim(os.Getenv(k)); v != "" { return v }; return d } +func atoi(s string, d int) int { n, e := strconv.Atoi(trim(s)); if e != nil || n < 1 { return d }; return n } +func trim(s string) string { return strings.TrimSpace(s) } +func esc(s string) string { return url.QueryEscape(s) } + +func fnv64(s string) uint64 { + h := uint64(14695981039346656037) + for i := 0; i < len(s); i++ { h ^= uint64(s[i]); h *= 1099511628211 } + return h +} + +func splitHist(h string) []string { + if trim(h) == "" { return nil } + var out []string + for _, p := range strings.Split(h, ",") { if p = trim(p); p != "" { out = append(out, p) } } + return out +} + +// --- cache --- + +func ramKey(tab, q, npt string, pg int, h []string) string { + return tab + "\x00" + q + "\x00" + npt + "\x00" + strconv.Itoa(pg) + "\x00" + strings.Join(h, ",") +} +func ramGet(k string) (Page, bool) { ramMu.RLock(); p, ok := ram[k]; ramMu.RUnlock(); return p, ok } +func ramSet(k string, p Page) { ramMu.Lock(); if len(ram) > 8192 { clear(ram) }; ram[k] = p; ramMu.Unlock() } + +func diskPath(tab, q, npt string) string { + k := tab + "\n" + strings.ToLower(strings.Join(strings.Fields(q), " ")) + "\n" + npt + return cdir + "/" + strconv.FormatUint(fnv64(k), 36) + ".bin" +} +func diskGet(tab, q, npt string) ([]byte, bool) { + p := diskPath(tab, q, npt) + i, err := os.Stat(p); if err != nil || time.Since(i.ModTime()) > cacheTTL { return nil, false } + b, err := os.ReadFile(p); if err != nil { return nil, false } + return b, true +} +func diskSet(tab, q, npt string, b []byte) { + p := diskPath(tab, q, npt) + if os.WriteFile(p+".tmp", b, 0o644) == nil { _ = os.Rename(p+".tmp", p) } +} +func pruneDisk() { + es, _ := os.ReadDir(cdir); var total int64 + for _, e := range es { + if e.IsDir() { continue } + i, err := e.Info(); if err != nil { continue } + if time.Since(i.ModTime()) > cacheTTL { _ = os.Remove(cdir + "/" + e.Name()); continue } + total += i.Size() + } + if total <= cacheMax { return } + es, _ = os.ReadDir(cdir) + for _, e := range es { + if total <= cacheMax { break } + if e.IsDir() { continue } + i, _ := e.Info(); if i == nil { continue } + if os.Remove(cdir+"/"+e.Name()) == nil { total -= i.Size() } + } +} + +// --- API --- + +func endpoint(tab string) string { + switch tab { + case "images": return "/api/v1/images" + case "news": return "/api/v1/news" + default: return "/api/v1/web" + } +} + +func spellText(s *apiSpell) string { + if s == nil { return "" } + u, c := strings.Trim(trim(s.Using), "\""), strings.Trim(trim(s.Correction), "\"") + if u == "" || c == "" { return "" } + switch trim(s.Type) { + case "including": return "Including results for " + u + ". Did you mean \"" + c + "\"?" + case "not_many": return "Not many results for " + c + ". Did you mean \"" + u + "\"?" + } + return "" +} + +func answerHTML(bs []struct{ Type, Value, URL string }) template.HTML { + var b strings.Builder + for _, x := range bs { + v, u := template.HTMLEscapeString(trim(x.Value)), template.HTMLEscapeString(trim(x.URL)) + switch trim(x.Type) { + case "text", "italic", "quote", "code", "inline_code", "title": + if v != "" { if b.Len() > 0 { b.WriteString("
") }; b.WriteString(v) } + case "link": + if u != "" { + if b.Len() > 0 { b.WriteString("
") } + if v == "" { v = u } + b.WriteString(`` + v + ``) + } + } + } + return template.HTML(b.String()) +} + +func fetchParse(tab, q, npt string, p *Page) error { + var body []byte; cached := false + if b, ok := diskGet(tab, q, npt); ok { + body, cached = b, true + } else { + qs := "s=" + esc(q); if npt != "" { qs = "npt=" + esc(npt) } + req, _ := http.NewRequest("GET", base+endpoint(tab)+"?"+qs, nil) + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req); if err != nil { return err } + body, _ = io.ReadAll(resp.Body); resp.Body.Close() + } + var a apiResp + if err := json.Unmarshal(body, &a); err != nil { return err } + if a.Status != "" && a.Status != "ok" { p.Error = a.Status; return nil } + if !cached { diskSet(tab, q, npt, body) } + if t := trim(a.NPT); t != "" { p.EscNPT = template.URL(esc(t)); p.HasNext = true } + + switch tab { + case "web": + p.Spelling = spellText(a.Spell) + if len(a.Answer) > 0 { p.AnswerTitle = trim(a.Answer[0].Title); p.Answer = answerHTML(a.Answer[0].Description) } + for _, r := range a.Web { + t, u, d := trim(r.Title), trim(r.URL), trim(r.Description) + if t == "" && u == "" && d == "" { continue } + if t == "" { t = u } + w := WebRes{Title: t, URL: u, Desc: d} + for _, sl := range r.Sublink { + st, su := trim(sl.Title), trim(sl.URL) + if st != "" && su != "" { w.Sub = append(w.Sub, Link{Title: st, URL: su}) } + } + p.Web = append(p.Web, w) + } + p.Count = len(p.Web) + case "news": + for _, r := range a.News { + t, u, d := trim(r.Title), trim(r.URL), trim(r.Description) + if t == "" && u == "" && d == "" { continue } + if t == "" { t = u } + p.News = append(p.News, NewsRes{t, u, d, strings.Trim(trim(string(r.Date)), "\"")}) + } + p.Count = len(p.News) + case "images": + for _, r := range a.Image { + t, u, th := trim(r.Title), "", "" + if len(r.Source) > 0 { u = trim(r.Source[0].URL); th = trim(r.Source[len(r.Source)-1].URL) } + if t == "" { t = u } + if t == "" && u == "" && th == "" { continue } + dims := "" + if r.Width != nil && r.Height != nil { dims = strconv.Itoa(*r.Width) + " × " + strconv.Itoa(*r.Height) } + p.Images = append(p.Images, ImgRes{t, u, th, dims}) + } + p.Count = len(p.Images) + } + return nil +} + +// --- handlers --- + +func home(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") + w.Header().Set("Cache-Control", "public, max-age=86400") + _, _ = w.Write(homeHTML) +} + +func search(w http.ResponseWriter, r *http.Request) { + q := trim(r.URL.Query().Get("q")) + npt := trim(r.URL.Query().Get("npt")) + h := splitHist(r.URL.Query().Get("h")) + tab := trim(r.URL.Query().Get("tab")) + pg := atoi(r.URL.Query().Get("page"), 1) + if tab != "images" && tab != "news" { tab = "web" } + + var conv utils.Conv + if amtStr := r.URL.Query().Get("amt"); amtStr != "" { + conv = utils.FormConvert(amtStr, r.URL.Query().Get("cfrom"), r.URL.Query().Get("cto")) + if conv.Show { q = amtStr + " " + r.URL.Query().Get("cfrom") + " to " + r.URL.Query().Get("cto") } + } + if !conv.Show && q != "" { conv = utils.TryConvert(q) } + var calc utils.Calc + if !conv.Show && q != "" { calc = utils.TryCalc(q) } + var dict utils.Dict + if !conv.Show && !calc.Show && q != "" { dict = utils.TryDict(q) } + if q == "" && npt == "" { http.Redirect(w, r, "/", http.StatusFound); return } + + p := Page{ + Tab: tab, Query: q, + EscQ: template.URL(esc(q)), EscNPT: template.URL(esc(npt)), + ShowTabs: true, Page: pg, PrevPage: pg - 1, NextPage: pg + 1, HasPrev: pg > 1, + Conv: conv, Calc: calc, Dict: dict, + } + if p.HasPrev && len(h) > 0 { + p.PrevNPT = template.URL(esc(h[len(h)-1])) + if len(h) > 1 { p.PrevH = template.URL(esc(strings.Join(h[:len(h)-1], ","))) } + } + + rk := ramKey(tab, q, npt, pg, h) + if c, ok := ramGet(rk); ok { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "private, max-age=60") + _ = tpl.ExecuteTemplate(w, "head", c); _ = tpl.ExecuteTemplate(w, "tail", c) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "private, max-age=60") + _ = tpl.ExecuteTemplate(w, "head", p) + if f, ok := w.(http.Flusher); ok { f.Flush() } + + if err := fetchParse(tab, q, npt, &p); err != nil { p.Error = err.Error() } + if p.HasNext { + nh := append(append([]string{}, h...), npt); if npt == "" { nh = h } + p.NextH = template.URL(esc(strings.Join(nh, ","))) + } + _ = tpl.ExecuteTemplate(w, "tail", p) + if p.Error == "" { ramSet(rk, p) } +} + +func main() { + cdir = env("CACHE_DIR", "/cache") + base = strings.TrimRight(env("FOURGET_URL", "http://10.88.0.134"), "/") + _ = os.MkdirAll(cdir, 0o755) + utils.ConvInit() + + var b strings.Builder + _ = tpl.ExecuteTemplate(&b, "head", Page{Tab: "web"}) + _ = tpl.ExecuteTemplate(&b, "tail", Page{Tab: "web"}) + homeHTML = []byte(b.String()) + + go func() { for range time.Tick(5 * time.Minute) { pruneDisk() } }() + go func() { for range time.Tick(30 * time.Minute) { ramMu.Lock(); clear(ram); ramMu.Unlock() } }() + + port := env("PORT", "11081") + http.HandleFunc("/", home) + http.HandleFunc("/search", search) + os.Stderr.WriteString(":" + port + " cache=" + cdir + "\n") + if err := http.ListenAndServe(":"+port, nil); err != nil { os.Stderr.WriteString(err.Error() + "\n"); os.Exit(1) } +} blob - /dev/null blob + c2b1273ff35b6d341ad3bb3efb584c4179a12a0a (mode 644) --- /dev/null +++ utils/calc.go @@ -0,0 +1,137 @@ +package utils + +import ( + "math" + "strconv" + "strings" +) + +type Calc struct{ Show bool; Result string } + +func TryCalc(q string) Calc { + s := strings.ToLower(strings.TrimSpace(q)) + for _, pfx := range []string{"calculate ", "calc ", "what is ", "what's ", "solve "} { + s = strings.TrimPrefix(s, pfx) + } + s = strings.NewReplacer("**", "^", "\u00d7", "*", "\u00f7", "/").Replace(strings.TrimSpace(s)) + if s == "" { return Calc{} } + + p := &par{s: s} + v, ok := p.expr() + p.ws() + if !ok || p.pos != len(p.s) { return Calc{} } + + r := fmtNum(v) + if math.IsNaN(v) { r = "undefined" } else if math.IsInf(v, 1) { r = "\u221e" } else if math.IsInf(v, -1) { r = "-\u221e" } + return Calc{true, "= " + r} +} + +type par struct{ s string; pos int } + +func (p *par) ws() { for p.pos < len(p.s) && p.s[p.pos] == ' ' { p.pos++ } } +func (p *par) eat(c byte) bool { + p.ws(); if p.pos < len(p.s) && p.s[p.pos] == c { p.pos++; return true }; return false +} + +func (p *par) expr() (float64, bool) { + l, ok := p.term(); if !ok { return 0, false } + for { switch { + case p.eat('+'): r, ok := p.term(); if !ok { return 0, false }; l += r + case p.eat('-'): r, ok := p.term(); if !ok { return 0, false }; l -= r + default: return l, true + }} +} + +func (p *par) term() (float64, bool) { + l, ok := p.power(); if !ok { return 0, false } + for { switch { + case p.eat('*'): r, ok := p.power(); if !ok { return 0, false }; l *= r + case p.eat('/'): r, ok := p.power(); if !ok || r == 0 { return 0, false }; l /= r + case p.eat('%'): r, ok := p.power(); if !ok || r == 0 { return 0, false }; l = math.Mod(l, r) + default: return l, true + }} +} + +func (p *par) power() (float64, bool) { + b, ok := p.unary(); if !ok { return 0, false } + if !p.eat('^') { return b, true } + e, ok := p.power(); if !ok { return 0, false } + return math.Pow(b, e), true +} + +func (p *par) unary() (float64, bool) { + if p.eat('-') { v, ok := p.unary(); return -v, ok } + if p.eat('+') { return p.unary() } + return p.primary() +} + +func (p *par) primary() (float64, bool) { + p.ws() + if p.eat('(') { v, ok := p.expr(); return v, ok && p.eat(')') } + if fn := p.fname(); fn != "" { + if !p.eat('(') { return 0, false } + v, ok := p.expr(); if !ok || !p.eat(')') { return 0, false } + return evalFn(fn, v) + } + if v, ok := p.constant(); ok { return v, true } + return p.number() +} + +var fnNames = []string{"sqrt","cbrt","ln","log2","log10","log","sin","cos","tan","asin","acos","atan","abs","ceil","floor","exp","round"} + +func (p *par) fname() string { + p.ws() + for _, fn := range fnNames { + end := p.pos + len(fn) + if end > len(p.s) || p.s[p.pos:end] != fn { continue } + if end < len(p.s) && p.s[end] >= 'a' && p.s[end] <= 'z' { continue } + p.pos = end; return fn + } + return "" +} + +var pos = func(v float64) bool { return v > 0 } +var unit = func(v float64) bool { return v >= -1 && v <= 1 } +var fnTab = map[string]struct{ f func(float64) float64; ok func(float64) bool }{ + "sqrt": {math.Sqrt, func(v float64) bool { return v >= 0 }}, + "cbrt": {math.Cbrt, nil}, "ln": {math.Log, pos}, "log": {math.Log10, pos}, + "log10": {math.Log10, pos}, "log2": {math.Log2, pos}, + "sin": {math.Sin, nil}, "cos": {math.Cos, nil}, "tan": {math.Tan, nil}, + "asin": {math.Asin, unit}, "acos": {math.Acos, unit}, "atan": {math.Atan, nil}, + "abs": {math.Abs, nil}, "ceil": {math.Ceil, nil}, "floor": {math.Floor, nil}, + "exp": {math.Exp, nil}, "round": {math.Round, nil}, +} + +func evalFn(name string, v float64) (float64, bool) { + e := fnTab[name]; if e.ok != nil && !e.ok(v) { return 0, false }; return e.f(v), true +} + +func (p *par) constant() (float64, bool) { + p.ws() + if p.pos+2 <= len(p.s) && p.s[p.pos:p.pos+2] == "pi" && + (p.pos+2 >= len(p.s) || p.s[p.pos+2] < 'a' || p.s[p.pos+2] > 'z') { + p.pos += 2; return math.Pi, true + } + if p.pos < len(p.s) && p.s[p.pos] == 'e' { + if n := p.pos + 1; n >= len(p.s) || (p.s[n] < '0' || p.s[n] > '9') && (p.s[n] < 'a' || p.s[n] > 'z') && p.s[n] != '.' { + p.pos++; return math.E, true + } + } + return 0, false +} + +func (p *par) number() (float64, bool) { + p.ws(); start := p.pos + for p.pos < len(p.s) && (p.s[p.pos] >= '0' && p.s[p.pos] <= '9' || p.s[p.pos] == '.') { p.pos++ } + if p.pos == start { return 0, false } + v, err := strconv.ParseFloat(p.s[start:p.pos], 64) + if err != nil { p.pos = start; return 0, false } + return v, true +} + +func fmtNum(v float64) string { + if v == float64(int64(v)) && v < 1e15 && v > -1e15 { return strconv.FormatInt(int64(v), 10) } + s := strings.TrimRight(strings.TrimRight(strconv.FormatFloat(v, 'f', 10, 64), "0"), ".") + if len(s) > 16 { return strconv.FormatFloat(v, 'g', 10, 64) } + return s +} blob - /dev/null blob + 4269ebba05e442004e2cae367ab3878e6fc31f91 (mode 644) --- /dev/null +++ utils/convert.go @@ -0,0 +1,156 @@ +package utils + +import ( + "encoding/json" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" +) + +type Conv struct { + Show bool + Result string + Amt string + FromID, ToID string + Units []CUnit +} +type CUnit struct{ ID, Label string } +type uref struct{ cat, uid string } + +var ( + alias map[string]uref + facs map[string]float64 + catIdx map[string]int + ratesMu sync.RWMutex + rates map[string]float64 +) + +func ConvInit() { + alias = make(map[string]uref, 256) + facs = make(map[string]float64, 128) + catIdx = make(map[string]int, len(cats)) + for i, c := range cats { + catIdx[c.id] = i + for _, u := range c.units { + ref := uref{c.id, u.id} + alias[u.id] = ref + facs[c.id+":"+u.id] = u.fac + for _, a := range strings.Fields(u.aka) { alias[a] = ref } + } + } + os.Stderr.WriteString("conv: " + strconv.Itoa(len(alias)) + " aliases loaded\n") + refreshRates() + go func() { for range time.Tick(12 * time.Hour) { refreshRates() } }() +} + +func refreshRates() { + req, _ := http.NewRequest("GET", "https://api.frankfurter.app/latest", nil) + resp, err := client.Do(req) + if err != nil { os.Stderr.WriteString("rates: " + err.Error() + "\n"); return } + b := readBody(resp) + resp.Body.Close() + var data struct{ Rates map[string]float64 `json:"rates"` } + if json.Unmarshal(b, &data) != nil || len(data.Rates) == 0 { + os.Stderr.WriteString("rates: bad response (" + strconv.Itoa(len(b)) + " bytes)\n") + return + } + m := make(map[string]float64, len(data.Rates)+3) + for k, v := range data.Rates { m[strings.ToLower(k)] = v } + m["eur"] = 1 + if usd := m["usd"]; usd > 0 { m["aed"] = usd * 3.6725; m["sar"] = usd * 3.75 } + ratesMu.Lock(); rates = m; ratesMu.Unlock() + os.Stderr.WriteString("rates: " + strconv.Itoa(len(m)) + " currencies loaded\n") +} + +func doConv(amt float64, cat, from, to string) (float64, bool) { + if cat == "temperature" { return convTemp(amt, from, to), true } + if cat == "currency" { + ratesMu.RLock(); rf, ok1 := rates[from]; rt, ok2 := rates[to]; ratesMu.RUnlock() + if !ok1 || !ok2 || rf == 0 { return 0, false } + return amt / rf * rt, true + } + ff := facs[cat+":"+from]; if ff == 0 { return 0, false } + return amt / ff * facs[cat+":"+to], true +} + +func convTemp(v float64, from, to string) float64 { + switch from { + case "f": v = (v - 32) * 5 / 9 + case "k": v -= 273.15 + } + switch to { + case "f": return v*9/5 + 32 + case "k": return v + 273.15 + } + return v +} + +func TryConvert(q string) Conv { + s := strings.ToLower(strings.TrimSpace(q)) + for _, pfx := range []string{"convert ", "how much is ", "what is "} { s = strings.TrimPrefix(s, pfx) } + idx := strings.LastIndex(s, " to ") + if idx < 1 { return Conv{} } + amt, fromStr := parseAmtUnit(s[:idx]) + toStr := strings.ReplaceAll(strings.TrimSpace(s[idx+4:]), " ", "") + if amt == 0 || fromStr == "" || toStr == "" { return Conv{} } + fromRef, ok1 := alias[fromStr]; toRef, ok2 := alias[toStr] + if !ok1 || !ok2 || fromRef.cat != toRef.cat { return Conv{} } + res, ok := doConv(amt, fromRef.cat, fromRef.uid, toRef.uid) + if !ok { return Conv{} } + return buildConv(amt, fromRef, toRef, res) +} + +func FormConvert(amtStr, from, to string) Conv { + amt, err := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(amtStr), ",", ""), 64) + if err != nil || amt == 0 { return Conv{} } + fromRef, ok1 := alias[strings.TrimSpace(from)]; toRef, ok2 := alias[strings.TrimSpace(to)] + if !ok1 || !ok2 || fromRef.cat != toRef.cat { return Conv{} } + res, ok := doConv(amt, fromRef.cat, fromRef.uid, toRef.uid) + if !ok { return Conv{} } + return buildConv(amt, fromRef, toRef, res) +} + +var symMap = [][2]string{{"$", "usd"}, {"\u20ac", "eur"}, {"\u00a3", "gbp"}, {"\u00a5", "jpy"}} + +func parseAmtUnit(s string) (float64, string) { + s = strings.TrimSpace(s) + sym := "" + for _, p := range symMap { + if strings.HasPrefix(s, p[0]) { s, sym = strings.TrimSpace(s[len(p[0]):]), p[1]; break } + } + i := 0 + for i < len(s) && (s[i] >= '0' && s[i] <= '9' || s[i] == '.' || s[i] == ',') { i++ } + if i == 0 { return 0, "" } + n, err := strconv.ParseFloat(strings.ReplaceAll(s[:i], ",", ""), 64) + if err != nil { return 0, "" } + unit := strings.ReplaceAll(strings.TrimSpace(s[i:]), " ", "") + if unit == "" { unit = sym } + return n, unit +} + +func buildConv(amt float64, from, to uref, res float64) Conv { + c := cats[catIdx[from.cat]] + units := make([]CUnit, len(c.units)) + fl, tl := "", "" + for i, u := range c.units { + units[i] = CUnit{u.id, u.label} + if u.id == from.uid { fl = u.label } + if u.id == to.uid { tl = u.label } + } + return Conv{true, fmtAmt(amt) + " " + fl + " = " + fmtResult(res, from.cat) + " " + tl, + fmtAmt(amt), from.uid, to.uid, units} +} + +func fmtAmt(v float64) string { + if v == float64(int64(v)) && v < 1e15 { return strconv.FormatInt(int64(v), 10) } + return strconv.FormatFloat(v, 'f', -1, 64) +} + +func fmtResult(v float64, cat string) string { + if cat == "currency" { return strconv.FormatFloat(v, 'f', 2, 64) } + if v == float64(int64(v)) && v < 1e15 && v > -1e15 { return strconv.FormatInt(int64(v), 10) } + return strings.TrimRight(strings.TrimRight(strconv.FormatFloat(v, 'f', 6, 64), "0"), ".") +} blob - /dev/null blob + 1649be6e7d86a0621ee12c7e0c9152516b8ae77b (mode 644) --- /dev/null +++ utils/dict.go @@ -0,0 +1,94 @@ +package utils + +import ( + "encoding/json" + "net/http" + "strings" +) + +type Dict struct { + Show bool + Word, Phonetic string + Meanings []DictMeaning +} +type DictMeaning struct { + Speech string + Defs []DictDef +} +type DictDef struct { + Num int + Def, Example, Syns string +} + +type dictEntry struct { + Word string `json:"word"` + Phonetic string `json:"phonetic"` + Phonetics []struct{ Text string } `json:"phonetics"` + Meanings []struct { + PartOfSpeech string `json:"partOfSpeech"` + Definitions []struct { + Definition string `json:"definition"` + Example string `json:"example"` + Synonyms []string `json:"synonyms"` + } `json:"definitions"` + Synonyms []string `json:"synonyms"` + } `json:"meanings"` +} + +var dictPrefixes = []string{"define ", "def ", "mean ", "meaning of ", "definition of ", + "what does ", "what is the definition of ", "what is the meaning of "} +var dictSuffixes = []string{" definition", " meaning", " def", " mean"} + +func TryDict(q string) Dict { + s := strings.ToLower(strings.TrimSpace(q)) + var word string + for _, sfx := range dictSuffixes { + if strings.HasSuffix(s, sfx) { word = strings.TrimSuffix(s, sfx); break } + } + if word == "" { + for _, pfx := range dictPrefixes { + if strings.HasPrefix(s, pfx) { word = strings.TrimPrefix(s, pfx); break } + } + } + word = strings.TrimSuffix(strings.TrimSpace(word), " mean") // "what does X mean" + if word == "" || len(strings.Fields(word)) > 2 { return Dict{} } + + u := "https://api.dictionaryapi.dev/api/v2/entries/en/" + strings.ReplaceAll(esc(word), "+", "%20") + req, _ := http.NewRequest("GET", u, nil) + resp, err := client.Do(req) + if err != nil { return Dict{} } + if resp.StatusCode != 200 { resp.Body.Close(); return Dict{} } + body := readBody(resp) + resp.Body.Close() + + var entries []dictEntry + if json.Unmarshal(body, &entries) != nil || len(entries) == 0 { return Dict{} } + e := entries[0] + + d := Dict{Show: true, Word: e.Word, Phonetic: e.Phonetic} + if d.Phonetic == "" { + for _, ph := range e.Phonetics { + if ph.Text != "" { d.Phonetic = ph.Text; break } + } + } + + for _, m := range e.Meanings[:min(3, len(e.Meanings))] { + dm := DictMeaning{Speech: m.PartOfSpeech} + for i, def := range m.Definitions[:min(4, len(m.Definitions))] { + syns := def.Synonyms + if i == 0 { syns = append(syns, m.Synonyms...) } + dm.Defs = append(dm.Defs, DictDef{i + 1, def.Definition, def.Example, joinUniq(syns, 6)}) + } + d.Meanings = append(d.Meanings, dm) + } + return d +} + +func joinUniq(ss []string, max int) string { + seen := map[string]bool{} + var out []string + for _, s := range ss { + if s != "" && !seen[s] { seen[s] = true; out = append(out, s); if len(out) >= max { break } } + } + return strings.Join(out, ", ") +} blob - /dev/null blob + 044ec3d1d85a9f3028a5c4f3f050d962415439d3 (mode 644) --- /dev/null +++ utils/http.go @@ -0,0 +1,42 @@ +package utils + +import ( + "io" + "net/http" + "strings" + "time" +) + +var client = &http.Client{ + Timeout: 12 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 64, + MaxIdleConnsPerHost: 64, + IdleConnTimeout: 90 * time.Second, + }, +} + +const hex = "0123456789ABCDEF" + +func esc(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-', c == '_', c == '.', c == '~': + b.WriteByte(c) + case c == ' ': + b.WriteByte('+') + default: + b.WriteByte('%') + b.WriteByte(hex[c>>4]) + b.WriteByte(hex[c&15]) + } + } + return b.String() +} + +func readBody(resp *http.Response) []byte { + b, _ := io.ReadAll(resp.Body) + return b +} blob - /dev/null blob + a2d18a4578c6027b8664f021ed4f7cda7376427a (mode 644) --- /dev/null +++ utils/units.go @@ -0,0 +1,123 @@ +package utils + +// Unit catalog — conversion factors are relative to the base unit of each category. +// Currency factors are unused (fetched live); temperature uses special-case math. + +type udef struct{ id, label string; fac float64; aka string } +type cdef struct{ id, label string; units []udef } + +var cats = []cdef{ + {"length", "Length", []udef{ + {"mm", "mm", 1e3, "millimeter millimeters millimetre millimetres"}, + {"cm", "cm", 1e2, "centimeter centimeters centimetre centimetres"}, + {"m", "m", 1, "meter meters metre metres"}, + {"km", "km", 1e-3, "kilometer kilometers kilometre kilometres"}, + {"in", "in", 39.3701, "inch inches"}, + {"ft", "ft", 3.28084, "foot feet"}, + {"yd", "yd", 1.09361, "yard yards"}, + {"mi", "mi", 6.21371e-4, "mile miles"}, + {"nmi", "nmi", 5.39957e-4, "nauticalmile nauticalmiles"}, + }}, + {"mass", "Mass", []udef{ + {"mg", "mg", 1e6, "milligram milligrams"}, + {"g", "g", 1e3, "gram grams"}, + {"kg", "kg", 1, "kilogram kilograms kilo kilos"}, + {"lb", "lb", 2.20462, "pound pounds lbs"}, + {"oz", "oz", 35.274, "ounce ounces"}, + {"st", "st", 0.157473, "stone stones"}, + {"t", "t", 1e-3, "tonne tonnes ton tons metricton"}, + }}, + {"temperature", "Temperature", []udef{ + {"c", "\u00b0C", 0, "celsius centigrade \u00b0c"}, + {"f", "\u00b0F", 0, "fahrenheit \u00b0f"}, + {"k", "K", 0, "kelvin"}, + }}, + {"data", "Data", []udef{ + {"bit", "bit", 8, "bits"}, + {"B", "B", 1, "b byte bytes"}, + {"kb", "KB", 1e-3, "kilobyte kilobytes"}, + {"mb", "MB", 1e-6, "megabyte megabytes"}, + {"gb", "GB", 1e-9, "gigabyte gigabytes"}, + {"tb", "TB", 1e-12, "terabyte terabytes"}, + {"pb", "PB", 1e-15, "petabyte petabytes"}, + {"kib", "KiB", 1.0 / 1024, "kibibyte kibibytes"}, + {"mib", "MiB", 1.0 / (1024 * 1024), "mebibyte mebibytes"}, + {"gib", "GiB", 1.0 / (1024 * 1024 * 1024), "gibibyte gibibytes"}, + {"tib", "TiB", 1.0 / (1024 * 1024 * 1024 * 1024), "tebibyte tebibytes"}, + }}, + {"speed", "Speed", []udef{ + {"mps", "m/s", 1, "m/s meterspersecond"}, + {"kmh", "km/h", 3.6, "km/h kph kmph kilometersperhour"}, + {"mph", "mph", 2.23694, "milesperhour"}, + {"kn", "knot", 1.94384, "knot knots kt"}, + {"fps", "ft/s", 3.28084, "ft/s feetpersecond"}, + }}, + {"volume", "Volume", []udef{ + {"ml", "mL", 1e3, "milliliter milliliters millilitre ml"}, + {"l", "L", 1, "liter liters litre litres"}, + {"gal", "gal", 0.264172, "gallon gallons"}, + {"qt", "qt", 1.05669, "quart quarts"}, + {"pt", "pt", 2.11338, "pint pints"}, + {"cup", "cup", 4.22675, "cups"}, + {"floz", "fl oz", 33.814, "fluidounce fluidounces floz"}, + {"tbsp", "tbsp", 67.628, "tablespoon tablespoons"}, + {"tsp", "tsp", 202.884, "teaspoon teaspoons"}, + }}, + {"time", "Time", []udef{ + {"msec", "ms", 1e3, "ms millisecond milliseconds"}, + {"sec", "s", 1, "s second seconds sec secs"}, + {"min", "min", 1.0 / 60, "minute minutes mins"}, + {"hr", "hr", 1.0 / 3600, "hour hours hrs h"}, + {"day", "day", 1.0 / 86400, "days"}, + {"wk", "week", 1.0 / 604800, "weeks wk wks"}, + {"mo", "month", 1.0 / 2629746, "months mo"}, + {"yr", "year", 1.0 / 31557600, "years yr yrs"}, + }}, + {"area", "Area", []udef{ + {"mm2", "mm\u00b2", 1e6, "sqmm squaremillimeter squaremillimeters"}, + {"cm2", "cm\u00b2", 1e4, "sqcm squarecentimeter squarecentimeters"}, + {"m2", "m\u00b2", 1, "sqm squaremeter squaremeters squaremetre"}, + {"km2", "km\u00b2", 1e-6, "sqkm squarekilometer squarekilometers"}, + {"in2", "in\u00b2", 1550.0031, "sqin squareinch squareinches"}, + {"ft2", "ft\u00b2", 10.7639, "sqft squarefoot squarefeet"}, + {"yd2", "yd\u00b2", 1.19599, "sqyd squareyard squareyards"}, + {"mi2", "mi\u00b2", 3.861e-7, "sqmi squaremile squaremiles"}, + {"acre", "acre", 2.47105e-4, "acres"}, + {"ha", "ha", 1e-4, "hectare hectares"}, + }}, + {"currency", "Currency", []udef{ + {"eur", "EUR", 0, "euro euros \u20ac"}, + {"usd", "USD", 0, "dollar dollars $"}, + {"gbp", "GBP", 0, "sterling \u00a3 britishpound"}, + {"jpy", "JPY", 0, "yen \u00a5 japaneseyen"}, + {"aud", "AUD", 0, "australiandollar aud"}, + {"cad", "CAD", 0, "canadiandollar cad"}, + {"chf", "CHF", 0, "swissfranc franc francs"}, + {"cny", "CNY", 0, "yuan renminbi rmb"}, + {"sek", "SEK", 0, "swedishkrona"}, + {"nzd", "NZD", 0, "newzealanddollar"}, + {"mxn", "MXN", 0, "mexicanpeso"}, + {"sgd", "SGD", 0, "singaporedollar"}, + {"hkd", "HKD", 0, "hongkongdollar"}, + {"nok", "NOK", 0, "norwegiankrone"}, + {"krw", "KRW", 0, "won koreanwon"}, + {"try", "TRY", 0, "turkishlira lira"}, + {"inr", "INR", 0, "rupee rupees indianrupee"}, + {"brl", "BRL", 0, "real brazilianreal"}, + {"zar", "ZAR", 0, "rand southafricanrand"}, + {"dkk", "DKK", 0, "danishkrone"}, + {"pln", "PLN", 0, "zloty polishzloty"}, + {"thb", "THB", 0, "baht"}, + {"ils", "ILS", 0, "shekel israelishekel"}, + {"czk", "CZK", 0, "koruna czechkoruna"}, + {"huf", "HUF", 0, "forint"}, + {"php", "PHP", 0, "philippinepeso"}, + {"myr", "MYR", 0, "ringgit"}, + {"idr", "IDR", 0, "rupiah"}, + {"ron", "RON", 0, "leu romanianleu"}, + {"bgn", "BGN", 0, "lev bulgarianlev"}, + {"isk", "ISK", 0, "icelandickrona"}, + {"aed", "AED", 0, "dirham dirhams"}, + {"sar", "SAR", 0, "riyal riyals saudiriyal"}, + }}, +}