From e9353a66bf51486f702eaa0f591dea54c9bd4cd1 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Sun, 17 May 2026 17:13:32 +0700 Subject: refactor: rewrite localization for dynamic set and get feat: medley localnames feat: integrate package minor updates fix: on errors with blog client init write correct type --- l10n/en.yaml | 79 +++++++++++++++++++++++ l10n/ru.yaml | 79 +++++++++++++++++++++++ l10n/translator-hook.go | 39 +++++++++++ l10n/translator.go | 167 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 364 insertions(+) create mode 100644 l10n/en.yaml create mode 100644 l10n/ru.yaml create mode 100644 l10n/translator-hook.go create mode 100644 l10n/translator.go (limited to 'l10n') diff --git a/l10n/en.yaml b/l10n/en.yaml new file mode 100644 index 0000000..9571aca --- /dev/null +++ b/l10n/en.yaml @@ -0,0 +1,79 @@ +TagsLabel: "Tags:" +BlogSearch: + Header: "Blog Search" + Description: "The Saya Blog posts' list" + TagsHeader: "Tags" + OrderByHeader: "Order by..." + TitleOrdered: "Title" + ActionDateOrdered: "Action Date" + PublicationDateOrdered: "Publication Date" + ChooseAllTags: "Choose All" +GlobalMap: + Header: "Global Map" + Description: "Global map with all the tags of places I've been to" +HomePage: + Header: "Home Page" + Contacts: "Contacts" + DidYouKnowThat: "Did you know, that..." + SidebarDescription: "Describing the Sidebar" + BackDescription: "This button will lead back to higher page (like Home => Language Pick, Blog Search => Home)" + HomePageDescription: "Home Page -- where we are right now!" + BlogSearchDescription: "Blog Search. You can find my travel notes there and, maybe, something else!" + MarkerMapDescription: "Map with markers of my travel notes -- a convinient way to find an interesting region and find related posts!" + ThemeSwitchDescription: "Theme Palette lets you change the theme of the site -- there are 2 dark and 3 bright themes currently!" + AccountDescription: "This is where you can subscribe to any blog update via E-mail!" + Hymn1: "We welcome you to Sayasite!" + Hymn2: "We've been expecting you!" + Hymn3: "You bring such joy in Sayasite!" + Hymn4: "No matter where you roam, know our love is true!~" +UnsubscribePage: + Header: "Cancelling the Subscription" + UnsetCode: "Your cancel code is not set. You just having a walk around the site?" + InvalidCode: "Your cancel code could not be verified. Maybe, the code expired after a day. Or maybe, you've already unsubscribed." + OnServerError: "We couldn't unsubscribe you, the problem is on our side! We will solve this problem as soon as possible." + Success: "Cancelled your subscription successfully! We do hope we will engage you again someday." +Metadata: + Published: "Published" + Action: "Took place on" +Mail: + UnsubscribeFooter: "If this letter got you in a bad mood, you can unsubscribe from my blog by {}this link{/}." + VerifyEmail: + Subject: "Verification Link for SAYA.UZ is waiting for you!" + Welcome: "Greetings!" + Intro: "There are 2 ways for us to validate your profile:" + GotoLink: "Go by this link:" + InputCode: "Or input this code on the personal page:" + IfRandom: "If you are unsure what this letter this, you can ignore it, and also, if you are interested, you may visit my site :)" + NewPost: + Subject: "A new post arrived at SAYA.UZ!" + Intro: "A new post came!" + CapturedOn: "Captured on" +UserProfile: + Header: "Personal Settings" + Description: "Set and verify your e-mail here, as well as subscribe to favorite blog tags" + EmailHeader: "E-Mail" + VerificationCodeHeader: "Verification Code" + TypeEmail: "Please type your email" + SendCodeButton: "Send Code" + VerifyButton: "Verify" + VerificationCodeSent: "Verification code sent! Please check your e-mail. Your next verification attempt will be possible on {}." + DelayTilVerification: "You have already requested the verification before! Please wait until {} for a new verification request." + FailedEmailRender: "Failed to render the email status message, please ask administrator for a more detailed cause." + VerificationCodeSendingError: "Failed to send a verification code, please ask administrator for a more detailed cause." + EmailAlreadyValidated: "You already have this E-Mail validated." + EmailTaken: "This E-Mail is already taken." + EmailEmpty: "E-Mail is unset or empty." + VerificationSuccess: "Success! You are now able to subscribe with your new e-mail." + VerificationFailed: "Verification code is invalid, please ask administrator for a more detailed cause." + VerificationEmpty: "Verification code is unset or empty." + SubscriptionHeader: "Subscribe to..." + TagsNone: "None" + TagsAll: "All" + TagsSpecific: "Specific" + TagDoesNotExist: 'The tag "{}" does not exist on the site.' + TagAlreadyAdded: 'The tag "{}" is already in your subscribed list.' + SaveButton: "Save" + SubscribeInvalidType: "Invalid subscription type." + FailedToSubscribe: "Failed to subscribe by new settings, please ask administrator for a more detailed cause." + SubscribedSuccessfully: "Successfully subscribed you by new settings!" + RefreshPage: "Please refresh the profile page on the device, where you requested the code." diff --git a/l10n/ru.yaml b/l10n/ru.yaml new file mode 100644 index 0000000..1818998 --- /dev/null +++ b/l10n/ru.yaml @@ -0,0 +1,79 @@ +TagsLabel: "Тэги:" +BlogSearch: + Header: "Поиск по блогу" + Description: "Каталог записей Saya Blog" + TagsHeader: "Тэги" + OrderByHeader: "Упорядочить по..." + TitleOrdered: "названию" + ActionDateOrdered: "дате действия" + PublicationDateOrdered: "времени публикации" + ChooseAllTags: "Выбрать все" +GlobalMap: + Header: "Глобальная карта" + Description: "Глобальная карта с метками мест, где я побывал" +HomePage: + Header: "Домашняя страница" + Contacts: "Контакты" + DidYouKnowThat: "А вы знали, что..." + SidebarDescription: "Поясняем за сайдбар" + BackDescription: "Эта кнопка позволяет идти на страницу вверх (т.е. Домашняя страница => Выбор языка, Поиск по блогу => Домашняя страница)" + HomePageDescription: "Домашняя страница -- где мы сейчас и находимся!" + BlogSearchDescription: "Поиск по блогу. Там размещены мои путевые заметки и, может, что-то ещё!" + MarkerMapDescription: "Карта с маркерами моих путевых заметок, -- удобный способ найти интересный регион и перейти на заинтересовавшие посты!" + ThemeSwitchDescription: "Позволяет поменять тему сайта -- есть 3 светлые и 2 тёмные темы!" + AccountDescription: "Здесь можно подписаться на обновления в блоге, используя электронную почту!" + Hymn1: "Приветствуем Вас на Саясайте!" + Hymn2: "Мы Вас столько ждали!" + Hymn3: "Столько радости от Вас на Саясайте!" + Hymn4: "Где бы Вы не были, знайте, -- наша любовь крепка!" +UnsubscribePage: + Header: "Отписка" + UnsetCode: "Код для отписки не проставлен. Вы случайно не заблудились?" + InvalidCode: "Код для отписки не подтверждён. Может, истекло время в сутки для кода. А может, вы уже успели отписаться." + OnServerError: "Мы не смогли вас отписать, проблема на нашей стороне! В скором времени займёмся проблемой." + Success: "Вы отписались от рассылки! Надеюсь, мы ещё сможем вас заинтересовать." +Metadata: + Published: "Опубликовано" + Action: "Время действия" +Mail: + UnsubscribeFooter: "Если данное письмо пришло вам случайно, либо вы хотите отписаться, можете перейти по {}этой ссылке{/}." + VerifyEmail: + Subject: "Ссылка для верификации для подтверждения вашей почты SAYA.UZ" + Welcome: "Приветствую!" + Intro: "Есть 2 способа, чтобы подтвердить свою электронную почту:" + GotoLink: "Перейти по этой ссылке:" + InputCode: "Либо ввести этот код на странице подписок:" + IfRandom: "Если ты не понимаешь, что это за спам, можешь проигнорировать это письмо, а ещё, если интересно, можешь посетить мой сайт :)" + NewPost: + Subject: "Новый пост на SAYA.UZ!" + Intro: "Вышел новый пост!" + CapturedOn: "Снималось" +UserProfile: + Header: "Личные настройки" + Description: "Здесь можно настроить и верифицировать свою электронную почту, а также подписаться на избранные темы блога" + EmailHeader: "E-Mail" + VerificationCodeHeader: "Код верификации" + TypeEmail: "Пожалуйста, введите свою почту" + SendCodeButton: "Отправить код" + VerifyButton: "Верифицировать" + VerificationCodeSent: "Выслали вам код верификации! Проверьте свою почту. Следующая попытка верификации будет возможна {}." + DelayTilVerification: "Вы уже запрашивали недавно верификацию! Дождитесь {} для новой попытки." + FailedEmailRender: "Неудачная попытка создать электронное письмо, более подробно спрашивайте у администратора." + VerificationCodeSendingError: "Неудачная попытка прислать код для верификации, более подробно спрашивайте у администратора." + EmailAlreadyValidated: "Эту почту вы уже подтверждали." + EmailTaken: "Эта почта уже занята." + EmailEmpty: "Почта не введена." + VerificationSuccess: "Поздравляем! Теперь по этой почте можно подписаться на блог." + VerificationFailed: "Код для верификации невалиден, при вопросах, пожалуйста, обращайтесь к администратору." + VerificationEmpty: "Код для верификации не введён." + SubscriptionHeader: "Подписаться на..." + TagsNone: "Ничего" + TagsAll: "Всё" + TagsSpecific: "Определённое" + TagDoesNotExist: 'Тэга "{}" нет на сайте.' + TagAlreadyAdded: 'Тэг "{}" уже у вас добавлен.' + SaveButton: "Сохранить" + SubscribeInvalidType: "Неизвестный тип подписки." + FailedToSubscribe: "Неудачная попытка записать новую подписку, более подробно спрашивайте у администратора." + SubscribedSuccessfully: "Вы успешно подписались по новым настройкам!" + RefreshPage: "Пожалуйста, обновите страницу с профилем на устройстве, откуда запрашивали код." diff --git a/l10n/translator-hook.go b/l10n/translator-hook.go new file mode 100644 index 0000000..ff5837c --- /dev/null +++ b/l10n/translator-hook.go @@ -0,0 +1,39 @@ +package l10n + +var hooks map[string]*TranslatorHook + +type TranslatorHook struct { + t *Translator + prefix string +} + +func NewTranslatorHook(prefix string) *TranslatorHook { + if _, ok := hooks[prefix]; !ok { + hooks[prefix] = &TranslatorHook{T, prefix} + } + return hooks[prefix] +} + +func (h *TranslatorHook) Set(key string, val any, toOverwrite bool) (alreadySet bool, err error) { + return h.t.Set(h.prefix+key, val, toOverwrite) +} + +func (h *TranslatorHook) SetPath(val any, toOverwrite bool, path ...any) (alreadySet bool, err error) { + return h.t.SetPath(val, toOverwrite, append([]any{h.prefix}, path...)...) +} + +func (h *TranslatorHook) Get(key string) any { + return h.t.Get(h.prefix + "." + key) +} + +func (h *TranslatorHook) GetPath(path ...any) any { + return h.t.GetPath(append([]any{h.prefix}, path...)...) +} + +func (h *TranslatorHook) GetDefault(key string, def any) any { + return h.t.GetDefault(h.prefix+"."+key, def) +} + +func (h *TranslatorHook) GetDefaultPath(def any, path ...any) any { + return h.t.GetDefaultPath(def, append([]any{h.prefix}, path...)...) +} diff --git a/l10n/translator.go b/l10n/translator.go new file mode 100644 index 0000000..4d4b4eb --- /dev/null +++ b/l10n/translator.go @@ -0,0 +1,167 @@ +package l10n + +import ( + "errors" + "fmt" + "log/slog" + "os" + "strings" + "sync" + + "gopkg.in/yaml.v3" +) + +var T *Translator + +func init() { + var err error + if T, err = NewTranslator("ru", "en"); err != nil { + slog.Error("fail to load localization", slog.String("error", err.Error())) + os.Exit(1) + } +} + +type Translator struct { + l map[string]any + mu sync.RWMutex +} + +func NewTranslator(locales ...string) (*Translator, error) { + t := &Translator{l: make(map[string]any)} + errs := make([]error, 0) + + for _, locale := range locales { + contentBytes, err := os.ReadFile(fmt.Sprintf("l10n/%s.yaml", locale)) + if err != nil { + errs = append(errs, fmt.Errorf("opening one of files: l10n/%s.yaml: %w", locale, err)) + continue + } + + var content map[string]any + if err := yaml.Unmarshal(contentBytes, &content); err != nil { + errs = append(errs, fmt.Errorf("parsing one of files: l10n/%s.yaml: %w", locale, err)) + continue + } + + errs = append(errs, t.initMap("."+locale, content)...) + } + + return t, errors.Join(errs...) +} + +func (t *Translator) Set(key string, val any, toOverwrite bool) (alreadySet bool, err error) { + t.mu.RLock() + if _, alreadySet = t.l[key]; !alreadySet || (alreadySet && toOverwrite) { + t.mu.RUnlock() + return alreadySet, errors.Join(t.setSafe(key, val)...) + } + t.mu.RUnlock() + return alreadySet, nil +} + +func (t *Translator) SetPath(val any, toOverwrite bool, path ...any) (alreadySet bool, err error) { + key, err := formatPath(path...) + if err != nil { + return false, err + } + return t.Set(key, val, toOverwrite) +} + +func (t *Translator) Get(key string) any { + return t.GetDefault(key, key[strings.LastIndex(key, ".")+1:]) +} + +func (t *Translator) GetPath(path ...any) any { + return t.GetDefaultPath(path[len(path)-1], path...) +} + +func (t *Translator) GetDefault(key string, def any) any { + t.mu.RLock() + defer t.mu.RUnlock() + if v, ok := t.l[key]; ok { + return v + } + slog.Warn("failed to find localization for a key", slog.String("key", key)) + return def +} + +func (t *Translator) GetDefaultPath(def any, path ...any) any { + t.mu.RLock() + defer t.mu.RUnlock() + key, err := formatPath(path...) + if err != nil { + slog.Error("get localized value by path: path is invalid", slog.String("error", err.Error())) + return def + } + return t.GetDefault(key, def) +} + +func (t *Translator) initSlice(parent string, level []any) []error { + errs := make([]error, 0) + for i, v := range level { + newParent := parent + fmt.Sprintf("[%d]", i) + t.setSafe(newParent, v) + } + return errs +} + +func (t *Translator) initMap(parent string, level map[string]any) []error { + errs := make([]error, 0) + for k, v := range level { + key, err := formatPath(k) + if err != nil { + errs = append(errs, err) + continue + } + + errs = append(errs, t.setSafe(parent+key, v)...) + } + return errs +} + +func (t *Translator) setSafe(k string, v any) []error { + errs := make([]error, 0) + t.mu.Lock() + switch vv := v.(type) { + case int: + t.l[k] = vv + t.mu.Unlock() + case float64: + t.l[k] = vv + t.mu.Unlock() + case string: + t.l[k] = vv + t.mu.Unlock() + case []any: + t.mu.Unlock() + errs = append(errs, t.initSlice(k, vv)...) + case map[string]any: + t.mu.Unlock() + errs = append(errs, t.initMap(k, vv)...) + default: + t.mu.Unlock() + errs = append(errs, fmt.Errorf("invalid value type: %s, %v", k, vv)) + } + return errs +} + +func formatPath(path ...any) (string, error) { + var key string + for _, part := range path { + switch k := part.(type) { + case int: + key += fmt.Sprintf("[%d]", k) + case string: + key += "." + strings.ReplaceAll(k, ".", "\\.") + case []any: + add, err := formatPath(k...) + if err != nil { + return "", fmt.Errorf("while formatting nested slice: %w", err) + } + key += add + default: + return "", fmt.Errorf("invalid key part: after %s follows %v", key, k) + } + } + return key, nil +} -- cgit v1.3.1+13