diff --git a/.gitignore b/.gitignore index d07f2ff..d52b31e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ *.csv *.json *.swp +.env +golifehk +cmd/compilei18n/compliei18n diff --git a/adaptors/tg/botsend.go b/adaptors/tg/botsend.go index 8229da2..8625dc4 100644 --- a/adaptors/tg/botsend.go +++ b/adaptors/tg/botsend.go @@ -1,7 +1,6 @@ package tg import ( - "fmt" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" query "github.com/tgckpg/golifehk/query" ) @@ -56,7 +55,6 @@ func BotSend(bot *tgbotapi.BotAPI, update *tgbotapi.Update, qResult query.IQuery for _, cell := range row { button := tgbotapi.NewInlineKeyboardButtonData(cell.Name, cell.Value) buttons = append(buttons, button) - fmt.Println(cell) } buttonRows = append(buttonRows, buttons) } diff --git a/cmd/compilei18n/go.mod b/cmd/compilei18n/go.mod new file mode 100644 index 0000000..1edfb62 --- /dev/null +++ b/cmd/compilei18n/go.mod @@ -0,0 +1,3 @@ +module chatgpt.com/c/tools/compliei18n + +go 1.25.0 diff --git a/cmd/compilei18n/main.go b/cmd/compilei18n/main.go new file mode 100644 index 0000000..8d85b34 --- /dev/null +++ b/cmd/compilei18n/main.go @@ -0,0 +1,121 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "sort" + "strconv" +) + +func main() { + const goFile = "i18n/messages.go" + const outFile = "resources/src/messages.json" + + keys, err := extractStringConsts(goFile) + if err != nil { + panic(err) + } + + existing := map[string]string{} + if data, err := os.ReadFile(outFile); err == nil { + if len(bytes.TrimSpace(data)) > 0 { + if err := json.Unmarshal(data, &existing); err != nil { + panic(fmt.Errorf("parse %s: %w", outFile, err)) + } + } + } else if !os.IsNotExist(err) { + panic(err) + } + + merged := map[string]string{} + for _, key := range keys { + if val, ok := existing[key]; ok { + merged[key] = val + } else { + merged[key] = "TODO: " + key + } + } + + // Detect stale keys. + for key := range existing { + found := false + for _, k := range keys { + if k == key { + found = true + break + } + } + if !found { + fmt.Fprintf(os.Stderr, "stale key in %s: %s\n", outFile, key) + } + } + + // Write stable sorted JSON. + sorted := make([]string, 0, len(merged)) + for k := range merged { + sorted = append(sorted, k) + } + sort.Strings(sorted) + + buf := &bytes.Buffer{} + buf.WriteString("{\n") + for i, k := range sorted { + kj, _ := json.Marshal(k) + vj, _ := json.Marshal(merged[k]) + comma := "," + if i == len(sorted)-1 { + comma = "" + } + fmt.Fprintf(buf, " %s: %s%s\n", kj, vj, comma) + } + buf.WriteString("}\n") + + if err := os.WriteFile(outFile, buf.Bytes(), 0644); err != nil { + panic(err) + } +} + +func extractStringConsts(path string) ([]string, error) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return nil, err + } + + var keys []string + + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.CONST { + continue + } + + for _, spec := range gen.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + + for _, value := range vs.Values { + lit, ok := value.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + + s, err := strconv.Unquote(lit.Value) + if err != nil { + return nil, err + } + keys = append(keys, s) + } + } + } + + sort.Strings(keys) + return keys, nil +} diff --git a/cmd/po-build.sh b/cmd/po-build.sh new file mode 100755 index 0000000..de38e2b --- /dev/null +++ b/cmd/po-build.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +RES_ROOT=./resources + +# Primary language +cp -r "$RES_ROOT/src" "$RES_ROOT/langpacks/en" + +# zh-Hant +po2json -t "$RES_ROOT/src/" "$RES_ROOT/po/zh-Hant" "$RES_ROOT/langpacks/zh-Hant" diff --git a/cmd/po-export.sh b/cmd/po-export.sh new file mode 100755 index 0000000..7e928b5 --- /dev/null +++ b/cmd/po-export.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +RES_ROOT=./resources + +./cmd/compilei18n/compliei18n +json2po -P "$RES_ROOT/src/" "$RES_ROOT/pot/" +pot2po -t "$RES_ROOT/po/zh-Hant" "$RES_ROOT/pot" "$RES_ROOT/po/zh-Hant" diff --git a/datasources/kmb/QueryResult.go b/datasources/kmb/QueryResult.go index 730c411..95190f3 100644 --- a/datasources/kmb/QueryResult.go +++ b/datasources/kmb/QueryResult.go @@ -2,10 +2,12 @@ package kmb import ( "fmt" + "math" "sort" "strings" "time" + i18n "github.com/tgckpg/golifehk/i18n" query "github.com/tgckpg/golifehk/query" utils "github.com/tgckpg/golifehk/utils" ) @@ -19,6 +21,9 @@ type QueryResult struct { isConsumed bool dataType string tableData [][]query.TableCell + + FallbackNearest int + NearestRange float64 } func writeRouteHead(sb *strings.Builder, r *RouteStop) { @@ -64,6 +69,11 @@ func (this *QueryResult) Message() (string, error) { return "", this.Error } + langPack, err := i18n.LoadKeys(this.Lang) + if err != nil { + return "", err + } + sb := strings.Builder{} if 0 < len(*this.Query.Results) { @@ -72,23 +82,38 @@ func (this *QueryResult) Message() (string, error) { if this.Query.Key == "" { loc := this.Query.Message.Location if loc != nil { - sb.WriteString("九巴 100m") + this.dataType = "Table" table := [][]query.TableCell{} + minDist := math.MaxFloat64 + maxDist := -1.0 for _, item := range *this.Query.Results { b := any(item).(*BusStop) row := []query.TableCell{} + bDist := b.Dist(loc.Lat(), loc.Lon()) + if bDist < minDist { + minDist = bDist + } + + if maxDist < bDist { + maxDist = bDist + } + cell := query.TableCell{ - Name: fmt.Sprintf("%.2fm %s", b.Dist(loc.Lat(), loc.Lon()), (*b.Name)[this.Lang]), + Name: fmt.Sprintf("%s (%s)", (*b.Name)[this.Lang], i18n.FormatDistance(langPack, bDist)), Value: fmt.Sprintf("%s", b.BusStopId), } row = append(row, cell) - for _, r := range *b.Routes { + for colIndex, r := range *b.Routes { + if colIndex%6 == 0 { + table = append(table, row) + row = []query.TableCell{} + } sb_i := strings.Builder{} writeRouteHead(&sb_i, r) cell := query.TableCell{ @@ -102,6 +127,13 @@ func (this *QueryResult) Message() (string, error) { } this.tableData = table + rangeText := i18n.FormatDistance(langPack, this.NearestRange) + if maxDist < this.NearestRange { + utils.WriteMDv2Text(&sb, i18n.DS_KMB_NEAREST_STOPS.Text(langPack, rangeText)) + } else if this.NearestRange < minDist { + utils.WriteMDv2Text(&sb, i18n.DS_KMB_NO_NEAREST_STOPS.Text(langPack, rangeText, this.FallbackNearest)) + } + } else { busStops := map[string]*BusStop{} for _, item := range *this.Query.Results { @@ -149,16 +181,13 @@ func (this *QueryResult) Message() (string, error) { _m := schedule.ETA.Sub(now).Minutes() sb.WriteString(" \\* ") - txt := "%.0f min(s)" - if this.Lang == "zh-Hant" { - txt = "%.0f 分鐘" - } - - utils.WriteMDv2Text(&sb, fmt.Sprintf(txt, _m)) + eta := i18n.UNITS_MINUTE.Text(langPack, _m) if _m < 0 { - sb.WriteString(" 走左了?") + utils.WriteMDv2Text(&sb, i18n.DS_KMB_ETA_DEPARTED.Text(langPack, eta)) + } else { + utils.WriteMDv2Text(&sb, eta) } } diff --git a/datasources/kmb/query.go b/datasources/kmb/query.go index ba04adc..72948ec 100644 --- a/datasources/kmb/query.go +++ b/datasources/kmb/query.go @@ -14,7 +14,11 @@ func Query(q query.QueryMessage) query.IQueryResult { var err error var routeStops *[]query.ISearchable - qr := QueryResult{Lang: lang} + qr := QueryResult{ + Lang: lang, + FallbackNearest: 3, + NearestRange: 50, + } busStops, err := readBusStopsData() if err != nil { @@ -37,7 +41,7 @@ func Query(q query.QueryMessage) query.IQueryResult { bList = append(bList, b) } - qo, err = query.MatchNearest(*q.Location, &bList, 100, 3) + qo, err = query.MatchNearest(*q.Location, &bList, qr.NearestRange, qr.FallbackNearest) } qo.Message = &q diff --git a/datasources/kmb/query_test.go b/datasources/kmb/query_test.go index b832231..9a48801 100644 --- a/datasources/kmb/query_test.go +++ b/datasources/kmb/query_test.go @@ -1,36 +1,54 @@ package kmb import ( - "fmt" + "fmt" + query "github.com/tgckpg/golifehk/query" "testing" ) -func TestQuerySchedule( t *testing.T ) { - qo := Query( "zh-Hant", "68X" ) - mesg, err := qo.Message() - if err != nil { - t.Errorf( "Unexpected Error: %s", err ) - } - fmt.Println( mesg ) +func TestQuerySchedule(t *testing.T) { + qo := Query(query.QueryMessage{Lang: "zh-Hant", Text: "68X"}) + mesg, err := qo.Message() + if err != nil { + t.Errorf("Unexpected Error: %s", err) + } - qo = Query( "zh-Hant", "K66 朗屏" ) - mesg, err = qo.Message() - if err == nil { - t.Errorf( "Expected Error: %s, got \"\" instead", mesg ) - } + qo = Query(query.QueryMessage{Lang: "zh-Hant", Text: "K66 朗屏"}) + mesg, err = qo.Message() + if err == nil { + t.Errorf("Expected Error: %s, got \"\" instead", mesg) + } - qo = Query( "zh-Hant", "大欖" ) - mesg, err = qo.Message() - if err != nil { - t.Errorf( "Unexpected Error: %s", err ) - } + qo = Query(query.QueryMessage{Lang: "zh-Hant", Text: "大欖"}) + mesg, err = qo.Message() + if err != nil { + t.Errorf("Unexpected Error: %s", err) + } - fmt.Println( mesg ) + qo = Query(query.QueryMessage{Lang: "zh-Hant", Text: "261B 大欖"}) + mesg, err = qo.Message() + if err != nil { + t.Errorf("Unexpected Error: %s", err) + } - qo = Query( "zh-Hant", "261B 大欖" ) - mesg, err = qo.Message() - if err != nil { - t.Errorf( "Unexpected Error: %s", err ) - } - fmt.Println( mesg ) + qo = Query(query.QueryMessage{ + Lang: "zh-Hant", Text: "", + // Yuen Long Plaza + // Location: &query.GeoLocation{22.444894482044997, 114.02393826485495}, + // Nathan Rd + // Location: &query.GeoLocation{22.308944848482525, 114.17116565400259}, + // GO PARK + Location: &query.GeoLocation{22.427238734660868, 114.26595846515744}, + }) + mesg, err = qo.Message() + if err != nil { + t.Errorf("Unexpected Error: %s", err) + } + + for _, row := range qo.GetTableData() { + for _, cell := range row { + fmt.Printf("| %s |", cell.Name) + } + fmt.Print("\n") + } } diff --git a/datasources/kmb/routestops_test.go b/datasources/kmb/routestops_test.go index cd2ccf8..f06d952 100644 --- a/datasources/kmb/routestops_test.go +++ b/datasources/kmb/routestops_test.go @@ -5,8 +5,9 @@ import ( ) func TestRouteStops(t *testing.T) { - _, err := getRouteStops() - if err != nil { - t.Error( err ) - } + busStops, err := readBusStopsData() + _, err = getRouteStops(busStops) + if err != nil { + t.Error(err) + } } diff --git a/datasources/mtr/bus/BusStop.go b/datasources/mtr/bus/BusStop.go index d09f054..12bdf0b 100644 --- a/datasources/mtr/bus/BusStop.go +++ b/datasources/mtr/bus/BusStop.go @@ -1,6 +1,7 @@ package bus import ( + "fmt" i18n "github.com/tgckpg/golifehk/i18n" query "github.com/tgckpg/golifehk/query" ) @@ -53,10 +54,11 @@ func (this *BusStop) Reload() { } func (this BusStop) Register(registers map[string]struct{}) bool { - if _, ok := registers[this.StationId]; ok { + key := fmt.Sprintf("%s,%s", this.StationId, this.ReferenceId) + if _, ok := registers[key]; ok { return false } - registers[this.StationId] = struct{}{} + registers[key] = struct{}{} return true } diff --git a/datasources/mtr/bus/QueryResult.go b/datasources/mtr/bus/QueryResult.go index 87556aa..a6059f8 100644 --- a/datasources/mtr/bus/QueryResult.go +++ b/datasources/mtr/bus/QueryResult.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + i18n "github.com/tgckpg/golifehk/i18n" query "github.com/tgckpg/golifehk/query" utils "github.com/tgckpg/golifehk/utils" ) @@ -20,6 +21,9 @@ type QueryResult struct { isConsumed bool dataType string tableData [][]query.TableCell + + FallbackNearest int + NearestRange float64 } func writeShortRoute(lang *string, sb *strings.Builder, b *BusStop) { @@ -52,6 +56,11 @@ func (this *QueryResult) Message() (string, error) { return "", this.Error } + langPack, err := i18n.LoadKeys(this.Lang) + if err != nil { + return "", err + } + sb := strings.Builder{} if this.Schedules == nil { @@ -72,33 +81,83 @@ func (this *QueryResult) Message() (string, error) { if loc != nil { this.dataType = "Table" - sb.WriteString("K巴 100m") table := [][]query.TableCell{} + // Group by Station Name first + bGroups := map[string]*[]*BusStop{} + for _, entry := range *q.Results { busStop := any(entry).(*BusStop) - - sb_i := strings.Builder{} - sb_i.WriteString(fmt.Sprintf("%.2fm", busStop.Dist(loc.Lat(), loc.Lon()))) - sb_i.WriteString(" ") - utils.WriteMDv2Text(&sb_i, busStop.RouteId) - d := busStop.Direction - if d == "O" { - sb_i.WriteString("↑") - } else if d == "I" { - sb_i.WriteString("↓") - } else { - sb_i.WriteString("\\?") + bName := (*busStop.Name)[this.Lang] + bGroup, ok := bGroups[bName] + if !ok { + bGroup = &[]*BusStop{} + bGroups[bName] = bGroup } - sb_i.WriteString(" ") - utils.WriteMDv2Text(&sb_i, (*busStop.Name)[this.Lang]) + *bGroup = append(*bGroup, busStop) + } + + for bName, bGroup := range bGroups { row := []query.TableCell{ - query.TableCell{ + query.TableCell{Name: bName, Value: bName}, + } + gRow := row + + var minDist float64 + var maxDist float64 + + for colIndex, busStop := range *bGroup { + if colIndex%6 == 0 { + table = append(table, row) + row = []query.TableCell{} + } + + sb_i := strings.Builder{} + sb_i.WriteString(busStop.RouteId) + d := busStop.Direction + if d == "O" { + sb_i.WriteString("↑") + } else if d == "I" { + sb_i.WriteString("↓") + } else { + sb_i.WriteString("\\?") + } + + cell := query.TableCell{ Name: sb_i.String(), - Value: fmt.Sprintf("%s %s", busStop.RouteId, (*busStop.Name)[this.Lang]), - }, + Value: fmt.Sprintf("%s %s", busStop.RouteId, bName), + } + + // Data are already sorted by shortest dist + // So the first one must be min dist + if minDist == 0 { + minDist = busStop.Dist(loc.Lat(), loc.Lon()) + } + + if colIndex+1 == len(*bGroup) { + maxDist = busStop.Dist(loc.Lat(), loc.Lon()) + } + + row = append(row, cell) + } + + if minDist == maxDist { + gRow[0].Name = fmt.Sprintf("%s (%s)", bName, i18n.FormatDistance(langPack, minDist)) + } else { + gRow[0].Name = fmt.Sprintf( + "%s (%s~%s)", + bName, i18n.FormatDistance(langPack, minDist), + bName, i18n.FormatDistance(langPack, maxDist), + ) + } + + rangeText := i18n.FormatDistance(langPack, this.NearestRange) + if maxDist < this.NearestRange { + utils.WriteMDv2Text(&sb, i18n.DS_MTR_NEAREST_STOPS.Text(langPack, rangeText)) + } else if this.NearestRange < minDist { + utils.WriteMDv2Text(&sb, i18n.DS_MTR_NO_NEAREST_STOPS.Text(langPack, rangeText, this.FallbackNearest)) } table = append(table, row) @@ -235,7 +294,7 @@ func (this *QueryResult) Message() (string, error) { sb.WriteString("\n") } } else { - utils.WriteMDv2Text(&sb, "Schedules are empty...perhaps Out of Service Time?") + utils.WriteMDv2Text(&sb, i18n.DS_MTR_NO_SCHEDULES.Text(langPack)) } } diff --git a/datasources/mtr/bus/busstops.go b/datasources/mtr/bus/busstops.go index 832e057..bbfba10 100644 --- a/datasources/mtr/bus/busstops.go +++ b/datasources/mtr/bus/busstops.go @@ -7,6 +7,7 @@ import ( "net/http" "path/filepath" "strconv" + "strings" query "github.com/tgckpg/golifehk/query" "github.com/tgckpg/golifehk/utils" @@ -52,9 +53,9 @@ func readBusStopData(r io.Reader) (*map[string]*BusStop, error) { v, _ := strconv.ParseFloat(value, 64) entry.Longitude = v case "STATION_NAME_CHI": - entry.Name_zh = value + entry.Name_zh = strings.TrimSpace(value) case "STATION_NAME_ENG": - entry.Name_en = value + entry.Name_en = strings.TrimSpace(value) case "REFERENCE_ID": entry.ReferenceId = value default: diff --git a/datasources/mtr/bus/query.go b/datasources/mtr/bus/query.go index 6778e6c..52e8b00 100644 --- a/datasources/mtr/bus/query.go +++ b/datasources/mtr/bus/query.go @@ -13,7 +13,12 @@ func Query(q query.QueryMessage) query.IQueryResult { var qBusStops *query.QueryObject var err error - qr := QueryResult{Lang: lang} + qr := QueryResult{ + Lang: lang, + FallbackNearest: 3, + NearestRange: 50, + } + busStops, err := getBusStops() if err != nil { qr.Error = err @@ -23,16 +28,16 @@ func Query(q query.QueryMessage) query.IQueryResult { if q.Text != "" { qBusStops, err = query.MatchKeys(strings.ToUpper(q.Text), busStops) } else if q.Location != nil { - qBusStops, err = query.MatchNearest(*q.Location, busStops, 100, 3) + qBusStops, err = query.MatchNearest(*q.Location, busStops, qr.NearestRange, qr.FallbackNearest) } - qBusStops.Message = &q - if err != nil { qr.Error = err goto qrReturn } + qBusStops.Message = &q + qr.Query = qBusStops if 0 < len(*qBusStops.Results) && 1 < len(*qBusStops.SearchTerms) { schedules, err := getSchedule(lang, qBusStops.Key) diff --git a/datasources/mtr/bus/query_test.go b/datasources/mtr/bus/query_test.go index cb93d9b..062e3e7 100644 --- a/datasources/mtr/bus/query_test.go +++ b/datasources/mtr/bus/query_test.go @@ -1,33 +1,55 @@ package bus import ( - "fmt" - "strings" + "fmt" + query "github.com/tgckpg/golifehk/query" + "strings" "testing" ) -func TestQuery( t *testing.T ) { - qo := Query( "zh-Hant", "K73" ) - mesg, err := qo.Message() - if err != nil { - t.Errorf( "Unexpected Error: %s", err ) - } +func TestQuery(t *testing.T) { + qo := Query(query.QueryMessage{Lang: "zh-Hant", Text: "K73"}) + mesg, err := qo.Message() + if err != nil { + t.Errorf("Unexpected Error: %s", err) + } - if !strings.Contains( mesg, "K73\\-O" ) { - t.Errorf( "Expected Route Listing, got \"%s\" instead", mesg ) - } + if !strings.Contains(mesg, "K73↓") { + t.Errorf("Expected Route Listing, got \"%s\" instead", mesg) + } - qo = Query( "zh-Hant", "K76 池" ) - mesg, err = qo.Message() - if err == nil { - t.Errorf( "Expecting error, got \"%s\" instead", mesg ) - } + qo = Query(query.QueryMessage{Lang: "zh-Hant", Text: "K76 池"}) + mesg, err = qo.Message() + if err == nil { + t.Errorf("Expecting error, got \"%s\" instead", mesg) + } - qo = Query( "zh-Hant", "K73 池" ) - mesg, err = qo.Message() - if err != nil { - t.Errorf( "Unexpected Error: %s", err ) - } + qo = Query(query.QueryMessage{Lang: "zh-Hant", Text: "K73 池"}) + mesg, err = qo.Message() + if err != nil { + t.Errorf("Unexpected Error: %s", err) + } - fmt.Println( mesg ) + qo = Query(query.QueryMessage{ + Lang: "zh-Hant", Text: "", + // Yuen Long Plaza + Location: &query.GeoLocation{22.444894482044997, 114.02393826485495}, + // Nathan Rd + // Location: &query.GeoLocation{22.308944848482525, 114.17116565400259}, + // GO PARK + // Location: &query.GeoLocation{22.427238734660868, 114.26595846515744}, + // 288 Sa Po Kong + // Location: &query.GeoLocation{22.386886035837605, 113.92123399401174}, + }) + mesg, err = qo.Message() + if err != nil { + t.Errorf("Unexpected Error: %s", err) + } + + for _, row := range qo.GetTableData() { + for _, cell := range row { + fmt.Printf("| %s |", cell.Name) + } + fmt.Print("\n") + } } diff --git a/i18n/Generics.go b/i18n/Generics.go deleted file mode 100644 index 643d19b..0000000 --- a/i18n/Generics.go +++ /dev/null @@ -1,10 +0,0 @@ -package i18n - -import ( - query "github.com/tgckpg/golifehk/query" -) - -type Generics struct { - Name *map[string]string - query.Words -} diff --git a/i18n/distance.go b/i18n/distance.go new file mode 100644 index 0000000..249b511 --- /dev/null +++ b/i18n/distance.go @@ -0,0 +1,8 @@ +package i18n + +func FormatDistance(langPack LangPack, meters float64) string { + if meters < 1000 { + return UNITS_METER.Text(langPack, meters) + } + return UNITS_KM.Text(langPack, meters/1000) +} diff --git a/i18n/functions.go b/i18n/functions.go new file mode 100644 index 0000000..ebba8bc --- /dev/null +++ b/i18n/functions.go @@ -0,0 +1,66 @@ +package i18n + +import ( + "encoding/json" + "fmt" + "log" + "sync" + + langRes "github.com/tgckpg/golifehk/resources/langpacks" +) + +type LangPack map[string]string + +var ( + LangPacks = map[string]LangPack{} + defaultLang = "en" + langMu sync.RWMutex +) + +func LoadKeys(lang string) (LangPack, error) { + langMu.RLock() + langPack, ok := LangPacks[lang] + langMu.RUnlock() + if ok { + return langPack, nil + } + + langMu.Lock() + defer langMu.Unlock() + + // check again after obtaining write lock + if langPack, ok := LangPacks[lang]; ok { + return langPack, nil + } + + data, err := langRes.FS.ReadFile(lang + "/messages.json") + if err == nil { + var parsed LangPack + if err := json.Unmarshal(data, &parsed); err != nil { + log.Fatal(err) + } + LangPacks[lang] = parsed + return parsed, nil + } + + if lang == defaultLang { + return nil, fmt.Errorf("No language packs available: %s", lang) + } + + // avoid recursive locking mess; explicit fallback is cleaner + if fallback, ok := LangPacks[defaultLang]; ok { + return fallback, nil + } + + data, err = langRes.FS.ReadFile("langpacks/" + defaultLang + "/messages.json") + if err != nil { + return nil, fmt.Errorf("No language packs available: %s", lang) + } + + var fallback LangPack + if err := json.Unmarshal(data, &fallback); err != nil { + return nil, err + } + LangPacks[defaultLang] = fallback + return fallback, nil +} diff --git a/i18n/messages.go b/i18n/messages.go new file mode 100644 index 0000000..326422c --- /dev/null +++ b/i18n/messages.go @@ -0,0 +1,15 @@ +package i18n + +const ( + DS_KMB_ETA_DEPARTED Key = "DS.KMB.ETA.DEPARTED" + DS_KMB_NEAREST_STOPS Key = "DS.KMB.NEAREST_STOPS" + DS_KMB_NO_NEAREST_STOPS Key = "DS.KMB.NO_NEAREST_STOPS" + DS_MTR_NEAREST_STOPS Key = "DS.MTR.NEAREST_STOPS" + DS_MTR_NO_NEAREST_STOPS Key = "DS.MTR.NO_NEAREST_STOPS" + DS_MTR_NO_SCHEDULES Key = "DS.MTR.NO_SCHEDULES" + NO_RESULTS Key = "NO_RESULTS" + UNITS_MINUTE Key = "UNITS.MINUTE" + UNITS_KM Key = "UNITS.KM" + UNITS_METER Key = "UNITS.METER" + UNITS_MILE Key = "UNITS.MILE" +) diff --git a/i18n/race_test.go b/i18n/race_test.go new file mode 100644 index 0000000..057dd55 --- /dev/null +++ b/i18n/race_test.go @@ -0,0 +1,31 @@ +package i18n + +import ( + "sync" + "testing" +) + +func TestLoadKeysRace(t *testing.T) { + // Reset shared global state so the test starts clean + LangPacks = map[string]LangPack{} + + const goroutines = 200 + + var wg sync.WaitGroup + wg.Add(goroutines) + + start := make(chan struct{}) + + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + + <-start + _, _ = LoadKeys("en") + }() + } + + // Release all goroutines at once to maximize overlap + close(start) + wg.Wait() +} diff --git a/i18n/types.go b/i18n/types.go new file mode 100644 index 0000000..5045c6d --- /dev/null +++ b/i18n/types.go @@ -0,0 +1,26 @@ +package i18n + +import ( + "fmt" + query "github.com/tgckpg/golifehk/query" +) + +type Generics struct { + Name *map[string]string + query.Words +} + +type Key string + +func (k Key) Text(langPack map[string]string, args ...any) string { + txt, ok := langPack[string(k)] + if !ok { + return string(k) + } + + if len(args) > 0 { + return fmt.Sprintf(txt, args...) + } + + return txt +} diff --git a/query/match_locations.go b/query/match_locations.go index 96f07ad..7895cb4 100644 --- a/query/match_locations.go +++ b/query/match_locations.go @@ -4,7 +4,7 @@ import ( "fmt" ) -func MatchNearest(p IGeoLocation, entries *[]ISearchable, dist float64, limit int) (*QueryObject, error) { +func MatchNearest(p IGeoLocation, entries *[]ISearchable, dist float64, fallback_limit int) (*QueryObject, error) { terms := []*QTerm{ { @@ -19,12 +19,22 @@ func MatchNearest(p IGeoLocation, entries *[]ISearchable, dist float64, limit in locs.SortByNearest(p) matches := []ISearchable{} - for i, item := range *locs { + for _, item := range *locs { loc := item.(IGeoLocation) - if i < limit && loc.Dist(p.Lat(), p.Lon()) <= dist { + if loc.Dist(p.Lat(), p.Lon()) <= dist { matches = append(matches, item) } } + if len(matches) == 0 { + for i, item := range *locs { + if i < fallback_limit { + matches = append(matches, item) + } else { + break + } + } + } + return &QueryObject{Key: "", Results: &matches, SearchTerms: &terms}, nil } diff --git a/resources/langpacks/embed.go b/resources/langpacks/embed.go new file mode 100644 index 0000000..61d9cfb --- /dev/null +++ b/resources/langpacks/embed.go @@ -0,0 +1,6 @@ +package resources + +import "embed" + +//go:embed */*.json +var FS embed.FS diff --git a/resources/po/zh-Hant/messages.po b/resources/po/zh-Hant/messages.po new file mode 100644 index 0000000..955691a --- /dev/null +++ b/resources/po/zh-Hant/messages.po @@ -0,0 +1,60 @@ +#. extracted from ./resources/src/messages.json +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-03-10 15:02+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Translate Toolkit 3.19.3\n" + +#: .DS.KMB.ETA.DEPARTED +msgid "%s (Leaved?)" +msgstr "%s (走左了?)" + +#: .DS.KMB.NEAREST_STOPS +msgid "KMB: Showing nearest bus stops under %s" +msgstr "九巴:%s 範圍內車站" + +#: .DS.KMB.NO_NEAREST_STOPS +msgid "" +"KMB: Unable to find stations under %s. Listing nearest %d stations instead:" +msgstr "%s 範圍內找不到九巴站,最近 %d 個站為:" + +#: .DS.MTR.NEAREST_STOPS +msgid "MTR: Showing nearest bus stops under %s" +msgstr "%s 範圍內港鐵巴士車站" + +#: .DS.MTR.NO_NEAREST_STOPS +msgid "" +"MTR Bus: Unable to find stations under %s. Listing nearest %d stations " +"instead:" +msgstr "%s 範圍內找不到港鐵巴士站,最近 %d 個站為:" + +#: .DS.MTR.NO_SCHEDULES +msgid "Schedules are empty...perhaps Out of Service Time?" +msgstr "沒有行程(收左工了?)" + +#: .NO_RESULTS +msgid "No Results" +msgstr "找不到結果" + +#: .UNITS.KM +msgid "%.1f km" +msgstr "%.1f 公里" + +#: .UNITS.METER +msgid "%.0f m" +msgstr "%.0f 米" + +#: .UNITS.MILE +msgid "%.1f mi" +msgstr "%.1f 英哩" + +#: .UNITS.MINUTE +msgid "%.0f min" +msgstr "%.0f 分" diff --git a/resources/pot/messages.pot b/resources/pot/messages.pot new file mode 100644 index 0000000..4bc56de --- /dev/null +++ b/resources/pot/messages.pot @@ -0,0 +1,61 @@ +#. extracted from ./resources/src/messages.json +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-03-10 15:02+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Translate Toolkit 3.19.3\n" + +#: .DS.KMB.ETA.DEPARTED +msgid "%s (Leaved?)" +msgstr "" + +#: .DS.KMB.NEAREST_STOPS +msgid "KMB: Showing nearest bus stops under %s" +msgstr "" + +#: .DS.KMB.NO_NEAREST_STOPS +msgid "" +"KMB: Unable to find stations under %s. Listing nearest %d stations instead:" +msgstr "" + +#: .DS.MTR.NEAREST_STOPS +msgid "MTR: Showing nearest bus stops under %s" +msgstr "" + +#: .DS.MTR.NO_NEAREST_STOPS +msgid "" +"MTR Bus: Unable to find stations under %s. Listing nearest %d stations " +"instead:" +msgstr "" + +#: .DS.MTR.NO_SCHEDULES +msgid "Schedules are empty...perhaps Out of Service Time?" +msgstr "" + +#: .NO_RESULTS +msgid "No Results" +msgstr "" + +#: .UNITS.KM +msgid "%.1f km" +msgstr "" + +#: .UNITS.METER +msgid "%.0f m" +msgstr "" + +#: .UNITS.MILE +msgid "%.1f mi" +msgstr "" + +#: .UNITS.MINUTE +msgid "%.0f min" +msgstr ""