From d2071a80b379a7f8dfe2e98b30a01f4f25135c43 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Wed, 14 Oct 2026 20:52:57 +0530 Subject: [PATCH 1/7] Load PO catalogs for every installed module and scaffold i18n with new addons. Base install wrote en_US.po before sys.translation existed. Installed modules now import i18n/*.po on server start, and those strings apply to menus and view labels. sumeru-bp creates i18n/en_US.po for a new module. --- addons/i18n/README.md | 2 +- cmd/sumeru-bp/main.go | 64 +++---- cmd/sumeru-bp/scaffold.go | 181 ++++++++++-------- cmd/sumeru-bp/templates/actions.xml.tmpl | 8 +- cmd/sumeru-bp/templates/en_US.po.tmpl | 34 ++++ cmd/sumeru-bp/templates/form_view.xml.tmpl | 2 +- cmd/sumeru-bp/templates/init.go.tmpl | 6 +- cmd/sumeru-bp/templates/kanban_view.xml.tmpl | 2 +- cmd/sumeru-bp/templates/list_view.xml.tmpl | 2 +- cmd/sumeru-bp/templates/manifest.json.tmpl | 4 +- cmd/sumeru-bp/templates/menus.xml.tmpl | 6 +- cmd/sumeru-bp/templates/models.go.tmpl | 8 +- cmd/sumeru-bp/templates/security.xml.tmpl | 18 +- cmd/sumeru-bp/templates/sys.access.csv.tmpl | 4 +- cmd/sumeru-bp/utils.go | 55 ++++-- core/engine/render/menus.go | 9 +- core/engine/swcmeta/arch.go | 119 ++++++++++++ core/engine/swcmeta/search_meta.go | 1 + .../module/addon_template/MODULE_STANDARD.txt | 4 +- core/module/i18n_sync.go | 50 ++++- core/orm/i18n_translate.go | 17 +- core/server/run.go | 5 +- test/cmd/smoke_test.go | 24 +++ test/core/orm/platform_unit_test.go | 6 +- 24 files changed, 440 insertions(+), 191 deletions(-) create mode 100644 cmd/sumeru-bp/templates/en_US.po.tmpl diff --git a/addons/i18n/README.md b/addons/i18n/README.md index 5563ad7a..ecd4fb7c 100644 --- a/addons/i18n/README.md +++ b/addons/i18n/README.md @@ -12,7 +12,7 @@ addons//i18n/.po Examples: `i18n/fr_FR.po`, `i18n/es.po`. Language comes from the PO header `Language:` line or from the filename (normalized to Sumeru codes like `fr_FR`). -On **install** or **`-u `**, the server imports all `i18n/*.po` files into `sys.translation` with `module` set to that addon’s technical name. +On **server start**, **install**, or **`-u `**, the server imports `i18n/*.po` from every installed module on `addons_path` (kernel, standard, and custom) into `sys.translation`. `module` is that addon’s technical name. `msgid` is the English source (menu name, field label, button label). `en_US` stays the source language; set `core.user.lang` to the catalog language to see translations. ## CLI diff --git a/cmd/sumeru-bp/main.go b/cmd/sumeru-bp/main.go index 7d8ef768..27f0ecd2 100644 --- a/cmd/sumeru-bp/main.go +++ b/cmd/sumeru-bp/main.go @@ -5,69 +5,63 @@ import ( "flag" "fmt" "os" - "path/filepath" "strings" "sumeru/core/module" ) func main() { - namePtr := flag.String("name", "", "technical module name (folder + manifest name)") - bpPtr := flag.String("bp", "", "alias for -name") - - outDir := flag.String("out", "", "parent directory for the new module (default: detected workspace addons/)") + nameFlag := flag.String("name", "", "technical module name (folder and manifest name)") + bpFlag := flag.String("bp", "", "alias for -name") + parentFlag := flag.String("out", "", "parent directory for the new module (default: addons/)") flag.Parse() - name := *namePtr - if name == "" { - name = *bpPtr + moduleName := strings.TrimSpace(*nameFlag) + if moduleName == "" { + moduleName = strings.TrimSpace(*bpFlag) } - - if strings.TrimSpace(name) == "" { + if moduleName == "" { fmt.Fprintln(os.Stderr, "usage: sumeru-bp -name [-out ]") fmt.Fprintln(os.Stderr, "alias: -bp") os.Exit(2) } - if !module.ModuleNamePattern.MatchString(name) { - fmt.Fprintf(os.Stderr, "invalid module name %q (must match %s)\n", name, module.ModuleNamePattern.String()) + if !module.ModuleNamePattern.MatchString(moduleName) { + fmt.Fprintf(os.Stderr, "sumeru-bp: invalid module name %q (must match %s)\n", moduleName, module.ModuleNamePattern.String()) os.Exit(2) } - cwd, err := os.Getwd() + workingDir, err := os.Getwd() if err != nil { - fmt.Fprintln(os.Stderr, err) + fmt.Fprintf(os.Stderr, "sumeru-bp: %v\n", err) os.Exit(1) } - - // Try to find the repo root - repoRoot, err := module.FindRepoRoot(cwd) + repoRoot, err := module.FindRepoRoot(workingDir) if err != nil { fmt.Fprintf(os.Stderr, "sumeru-bp: %v\n", err) os.Exit(1) } - - modPath, err := module.ReadModulePath(repoRoot) + goModule, err := module.ReadModulePath(repoRoot) if err != nil { fmt.Fprintf(os.Stderr, "sumeru-bp: %v\n", err) os.Exit(1) } - // Workspace detection and outDir defaulting - resolvedOut := *outDir - if resolvedOut == "" { - resolvedOut = "addons" + parentDir := strings.TrimSpace(*parentFlag) + if parentDir == "" { + parentDir = "addons" } - - cfg := &ScaffoldConfig{ - Name: name, - HumanTitle: humanTitle(name), - TypeName: typeName(name), - ModelDotted: strings.ReplaceAll(name, "_", "."), - ModelImport: modPath + "/" + filepath.ToSlash(filepath.Join(resolvedOut, name, "models")), - OutDir: resolvedOut, - RepoRoot: repoRoot, - ModPath: modPath, + err = scaffoldModule(moduleScaffold{ + ModuleName: moduleName, + DisplayName: displayName(moduleName), + StructName: goStructName(moduleName), + ModelName: strings.ReplaceAll(moduleName, "_", "."), + ModelsImport: modelsImportPath(goModule, repoRoot, parentDir, moduleName), + ParentDir: parentDir, + RepoRoot: repoRoot, + GoModule: goModule, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "sumeru-bp: %v\n", err) + os.Exit(1) } - - RunScaffold(cfg) } diff --git a/cmd/sumeru-bp/scaffold.go b/cmd/sumeru-bp/scaffold.go index 93b95d82..59d4ba14 100644 --- a/cmd/sumeru-bp/scaffold.go +++ b/cmd/sumeru-bp/scaffold.go @@ -6,107 +6,130 @@ import ( "fmt" "os" "path/filepath" - "sync" "text/template" ) //go:embed templates/*.tmpl var templateFS embed.FS -var ( - templateCache map[string]*template.Template - templateCacheMu sync.Once -) +// moduleScaffold is the data rendered into a new addon's files. +type moduleScaffold struct { + ModuleName string // technical name: folder, package, and manifest name + DisplayName string // human title, for example "Sale Order" + StructName string // Go struct name, for example "SaleOrder" + ModelName string // dotted model name, for example "sale.order" + ModelsImport string // Go import path of the models package + ParentDir string // -out directory, relative to the repo or absolute + RepoRoot string + GoModule string // module path from go.mod +} -type ScaffoldConfig struct { - Name string - HumanTitle string - TypeName string - ModelDotted string - ModelImport string - OutDir string - RepoRoot string - ModPath string +var scaffoldDirectories = []string{ + "models", + "views", + "security", + "static/src/img", + "i18n", } -func RunScaffold(cfg *ScaffoldConfig) { - target := filepath.Join(cfg.RepoRoot, filepath.FromSlash(cfg.OutDir), cfg.Name) - if fi, err := os.Stat(target); err == nil && fi.IsDir() { - fmt.Fprintf(os.Stderr, "sumeru-bp: %q already exists\n", target) - os.Exit(1) - } +var scaffoldFiles = []struct { + template string + relPath string +}{ + {"manifest.json.tmpl", "manifest.json"}, + {"init.go.tmpl", "init.go"}, + {"models.go.tmpl", filepath.Join("models", "models.go")}, + {"security.xml.tmpl", filepath.Join("security", "security.xml")}, + {"sys.access.csv.tmpl", filepath.Join("security", "sys.access.csv")}, + {"actions.xml.tmpl", filepath.Join("views", "actions.xml")}, + {"form_view.xml.tmpl", filepath.Join("views", "form_view.xml")}, + {"list_view.xml.tmpl", filepath.Join("views", "list_view.xml")}, + {"kanban_view.xml.tmpl", filepath.Join("views", "kanban_view.xml")}, + {"menus.xml.tmpl", filepath.Join("views", "menus.xml")}, + {"en_US.po.tmpl", filepath.Join("i18n", "en_US.po")}, +} - dirs := []string{ - "models", - "views", - "security", - "static/src/img", +func scaffoldModule(module moduleScaffold) error { + directory, err := moduleDirectory(module) + if err != nil { + return err } - for _, d := range dirs { - if err := os.MkdirAll(filepath.Join(target, filepath.FromSlash(d)), 0o755); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) + for _, rel := range scaffoldDirectories { + if err := os.MkdirAll(filepath.Join(directory, filepath.FromSlash(rel)), 0o755); err != nil { + return fmt.Errorf("create %s: %w", rel, err) + } + } + templates, err := parseTemplates() + if err != nil { + return err + } + for _, file := range scaffoldFiles { + parsed := templates[file.template] + if parsed == nil { + return fmt.Errorf("template %q is not embedded", file.template) + } + var rendered bytes.Buffer + if err := parsed.Execute(&rendered, module); err != nil { + return fmt.Errorf("render %s: %w", file.template, err) + } + dest := filepath.Join(directory, file.relPath) + if err := writeFile(dest, rendered.String()); err != nil { + return err } } - renderTemplate(cfg, "manifest.json.tmpl", filepath.Join(target, "manifest.json")) - renderTemplate(cfg, "init.go.tmpl", filepath.Join(target, "init.go")) - renderTemplate(cfg, "models.go.tmpl", filepath.Join(target, "models", "models.go")) - renderTemplate(cfg, "security.xml.tmpl", filepath.Join(target, "security", "security.xml")) - renderTemplate(cfg, "sys.access.csv.tmpl", filepath.Join(target, "security", "sys.access.csv")) - renderTemplate(cfg, "actions.xml.tmpl", filepath.Join(target, "views", "actions.xml")) - renderTemplate(cfg, "form_view.xml.tmpl", filepath.Join(target, "views", "form_view.xml")) - renderTemplate(cfg, "list_view.xml.tmpl", filepath.Join(target, "views", "list_view.xml")) - renderTemplate(cfg, "kanban_view.xml.tmpl", filepath.Join(target, "views", "kanban_view.xml")) - renderTemplate(cfg, "menus.xml.tmpl", filepath.Join(target, "views", "menus.xml")) - - fmt.Printf("Successfully created premium modular addon at: %s\n", target) - fmt.Printf("Next steps:\n") - if cfg.ModPath == "sumeru_custom_addons" { + fmt.Printf("Created %s\n", directory) + fmt.Println("Next:") + if module.GoModule == "sumeru_custom_addons" { fmt.Printf(" 1. Run 'make generate' in the workspace\n") - fmt.Printf(" 2. Run 'make install MODULES=%s'\n", cfg.Name) + fmt.Printf(" 2. Run 'make install MODULES=%s'\n", module.ModuleName) } else { - fmt.Printf(" 1. Run 'make generate'\n") - fmt.Printf(" 2. Run 'go run ./cmd/sumeru -- -i %s'\n", cfg.Name) + fmt.Println(" 1. Run 'make generate'") + fmt.Printf(" 2. Run 'go run ./cmd/sumeru -- -i %s'\n", module.ModuleName) } + return nil } -func loadTemplates() { - templateCacheMu.Do(func() { - names, err := templateFS.ReadDir("templates") - if err != nil { - fmt.Fprintf(os.Stderr, "error reading templates: %v\n", err) - os.Exit(1) - } - templateCache = make(map[string]*template.Template, len(names)) - for _, entry := range names { - if entry.IsDir() { - continue - } - name := entry.Name() - t, err := template.ParseFS(templateFS, "templates/"+name) - if err != nil { - fmt.Fprintf(os.Stderr, "error parsing template %s: %v\n", name, err) - os.Exit(1) - } - templateCache[name] = t +// moduleDirectory is the new addon folder. A relative ParentDir is under RepoRoot. +// An empty ParentDir means addons/. An existing path is refused. +func moduleDirectory(module moduleScaffold) (string, error) { + parent := module.ParentDir + if parent == "" { + parent = "addons" + } + if !filepath.IsAbs(parent) { + parent = filepath.Join(module.RepoRoot, filepath.FromSlash(parent)) + } + directory := filepath.Join(parent, module.ModuleName) + info, err := os.Stat(directory) + if err == nil { + if !info.IsDir() { + return "", fmt.Errorf("%q exists and is not a directory", directory) } - }) + return "", fmt.Errorf("%q already exists", directory) + } + if !os.IsNotExist(err) { + return "", err + } + return directory, nil } -func renderTemplate(cfg *ScaffoldConfig, tmplName, outPath string) { - loadTemplates() - t := templateCache[tmplName] - if t == nil { - fmt.Fprintf(os.Stderr, "missing template %s\n", tmplName) - os.Exit(1) +func parseTemplates() (map[string]*template.Template, error) { + entries, err := templateFS.ReadDir("templates") + if err != nil { + return nil, fmt.Errorf("read templates: %w", err) } - - var buf bytes.Buffer - if err := t.Execute(&buf, cfg); err != nil { - fmt.Fprintf(os.Stderr, "error executing template %s: %v\n", tmplName, err) - os.Exit(1) + parsed := make(map[string]*template.Template, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + t, err := template.ParseFS(templateFS, "templates/"+name) + if err != nil { + return nil, fmt.Errorf("parse template %s: %w", name, err) + } + parsed[name] = t } - - writeOrDie(outPath, buf.String()) + return parsed, nil } diff --git a/cmd/sumeru-bp/templates/actions.xml.tmpl b/cmd/sumeru-bp/templates/actions.xml.tmpl index e0c5873e..77fee6a5 100644 --- a/cmd/sumeru-bp/templates/actions.xml.tmpl +++ b/cmd/sumeru-bp/templates/actions.xml.tmpl @@ -2,13 +2,13 @@ - - {{.HumanTitle}} - {{.ModelDotted}} + + {{.DisplayName}} + {{.ModelName}} list,form,kanban

- Create your first {{.HumanTitle}}! + Create your first {{.DisplayName}}!

diff --git a/cmd/sumeru-bp/templates/en_US.po.tmpl b/cmd/sumeru-bp/templates/en_US.po.tmpl new file mode 100644 index 00000000..e1c54d80 --- /dev/null +++ b/cmd/sumeru-bp/templates/en_US.po.tmpl @@ -0,0 +1,34 @@ +# Source catalog. Copy to hi_IN.po (or another language code) and translate msgstr. +msgid "" +msgstr "" +"Language: en_US\n" + +msgid "{{.DisplayName}}" +msgstr "{{.DisplayName}}" +{{if ne .DisplayName "Configuration"}} +msgid "Configuration" +msgstr "Configuration" +{{end}}{{if ne .DisplayName "Confirm"}} +msgid "Confirm" +msgstr "Confirm" +{{end}}{{if ne .DisplayName "Description"}} +msgid "Description" +msgstr "Description" +{{end}}{{if ne .DisplayName "Name"}} +msgid "Name" +msgstr "Name" +{{end}}{{if ne .DisplayName "Active"}} +msgid "Active" +msgstr "Active" +{{end}}{{if ne .DisplayName "Sequence"}} +msgid "Sequence" +msgstr "Sequence" +{{end}}{{if ne .DisplayName "Title..."}} +msgid "Title..." +msgstr "Title..." +{{end}}{{if ne .DisplayName "Internal notes..."}} +msgid "Internal notes..." +msgstr "Internal notes..." +{{end}} +msgid "Create your first {{.DisplayName}}!" +msgstr "Create your first {{.DisplayName}}!" diff --git a/cmd/sumeru-bp/templates/form_view.xml.tmpl b/cmd/sumeru-bp/templates/form_view.xml.tmpl index 8fca7406..f79aa547 100644 --- a/cmd/sumeru-bp/templates/form_view.xml.tmpl +++ b/cmd/sumeru-bp/templates/form_view.xml.tmpl @@ -1,7 +1,7 @@ - +