diff --git a/.drone.yml b/.drone.yml index 1233aadb9..dd1d8014a 100644 --- a/.drone.yml +++ b/.drone.yml @@ -149,6 +149,18 @@ steps: when: event: [ push, tag, pull_request ] + - name: api-check-translations + image: vikunja/golang-build:latest + pull: always + environment: + GOPROXY: 'https://goproxy.kolaente.de' + depends_on: [ mage ] + commands: + - export "GOROOT=$(go env GOROOT)" + - ./mage-static check:translations + when: + event: [ push, tag, pull_request ] + - name: test-migration-prepare image: kolaente/toolbox:latest pull: always @@ -1063,7 +1075,7 @@ trigger: - update_translations steps: - - name: download + - name: download-frontend pull: always image: ghcr.io/kolaente/kolaente/drone-crowdin-v2:latest settings: @@ -1073,14 +1085,26 @@ steps: target: download download_to: frontend/src/i18n/lang/ download_export_approved_only: true + + - name: download-api + pull: always + image: ghcr.io/kolaente/kolaente/drone-crowdin-v2:latest + settings: + crowdin_key: + from_secret: crowdin_key + project_id: 462614 + target: download + download_to: pkg/i18n/lang/ + download_export_approved_only: true - name: move-files pull: always image: bash depends_on: - - download + - download-frontend commands: - mv frontend/src/i18n/lang/*/*.json frontend/src/i18n/lang + - mv pkg/i18n/lang/*/*.json pkg/i18n/lang - name: push pull: always @@ -1109,6 +1133,7 @@ steps: target: upload upload_files: frontend/src/i18n/lang/en.json: en.json + pkg/i18n/lang/en.json: en-api.json --- kind: pipeline @@ -1189,6 +1214,6 @@ steps: --- kind: signature -hmac: df9858e2b37dfddccd4892f007bbe69a61321352e94ebcf6adf82fa6560665bb +hmac: bf9f28a874dae9397c45062a2e1c8839ab9624b2cf265838577e1baad7fe3d03 ... diff --git a/magefile.go b/magefile.go index cdd60fa82..7341d24e0 100644 --- a/magefile.go +++ b/magefile.go @@ -31,6 +31,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "runtime" "strconv" "strings" @@ -418,6 +419,158 @@ func (Check) GotSwag() { } } +// Checks if all translation keys used in the code exist in the English translation file +func (Check) Translations() { + mg.Deps(initVars) + fmt.Println("Checking for missing translation keys...") + + // Load translations from the English translation file + translationFile := RootPath + "/pkg/i18n/lang/en.json" + translations, err := loadTranslations(translationFile) + if err != nil { + fmt.Printf("Error loading translations: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Loaded %d translation keys from %s\n", len(translations), translationFile) + + // Extract keys from codebase + keys, err := walkCodebaseForTranslationKeys(RootPath) + if err != nil { + fmt.Printf("Error walking codebase: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Found %d translation keys in the codebase\n", len(keys)) + + // Check for missing keys + missingKeys := make(map[string][]TranslationKey) + for _, key := range keys { + if !translations[key.Key] { + missingKeys[key.Key] = append(missingKeys[key.Key], key) + } + } + + // Print results + if len(missingKeys) > 0 { + fmt.Printf("\nFound %d missing translation keys:\n", len(missingKeys)) + for key, occurrences := range missingKeys { + fmt.Printf("\nKey: %s\n", key) + for _, occurrence := range occurrences { + fmt.Printf(" - %s:%d\n", occurrence.FilePath, occurrence.Line) + } + } + os.Exit(1) + } else { + printSuccess("All translation keys are present in the translation file!") + } +} + +// TranslationKey represents a translation key found in the code +type TranslationKey struct { + Key string + FilePath string + Line int +} + +// loadTranslations loads the English translation file and returns a flattened map +func loadTranslations(filePath string) (map[string]bool, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("error reading translation file: %v", err) + } + + var translationsMap map[string]interface{} + if err := json.Unmarshal(data, &translationsMap); err != nil { + return nil, fmt.Errorf("error parsing JSON: %v", err) + } + + // Flatten the nested structure + flattenedMap := make(map[string]bool) + flattenTranslations("", translationsMap, flattenedMap) + + return flattenedMap, nil +} + +// flattenTranslations recursively flattens a nested map structure into a flat map with dot-separated keys +func flattenTranslations(prefix string, src map[string]interface{}, dest map[string]bool) { + for k, v := range src { + key := k + if prefix != "" { + key = prefix + "." + k + } + + switch vv := v.(type) { + case string: + dest[key] = true + case map[string]interface{}: + flattenTranslations(key, vv, dest) + } + } +} + +// walkCodebaseForTranslationKeys walks the codebase and extracts all translation keys +func walkCodebaseForTranslationKeys(rootDir string) ([]TranslationKey, error) { + var allKeys []TranslationKey + + err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip non-Go files, vendor directory, and the contrib directory + if !info.IsDir() && strings.HasSuffix(path, ".go") && + !strings.Contains(path, "/vendor/") && + !strings.Contains(path, "/contrib/") { + keys, err := extractTranslationKeysFromFile(path) + if err != nil { + fmt.Printf("Warning: %v\n", err) + return nil + } + allKeys = append(allKeys, keys...) + } + + return nil + }) + + return allKeys, err +} + +// extractTranslationKeysFromFile extracts all i18n.T and i18n.TWithParams calls from a file +func extractTranslationKeysFromFile(filePath string) ([]TranslationKey, error) { + // Read the file content + content, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("error reading file %s: %v", filePath, err) + } + + var keys []TranslationKey + + // Regex to match i18n.T and i18n.TWithParams calls + re := regexp.MustCompile(`i18n\.(T|TWithParams)\([^,]+,\s*"([^"]+)"`) + matches := re.FindAllSubmatchIndex(content, -1) + + for _, match := range matches { + if len(match) >= 4 { + // Extract the key from the match + keyStart, keyEnd := match[4], match[5] + key := string(content[keyStart:keyEnd]) + + // Count lines to determine the line number + beforeMatch := content[:keyStart] + lineCount := bytes.Count(beforeMatch, []byte("\n")) + 1 + + keys = append(keys, TranslationKey{ + Key: key, + FilePath: filePath, + Line: lineCount, + }) + } + } + + return keys, nil +} + func checkGolangCiLintInstalled() { mg.Deps(initVars) if err := exec.Command("golangci-lint").Run(); err != nil && strings.Contains(err.Error(), "executable file not found") { @@ -443,6 +596,7 @@ func (Check) All() { mg.Deps( Check.Golangci, Check.GotSwag, + Check.Translations, ) } diff --git a/pkg/cmd/testmail.go b/pkg/cmd/testmail.go index 6f660a106..0cf2ad2eb 100644 --- a/pkg/cmd/testmail.go +++ b/pkg/cmd/testmail.go @@ -51,7 +51,7 @@ var testmailCmd = &cobra.Command{ Line("If you received this, Vikunja is correctly set up to send emails."). Action("Go to your instance", config.ServicePublicURL.GetString()) - opts, err := notifications.RenderMail(message) + opts, err := notifications.RenderMail(message, "en") if err != nil { log.Errorf("Error rendering test mail: %s", err.Error()) return diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go new file mode 100644 index 000000000..de78f79f7 --- /dev/null +++ b/pkg/i18n/i18n.go @@ -0,0 +1,173 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package i18n + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "path/filepath" + "strings" + "sync" + + "code.vikunja.io/api/pkg/log" +) + +//go:embed lang/*.json +var localeFS embed.FS + +// TranslationStore represents a collection of translation entries +type TranslationStore map[string]string + +// Translator manages translations for different languages +type Translator struct { + translations map[string]TranslationStore // language code -> flattened key-value pairs + fallbackLang string + mu sync.RWMutex +} + +// singleton instance +var translator = &Translator{ + translations: make(map[string]TranslationStore), + fallbackLang: "en", +} + +// Init initializes the global translator with translation files +func Init() { + dir := "lang" + entries, err := fs.ReadDir(localeFS, dir) + if err != nil { + log.Fatalf("Failed to read embedded translation directory: %v", err) + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + langCode := strings.TrimSuffix(entry.Name(), ".json") + filePath := filepath.Join(dir, entry.Name()) + + err = translator.loadFile(localeFS, langCode, filePath) + if err != nil { + log.Fatalf("Failed to load translation file %s: %v", filePath, err) + } + } +} + +// loadFile loads a translation file for the specified language from the embedded filesystem +func (t *Translator) loadFile(fs embed.FS, langCode, filePath string) error { + data, err := fs.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + var nestedData map[string]interface{} + if err := json.Unmarshal(data, &nestedData); err != nil { + return fmt.Errorf("failed to parse JSON: %w", err) + } + + t.mu.Lock() + // Create or get the flattened map for this language + if _, exists := t.translations[langCode]; !exists { + t.translations[langCode] = make(TranslationStore) + } + + // Flatten the nested structure + t.flattenTranslations(langCode, nestedData, "") + t.mu.Unlock() + + return nil +} + +// flattenTranslations recursively flattens the nested translation structure +func (t *Translator) flattenTranslations(langCode string, data map[string]interface{}, prefix string) { + for key, value := range data { + // Build the full key path + fullKey := key + if prefix != "" { + fullKey = prefix + "." + key + } + + // If value is a string, add it to the flattened map + if strValue, ok := value.(string); ok { + t.translations[langCode][fullKey] = strValue + } else if mapValue, ok := value.(map[string]interface{}); ok { + // If value is another map, recurse with the updated prefix + t.flattenTranslations(langCode, mapValue, fullKey) + } + } +} + +// GetAvailableLanguages returns a list of available language codes +func GetAvailableLanguages() []string { + translator.mu.RLock() + defer translator.mu.RUnlock() + + languages := make([]string, 0, len(translator.translations)) + for lang := range translator.translations { + languages = append(languages, lang) + } + + return languages +} + +// T returns the translation for the specified key using dot notation in the specified language +func T(lang, key string) string { + return TWithParams(lang, key) +} + +// TWithParams returns the translation with parameter substitution in the specified language +func TWithParams(lang, key string, params ...string) string { + translator.mu.RLock() + defer translator.mu.RUnlock() + + // Try requested language + if langMap, exists := translator.translations[lang]; exists { + if translation, found := langMap[key]; found { + if len(params) > 0 { + return fmt.Sprintf(translation, stringSliceToInterfaceSlice(params)...) + } + return translation + } + } + + // Try fallback language if different from requested + if translator.fallbackLang != lang { + if langMap, exists := translator.translations[translator.fallbackLang]; exists { + if translation, found := langMap[key]; found { + if len(params) > 0 { + return fmt.Sprintf(translation, stringSliceToInterfaceSlice(params)...) + } + return translation + } + } + } + + // Return the key if no translation found + return key +} + +// stringSliceToInterfaceSlice converts a string slice to an interface slice for fmt.Sprintf +func stringSliceToInterfaceSlice(strings []string) []interface{} { + interfaces := make([]interface{}, len(strings)) + for i, s := range strings { + interfaces[i] = s + } + return interfaces +} diff --git a/pkg/i18n/lang/en.json b/pkg/i18n/lang/en.json new file mode 100644 index 000000000..5cefcea98 --- /dev/null +++ b/pkg/i18n/lang/en.json @@ -0,0 +1,126 @@ +{ + "notifications": { + "greeting": "Hi %[1]s,", + "email_confirm": { + "subject": "%[1]s, please confirm your email address at Vikunja", + "subject_new": "%[1]s + Vikunja = <3", + "welcome": "Welcome to Vikunja!", + "confirm": "To confirm your email address, click the link below:" + }, + "password": { + "changed": { + "subject": "Your Password on Vikunja was changed", + "success": "Your account password was successfully changed.", + "warning": "If this wasn't you, it could mean someone compromised your account. In this case contact your server's administrator." + }, + "reset": { + "subject": "Reset your password on Vikunja", + "instructions": "To reset your password, click the link below:", + "valid_duration": "This link will be valid for 24 hours." + } + }, + "totp": { + "invalid": { + "subject": "Someone just tried to login to your Vikunja account, but failed", + "message": "Someone just tried to log in into your account with correct username and password but a wrong TOTP passcode.", + "warning": "**If this was not you, someone else knows your password. You should set a new one immediately!**" + }, + "account_locked": { + "subject": "We've disabled your account on Vikunja", + "message": "Someone tried to log in with your credentials but failed to provide a valid TOTP passcode.", + "disabled": "After 10 failed attempts, we've disabled your account and reset your password. To set a new one, follow the instructions in the reset email we just sent you.", + "reset_instructions": "If you did not receive an email with reset instructions, you can always request a new one at [%[1]s](%[2]s)." + } + }, + "login": { + "failed": { + "subject": "Someone just tried to login to your Vikunja account, but failed to provide a correct password", + "message": "Someone just tried to log in into your account with a wrong password three times in a row.", + "warning": "If this was not you, this could be someone else trying to break into your account.", + "enhance_security": "To enhance the security of you account you may want to set a stronger password or enable TOTP authentication in the settings:" + } + }, + "account": { + "deletion": { + "confirm": { + "subject": "Please confirm the deletion of your Vikunja account", + "request": "You have requested the deletion of your account. To confirm this, please click the link below:", + "valid_duration": "This link will be valid for 24 hours.", + "schedule_info": "Once you confirm the deletion we will schedule the deletion of your account in three days and send you another email until then.", + "consequences": "If you proceed with the deletion of your account, we will remove all of your projects and tasks you created. Everything you shared with another user or team will transfer ownership to them.", + "changed_mind": "If you did not requested the deletion or changed your mind, you can simply ignore this email." + }, + "scheduled": { + "subject_days": "Your Vikunja account will be deleted in %[1]s days", + "subject_tomorrow": "Your Vikunja account will be deleted tomorrow", + "request_reminder": "You recently requested the deletion of your Vikunja account.", + "deletion_time_days": "We will delete your account in %[1]s days.", + "deletion_time_tomorrow": "We will delete your account tomorrow.", + "changed_mind": "If you changed your mind, simply click the link below to cancel the deletion and follow the instructions there:" + }, + "completed": { + "subject": "Your Vikunja Account has been deleted", + "confirmation": "As requested, we've deleted your Vikunja account.", + "permanent": "This deletion is permanent. If did not create a backup and need your data back now, talk to your administrator." + } + } + }, + "task": { + "reminder": { + "subject": "Reminder for \"%[1]s\" (%[2]s)", + "message": "This is a friendly reminder of the task \"%[1]s\" (%[2]s)." + }, + "comment": { + "subject": "Re: %[1]s", + "mentioned_subject": "%[1]s mentioned you in a comment in \"%[2]s\"", + "mentioned_message": "**%[1]s** mentioned you in a comment:" + }, + "assigned": { + "subject_to_assignee": "You have been assigned to %[1]s (%[2]s)", + "message_to_assignee": "%[1]s has assigned you to %[2]s.", + "subject_to_others": "%[1]s(%[2]s) has been assigned to %[3]s", + "message_to_others": "%[1]s has assigned this task to %[2]s." + }, + "deleted": { + "subject": "%[1]s (%[2]s) has been deleted", + "message": "%[1]s has deleted the task %[2]s (%[3]s)" + }, + "mentioned": { + "subject_new": "%[1]s mentioned you in a new task \"%[2]s\"", + "subject": "%[1]s mentioned you in a task \"%[2]s\"", + "message": "**%[1]s** mentioned you in a task:" + }, + "overdue": { + "subject": "Task \"%[1]s\" (%[2]s) is overdue", + "message": "This is a friendly reminder of the task \"%[1]s\" (%[2]s) which is %[3]s and not yet done.", + "multiple_subject": "Your overdue tasks", + "multiple_message": "You have the following overdue tasks:", + "overdue_since": "overdue since %[1]s", + "overdue_now": "overdue now" + } + }, + "project": { + "created": { + "subject": "%[1]s created the project \"%[2]s\"", + "message": "%[1]s created the project \"%[2]s\"" + } + }, + "team": { + "member_added": { + "subject": "%[1]s added you to the %[2]s team in Vikunja", + "message": "%[1]s has just added you to the %[2]s team in Vikunja." + } + }, + "data_export": { + "ready": { + "subject": "Your Vikunja Data Export is ready", + "message": "Your Vikunja Data Export is ready for you to download. Click the button below to download it:", + "availability": "The download will be available for the next 7 days." + } + }, + "common": { + "have_nice_day": "Have a nice day!", + "copy_url": "If the button above doesn't work, copy the url below and paste it in your browser's address bar:" + } + } +} \ No newline at end of file diff --git a/pkg/initialize/init.go b/pkg/initialize/init.go index 506e5bce6..e622201a3 100644 --- a/pkg/initialize/init.go +++ b/pkg/initialize/init.go @@ -23,6 +23,7 @@ import ( "code.vikunja.io/api/pkg/cron" "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/i18n" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/mail" "code.vikunja.io/api/pkg/migration" @@ -35,7 +36,7 @@ import ( "code.vikunja.io/api/pkg/user" ) -// LightInit will only fullInit config, redis, logger but no db connection. +// LightInit will only init config, redis, logger but no db connection. func LightInit() { // Set logger log.InitLogger() @@ -83,6 +84,9 @@ func FullInitWithoutAsync() { // Connect to ldap if enabled ldap.InitializeLDAPConnection() + + // Load translations + i18n.Init() } // FullInit initializes all kinds of things in the right order diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go index 96442e59c..7049b9812 100644 --- a/pkg/models/notifications.go +++ b/pkg/models/notifications.go @@ -22,6 +22,7 @@ import ( "time" "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/i18n" "code.vikunja.io/api/pkg/notifications" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/utils" @@ -39,11 +40,11 @@ func (n *ReminderDueNotification) ToMail() *notifications.Mail { return notifications.NewMail(). IncludeLinkToSettings(). To(n.User.Email). - Subject(`Reminder for "`+n.Task.Title+`" (`+n.Project.Title+`)`). - Greeting("Hi "+n.User.GetName()+","). - Line(`This is a friendly reminder of the task "`+n.Task.Title+`" (`+n.Project.Title+`).`). + Subject(i18n.TWithParams(n.User.Language, "notifications.task.reminder.subject", n.Task.Title, n.Project.Title)). + Greeting(i18n.TWithParams(n.User.Language, "notifications.greeting", n.User.GetName())). + Line(i18n.TWithParams(n.User.Language, "notifications.task.reminder.message", n.Task.Title, n.Project.Title)). Action("Open Task", config.ServicePublicURL.GetString()+"tasks/"+strconv.FormatInt(n.Task.ID, 10)). - Line("Have a nice day!") + Line(i18n.T(n.User.Language, "notifications.common.have_nice_day")) } // ToDB returns the ReminderDueNotification notification in a format which can be saved in the db @@ -65,6 +66,7 @@ type TaskCommentNotification struct { Task *Task `json:"task"` Comment *TaskComment `json:"comment"` Mentioned bool `json:"mentioned"` + User *user.User `json:"-"` // Target user } func (n *TaskCommentNotification) SubjectID() int64 { @@ -76,12 +78,12 @@ func (n *TaskCommentNotification) ToMail() *notifications.Mail { mail := notifications.NewMail(). From(n.Doer.GetNameAndFromEmail()). - Subject("Re: " + n.Task.Title) + Subject(i18n.TWithParams(n.User.Language, "notifications.task.comment.subject", n.Task.Title)) if n.Mentioned { mail. - Line("**" + n.Doer.GetName() + "** mentioned you in a comment:"). - Subject(n.Doer.GetName() + ` mentioned you in a comment in "` + n.Task.Title + `"`) + Line(i18n.TWithParams(n.User.Language, "notifications.task.comment.mentioned_message", n.Doer.GetName())). + Subject(i18n.TWithParams(n.User.Language, "notifications.task.comment.mentioned_subject", n.Doer.GetName(), n.Task.Title)) } mail.HTML(n.Comment.Comment) @@ -112,14 +114,14 @@ type TaskAssignedNotification struct { func (n *TaskAssignedNotification) ToMail() *notifications.Mail { if n.Target.ID == n.Assignee.ID { return notifications.NewMail(). - Subject("You have been assigned to "+n.Task.Title+"("+n.Task.GetFullIdentifier()+")"). - Line(n.Doer.GetName()+" has assigned you to "+n.Task.Title+"."). + Subject(i18n.TWithParams(n.Target.Language, "notifications.task.assigned.subject_to_assignee", n.Task.Title, n.Task.GetFullIdentifier())). + Line(i18n.TWithParams(n.Target.Language, "notifications.task.assigned.message_to_assignee", n.Doer.GetName(), n.Task.Title)). Action("View Task", n.Task.GetFrontendURL()) } return notifications.NewMail(). - Subject(n.Task.Title+"("+n.Task.GetFullIdentifier()+")"+" has been assigned to "+n.Assignee.GetName()). - Line(n.Doer.GetName()+" has assigned this task to "+n.Assignee.GetName()+"."). + Subject(i18n.TWithParams(n.Target.Language, "notifications.task.assigned.subject_to_others", n.Task.Title, n.Task.GetFullIdentifier(), n.Assignee.GetName())). + Line(i18n.TWithParams(n.Target.Language, "notifications.task.assigned.message_to_others", n.Doer.GetName(), n.Assignee.GetName())). Action("View Task", n.Task.GetFrontendURL()) } @@ -137,13 +139,14 @@ func (n *TaskAssignedNotification) Name() string { type TaskDeletedNotification struct { Doer *user.User `json:"doer"` Task *Task `json:"task"` + User *user.User `json:"-"` // Target user } // ToMail returns the mail notification for TaskDeletedNotification func (n *TaskDeletedNotification) ToMail() *notifications.Mail { return notifications.NewMail(). - Subject(n.Task.Title + " (" + n.Task.GetFullIdentifier() + ")" + " has been deleted"). - Line(n.Doer.GetName() + " has deleted the task " + n.Task.Title + " (" + n.Task.GetFullIdentifier() + ")") + Subject(i18n.TWithParams(n.User.Language, "notifications.task.deleted.subject", n.Task.Title, n.Task.GetFullIdentifier())). + Line(i18n.TWithParams(n.User.Language, "notifications.task.deleted.message", n.Doer.GetName(), n.Task.Title, n.Task.GetFullIdentifier())) } // ToDB returns the TaskDeletedNotification notification in a format which can be saved in the db @@ -160,13 +163,14 @@ func (n *TaskDeletedNotification) Name() string { type ProjectCreatedNotification struct { Doer *user.User `json:"doer"` Project *Project `json:"project"` + User *user.User `json:"-"` // Target user } // ToMail returns the mail notification for ProjectCreatedNotification func (n *ProjectCreatedNotification) ToMail() *notifications.Mail { return notifications.NewMail(). - Subject(n.Doer.GetName()+` created the project "`+n.Project.Title+`"`). - Line(n.Doer.GetName()+` created the project "`+n.Project.Title+`"`). + Subject(i18n.TWithParams(n.User.Language, "notifications.project.created.subject", n.Doer.GetName(), n.Project.Title)). + Line(i18n.TWithParams(n.User.Language, "notifications.project.created.message", n.Doer.GetName(), n.Project.Title)). Action("View Project", config.ServicePublicURL.GetString()+"projects/") } @@ -190,10 +194,10 @@ type TeamMemberAddedNotification struct { // ToMail returns the mail notification for TeamMemberAddedNotification func (n *TeamMemberAddedNotification) ToMail() *notifications.Mail { return notifications.NewMail(). - Subject(n.Doer.GetName()+" added you to the "+n.Team.Name+" team in Vikunja"). + Subject(i18n.TWithParams(n.Member.Language, "notifications.team.member_added.subject", n.Doer.GetName(), n.Team.Name)). From(n.Doer.GetNameAndFromEmail()). - Greeting("Hi "+n.Member.GetName()+","). - Line(n.Doer.GetName()+" has just added you to the "+n.Team.Name+" team in Vikunja."). + Greeting(i18n.TWithParams(n.Member.Language, "notifications.greeting", n.Member.GetName())). + Line(i18n.TWithParams(n.Member.Language, "notifications.team.member_added.message", n.Doer.GetName(), n.Team.Name)). Action("View Team", config.ServicePublicURL.GetString()+"teams/"+strconv.FormatInt(n.Team.ID, 10)+"/edit") } @@ -207,10 +211,10 @@ func (n *TeamMemberAddedNotification) Name() string { return "team.member.added" } -func getOverdueSinceString(until time.Duration) (overdueSince string) { - overdueSince = `overdue since ` + utils.HumanizeDuration(until) +func getOverdueSinceString(until time.Duration, language string) (overdueSince string) { + overdueSince = i18n.TWithParams(language, "notifications.task.overdue.overdue_since", utils.HumanizeDuration(until)) if until == 0 { - overdueSince = `overdue now` + overdueSince = i18n.T(language, "notifications.task.overdue.overdue_now") } return @@ -228,11 +232,11 @@ func (n *UndoneTaskOverdueNotification) ToMail() *notifications.Mail { until := time.Until(n.Task.DueDate).Round(1*time.Hour) * -1 return notifications.NewMail(). IncludeLinkToSettings(). - Subject(`Task "`+n.Task.Title+`" (`+n.Project.Title+`) is overdue`). - Greeting("Hi "+n.User.GetName()+","). - Line(`This is a friendly reminder of the task "`+n.Task.Title+`" (`+n.Project.Title+`) which is `+getOverdueSinceString(until)+` and not yet done.`). + Subject(i18n.TWithParams(n.User.Language, "notifications.task.overdue.subject", n.Task.Title, n.Project.Title)). + Greeting(i18n.TWithParams(n.User.Language, "notifications.greeting", n.User.GetName())). + Line(i18n.TWithParams(n.User.Language, "notifications.task.overdue.message", n.Task.Title, n.Project.Title, getOverdueSinceString(until, n.User.Language))). Action("Open Task", config.ServicePublicURL.GetString()+"tasks/"+strconv.FormatInt(n.Task.ID, 10)). - Line("Have a nice day!") + Line(i18n.T(n.User.Language, "notifications.common.have_nice_day")) } // ToDB returns the UndoneTaskOverdueNotification notification in a format which can be saved in the db @@ -267,17 +271,17 @@ func (n *UndoneTasksOverdueNotification) ToMail() *notifications.Mail { overdueLine := "" for _, task := range sortedTasks { until := time.Until(task.DueDate).Round(1*time.Hour) * -1 - overdueLine += `* [` + task.Title + `](` + config.ServicePublicURL.GetString() + "tasks/" + strconv.FormatInt(task.ID, 10) + `) (` + n.Projects[task.ProjectID].Title + `), ` + getOverdueSinceString(until) + "\n" + overdueLine += `* [` + task.Title + `](` + config.ServicePublicURL.GetString() + "tasks/" + strconv.FormatInt(task.ID, 10) + `) (` + n.Projects[task.ProjectID].Title + `), ` + getOverdueSinceString(until, n.User.Language) + "\n" } return notifications.NewMail(). IncludeLinkToSettings(). - Subject(`Your overdue tasks`). - Greeting("Hi "+n.User.GetName()+","). - Line("You have the following overdue tasks:"). + Subject(i18n.T(n.User.Language, "notifications.task.overdue.multiple_subject")). + Greeting(i18n.TWithParams(n.User.Language, "notifications.greeting", n.User.GetName())). + Line(i18n.T(n.User.Language, "notifications.task.overdue.multiple_message")). Line(overdueLine). Action("Open Vikunja", config.ServicePublicURL.GetString()). - Line("Have a nice day!") + Line(i18n.T(n.User.Language, "notifications.common.have_nice_day")) } // ToDB returns the UndoneTasksOverdueNotification notification in a format which can be saved in the db @@ -295,6 +299,7 @@ type UserMentionedInTaskNotification struct { Doer *user.User `json:"doer"` Task *Task `json:"task"` IsNew bool `json:"is_new"` + User *user.User `json:"-"` // Target user } func (n *UserMentionedInTaskNotification) SubjectID() int64 { @@ -303,15 +308,17 @@ func (n *UserMentionedInTaskNotification) SubjectID() int64 { // ToMail returns the mail notification for UserMentionedInTaskNotification func (n *UserMentionedInTaskNotification) ToMail() *notifications.Mail { - subject := n.Doer.GetName() + ` mentioned you in a new task "` + n.Task.Title + `"` + var subject string if n.IsNew { - subject = n.Doer.GetName() + ` mentioned you in a task "` + n.Task.Title + `"` + subject = i18n.TWithParams(n.User.Language, "notifications.task.mentioned.subject_new", n.Doer.GetName(), n.Task.Title) + } else { + subject = i18n.TWithParams(n.User.Language, "notifications.task.mentioned.subject", n.Doer.GetName(), n.Task.Title) } mail := notifications.NewMail(). From(n.Doer.GetNameAndFromEmail()). Subject(subject). - Line("**" + n.Doer.GetName() + "** mentioned you in a task:"). + Line(i18n.TWithParams(n.User.Language, "notifications.task.mentioned.message", n.Doer.GetName())). HTML(n.Task.Description) return mail. @@ -336,12 +343,12 @@ type DataExportReadyNotification struct { // ToMail returns the mail notification for DataExportReadyNotification func (n *DataExportReadyNotification) ToMail() *notifications.Mail { return notifications.NewMail(). - Subject("Your Vikunja Data Export is ready"). - Greeting("Hi "+n.User.GetName()+","). - Line("Your Vikunja Data Export is ready for you to download. Click the button below to download it:"). + Subject(i18n.T(n.User.Language, "notifications.data_export.ready.subject")). + Greeting(i18n.TWithParams(n.User.Language, "notifications.greeting", n.User.GetName())). + Line(i18n.T(n.User.Language, "notifications.data_export.ready.message")). Action("Download", config.ServicePublicURL.GetString()+"user/export/download"). - Line("The download will be available for the next 7 days."). - Line("Have a nice day!") + Line(i18n.T(n.User.Language, "notifications.data_export.ready.availability")). + Line(i18n.T(n.User.Language, "notifications.common.have_nice_day")) } // ToDB returns the DataExportReadyNotification notification in a format which can be saved in the db diff --git a/pkg/notifications/mail.go b/pkg/notifications/mail.go index 88f50a76d..3b29412bc 100644 --- a/pkg/notifications/mail.go +++ b/pkg/notifications/mail.go @@ -114,8 +114,8 @@ func (m *Mail) appendLine(line string, isHTML bool) *Mail { } // SendMail passes the notification to the mailing queue for sending -func SendMail(m *Mail) error { - opts, err := RenderMail(m) +func SendMail(m *Mail, lang string) error { + opts, err := RenderMail(m, lang) if err != nil { return err } diff --git a/pkg/notifications/mail_render.go b/pkg/notifications/mail_render.go index 5c8265af9..3cacee3a1 100644 --- a/pkg/notifications/mail_render.go +++ b/pkg/notifications/mail_render.go @@ -23,12 +23,12 @@ import ( templatehtml "html/template" templatetext "text/template" - "github.com/microcosm-cc/bluemonday" - "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/i18n" "code.vikunja.io/api/pkg/mail" "code.vikunja.io/api/pkg/utils" + "github.com/microcosm-cc/bluemonday" "github.com/yuin/goldmark" ) @@ -81,7 +81,7 @@ const mailTemplateHTML = ` {{ if .ActionURL }}

- If the button above doesn't work, copy the url below and paste it in your browser's address bar:
+ {{ .CopyURLText }}
{{ .ActionURL }}

{{ range $line := .FooterLinesHTML}} @@ -131,7 +131,7 @@ func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err e } // RenderMail takes a precomposed mail message and renders it into a ready to send mail.Opts object -func RenderMail(m *Mail) (mailOpts *mail.Opts, err error) { +func RenderMail(m *Mail, lang string) (mailOpts *mail.Opts, err error) { var htmlContent bytes.Buffer var plainContent bytes.Buffer @@ -158,6 +158,7 @@ func RenderMail(m *Mail) (mailOpts *mail.Opts, err error) { data["ActionURL"] = m.actionURL data["Boundary"] = boundary data["FrontendURL"] = config.ServicePublicURL.GetString() + data["CopyURLText"] = i18n.T(lang, "notifications.common.copy_url") data["IntroLinesHTML"], err = convertLinesToHTML(m.introLines) if err != nil { diff --git a/pkg/notifications/mail_test.go b/pkg/notifications/mail_test.go index 022be4a02..e508db69d 100644 --- a/pkg/notifications/mail_test.go +++ b/pkg/notifications/mail_test.go @@ -97,7 +97,7 @@ func TestRenderMail(t *testing.T) { Greeting("Hi there,"). Line("This is a line") - mailopts, err := RenderMail(mail) + mailopts, err := RenderMail(mail, "en") require.NoError(t, err) assert.Equal(t, mail.from, mailopts.From) assert.Equal(t, mail.to, mailopts.To) @@ -159,7 +159,7 @@ This is a line Line("This should be an outro line"). Line("And one more, because why not?") - mailopts, err := RenderMail(mail) + mailopts, err := RenderMail(mail, "en") require.NoError(t, err) assert.Equal(t, mail.from, mailopts.From) assert.Equal(t, mail.to, mailopts.To) @@ -249,7 +249,7 @@ And one more, because why not? Line("This is a line"). FooterLine("This is a footer line") - mailopts, err := RenderMail(mail) + mailopts, err := RenderMail(mail, "en") require.NoError(t, err) assert.Equal(t, mail.from, mailopts.From) assert.Equal(t, mail.to, mailopts.To) @@ -321,7 +321,7 @@ This is a footer line Line("And one more, because why not?"). FooterLine("This is a footer line") - mailopts, err := RenderMail(mail) + mailopts, err := RenderMail(mail, "en") require.NoError(t, err) assert.Equal(t, mail.from, mailopts.From) assert.Equal(t, mail.to, mailopts.To) diff --git a/pkg/notifications/notification.go b/pkg/notifications/notification.go index 6cd94dda1..4b91ea565 100644 --- a/pkg/notifications/notification.go +++ b/pkg/notifications/notification.go @@ -48,6 +48,8 @@ type Notifiable interface { // ShouldNotify provides a last-minute way to cancel a notification. It will be called immediately before // sending a notification. ShouldNotify() (should bool, err error) + // Lang provides the language which should be used for translations in the mail. + Lang() string } // Notify notifies a notifiable of a notification @@ -83,7 +85,7 @@ func notifyMail(notifiable Notifiable, notification Notification) error { } mail.To(to) - return SendMail(mail) + return SendMail(mail, notifiable.Lang()) } func notifyDB(notifiable Notifiable, notification Notification) (err error) { diff --git a/pkg/notifications/notification_test.go b/pkg/notifications/notification_test.go index 509d01c33..aff7670c6 100644 --- a/pkg/notifications/notification_test.go +++ b/pkg/notifications/notification_test.go @@ -52,6 +52,7 @@ func (n *testNotification) Name() string { type testNotifiable struct { ShouldSendNotification bool + Language string } // RouteForMail routes a test notification for mail @@ -68,6 +69,10 @@ func (t *testNotifiable) ShouldNotify() (should bool, err error) { return t.ShouldSendNotification, nil } +func (t *testNotifiable) Lang() string { + return t.Language +} + func TestNotify(t *testing.T) { t.Run("normal", func(t *testing.T) { @@ -82,6 +87,7 @@ func TestNotify(t *testing.T) { } tnf := &testNotifiable{ ShouldSendNotification: true, + Language: "en", } err = Notify(tnf, tn) @@ -116,6 +122,7 @@ func TestNotify(t *testing.T) { } tnf := &testNotifiable{ ShouldSendNotification: false, + Language: "en", } err = Notify(tnf, tn) diff --git a/pkg/user/notifications.go b/pkg/user/notifications.go index 5e6b7c071..48f9dea86 100644 --- a/pkg/user/notifications.go +++ b/pkg/user/notifications.go @@ -20,6 +20,7 @@ import ( "strconv" "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/i18n" "code.vikunja.io/api/pkg/notifications" ) @@ -33,23 +34,23 @@ type EmailConfirmNotification struct { // ToMail returns the mail notification for EmailConfirmNotification func (n *EmailConfirmNotification) ToMail() *notifications.Mail { - subject := n.User.GetName() + ", please confirm your email address at Vikunja" + subject := i18n.TWithParams(n.User.Language, "notifications.email_confirm.subject", n.User.GetName()) if n.IsNew { - subject = n.User.GetName() + " + Vikunja = <3" + subject = i18n.TWithParams(n.User.Language, "notifications.email_confirm.subject_new", n.User.GetName()) } nn := notifications.NewMail(). Subject(subject). - Greeting("Hi " + n.User.GetName() + ",") + Greeting(i18n.TWithParams(n.User.Language, "notifications.greeting", n.User.GetName())) if n.IsNew { - nn.Line("Welcome to Vikunja!") + nn.Line(i18n.T(n.User.Language, "notifications.email_confirm.welcome")) } return nn. - Line("To confirm your email address, click the link below:"). + Line(i18n.T(n.User.Language, "notifications.email_confirm.confirm")). Action("Confirm your email address", config.ServicePublicURL.GetString()+"?userEmailConfirm="+n.ConfirmToken). - Line("Have a nice day!") + Line(i18n.T(n.User.Language, "notifications.common.have_nice_day")) } // ToDB returns the EmailConfirmNotification notification in a format which can be saved in the db @@ -70,10 +71,10 @@ type PasswordChangedNotification struct { // ToMail returns the mail notification for PasswordChangedNotification func (n *PasswordChangedNotification) ToMail() *notifications.Mail { return notifications.NewMail(). - Subject("Your Password on Vikunja was changed"). - Greeting("Hi " + n.User.GetName() + ","). - Line("Your account password was successfully changed."). - Line("If this wasn't you, it could mean someone compromised your account. In this case contact your server's administrator.") + Subject(i18n.T(n.User.Language, "notifications.password.changed.subject")). + Greeting(i18n.TWithParams(n.User.Language, "notifications.greeting", n.User.GetName())). + Line(i18n.T(n.User.Language, "notifications.password.changed.success")). + Line(i18n.T(n.User.Language, "notifications.password.changed.warning")) } // ToDB returns the PasswordChangedNotification notification in a format which can be saved in the db @@ -95,12 +96,12 @@ type ResetPasswordNotification struct { // ToMail returns the mail notification for ResetPasswordNotification func (n *ResetPasswordNotification) ToMail() *notifications.Mail { return notifications.NewMail(). - Subject("Reset your password on Vikunja"). - Greeting("Hi "+n.User.GetName()+","). - Line("To reset your password, click the link below:"). + Subject(i18n.T(n.User.Language, "notifications.password.reset.subject")). + Greeting(i18n.TWithParams(n.User.Language, "notifications.greeting", n.User.GetName())). + Line(i18n.T(n.User.Language, "notifications.password.reset.instructions")). Action("Reset your password", config.ServicePublicURL.GetString()+"?userPasswordReset="+n.Token.Token). - Line("This link will be valid for 24 hours."). - Line("Have a nice day!") + Line(i18n.T(n.User.Language, "notifications.password.reset.valid_duration")). + Line(i18n.T(n.User.Language, "notifications.common.have_nice_day")) } // ToDB returns the ResetPasswordNotification notification in a format which can be saved in the db @@ -121,10 +122,10 @@ type InvalidTOTPNotification struct { // ToMail returns the mail notification for InvalidTOTPNotification func (n *InvalidTOTPNotification) ToMail() *notifications.Mail { return notifications.NewMail(). - Subject("Someone just tried to login to your Vikunja account, but failed"). - Greeting("Hi "+n.User.GetName()+","). - Line("Someone just tried to log in into your account with correct username and password but a wrong TOTP passcode."). - Line("**If this was not you, someone else knows your password. You should set a new one immediately!**"). + Subject(i18n.T(n.User.Language, "notifications.totp.invalid.subject")). + Greeting(i18n.TWithParams(n.User.Language, "notifications.greeting", n.User.GetName())). + Line(i18n.T(n.User.Language, "notifications.totp.invalid.message")). + Line(i18n.T(n.User.Language, "notifications.totp.invalid.warning")). Action("Reset your password", config.ServicePublicURL.GetString()+"get-password-reset") } @@ -145,12 +146,13 @@ type PasswordAccountLockedAfterInvalidTOTOPNotification struct { // ToMail returns the mail notification for PasswordAccountLockedAfterInvalidTOTOPNotification func (n *PasswordAccountLockedAfterInvalidTOTOPNotification) ToMail() *notifications.Mail { + resetURL := config.ServicePublicURL.GetString() + "get-password-reset" return notifications.NewMail(). - Subject("We've disabled your account on Vikunja"). - Greeting("Hi " + n.User.GetName() + ","). - Line("Someone tried to log in with your credentials but failed to provide a valid TOTP passcode."). - Line("After 10 failed attempts, we've disabled your account and reset your password. To set a new one, follow the instructions in the reset email we just sent you."). - Line("If you did not receive an email with reset instructions, you can always request a new one at [" + config.ServicePublicURL.GetString() + "get-password-reset](" + config.ServicePublicURL.GetString() + "get-password-reset).") + Subject(i18n.T(n.User.Language, "notifications.totp.account_locked.subject")). + Greeting(i18n.TWithParams(n.User.Language, "notifications.greeting", n.User.GetName())). + Line(i18n.T(n.User.Language, "notifications.totp.account_locked.message")). + Line(i18n.T(n.User.Language, "notifications.totp.account_locked.disabled")). + Line(i18n.TWithParams(n.User.Language, "notifications.totp.account_locked.reset_instructions", resetURL, resetURL)) } // ToDB returns the PasswordAccountLockedAfterInvalidTOTOPNotification notification in a format which can be saved in the db @@ -171,11 +173,11 @@ type FailedLoginAttemptNotification struct { // ToMail returns the mail notification for FailedLoginAttemptNotification func (n *FailedLoginAttemptNotification) ToMail() *notifications.Mail { return notifications.NewMail(). - Subject("Someone just tried to login to your Vikunja account, but failed to provide a correct password"). - Greeting("Hi "+n.User.GetName()+","). - Line("Someone just tried to log in into your account with a wrong password three times in a row."). - Line("If this was not you, this could be someone else trying to break into your account."). - Line("To enhance the security of you account you may want to set a stronger password or enable TOTP authentication in the settings:"). + Subject(i18n.T(n.User.Language, "notifications.login.failed.subject")). + Greeting(i18n.TWithParams(n.User.Language, "notifications.greeting", n.User.GetName())). + Line(i18n.T(n.User.Language, "notifications.login.failed.message")). + Line(i18n.T(n.User.Language, "notifications.login.failed.warning")). + Line(i18n.T(n.User.Language, "notifications.login.failed.enhance_security")). Action("Go to settings", config.ServicePublicURL.GetString()+"user/settings") } @@ -198,15 +200,15 @@ type AccountDeletionConfirmNotification struct { // ToMail returns the mail notification for AccountDeletionConfirmNotification func (n *AccountDeletionConfirmNotification) ToMail() *notifications.Mail { return notifications.NewMail(). - Subject("Please confirm the deletion of your Vikunja account"). - Greeting("Hi "+n.User.GetName()+","). - Line("You have requested the deletion of your account. To confirm this, please click the link below:"). + Subject(i18n.T(n.User.Language, "notifications.account.deletion.confirm.subject")). + Greeting(i18n.TWithParams(n.User.Language, "notifications.greeting", n.User.GetName())). + Line(i18n.T(n.User.Language, "notifications.account.deletion.confirm.request")). Action("Confirm the deletion of my account", config.ServicePublicURL.GetString()+"?accountDeletionConfirm="+n.ConfirmToken). - Line("This link will be valid for 24 hours."). - Line("Once you confirm the deletion we will schedule the deletion of your account in three days and send you another email until then."). - Line("If you proceed with the deletion of your account, we will remove all of your projects and tasks you created. Everything you shared with another user or team will transfer ownership to them."). - Line("If you did not requested the deletion or changed your mind, you can simply ignore this email."). - Line("Have a nice day!") + Line(i18n.T(n.User.Language, "notifications.account.deletion.confirm.valid_duration")). + Line(i18n.T(n.User.Language, "notifications.account.deletion.confirm.schedule_info")). + Line(i18n.T(n.User.Language, "notifications.account.deletion.confirm.consequences")). + Line(i18n.T(n.User.Language, "notifications.account.deletion.confirm.changed_mind")). + Line(i18n.T(n.User.Language, "notifications.common.have_nice_day")) } // ToDB returns the AccountDeletionConfirmNotification notification in a format which can be saved in the db @@ -227,20 +229,26 @@ type AccountDeletionNotification struct { // ToMail returns the mail notification for AccountDeletionNotification func (n *AccountDeletionNotification) ToMail() *notifications.Mail { - durationString := "in " + strconv.Itoa(n.NotificationNumber) + " days" + var subject string + var deletionTimeLine string if n.NotificationNumber == 1 { - durationString = "tomorrow" + subject = i18n.T(n.User.Language, "notifications.account.deletion.scheduled.subject_tomorrow") + deletionTimeLine = i18n.T(n.User.Language, "notifications.account.deletion.scheduled.deletion_time_tomorrow") + } else { + days := strconv.Itoa(n.NotificationNumber) + subject = i18n.TWithParams(n.User.Language, "notifications.account.deletion.scheduled.subject_days", days) + deletionTimeLine = i18n.TWithParams(n.User.Language, "notifications.account.deletion.scheduled.deletion_time_days", days) } return notifications.NewMail(). - Subject("Your Vikunja account will be deleted "+durationString). - Greeting("Hi "+n.User.GetName()+","). - Line("You recently requested the deletion of your Vikunja account."). - Line("We will delete your account "+durationString+"."). - Line("If you changed your mind, simply click the link below to cancel the deletion and follow the instructions there:"). + Subject(subject). + Greeting(i18n.TWithParams(n.User.Language, "notifications.greeting", n.User.GetName())). + Line(i18n.T(n.User.Language, "notifications.account.deletion.scheduled.request_reminder")). + Line(deletionTimeLine). + Line(i18n.T(n.User.Language, "notifications.account.deletion.scheduled.changed_mind")). Action("Abort the deletion", config.ServicePublicURL.GetString()). - Line("Have a nice day!") + Line(i18n.T(n.User.Language, "notifications.common.have_nice_day")) } // ToDB returns the AccountDeletionNotification notification in a format which can be saved in the db @@ -261,11 +269,11 @@ type AccountDeletedNotification struct { // ToMail returns the mail notification for AccountDeletedNotification func (n *AccountDeletedNotification) ToMail() *notifications.Mail { return notifications.NewMail(). - Subject("Your Vikunja Account has been deleted"). - Greeting("Hi " + n.User.GetName() + ","). - Line("As requested, we've deleted your Vikunja account."). - Line("This deletion is permanent. If did not create a backup and need your data back now, talk to your administrator."). - Line("Have a nice day!") + Subject(i18n.T(n.User.Language, "notifications.account.deletion.completed.subject")). + Greeting(i18n.TWithParams(n.User.Language, "notifications.greeting", n.User.GetName())). + Line(i18n.T(n.User.Language, "notifications.account.deletion.completed.confirmation")). + Line(i18n.T(n.User.Language, "notifications.account.deletion.completed.permanent")). + Line(i18n.T(n.User.Language, "notifications.common.have_nice_day")) } // ToDB returns the AccountDeletedNotification notification in a format which can be saved in the db diff --git a/pkg/user/user.go b/pkg/user/user.go index 34f33fe8b..4743b958d 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -149,6 +149,10 @@ func (u *User) ShouldNotify() (bool, error) { return user.Status != StatusDisabled, err } +func (u *User) Lang() string { + return u.Language +} + // GetID implements the Auth interface func (u *User) GetID() int64 { return u.ID