penguin/golifehk

@golifehk bot in Telegram

commit 7d1de5f78175311612204971a8c6a3f09eb054da

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-03-10T07:18:34Z
subjectAdded basic i18n
commit 7d1de5f78175311612204971a8c6a3f09eb054da
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-03-10T07:18:34Z

    Added basic i18n
---
 .gitignore                         |   3 +
 adaptors/tg/botsend.go             |   2 -
 cmd/compilei18n/go.mod             |   3 +
 cmd/compilei18n/main.go            | 121 +++++++++++++++++++++++++++++++++++++
 cmd/po-build.sh                    |   9 +++
 cmd/po-export.sh                   |   7 +++
 datasources/kmb/QueryResult.go     |  49 ++++++++++++---
 datasources/kmb/query.go           |   8 ++-
 datasources/kmb/query_test.go      |  68 +++++++++++++--------
 datasources/kmb/routestops_test.go |   9 +--
 datasources/mtr/bus/BusStop.go     |   6 +-
 datasources/mtr/bus/QueryResult.go |  97 +++++++++++++++++++++++------
 datasources/mtr/bus/busstops.go    |   5 +-
 datasources/mtr/bus/query.go       |  13 ++--
 datasources/mtr/bus/query_test.go  |  66 +++++++++++++-------
 i18n/Generics.go                   |  10 ---
 i18n/distance.go                   |   8 +++
 i18n/functions.go                  |  66 ++++++++++++++++++++
 i18n/messages.go                   |  15 +++++
 i18n/race_test.go                  |  31 ++++++++++
 i18n/types.go                      |  26 ++++++++
 query/match_locations.go           |  16 ++++-
 resources/langpacks/embed.go       |   6 ++
 resources/po/zh-Hant/messages.po   |  60 ++++++++++++++++++
 resources/pot/messages.pot         |  61 +++++++++++++++++++
 25 files changed, 660 insertions(+), 105 deletions(-)

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 <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\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 <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\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 ""