diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..b08736cdf --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# Installation +INSTALL_PORT=80 +AUTO_INSTALL=false + +# Database +DB_TYPE= +DB_USERNAME= +DB_PASSWORD= +DB_HOST= +DB_NAME= +DB_FILE= + +# Site +LANGUAGE=en-US +SITE_NAME=Apache Answer +SITE_URL= +CONTACT_EMAIL= + +# Admin +ADMIN_NAME= +ADMIN_PASSWORD= +ADMIN_EMAIL= + +# Content +EXTERNAL_CONTENT_DISPLAY=ask_before_display + +# Swagger +SWAGGER_HOST= +SWAGGER_ADDRESS_PORT= + +# Server +SITE_ADDR=0.0.0.0:3000 + +# Logging +LOG_LEVEL=INFO +LOG_PATH= diff --git a/.gitignore b/.gitignore index 257ef31d6..ba66f51a0 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ dist/ # Lint setup generated file .husky/ + +# Environment variables +.env \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index f166d2309..1f8153001 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -31,12 +31,18 @@ import ( "github.com/apache/answer/internal/base/path" "github.com/apache/answer/internal/schema" "github.com/gin-gonic/gin" + "github.com/joho/godotenv" "github.com/segmentfault/pacman" "github.com/segmentfault/pacman/contrib/log/zap" "github.com/segmentfault/pacman/contrib/server/http" "github.com/segmentfault/pacman/log" ) +func init() { + // Load .env if present, ignore error to keep backward compatibility + _ = godotenv.Load() +} + // go build -ldflags "-X github.com/apache/answer/cmd.Version=x.y.z" var ( // Name is the name of the project diff --git a/go.mod b/go.mod index 969d9ebbc..52d64c738 100644 --- a/go.mod +++ b/go.mod @@ -117,6 +117,7 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect diff --git a/go.sum b/go.sum index 35db004db..69d97751e 100644 --- a/go.sum +++ b/go.sum @@ -354,6 +354,8 @@ github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg= github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= diff --git a/internal/base/handler/lang.go b/internal/base/handler/lang.go index 8886f0631..202449e9d 100644 --- a/internal/base/handler/lang.go +++ b/internal/base/handler/lang.go @@ -23,11 +23,22 @@ import ( "context" "github.com/apache/answer/internal/base/constant" + "github.com/gin-gonic/gin" "github.com/segmentfault/pacman/i18n" ) // GetLangByCtx get language from header func GetLangByCtx(ctx context.Context) i18n.Language { + if ginCtx, ok := ctx.(*gin.Context); ok { + acceptLanguage, ok := ginCtx.Get(constant.AcceptLanguageFlag) + if ok { + if acceptLanguage, ok := acceptLanguage.(i18n.Language); ok { + return acceptLanguage + } + return i18n.DefaultLanguage + } + } + acceptLanguage, ok := ctx.Value(constant.AcceptLanguageContextKey).(i18n.Language) if ok { return acceptLanguage diff --git a/internal/base/translator/provider.go b/internal/base/translator/provider.go index 47212e84f..1a465b1e8 100644 --- a/internal/base/translator/provider.go +++ b/internal/base/translator/provider.go @@ -23,6 +23,8 @@ import ( "fmt" "os" "path/filepath" + "sort" + "strings" "github.com/google/wire" myTran "github.com/segmentfault/pacman/contrib/i18n" @@ -100,6 +102,7 @@ func NewTranslator(c *I18n) (tr i18n.Translator, err error) { // add translator use backend translation if err = myTran.AddTranslator(content, file.Name()); err != nil { log.Debugf("add translator failed: %s %s", file.Name(), err) + reportTranslatorFormatError(file.Name(), buf) continue } } @@ -160,3 +163,165 @@ func TrWithData(lang i18n.Language, key string, templateData any) string { } return translation } + +// reportTranslatorFormatError re-parses the YAML file to locate the invalid entry +// when go-i18n fails to add the translator. +func reportTranslatorFormatError(fileName string, content []byte) { + var raw any + if err := yaml.Unmarshal(content, &raw); err != nil { + log.Errorf("parse translator file %s failed when diagnosing format error: %s", fileName, err) + return + } + if err := inspectTranslatorNode(raw, nil, true); err != nil { + log.Errorf("translator file %s invalid: %s", fileName, err) + } +} + +func inspectTranslatorNode(node any, path []string, isRoot bool) error { + switch data := node.(type) { + case nil: + if isRoot { + return fmt.Errorf("root value is empty") + } + return fmt.Errorf("%s contains an empty value", formatTranslationPath(path)) + case string: + if isRoot { + return fmt.Errorf("root value must be an object but found string") + } + return nil + case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + if isRoot { + return fmt.Errorf("root value must be an object but found %T", data) + } + return fmt.Errorf("%s expects a string translation but found %T", formatTranslationPath(path), data) + case map[string]any: + if isMessageMap(data) { + return nil + } + keys := make([]string, 0, len(data)) + for key := range data { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + if err := inspectTranslatorNode(data[key], append(path, key), false); err != nil { + return err + } + } + return nil + case map[string]string: + mapped := make(map[string]any, len(data)) + for k, v := range data { + mapped[k] = v + } + return inspectTranslatorNode(mapped, path, isRoot) + case map[any]any: + if isMessageMap(data) { + return nil + } + type kv struct { + key string + val any + } + items := make([]kv, 0, len(data)) + for key, val := range data { + strKey, ok := key.(string) + if !ok { + return fmt.Errorf("%s uses a non-string key %#v", formatTranslationPath(path), key) + } + items = append(items, kv{key: strKey, val: val}) + } + sort.Slice(items, func(i, j int) bool { + return items[i].key < items[j].key + }) + for _, item := range items { + if err := inspectTranslatorNode(item.val, append(path, item.key), false); err != nil { + return err + } + } + return nil + case []any: + for idx, child := range data { + nextPath := append(path, fmt.Sprintf("[%d]", idx)) + if err := inspectTranslatorNode(child, nextPath, false); err != nil { + return err + } + } + return nil + case []map[string]any: + for idx, child := range data { + nextPath := append(path, fmt.Sprintf("[%d]", idx)) + if err := inspectTranslatorNode(child, nextPath, false); err != nil { + return err + } + } + return nil + default: + if isRoot { + return fmt.Errorf("root value must be an object but found %T", data) + } + return fmt.Errorf("%s contains unsupported value type %T", formatTranslationPath(path), data) + } +} + +var translatorReservedKeys = []string{ + "id", "description", "hash", "leftdelim", "rightdelim", + "zero", "one", "two", "few", "many", "other", +} + +func isMessageMap(data any) bool { + switch v := data.(type) { + case map[string]any: + for _, key := range translatorReservedKeys { + val, ok := v[key] + if !ok { + continue + } + if _, ok := val.(string); ok { + return true + } + } + case map[string]string: + for _, key := range translatorReservedKeys { + val, ok := v[key] + if !ok { + continue + } + if val != "" { + return true + } + } + case map[any]any: + for _, key := range translatorReservedKeys { + val, ok := v[key] + if !ok { + continue + } + if _, ok := val.(string); ok { + return true + } + } + } + return false +} + +func formatTranslationPath(path []string) string { + if len(path) == 0 { + return "root" + } + var b strings.Builder + for _, part := range path { + if part == "" { + continue + } + if part[0] == '[' { + b.WriteString(part) + continue + } + if b.Len() > 0 { + b.WriteByte('.') + } + b.WriteString(part) + } + return b.String() +} diff --git a/internal/install/install_main.go b/internal/install/install_main.go index 41ccdcf40..eae914af0 100644 --- a/internal/install/install_main.go +++ b/internal/install/install_main.go @@ -25,13 +25,20 @@ import ( "github.com/apache/answer/internal/base/path" "github.com/apache/answer/internal/base/translator" + "github.com/joho/godotenv" ) var ( - port = os.Getenv("INSTALL_PORT") + port string confPath = "" ) +func init() { + _ = godotenv.Load() + port = os.Getenv("INSTALL_PORT") + +} + func Run(configPath string) { confPath = configPath // initialize translator for return internationalization error when installing. diff --git a/ui/src/pages/SideNavLayout/index.tsx b/ui/src/pages/SideNavLayout/index.tsx index 907b9b281..b9bc38b9c 100644 --- a/ui/src/pages/SideNavLayout/index.tsx +++ b/ui/src/pages/SideNavLayout/index.tsx @@ -38,7 +38,7 @@ const Index: FC = () => { -
+