Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions internal/defaultengine/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ package defaultengine
import (
"github.com/choysum-dev/choysum/internal/defaultengine/quickjsruntime"
i18nbridge "github.com/choysum-dev/choysum/internal/i18n/bridge"
i18nstore "github.com/choysum-dev/choysum/internal/i18n/store"
"github.com/choysum-dev/choysum/pkg/auth"
"github.com/choysum-dev/choysum/pkg/jsengine"
"github.com/choysum-dev/choysum/pkg/jsengine/quickjsbridge"
Expand Down Expand Up @@ -39,9 +38,8 @@ func defaultQuickjsReplaceableRuntimePlugins() []jsengine.RuntimePlugin {
jsengine.NewRuntimePlugin(quickjsengine.RuntimePluginFS, func(runtimeScope scope.Scope, authenticator auth.Authenticator) []jsengine.JsEngineOption {
return []jsengine.JsEngineOption{quickjsruntime.WithCompilerFs()}
}),
jsengine.NewRuntimePlugin(quickjsengine.RuntimePluginI18n, func(runtimeScope scope.Scope, authenticator auth.Authenticator) []jsengine.JsEngineOption {
reg := i18nstore.RegistryFor(runtimeScope)
return []jsengine.JsEngineOption{i18nbridge.WithTerminology(reg)}
jsengine.NewRuntimePluginWithProvider(quickjsengine.RuntimePluginI18n, func(scopeProvider jsengine.ScopeProvider, authenticator auth.Authenticator) []jsengine.JsEngineOption {
return []jsengine.JsEngineOption{i18nbridge.WithTerminologyProvider(scopeProvider)}
Comment thread
buke marked this conversation as resolved.
}),
jsengine.NewRuntimePlugin(quickjsengine.RuntimePluginGRPC, func(runtimeScope scope.Scope, authenticator auth.Authenticator) []jsengine.JsEngineOption {
return []jsengine.JsEngineOption{quickjsbridge.WithGrpc(runtimeScope)}
Expand Down
194 changes: 178 additions & 16 deletions internal/i18n/bridge/terminology.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,47 +4,100 @@
package bridge

import (
"context"
"fmt"
"strings"

"github.com/buke/quickjs-go"
i18nimport "github.com/choysum-dev/choysum/internal/i18n/import"
"github.com/choysum-dev/choysum/internal/i18n/models"
"github.com/choysum-dev/choysum/internal/i18n/store"
"github.com/choysum-dev/choysum/pkg/jsengine"
"github.com/choysum-dev/choysum/pkg/jsengine/quickjsengine"
"github.com/choysum-dev/choysum/pkg/scope"
)

// LookupFunc is the sync terminology lookup used by $choysum.i18n.t.
type LookupFunc func(module, lang, scope, src, kind string) (value string, ok bool)

// WithTerminology registers sync $choysum.i18n.t(module, lang, scope, src[, kind]).
// Miss returns empty string; TS _t is responsible for falling back to src.
type upsertPackagedDeps struct {
upsert func(runtimeScope scope.Scope, reg *store.Registry, application, module, lang string, poText []byte) (*i18nimport.ImportStats, error)
marshal func(ctx *quickjs.Context, v any) (*quickjs.Value, error)
}

func defaultUpsertPackagedDeps() upsertPackagedDeps {
return upsertPackagedDeps{
upsert: i18nimport.UpsertPackagedTerms,
marshal: func(ctx *quickjs.Context, v any) (*quickjs.Value, error) {
return ctx.Marshal(v)
},
}
}

// WithTerminology registers sync $choysum.i18n.t against a fixed registry (lookup only).
// Prefer WithTerminologyProvider when invalidate/import are needed.
func WithTerminology(reg *store.Registry) jsengine.JsEngineOption {
if reg == nil {
return WithTerminologyLookup(nil)
}
return WithTerminologyLookup(reg.Lookup)
}

// WithTerminologyLookup registers $choysum.i18n.t with a custom lookup (tests).
// WithTerminologyProvider registers $choysum.i18n.t, invalidateModule, and upsertPackagedTerms.
// The process-shared Registry is captured once at install (StaticScopeProvider wraps a new
// scope per ResolveScope call; re-calling RegistryFor would reset the cache). DB writes for
// upsert resolve the request scope via provider.
//
// Note: quickjs-go Value.Set consumes the property value (JS_SetProperty without
// Dup). Do not Free() values after Set — that double-frees and crashes.
func WithTerminologyLookup(lookup LookupFunc) jsengine.JsEngineOption {
func WithTerminologyProvider(scopeProvider jsengine.ScopeProvider) jsengine.JsEngineOption {
return func(jsEngine jsengine.JsEngine) error {
jse := jsEngine.(*quickjsengine.QuickjsEngine)
globalsObj := jse.Ctx.Globals()

choysumObj := globalsObj.Get("$choysum")
if !choysumObj.IsObject() {
// Get() returns an owned handle; free before replacing non-objects
// (undefined/null/primitives). Do not Free() Globals() itself.
choysumObj.Free()
choysumObj = jse.Ctx.Object()
base := jsengine.ResolveScope(scopeProvider, context.Background())
var reg *store.Registry
if base != nil {
reg = store.RegistryFor(base)
}
return installI18nObject(jse, scopeProvider, reg, nil)
}
}

// WithTerminologyLookup registers $choysum.i18n.t with a custom lookup (tests).
func WithTerminologyLookup(lookup LookupFunc) jsengine.JsEngineOption {
return func(jsEngine jsengine.JsEngine) error {
jse := jsEngine.(*quickjsengine.QuickjsEngine)
return installI18nObject(jse, nil, nil, lookup)
}
}

func installI18nObject(jse *quickjsengine.QuickjsEngine, scopeProvider jsengine.ScopeProvider, reg *store.Registry, lookup LookupFunc) error {
globalsObj := jse.Ctx.Globals()

i18nObj := jse.Ctx.Object()
choysumObj := globalsObj.Get("$choysum")
if !choysumObj.IsObject() {
// Get() returns an owned handle; free before replacing non-objects
// (undefined/null/primitives). Do not Free() Globals() itself.
choysumObj.Free()
choysumObj = jse.Ctx.Object()
}

i18nObj := jse.Ctx.Object()
if lookup != nil {
i18nObj.Set("t", jse.Ctx.Function(terminologyLookupFunc(lookup)))
choysumObj.Set("i18n", i18nObj)
globalsObj.Set("$choysum", choysumObj)
return nil
} else if reg != nil {
i18nObj.Set("t", jse.Ctx.Function(terminologyLookupFunc(reg.Lookup)))
} else {
i18nObj.Set("t", jse.Ctx.Function(terminologyLookupFunc(nil)))
}
if reg != nil {
i18nObj.Set("invalidateModule", jse.Ctx.Function(invalidateModuleFunc(reg)))
}
if scopeProvider != nil && reg != nil {
i18nObj.Set("upsertPackagedTerms", jse.Ctx.NewFunction(upsertPackagedTermsAsyncFactory(jse, scopeProvider, reg)))
}
choysumObj.Set("i18n", i18nObj)
globalsObj.Set("$choysum", choysumObj)
return nil
}

func terminologyLookupFunc(lookup LookupFunc) func(ctx *quickjs.Context, this *quickjs.Value, args []*quickjs.Value) *quickjs.Value {
Expand All @@ -69,3 +122,112 @@ func terminologyLookupFunc(lookup LookupFunc) func(ctx *quickjs.Context, this *q
return ctx.String(val)
}
}

func invalidateModuleFunc(reg *store.Registry) func(ctx *quickjs.Context, this *quickjs.Value, args []*quickjs.Value) *quickjs.Value {
return func(ctx *quickjs.Context, this *quickjs.Value, args []*quickjs.Value) *quickjs.Value {
if reg == nil || len(args) < 2 {
return ctx.Bool(false)
}
if args[0] == nil || args[0].IsUndefined() || args[0].IsNull() ||
args[1] == nil || args[1].IsUndefined() || args[1].IsNull() {
return ctx.Bool(false)
}
application := strings.TrimSpace(args[0].String())
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
module := strings.TrimSpace(args[1].String())
if application == "" || application == "core" || module == "" {
return ctx.Bool(false)
}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
ts, ok := reg.ExistingStore(application)
if !ok {
return ctx.Bool(false)
}
ts.InvalidateModule(module)
return ctx.Bool(true)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func upsertPackagedTermsAsyncFactory(jse *quickjsengine.QuickjsEngine, scopeProvider jsengine.ScopeProvider, reg *store.Registry) func(ctx *quickjs.Context, this *quickjs.Value, args []*quickjs.Value) *quickjs.Value {
deps := defaultUpsertPackagedDeps()
return func(ctx *quickjs.Context, this *quickjs.Value, args []*quickjs.Value) *quickjs.Value {
return ctx.NewPromise(func(resolve, reject func(*quickjs.Value)) {
ret := performUpsertPackagedTerms(ctx, jse, scopeProvider, reg, args, deps)
// NewError values are IsError; never resolve ThrowError's JS_EXCEPTION sentinel.
if ret.IsError() {
defer ret.Free()
reject(ret)
return
}
defer ret.Free()
resolve(ret)
})
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func performUpsertPackagedTerms(ctx *quickjs.Context, jse *quickjsengine.QuickjsEngine, scopeProvider jsengine.ScopeProvider, reg *store.Registry, args []*quickjs.Value, deps upsertPackagedDeps) *quickjs.Value {
if deps.upsert == nil {
deps.upsert = defaultUpsertPackagedDeps().upsert
}
if deps.marshal == nil {
deps.marshal = defaultUpsertPackagedDeps().marshal
}
if len(args) < 4 {
return ctx.NewError(fmt.Errorf("upsertPackagedTerms requires application, module, lang, poText"))
}
if args[0] == nil || args[0].IsUndefined() || args[0].IsNull() ||
args[1] == nil || args[1].IsUndefined() || args[1].IsNull() ||
args[2] == nil || args[2].IsUndefined() || args[2].IsNull() {
return ctx.NewError(fmt.Errorf("upsertPackagedTerms: application, module, and lang are required"))
}
application := strings.TrimSpace(args[0].String())
module := strings.TrimSpace(args[1].String())
lang := strings.TrimSpace(args[2].String())
poText, err := poTextBytes(args[3])
if err != nil {
return ctx.NewError(err)
}
if application == "" || application == "core" || module == "" || lang == "" {
return ctx.NewError(fmt.Errorf("upsertPackagedTerms: application, module, and lang are required"))
}

execCtx := jse.ExecContext()
rs := jsengine.ResolveScope(scopeProvider, execCtx)
if rs == nil || rs.Session() == nil {
return ctx.NewError(fmt.Errorf("upsertPackagedTerms: missing runtime session"))
}
stats, err := deps.upsert(rs, reg, application, module, lang, poText)
if err != nil {
return ctx.NewError(err)
}
if stats == nil {
stats = &i18nimport.ImportStats{Lang: lang}
}
payload := map[string]any{
"upserted": stats.Upserted,
"skippedOverride": stats.SkippedOverride,
"rejectedNoCtxt": stats.RejectedNoCtxt,
"skippedObsolete": stats.SkippedObsolete,
"purgedRetired": stats.PurgedRetired,
"lang": stats.Lang,
}
val, marshalErr := deps.marshal(ctx, payload)
if marshalErr != nil {
return ctx.NewError(marshalErr)
}
return val
}

func poTextBytes(v *quickjs.Value) ([]byte, error) {
if v == nil || v.IsUndefined() || v.IsNull() {
return nil, fmt.Errorf("poText is required")
}
if v.IsString() {
return []byte(v.String()), nil
}
if v.IsUint8Array() || v.IsUint8ClampedArray() {
return v.ToUint8Array()
}
if v.IsByteArray() {
return v.ToByteArray(uint(v.ByteLen()))
}
return nil, fmt.Errorf("poText must be a string or Uint8Array")
}
Loading