From e99f22488f1bbc7dbef0afb06d78ae10c0bb7fae Mon Sep 17 00:00:00 2001 From: Daniel Oaks Date: Mon, 22 Jan 2018 17:30:31 +1000 Subject: [PATCH] Make LANGUAGE support work --- irc/commands.go | 5 +++ irc/config.go | 79 ++++++++++++++++++++++++++++++++ irc/help.go | 5 +++ irc/languages.go | 61 ++++++++++++++++++++++--- irc/numerics.go | 4 ++ irc/server.go | 90 ++++++++++++++++++++++++++++++++++++- languages/example.lang.yaml | 13 +++--- oragono.yaml | 12 +++++ 8 files changed, 255 insertions(+), 14 deletions(-) diff --git a/irc/commands.go b/irc/commands.go index 532cd332..2ce64217 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -131,6 +131,11 @@ var Commands = map[string]Command{ minParams: 1, oper: true, }, + "LANGUAGE": { + handler: languageHandler, + usablePreReg: true, + minParams: 1, + }, "LIST": { handler: listHandler, minParams: 0, diff --git a/irc/config.go b/irc/config.go index d4c2cfde..2247ec44 100644 --- a/irc/config.go +++ b/irc/config.go @@ -11,6 +11,7 @@ import ( "fmt" "io/ioutil" "log" + "path/filepath" "strings" "time" @@ -143,6 +144,15 @@ type StackImpactConfig struct { AppName string `yaml:"app-name"` } +// LangData is the data contained in a language file. +type LangData struct { + Name string + Code string + Maintainers string + Incomplete bool + Translations map[string]string +} + // Config defines the overall configuration. type Config struct { Network struct { @@ -170,6 +180,8 @@ type Config struct { Languages struct { Enabled bool Path string + Default string + Data map[string]LangData } Datastore struct { @@ -470,5 +482,72 @@ func LoadConfig(filename string) (config *Config, err error) { return nil, fmt.Errorf("Could not parse maximum SendQ size (make sure it only contains whole numbers): %s", err.Error()) } + // get language files + config.Languages.Data = make(map[string]LangData) + if config.Languages.Enabled { + files, err := ioutil.ReadDir(config.Languages.Path) + if err != nil { + return nil, fmt.Errorf("Could not load language files: %s", err.Error()) + } + + for _, f := range files { + // skip dirs + if f.IsDir() { + continue + } + + // only load .lang.yaml files + name := f.Name() + if !strings.HasSuffix(strings.ToLower(name), ".lang.yaml") { + continue + } + + data, err = ioutil.ReadFile(filepath.Join(config.Languages.Path, name)) + if err != nil { + return nil, fmt.Errorf("Could not load language file [%s]: %s", name, err.Error()) + } + + var langInfo LangData + err = yaml.Unmarshal(data, &langInfo) + if err != nil { + return nil, fmt.Errorf("Could not parse language file [%s]: %s", name, err.Error()) + } + + // confirm that values are correct + if langInfo.Code == "en" { + return nil, fmt.Errorf("Cannot have language file with code 'en' (this is the default language using strings inside the server code). If you're making an English variant, name it with a more specific code") + } + + if langInfo.Code == "" || langInfo.Name == "" || langInfo.Maintainers == "" { + return nil, fmt.Errorf("Code, name or maintainers is empty in language file [%s]", name) + } + + if len(langInfo.Translations) == 0 { + return nil, fmt.Errorf("Language file [%s] contains no translations", name) + } + + // check for duplicate languages + _, exists := config.Languages.Data[strings.ToLower(langInfo.Code)] + if exists { + return nil, fmt.Errorf("Language code [%s] defined twice", langInfo.Code) + } + + // and insert into lang info + config.Languages.Data[strings.ToLower(langInfo.Code)] = langInfo + } + + // confirm that default language exists + if config.Languages.Default == "" { + config.Languages.Default = "en" + } else { + config.Languages.Default = strings.ToLower(config.Languages.Default) + } + + _, exists := config.Languages.Data[config.Languages.Default] + if config.Languages.Default != "en" && !exists { + return nil, fmt.Errorf("Cannot find default language [%s]", config.Languages.Default) + } + } + return config, nil } diff --git a/irc/help.go b/irc/help.go index 808d6c29..0b6f5b1d 100644 --- a/irc/help.go +++ b/irc/help.go @@ -249,6 +249,11 @@ ON specifies that the ban is to be set on that specific server. [reason] and [oper reason], if they exist, are separated by a vertical bar (|). If "KLINE LIST" is sent, the server sends back a list of our current KLINEs.`, + }, + "language": { + text: `LANGUAGE { } + +Sets your preferred languages to the given ones.`, }, "list": { text: `LIST [{,}] [{,}] diff --git a/irc/languages.go b/irc/languages.go index 67debcff..72a712bd 100644 --- a/irc/languages.go +++ b/irc/languages.go @@ -4,30 +4,72 @@ package irc import ( + "strings" "sync" ) // LanguageManager manages our languages and provides translation abilities. type LanguageManager struct { sync.RWMutex - langMap map[string]map[string]string + Info map[string]LangData + translations map[string]map[string]string } // NewLanguageManager returns a new LanguageManager. -func NewLanguageManager() *LanguageManager { +func NewLanguageManager(languageData map[string]LangData) *LanguageManager { lm := LanguageManager{ - langMap: make(map[string]map[string]string), + Info: make(map[string]LangData), + translations: make(map[string]map[string]string), } - //TODO(dan): load language files here + // make fake "en" info + lm.Info["en"] = LangData{ + Code: "en", + Name: "English", + Maintainers: "Oragono contributors and the IRC community", + } + + // load language data + for name, data := range languageData { + lm.Info[name] = data + lm.translations[name] = data.Translations + } return &lm } +// Count returns how many languages we have. +func (lm *LanguageManager) Count() int { + lm.RLock() + defer lm.RUnlock() + + return len(lm.Info) +} + +// Codes returns the proper language codes for the given casefolded language codes. +func (lm *LanguageManager) Codes(codes []string) []string { + lm.RLock() + defer lm.RUnlock() + + var newCodes []string + for _, code := range codes { + info, exists := lm.Info[code] + if exists { + newCodes = append(newCodes, info.Code) + } + } + + if len(newCodes) == 0 { + newCodes = []string{"en"} + } + + return newCodes +} + // Translate returns the given string, translated into the given language. func (lm *LanguageManager) Translate(languages []string, originalString string) string { // not using any special languages - if len(languages) == 0 { + if len(languages) == 0 || languages[0] == "en" || len(lm.translations) == 0 { return originalString } @@ -35,12 +77,17 @@ func (lm *LanguageManager) Translate(languages []string, originalString string) defer lm.RUnlock() for _, lang := range languages { - langMap, exists := lm.langMap[lang] + lang = strings.ToLower(lang) + if lang == "en" { + return originalString + } + + translations, exists := lm.translations[lang] if !exists { continue } - newString, exists := langMap[originalString] + newString, exists := translations[originalString] if !exists { continue } diff --git a/irc/numerics.go b/irc/numerics.go index 9265fcf1..60c622df 100644 --- a/irc/numerics.go +++ b/irc/numerics.go @@ -161,6 +161,8 @@ const ( ERR_HELPNOTFOUND = "524" ERR_CANNOTSENDRP = "573" RPL_WHOISSECURE = "671" + RPL_YOURLANGUAGESARE = "687" + RPL_WHOISLANGUAGE = "690" RPL_HELPSTART = "704" RPL_HELPTXT = "705" RPL_ENDOFHELP = "706" @@ -188,4 +190,6 @@ const ( RPL_REG_VERIFICATION_REQUIRED = "927" ERR_REG_INVALID_CRED_TYPE = "928" ERR_REG_INVALID_CALLBACK = "929" + ERR_TOOMANYLANGUAGES = "981" + ERR_NOLANGUAGE = "982" ) diff --git a/irc/server.go b/irc/server.go index bb927413..ea1639c5 100644 --- a/irc/server.go +++ b/irc/server.go @@ -154,7 +154,7 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) { commands: make(chan Command), connectionLimiter: connection_limits.NewLimiter(), connectionThrottler: connection_limits.NewThrottler(), - languages: NewLanguageManager(), + languages: NewLanguageManager(config.Languages.Data), listeners: make(map[string]*ListenerWrapper), logger: logger, monitorManager: NewMonitorManager(), @@ -984,6 +984,9 @@ func whoisHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { } func (client *Client) getWhoisOf(target *Client) { + target.stateMutex.RLock() + defer target.stateMutex.RUnlock() + client.Send(nil, client.server.name, RPL_WHOISUSER, client.nick, target.nick, target.username, target.hostname, "*", target.realname) whoischannels := client.WhoisChannelsNames(target) @@ -1002,6 +1005,16 @@ func (client *Client) getWhoisOf(target *Client) { if target.flags[Bot] { client.Send(nil, client.server.name, RPL_WHOISBOT, client.nick, target.nick, ircfmt.Unescape("is a $bBot$b on ")+client.server.networkName) } + + if 0 < len(target.languages) { + params := []string{client.nick, target.nick} + for _, str := range client.server.languages.Codes(target.languages) { + params = append(params, str) + } + params = append(params, "can speak these languages") + client.Send(nil, client.server.name, RPL_WHOISLANGUAGE, params...) + } + if target.certfp != "" && (client.flags[Operator] || client == target) { client.Send(nil, client.server.name, RPL_WHOISCERTFP, client.nick, target.nick, fmt.Sprintf("has client certificate fingerprint %s", target.certfp)) } @@ -1237,6 +1250,25 @@ func (server *Server) applyConfig(config *Config, initial bool) error { removedCaps := caps.NewSet() updatedCaps := caps.NewSet() + // Translations + currentLanguageValue, _ := CapValues.Get(caps.Languages) + + langCodes := []string{strconv.Itoa(len(config.Languages.Data) + 1), "en"} + for _, info := range config.Languages.Data { + if info.Incomplete { + langCodes = append(langCodes, "~"+info.Code) + } else { + langCodes = append(langCodes, info.Code) + } + } + newLanguageValue := strings.Join(langCodes, ",") + server.logger.Debug("rehash", "Languages:", newLanguageValue) + + if currentLanguageValue != newLanguageValue { + updatedCaps.Add(caps.Languages) + CapValues.Set(caps.Languages, newLanguageValue) + } + // SASL if config.Accounts.AuthenticationEnabled && !server.accountAuthenticationEnabled { // enabling SASL @@ -2077,6 +2109,62 @@ func userhostHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool return false } +// LANGUAGE { } +func languageHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { + alreadyDoneLanguages := make(map[string]bool) + var appliedLanguages []string + + supportedLanguagesCount := server.languages.Count() + if supportedLanguagesCount < len(msg.Params) { + client.Send(nil, client.server.name, ERR_TOOMANYLANGUAGES, client.nick, strconv.Itoa(supportedLanguagesCount), "You specified too many languages") + return false + } + + for _, value := range msg.Params { + value = strings.ToLower(value) + // strip ~ from the language if it has it + value = strings.TrimPrefix(value, "~") + + // silently ignore empty languages or those with spaces in them + if len(value) == 0 || strings.Contains(value, " ") { + continue + } + + _, exists := server.languages.Info[value] + if !exists { + client.Send(nil, client.server.name, ERR_NOLANGUAGE, client.nick, "Languages are not supported by this server") + return false + } + + // if we've already applied the given language, skip it + _, exists = alreadyDoneLanguages[value] + if exists { + continue + } + + appliedLanguages = append(appliedLanguages, value) + } + + client.stateMutex.Lock() + if len(appliedLanguages) == 1 && appliedLanguages[0] == "en" { + // premature optimisation ahoy! + client.languages = []string{} + } else { + client.languages = appliedLanguages + } + client.stateMutex.Unlock() + + params := []string{client.nick} + for _, lang := range appliedLanguages { + params = append(params, lang) + } + params = append(params, client.t("Language preferences have been set")) + + client.Send(nil, client.server.name, RPL_YOURLANGUAGESARE, params...) + + return false +} + var ( infoString = strings.Split(` ▄▄▄ ▄▄▄· ▄▄ • ▐ ▄ ▪ ▀▄ █·▐█ ▀█ ▐█ ▀ ▪▪ •█▌▐█▪ diff --git a/languages/example.lang.yaml b/languages/example.lang.yaml index 2c8424ce..ab0fd61c 100644 --- a/languages/example.lang.yaml +++ b/languages/example.lang.yaml @@ -13,9 +13,10 @@ maintainers: "Daniel Oaks " # incomplete - whether to mark this language as incomplete incomplete: true -# strings - this holds the actual replacements -# make sure this is the last part of the file, and that the below string, "strings:", stays as-is -# the language-update processor uses the next line to designate which part of the file to ignore and -# which part to actually process. -strings: - "Welcome to the Internet Relay Network %s": "Welcome bro to the IRN broski %s" +# translations - this holds the actual replacements +# make sure this is the last part of the file, and that the below string, "translations:", +# stays as-is. the language-update processor uses the next line to designate which part of +# the file to ignore and which part to actually process. +translations: + "Welcome to the Internet Relay Network %s": "Welcome braaaah to the IRN broski %s" + "Language preferences have been set": "You've set your languages man, wicked!" diff --git a/oragono.yaml b/oragono.yaml index 0d72a0b3..1347bf13 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -291,6 +291,18 @@ datastore: # path to the datastore path: ircd.db +# languages config +languages: + # whether to load languages + enabled: true + + # default language to use for new clients + # 'en' is the default English language in the code + default: en + + # which directory contains our language files + path: languages + # limits - these need to be the same across the network limits: # nicklen is the max nick length allowed