diff --git a/Makefile b/Makefile index 263d4fdc2d..570bf1823d 100644 --- a/Makefile +++ b/Makefile @@ -93,6 +93,22 @@ else BUILD_BOARDS = false endif +# Playbooks +BUILD_PLAYBOOKS_DIR ?= ../mattermost-plugin-playbooks +BUILD_PLAYBOOKS ?= false +BUILD_HASH_PLAYBOOKS = none + +ifneq ($(wildcard $(BUILD_PLAYBOOKS_DIR)/.),) + ifeq ($(BUILD_PLAYBOOKS),true) + BUILD_PLAYBOOKS = true + BUILD_HASH_PLAYBOOKS = $(shell cd $(BUILD_PLAYBOOKS_DIR) && git rev-parse HEAD) + else + BUILD_PLAYBOOKS = false + endif +else + BUILD_PLAYBOOKS = false +endif + # We need current user's UID for `run-haserver` so docker compose does not run server # as root and mess up file permissions for devs. When running like this HOME will be blank # and docker will add '/', so we need to set the go-build cache location or we'll get @@ -116,6 +132,7 @@ LDFLAGS += -X "github.com/mattermost/mattermost-server/v6/model.BuildHashEnterpr LDFLAGS += -X "github.com/mattermost/mattermost-server/v6/model.BuildEnterpriseReady=$(BUILD_ENTERPRISE_READY)" LDFLAGS += -X "github.com/mattermost/mattermost-server/v6/model.BuildHashBoards=$(BUILD_HASH_BOARDS)" LDFLAGS += -X "github.com/mattermost/mattermost-server/v6/model.BuildBoards=$(BUILD_BOARDS)" +LDFLAGS += -X "github.com/mattermost/mattermost-server/v6/model.BuildHashPlaybooks=$(BUILD_HASH_PLAYBOOKS)" GO_MAJOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f1) GO_MINOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f2) @@ -198,6 +215,12 @@ else IGNORE:=$(shell rm -f imports/boards_imports.go) endif +ifeq ($(BUILD_PLAYBOOKS),true) +IGNORE:=$(shell cp $(BUILD_PLAYBOOKS_DIR)/product/imports/playbooks_imports.go imports/) +else + IGNORE:=$(shell rm -f imports/playbooks_imports.go) +endif + all: run ## Alias for 'run'. -include config.override.mk @@ -421,6 +444,7 @@ endif setup-go-work: export BUILD_ENTERPRISE_READY := $(BUILD_ENTERPRISE_READY) setup-go-work: export BUILD_BOARDS := $(BUILD_BOARDS) +setup-go-work: export BUILD_PLAYBOOKS := $(BUILD_PLAYBOOKS) setup-go-work: ## Sets up your go.work file ./scripts/setup_go_work.sh $(IGNORE_GO_WORK_IF_EXISTS) diff --git a/app/app_iface.go b/app/app_iface.go index b6af1556a0..56ff307253 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -960,6 +960,7 @@ type AppIface interface { RegenOutgoingWebhookToken(hook *model.OutgoingWebhook) (*model.OutgoingWebhook, *model.AppError) RegenerateOAuthAppSecret(app *model.OAuthApp) (*model.OAuthApp, *model.AppError) RegenerateTeamInviteId(teamID string) (*model.Team, *model.AppError) + RegisterCollectionAndTopic(pluginID, collectionType, topicType string) error RegisterPluginCommand(pluginID string, command *model.Command) error ReloadConfig() error RemoveAllDeactivatedMembersFromChannel(c request.CTX, channel *model.Channel) *model.AppError diff --git a/app/channel.go b/app/channel.go index 317caccdc5..3239fc9466 100644 --- a/app/channel.go +++ b/app/channel.go @@ -27,30 +27,82 @@ import ( // channelsWrapper provides an implementation of `product.ChannelService` to be used by products. type channelsWrapper struct { - srv *Server app *App } func (s *channelsWrapper) GetDirectChannel(userID1, userID2 string) (*model.Channel, *model.AppError) { - return s.srv.getDirectChannel(request.EmptyContext(s.srv.Log()), userID1, userID2) + return s.app.getDirectChannel(request.EmptyContext(s.app.Log()), userID1, userID2) } // GetChannelByID gets a Channel by its ID. func (s *channelsWrapper) GetChannelByID(channelID string) (*model.Channel, *model.AppError) { - return s.srv.getChannel(request.EmptyContext(s.srv.Log()), channelID) + return s.app.GetChannel(request.EmptyContext(s.app.Log()), channelID) } // GetChannelMember gets a channel member by userID. func (s *channelsWrapper) GetChannelMember(channelID string, userID string) (*model.ChannelMember, *model.AppError) { - return s.srv.getChannelMember(request.EmptyContext(s.srv.Log()), channelID, userID) + return s.app.GetChannelMember(request.EmptyContext(s.app.Log()), channelID, userID) } func (s *channelsWrapper) GetChannelsForTeamForUser(teamID string, userID string, opts *model.ChannelSearchOpts) (model.ChannelList, *model.AppError) { - return s.srv.getChannelsForTeamForUser(request.EmptyContext(s.srv.Log()), teamID, userID, opts) + return s.app.GetChannelsForTeamForUser(request.EmptyContext(s.app.Log()), teamID, userID, opts) +} + +func (s *channelsWrapper) GetChannelSidebarCategories(userID, teamID string) (*model.OrderedSidebarCategories, *model.AppError) { + return s.app.GetSidebarCategoriesForTeamForUser(request.EmptyContext(s.app.Log()), userID, teamID) +} + +func (s *channelsWrapper) GetChannelMembers(channelID string, page, perPage int) (model.ChannelMembers, *model.AppError) { + return s.app.GetChannelMembersPage(request.EmptyContext(s.app.Log()), channelID, page, perPage) +} + +func (s *channelsWrapper) CreateChannelSidebarCategory(userID, teamID string, newCategory *model.SidebarCategoryWithChannels) (*model.SidebarCategoryWithChannels, *model.AppError) { + return s.app.CreateSidebarCategory(request.EmptyContext(s.app.Log()), userID, teamID, newCategory) +} + +func (s *channelsWrapper) UpdateChannelSidebarCategories(userID, teamID string, categories []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, *model.AppError) { + return s.app.UpdateSidebarCategories(request.EmptyContext(s.app.Log()), userID, teamID, categories) +} + +func (s *channelsWrapper) CreateChannel(channel *model.Channel) (*model.Channel, *model.AppError) { + return s.app.CreateChannel(request.EmptyContext(s.app.Log()), channel, false) +} + +func (s *channelsWrapper) AddUserToChannel(channelID, userID, asUserID string) (*model.ChannelMember, *model.AppError) { + ctx := request.EmptyContext(s.app.Log()) + channel, err := s.app.GetChannel(ctx, channelID) + if err != nil { + return nil, err + } + + return s.app.AddChannelMember(ctx, userID, channel, ChannelMemberOpts{ + UserRequestorID: asUserID, + }) +} + +func (s *channelsWrapper) UpdateChannelMemberRoles(channelID, userID, newRoles string) (*model.ChannelMember, *model.AppError) { + return s.app.UpdateChannelMemberRoles(request.EmptyContext(s.app.Log()), channelID, userID, newRoles) +} + +func (s *channelsWrapper) DeleteChannelMember(channelID, userID string) *model.AppError { + return s.app.LeaveChannel(request.EmptyContext(s.app.Log()), channelID, userID) +} + +func (s *channelsWrapper) AddChannelMember(channelID, userID string) (*model.ChannelMember, *model.AppError) { + channel, err := s.GetChannelByID(channelID) + if err != nil { + return nil, err + } + + return s.app.AddChannelMember(request.EmptyContext(s.app.Log()), userID, channel, ChannelMemberOpts{ + // For now, don't allow overriding these via the plugin API. + UserRequestorID: "", + PostRootID: "", + }) } func (s *channelsWrapper) GetDirectChannelOrCreate(userID1, userID2 string) (*model.Channel, *model.AppError) { - return s.app.GetOrCreateDirectChannel(request.EmptyContext(s.srv.Log()), userID1, userID2) + return s.app.GetOrCreateDirectChannel(request.EmptyContext(s.app.Log()), userID1, userID2) } // Ensure the wrapper implements the product service. diff --git a/app/channels.go b/app/channels.go index 9539078eda..470493ec60 100644 --- a/app/channels.go +++ b/app/channels.go @@ -214,7 +214,6 @@ func NewChannels(services map[product.ServiceKey]any) (*Channels, error) { pluginsRoute.HandleFunc("/{anything:.*}", ch.ServePluginRequest) services[product.ChannelKey] = &channelsWrapper{ - srv: s, app: &App{ch: ch}, } @@ -244,6 +243,10 @@ func NewChannels(services map[product.ServiceKey]any) (*Channels, error) { app: &App{ch: ch}, } + services[product.CommandKey] = &App{ch: ch} + + services[product.ThreadsKey] = &App{ch: ch} + return ch, nil } diff --git a/app/collection.go b/app/collection.go index 9b895e3bc0..ad7e4b4029 100644 --- a/app/collection.go +++ b/app/collection.go @@ -10,7 +10,7 @@ import ( "github.com/mattermost/mattermost-server/v6/shared/mlog" ) -func (a *App) registerCollectionAndTopic(pluginID, collectionType, topicType string) error { +func (a *App) RegisterCollectionAndTopic(pluginID, collectionType, topicType string) error { // we have a race condition due to multiple plugins calling this method a.ch.collectionAndTopicTypesMut.Lock() defer a.ch.collectionAndTopicTypesMut.Unlock() diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 9f64d4e6ce..f087eed46c 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -13682,6 +13682,28 @@ func (a *OpenTracingAppLayer) RegenerateTeamInviteId(teamID string) (*model.Team return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) RegisterCollectionAndTopic(pluginID string, collectionType string, topicType string) error { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.RegisterCollectionAndTopic") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.RegisterCollectionAndTopic(pluginID, collectionType, topicType) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) RegisterPluginCommand(pluginID string, command *model.Command) error { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.RegisterPluginCommand") diff --git a/app/permissions.go b/app/permissions.go index 2b34b85bb3..251dc4722d 100644 --- a/app/permissions.go +++ b/app/permissions.go @@ -41,6 +41,10 @@ func (s *permissionsServiceWrapper) HasPermissionToChannel(askingUserID string, return s.app.HasPermissionToChannel(request.EmptyContext(s.app.Log()), askingUserID, channelID, permission) } +func (s *permissionsServiceWrapper) RolesGrantPermission(roleNames []string, permissionId string) bool { + return s.app.RolesGrantPermission(roleNames, permissionId) +} + func (a *App) ResetPermissionsSystem() *model.AppError { // Reset all Teams to not have a scheme. if err := a.Srv().Store().Team().ResetAllTeamSchemes(); err != nil { diff --git a/app/plugin_api.go b/app/plugin_api.go index 5c8ee9fad1..d1bf97aef1 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -1235,7 +1235,7 @@ func (api *PluginAPI) GetCloudLimits() (*model.ProductLimits, error) { // RegisterCollectionAndTopic informs the server that this plugin handles // the given collection and topic types. func (api *PluginAPI) RegisterCollectionAndTopic(collectionType, topicType string) error { - return api.app.registerCollectionAndTopic(api.id, collectionType, topicType) + return api.app.RegisterCollectionAndTopic(api.id, collectionType, topicType) } func (api *PluginAPI) CreateUploadSession(us *model.UploadSession) (*model.UploadSession, error) { diff --git a/app/post.go b/app/post.go index 095494d080..759bb3c4a2 100644 --- a/app/post.go +++ b/app/post.go @@ -46,6 +46,26 @@ func (s *postServiceWrapper) CreatePost(ctx *request.Context, post *model.Post) return s.app.CreatePostMissingChannel(ctx, post, true) } +func (s *postServiceWrapper) GetPostsByIds(postIDs []string) ([]*model.Post, int64, *model.AppError) { + return s.app.GetPostsByIds(postIDs) +} + +func (s *postServiceWrapper) SendEphemeralPost(ctx *request.Context, userID string, post *model.Post) *model.Post { + return s.app.SendEphemeralPost(ctx, userID, post) +} + +func (s *postServiceWrapper) GetPost(postID string) (*model.Post, *model.AppError) { + return s.app.GetSinglePost(postID, false) +} + +func (s *postServiceWrapper) DeletePost(ctx *request.Context, postID, productID string) (*model.Post, *model.AppError) { + return s.app.DeletePost(ctx, postID, productID) +} + +func (s *postServiceWrapper) UpdatePost(ctx *request.Context, post *model.Post, safeUpdate bool) (*model.Post, *model.AppError) { + return s.app.UpdatePost(ctx, post, false) +} + func (a *App) CreatePostAsUser(c request.CTX, post *model.Post, currentSessionId string, setOnline bool) (*model.Post, *model.AppError) { // Check that channel has not been deleted channel, errCh := a.Srv().Store().Channel().Get(post.ChannelId, true) diff --git a/app/server.go b/app/server.go index 83c2582837..78cea110c4 100644 --- a/app/server.go +++ b/app/server.go @@ -241,7 +241,6 @@ func NewServer(options ...Option) (*Server, error) { app := New(ServerConnector(s.Channels())) serviceMap := map[product.ServiceKey]any{ ServerKey: s, - product.ChannelKey: &channelsWrapper{srv: s, app: app}, product.ConfigKey: s.platform, product.LicenseKey: s.licenseWrapper, product.FilestoreKey: s.platform.FileBackend(), @@ -253,6 +252,8 @@ func NewServer(options ...Option) (*Server, error) { product.KVStoreKey: s.platform, product.StoreKey: store.NewStoreServiceAdapter(s.Store()), product.SystemKey: &systemServiceAdapter{server: s}, + product.SessionKey: app, + product.FrontendKey: app, } // Step 4: Initialize products. @@ -262,6 +263,13 @@ func NewServer(options ...Option) (*Server, error) { return nil, errors.Wrap(err, "failed to initialize products") } + // After channel is initialized set it to the App object + channelsWrapper, ok := serviceMap[product.ChannelKey].(*channelsWrapper) + if !ok { + return nil, errors.Wrap(err, "channels product is not initialized") + } + app.ch = channelsWrapper.app.ch + // It is important to initialize the hub only after the global logger is set // to avoid race conditions while logging from inside the hub. // Step 5: Start hub in platform which the hub depends on s.Channels() (step 4) diff --git a/app/team.go b/app/team.go index 222d10e442..12d9af0e91 100644 --- a/app/team.go +++ b/app/team.go @@ -44,6 +44,19 @@ func (w *teamServiceWrapper) CreateMember(ctx *request.Context, teamID, userID s return w.app.AddTeamMember(ctx, teamID, userID) } +func (w *teamServiceWrapper) GetGroup(groupID string) (*model.Group, *model.AppError) { + return w.app.GetGroup(groupID, nil, nil) +} + +func (w *teamServiceWrapper) GetTeam(teamID string) (*model.Team, *model.AppError) { + return w.app.GetTeam(teamID) +} + +func (w *teamServiceWrapper) GetGroupMemberUsers(groupID string, page, perPage int) ([]*model.User, *model.AppError) { + users, _, err := w.app.GetGroupMemberUsersPage(groupID, page, perPage, nil) + return users, err +} + // Ensure the wrapper implements the product service. var _ product.TeamService = (*teamServiceWrapper)(nil) diff --git a/config/client.go b/config/client.go index 047d9eef1a..589a88688b 100644 --- a/config/client.go +++ b/config/client.go @@ -225,6 +225,7 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m props["BuildEnterpriseReady"] = model.BuildEnterpriseReady props["BuildHashBoards"] = model.BuildHashBoards props["BuildBoards"] = model.BuildBoards + props["BuildHashPlaybooks"] = model.BuildHashPlaybooks props["EnableBotAccountCreation"] = strconv.FormatBool(*c.ServiceSettings.EnableBotAccountCreation) props["EnableFile"] = strconv.FormatBool(*c.LogSettings.EnableFile) diff --git a/model/version.go b/model/version.go index 7cf3f2c531..e5398a1907 100644 --- a/model/version.go +++ b/model/version.go @@ -118,6 +118,7 @@ var BuildHashEnterprise string var BuildEnterpriseReady string var BuildHashBoards string var BuildBoards string +var BuildHashPlaybooks string var versionsWithoutHotFixes []string func init() { diff --git a/product/api.go b/product/api.go index 5cddab6723..2d65be8f45 100644 --- a/product/api.go +++ b/product/api.go @@ -33,6 +33,11 @@ type RouterService interface { // The service shall be registered via app.PostKey service key. type PostService interface { CreatePost(context *request.Context, post *model.Post) (*model.Post, *model.AppError) + GetPostsByIds(postIDs []string) ([]*model.Post, int64, *model.AppError) + SendEphemeralPost(ctx *request.Context, userID string, post *model.Post) *model.Post + GetPost(postID string) (*model.Post, *model.AppError) + DeletePost(ctx *request.Context, postID, productID string) (*model.Post, *model.AppError) + UpdatePost(c *request.Context, post *model.Post, safeUpdate bool) (*model.Post, *model.AppError) } // PermissionService provides permissions related utilities. For now, the service implementation @@ -44,6 +49,7 @@ type PermissionService interface { HasPermissionTo(userID string, permission *model.Permission) bool HasPermissionToTeam(userID, teamID string, permission *model.Permission) bool HasPermissionToChannel(askingUserID string, channelID string, permission *model.Permission) bool + RolesGrantPermission(roleNames []string, permissionID string) bool } // ClusterService enables to publish cluster events. In addition to that, It's being used for @@ -66,6 +72,15 @@ type ChannelService interface { GetChannelByID(channelID string) (*model.Channel, *model.AppError) GetChannelMember(channelID string, userID string) (*model.ChannelMember, *model.AppError) GetChannelsForTeamForUser(teamID string, userID string, opts *model.ChannelSearchOpts) (model.ChannelList, *model.AppError) + GetChannelSidebarCategories(userID, teamID string) (*model.OrderedSidebarCategories, *model.AppError) + GetChannelMembers(channelID string, page, perPage int) (model.ChannelMembers, *model.AppError) + CreateChannelSidebarCategory(userID, teamID string, newCategory *model.SidebarCategoryWithChannels) (*model.SidebarCategoryWithChannels, *model.AppError) + UpdateChannelSidebarCategories(userID, teamID string, categories []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, *model.AppError) + CreateChannel(channel *model.Channel) (*model.Channel, *model.AppError) + AddUserToChannel(channelID, userID, asUserID string) (*model.ChannelMember, *model.AppError) + UpdateChannelMemberRoles(channelID, userID, newRoles string) (*model.ChannelMember, *model.AppError) + DeleteChannelMember(channelID, userID string) *model.AppError + AddChannelMember(channelID, userID string) (*model.ChannelMember, *model.AppError) } // LicenseService provides license related utilities. @@ -96,6 +111,9 @@ type UserService interface { type TeamService interface { GetMember(teamID, userID string) (*model.TeamMember, *model.AppError) CreateMember(ctx *request.Context, teamID, userID string) (*model.TeamMember, *model.AppError) + GetGroup(groupId string) (*model.Group, *model.AppError) + GetTeam(teamID string) (*model.Team, *model.AppError) + GetGroupMemberUsers(groupID string, page, perPage int) ([]*model.User, *model.AppError) } // BotService is just a copy implementation of mattermost-plugin-api EnsureBot method. @@ -161,6 +179,9 @@ type CloudService interface { // The service shall be registered via app.KVStoreKey service key. type KVStoreService interface { SetPluginKeyWithOptions(pluginID string, key string, value []byte, options model.PluginKVSetOptions) (bool, *model.AppError) + KVGet(productID, key string) ([]byte, *model.AppError) + KVDelete(productID, key string) *model.AppError + KVList(productID string, page, perPage int) ([]string, *model.AppError) } // LogService is the API for accessing the log service APIs. @@ -212,3 +233,32 @@ type BoardsService interface { HasPermissionToBoard(userID, boardID string, permission *model.Permission) bool DuplicateBoard(boardID string, userID string, toTeam string, asTemplate bool) (*fb_model.BoardsAndBlocks, []*fb_model.BoardMember, error) } + +// SessionService is the API for accessing the session. +// +// The service shall be registered via app.SessionKey service key. +type SessionService interface { + GetSessionById(sessionID string) (*model.Session, *model.AppError) +} + +// FrontendService is the API for interacting with front end. +// +// The service shall be registered via app.FrontendKey service key. +type FrontendService interface { + OpenInteractiveDialog(dialog model.OpenDialogRequest) *model.AppError +} + +// CommandService is the API for interacting with front end. +// +// The service shall be registered via app.CommandKey service key. +type CommandService interface { + ExecuteCommand(c request.CTX, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) + RegisterPluginCommand(pluginID string, command *model.Command) error +} + +// ThreadsService is the API for interacting with threads anywhere. +// +// The service shall be registered via app.ThreadsKey service key. +type ThreadsService interface { + RegisterCollectionAndTopic(productID string, collectionType, topicType string) error +} diff --git a/product/service.go b/product/service.go index a0a92e6adc..7e629fc488 100644 --- a/product/service.go +++ b/product/service.go @@ -26,4 +26,8 @@ const ( SystemKey ServiceKey = "systemkey" PreferencesKey ServiceKey = "preferenceskey" BoardsKey ServiceKey = "boards" + SessionKey ServiceKey = "sessionkey" + FrontendKey ServiceKey = "frontendkey" + CommandKey ServiceKey = "commandkey" + ThreadsKey ServiceKey = "threadskey" ) diff --git a/scripts/setup_go_work.sh b/scripts/setup_go_work.sh index d6aa060883..de7c44df40 100755 --- a/scripts/setup_go_work.sh +++ b/scripts/setup_go_work.sh @@ -16,5 +16,10 @@ then txt="${txt}use ../focalboard/server\nuse ../focalboard/mattermost-plugin\n" fi + if [ "$BUILD_PLAYBOOKS" == "true" ] + then + txt="${txt}use ../mattermost-plugin-playbooks\n" + fi + printf "$txt" > "go.work" fi diff --git a/shared/i18n/i18n.go b/shared/i18n/i18n.go index 5bf0f13bfe..1bf063546b 100644 --- a/shared/i18n/i18n.go +++ b/shared/i18n/i18n.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/mattermost/go-i18n/i18n" + "github.com/mattermost/go-i18n/i18n/bundle" "github.com/mattermost/mattermost-server/v6/shared/mlog" ) @@ -22,6 +23,9 @@ const defaultLocale = "en" // TranslateFunc is the type of the translate functions type TranslateFunc func(translationID string, args ...any) string +// TranslationFuncByLocal is the type of function that takes local as a string and returns the translation function +type TranslationFuncByLocal func(locale string) TranslateFunc + // T is the translate function using the default server language as fallback language var T TranslateFunc @@ -74,6 +78,41 @@ func initTranslationsWithDir(dir string) error { return nil } +// GetTranslationFuncForDir loads translations from the filesystem into a new instance of the bundle. +// It returns a function to access loaded translations. +func GetTranslationFuncForDir(dir string) (TranslationFuncByLocal, error) { + var availableLocals map[string]string = make(map[string]string) + bundle := bundle.New() + files, _ := os.ReadDir(dir) + for _, f := range files { + if filepath.Ext(f.Name()) != ".json" { + continue + } + + filename := f.Name() + availableLocals[strings.Split(filename, ".")[0]] = filepath.Join(dir, filename) + if err := bundle.LoadTranslationFile(filepath.Join(dir, filename)); err != nil { + return nil, err + } + } + + return func(locale string) TranslateFunc { + if _, ok := availableLocals[locale]; !ok { + locale = defaultLocale + } + + t, _ := bundle.Tfunc(locale) + return func(translationID string, args ...any) string { + if translated := t(translationID, args...); translated != translationID { + return translated + } + + t, _ := bundle.Tfunc(defaultLocale) + return t(translationID, args...) + } + }, nil +} + func getTranslationsBySystemLocale() (TranslateFunc, error) { locale := defaultServerLocale if _, ok := locales[locale]; !ok {