Added basic i18n

This commit is contained in:
2026-03-10 15:18:34 +08:00
parent 093a8745ac
commit 7d1de5f781
25 changed files with 660 additions and 105 deletions

3
.gitignore vendored
View File

@@ -3,3 +3,6 @@
*.csv *.csv
*.json *.json
*.swp *.swp
.env
golifehk
cmd/compilei18n/compliei18n

View File

@@ -1,7 +1,6 @@
package tg package tg
import ( import (
"fmt"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
query "github.com/tgckpg/golifehk/query" 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 { for _, cell := range row {
button := tgbotapi.NewInlineKeyboardButtonData(cell.Name, cell.Value) button := tgbotapi.NewInlineKeyboardButtonData(cell.Name, cell.Value)
buttons = append(buttons, button) buttons = append(buttons, button)
fmt.Println(cell)
} }
buttonRows = append(buttonRows, buttons) buttonRows = append(buttonRows, buttons)
} }

3
cmd/compilei18n/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module chatgpt.com/c/tools/compliei18n
go 1.25.0

121
cmd/compilei18n/main.go Normal file
View File

@@ -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
}

9
cmd/po-build.sh Executable file
View File

@@ -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"

7
cmd/po-export.sh Executable file
View File

@@ -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"

View File

@@ -2,10 +2,12 @@ package kmb
import ( import (
"fmt" "fmt"
"math"
"sort" "sort"
"strings" "strings"
"time" "time"
i18n "github.com/tgckpg/golifehk/i18n"
query "github.com/tgckpg/golifehk/query" query "github.com/tgckpg/golifehk/query"
utils "github.com/tgckpg/golifehk/utils" utils "github.com/tgckpg/golifehk/utils"
) )
@@ -19,6 +21,9 @@ type QueryResult struct {
isConsumed bool isConsumed bool
dataType string dataType string
tableData [][]query.TableCell tableData [][]query.TableCell
FallbackNearest int
NearestRange float64
} }
func writeRouteHead(sb *strings.Builder, r *RouteStop) { func writeRouteHead(sb *strings.Builder, r *RouteStop) {
@@ -64,6 +69,11 @@ func (this *QueryResult) Message() (string, error) {
return "", this.Error return "", this.Error
} }
langPack, err := i18n.LoadKeys(this.Lang)
if err != nil {
return "", err
}
sb := strings.Builder{} sb := strings.Builder{}
if 0 < len(*this.Query.Results) { if 0 < len(*this.Query.Results) {
@@ -72,23 +82,38 @@ func (this *QueryResult) Message() (string, error) {
if this.Query.Key == "" { if this.Query.Key == "" {
loc := this.Query.Message.Location loc := this.Query.Message.Location
if loc != nil { if loc != nil {
sb.WriteString("九巴 100m")
this.dataType = "Table" this.dataType = "Table"
table := [][]query.TableCell{} table := [][]query.TableCell{}
minDist := math.MaxFloat64
maxDist := -1.0
for _, item := range *this.Query.Results { for _, item := range *this.Query.Results {
b := any(item).(*BusStop) b := any(item).(*BusStop)
row := []query.TableCell{} row := []query.TableCell{}
bDist := b.Dist(loc.Lat(), loc.Lon())
if bDist < minDist {
minDist = bDist
}
if maxDist < bDist {
maxDist = bDist
}
cell := query.TableCell{ 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), Value: fmt.Sprintf("%s", b.BusStopId),
} }
row = append(row, cell) 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{} sb_i := strings.Builder{}
writeRouteHead(&sb_i, r) writeRouteHead(&sb_i, r)
cell := query.TableCell{ cell := query.TableCell{
@@ -102,6 +127,13 @@ func (this *QueryResult) Message() (string, error) {
} }
this.tableData = table 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 { } else {
busStops := map[string]*BusStop{} busStops := map[string]*BusStop{}
for _, item := range *this.Query.Results { for _, item := range *this.Query.Results {
@@ -149,16 +181,13 @@ func (this *QueryResult) Message() (string, error) {
_m := schedule.ETA.Sub(now).Minutes() _m := schedule.ETA.Sub(now).Minutes()
sb.WriteString(" \\* ") sb.WriteString(" \\* ")
txt := "%.0f min(s)"
if this.Lang == "zh-Hant" { eta := i18n.UNITS_MINUTE.Text(langPack, _m)
txt = "%.0f 分鐘"
}
utils.WriteMDv2Text(&sb, fmt.Sprintf(txt, _m))
if _m < 0 { if _m < 0 {
sb.WriteString(" 走左了?") utils.WriteMDv2Text(&sb, i18n.DS_KMB_ETA_DEPARTED.Text(langPack, eta))
} else {
utils.WriteMDv2Text(&sb, eta)
} }
} }

View File

@@ -14,7 +14,11 @@ func Query(q query.QueryMessage) query.IQueryResult {
var err error var err error
var routeStops *[]query.ISearchable var routeStops *[]query.ISearchable
qr := QueryResult{Lang: lang} qr := QueryResult{
Lang: lang,
FallbackNearest: 3,
NearestRange: 50,
}
busStops, err := readBusStopsData() busStops, err := readBusStopsData()
if err != nil { if err != nil {
@@ -37,7 +41,7 @@ func Query(q query.QueryMessage) query.IQueryResult {
bList = append(bList, b) 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 qo.Message = &q

View File

@@ -1,36 +1,54 @@
package kmb package kmb
import ( import (
"fmt" "fmt"
query "github.com/tgckpg/golifehk/query"
"testing" "testing"
) )
func TestQuerySchedule( t *testing.T ) { func TestQuerySchedule(t *testing.T) {
qo := Query( "zh-Hant", "68X" ) qo := Query(query.QueryMessage{Lang: "zh-Hant", Text: "68X"})
mesg, err := qo.Message() mesg, err := qo.Message()
if err != nil { if err != nil {
t.Errorf( "Unexpected Error: %s", err ) t.Errorf("Unexpected Error: %s", err)
} }
fmt.Println( mesg )
qo = Query( "zh-Hant", "K66 朗屏" ) qo = Query(query.QueryMessage{Lang: "zh-Hant", Text: "K66 朗屏"})
mesg, err = qo.Message() mesg, err = qo.Message()
if err == nil { if err == nil {
t.Errorf( "Expected Error: %s, got \"\" instead", mesg ) t.Errorf("Expected Error: %s, got \"\" instead", mesg)
} }
qo = Query( "zh-Hant", "大欖" ) qo = Query(query.QueryMessage{Lang: "zh-Hant", Text: "大欖"})
mesg, err = qo.Message() mesg, err = qo.Message()
if err != nil { if err != nil {
t.Errorf( "Unexpected Error: %s", err ) 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 大欖" ) qo = Query(query.QueryMessage{
mesg, err = qo.Message() Lang: "zh-Hant", Text: "",
if err != nil { // Yuen Long Plaza
t.Errorf( "Unexpected Error: %s", err ) // Location: &query.GeoLocation{22.444894482044997, 114.02393826485495},
} // Nathan Rd
fmt.Println( mesg ) // 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")
}
} }

View File

@@ -5,8 +5,9 @@ import (
) )
func TestRouteStops(t *testing.T) { func TestRouteStops(t *testing.T) {
_, err := getRouteStops() busStops, err := readBusStopsData()
if err != nil { _, err = getRouteStops(busStops)
t.Error( err ) if err != nil {
} t.Error(err)
}
} }

View File

@@ -1,6 +1,7 @@
package bus package bus
import ( import (
"fmt"
i18n "github.com/tgckpg/golifehk/i18n" i18n "github.com/tgckpg/golifehk/i18n"
query "github.com/tgckpg/golifehk/query" query "github.com/tgckpg/golifehk/query"
) )
@@ -53,10 +54,11 @@ func (this *BusStop) Reload() {
} }
func (this BusStop) Register(registers map[string]struct{}) bool { 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 return false
} }
registers[this.StationId] = struct{}{} registers[key] = struct{}{}
return true return true
} }

View File

@@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"strings" "strings"
i18n "github.com/tgckpg/golifehk/i18n"
query "github.com/tgckpg/golifehk/query" query "github.com/tgckpg/golifehk/query"
utils "github.com/tgckpg/golifehk/utils" utils "github.com/tgckpg/golifehk/utils"
) )
@@ -20,6 +21,9 @@ type QueryResult struct {
isConsumed bool isConsumed bool
dataType string dataType string
tableData [][]query.TableCell tableData [][]query.TableCell
FallbackNearest int
NearestRange float64
} }
func writeShortRoute(lang *string, sb *strings.Builder, b *BusStop) { func writeShortRoute(lang *string, sb *strings.Builder, b *BusStop) {
@@ -52,6 +56,11 @@ func (this *QueryResult) Message() (string, error) {
return "", this.Error return "", this.Error
} }
langPack, err := i18n.LoadKeys(this.Lang)
if err != nil {
return "", err
}
sb := strings.Builder{} sb := strings.Builder{}
if this.Schedules == nil { if this.Schedules == nil {
@@ -72,33 +81,83 @@ func (this *QueryResult) Message() (string, error) {
if loc != nil { if loc != nil {
this.dataType = "Table" this.dataType = "Table"
sb.WriteString("K巴 100m")
table := [][]query.TableCell{} table := [][]query.TableCell{}
// Group by Station Name first
bGroups := map[string]*[]*BusStop{}
for _, entry := range *q.Results { for _, entry := range *q.Results {
busStop := any(entry).(*BusStop) busStop := any(entry).(*BusStop)
bName := (*busStop.Name)[this.Lang]
sb_i := strings.Builder{} bGroup, ok := bGroups[bName]
sb_i.WriteString(fmt.Sprintf("%.2fm", busStop.Dist(loc.Lat(), loc.Lon()))) if !ok {
sb_i.WriteString(" ") bGroup = &[]*BusStop{}
utils.WriteMDv2Text(&sb_i, busStop.RouteId) bGroups[bName] = bGroup
d := busStop.Direction
if d == "O" {
sb_i.WriteString("↑")
} else if d == "I" {
sb_i.WriteString("↓")
} else {
sb_i.WriteString("\\?")
} }
sb_i.WriteString(" ") *bGroup = append(*bGroup, busStop)
utils.WriteMDv2Text(&sb_i, (*busStop.Name)[this.Lang]) }
for bName, bGroup := range bGroups {
row := []query.TableCell{ 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(), 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) table = append(table, row)
@@ -235,7 +294,7 @@ func (this *QueryResult) Message() (string, error) {
sb.WriteString("\n") sb.WriteString("\n")
} }
} else { } else {
utils.WriteMDv2Text(&sb, "Schedules are empty...perhaps Out of Service Time?") utils.WriteMDv2Text(&sb, i18n.DS_MTR_NO_SCHEDULES.Text(langPack))
} }
} }

View File

@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
query "github.com/tgckpg/golifehk/query" query "github.com/tgckpg/golifehk/query"
"github.com/tgckpg/golifehk/utils" "github.com/tgckpg/golifehk/utils"
@@ -52,9 +53,9 @@ func readBusStopData(r io.Reader) (*map[string]*BusStop, error) {
v, _ := strconv.ParseFloat(value, 64) v, _ := strconv.ParseFloat(value, 64)
entry.Longitude = v entry.Longitude = v
case "STATION_NAME_CHI": case "STATION_NAME_CHI":
entry.Name_zh = value entry.Name_zh = strings.TrimSpace(value)
case "STATION_NAME_ENG": case "STATION_NAME_ENG":
entry.Name_en = value entry.Name_en = strings.TrimSpace(value)
case "REFERENCE_ID": case "REFERENCE_ID":
entry.ReferenceId = value entry.ReferenceId = value
default: default:

View File

@@ -13,7 +13,12 @@ func Query(q query.QueryMessage) query.IQueryResult {
var qBusStops *query.QueryObject var qBusStops *query.QueryObject
var err error var err error
qr := QueryResult{Lang: lang} qr := QueryResult{
Lang: lang,
FallbackNearest: 3,
NearestRange: 50,
}
busStops, err := getBusStops() busStops, err := getBusStops()
if err != nil { if err != nil {
qr.Error = err qr.Error = err
@@ -23,16 +28,16 @@ func Query(q query.QueryMessage) query.IQueryResult {
if q.Text != "" { if q.Text != "" {
qBusStops, err = query.MatchKeys(strings.ToUpper(q.Text), busStops) qBusStops, err = query.MatchKeys(strings.ToUpper(q.Text), busStops)
} else if q.Location != nil { } 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 { if err != nil {
qr.Error = err qr.Error = err
goto qrReturn goto qrReturn
} }
qBusStops.Message = &q
qr.Query = qBusStops qr.Query = qBusStops
if 0 < len(*qBusStops.Results) && 1 < len(*qBusStops.SearchTerms) { if 0 < len(*qBusStops.Results) && 1 < len(*qBusStops.SearchTerms) {
schedules, err := getSchedule(lang, qBusStops.Key) schedules, err := getSchedule(lang, qBusStops.Key)

View File

@@ -1,33 +1,55 @@
package bus package bus
import ( import (
"fmt" "fmt"
"strings" query "github.com/tgckpg/golifehk/query"
"strings"
"testing" "testing"
) )
func TestQuery( t *testing.T ) { func TestQuery(t *testing.T) {
qo := Query( "zh-Hant", "K73" ) qo := Query(query.QueryMessage{Lang: "zh-Hant", Text: "K73"})
mesg, err := qo.Message() mesg, err := qo.Message()
if err != nil { if err != nil {
t.Errorf( "Unexpected Error: %s", err ) t.Errorf("Unexpected Error: %s", err)
} }
if !strings.Contains( mesg, "K73\\-O" ) { if !strings.Contains(mesg, "K73↓") {
t.Errorf( "Expected Route Listing, got \"%s\" instead", mesg ) t.Errorf("Expected Route Listing, got \"%s\" instead", mesg)
} }
qo = Query( "zh-Hant", "K76 池" ) qo = Query(query.QueryMessage{Lang: "zh-Hant", Text: "K76 池"})
mesg, err = qo.Message() mesg, err = qo.Message()
if err == nil { if err == nil {
t.Errorf( "Expecting error, got \"%s\" instead", mesg ) t.Errorf("Expecting error, got \"%s\" instead", mesg)
} }
qo = Query( "zh-Hant", "K73 池" ) qo = Query(query.QueryMessage{Lang: "zh-Hant", Text: "K73 池"})
mesg, err = qo.Message() mesg, err = qo.Message()
if err != nil { if err != nil {
t.Errorf( "Unexpected Error: %s", err ) 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")
}
} }

View File

@@ -1,10 +0,0 @@
package i18n
import (
query "github.com/tgckpg/golifehk/query"
)
type Generics struct {
Name *map[string]string
query.Words
}

8
i18n/distance.go Normal file
View File

@@ -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)
}

66
i18n/functions.go Normal file
View File

@@ -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
}

15
i18n/messages.go Normal file
View File

@@ -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"
)

31
i18n/race_test.go Normal file
View File

@@ -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()
}

26
i18n/types.go Normal file
View File

@@ -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
}

View File

@@ -4,7 +4,7 @@ import (
"fmt" "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{ terms := []*QTerm{
{ {
@@ -19,12 +19,22 @@ func MatchNearest(p IGeoLocation, entries *[]ISearchable, dist float64, limit in
locs.SortByNearest(p) locs.SortByNearest(p)
matches := []ISearchable{} matches := []ISearchable{}
for i, item := range *locs { for _, item := range *locs {
loc := item.(IGeoLocation) 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) 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 return &QueryObject{Key: "", Results: &matches, SearchTerms: &terms}, nil
} }

View File

@@ -0,0 +1,6 @@
package resources
import "embed"
//go:embed */*.json
var FS embed.FS

View File

@@ -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 分"

View File

@@ -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 ""