diff --git a/cmd/database.go b/cmd/database.go index 6eda1a9f..636098da 100644 --- a/cmd/database.go +++ b/cmd/database.go @@ -141,5 +141,13 @@ func updateAllDatabaseTablesStructure(c *core.CliContext) error { log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user custom exchange rate table maintained successfully") + err = datastore.Container.UserDataStore.SyncStructs(new(models.UserApplicationCloudSetting)) + + if err != nil { + return err + } + + log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user application cloud settings table maintained successfully") + return nil } diff --git a/cmd/webserver.go b/cmd/webserver.go index 3e17a7ec..4156e9f8 100644 --- a/cmd/webserver.go +++ b/cmd/webserver.go @@ -276,6 +276,11 @@ func startWebServer(c *core.CliContext) error { apiV1Route.POST("/users/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByLoginedUserHandler)) } + // Application Cloud Settings + apiV1Route.GET("/users/settings/cloud/get.json", bindApi(api.UserApplicationCloudSettings.ApplicationSettingsGetHandler)) + apiV1Route.POST("/users/settings/cloud/update.json", bindApi(api.UserApplicationCloudSettings.ApplicationSettingsUpdateHandler)) + apiV1Route.POST("/users/settings/cloud/disable.json", bindApi(api.UserApplicationCloudSettings.ApplicationSettingsDisableHandler)) + // Two-Factor Authorization if config.EnableTwoFactor { apiV1Route.GET("/users/2fa/status.json", bindApi(api.TwoFactorAuthorizations.TwoFactorStatusHandler)) diff --git a/pkg/api/authorizations.go b/pkg/api/authorizations.go index efdaa17e..cdf049e6 100644 --- a/pkg/api/authorizations.go +++ b/pkg/api/authorizations.go @@ -19,6 +19,7 @@ type AuthorizationsApi struct { ApiUsingDuplicateChecker ApiWithUserInfo users *services.UserService + userAppCloudSettings *services.UserApplicationCloudSettingsService tokens *services.TokenService twoFactorAuthorizations *services.TwoFactorAuthorizationService } @@ -44,6 +45,7 @@ var ( }, }, users: services.Users, + userAppCloudSettings: services.UserApplicationCloudSettings, tokens: services.Tokens, twoFactorAuthorizations: services.TwoFactorAuthorizations, } @@ -140,9 +142,18 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err c.SetTokenClaims(claims) + userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid) + var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil + + if err != nil { + log.Warnf(c, "[authorizations.AuthorizeHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error()) + } else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 { + applicationCloudSettingSlice = &userApplicationCloudSettings.Settings + } + log.Infof(c, "[authorizations.AuthorizeHandler] user \"uid:%d\" has logined, token type is %d, token will be expired at %d", user.Uid, claims.Type, claims.ExpiresAt) - authResp := a.getAuthResponse(c, token, twoFactorEnable, user) + authResp := a.getAuthResponse(c, token, twoFactorEnable, user, applicationCloudSettingSlice) return authResp, nil } @@ -218,9 +229,18 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any, c.SetTextualToken(token) c.SetTokenClaims(claims) + userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid) + var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil + + if err != nil { + log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error()) + } else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 { + applicationCloudSettingSlice = &userApplicationCloudSettings.Settings + } + log.Infof(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has authorized two-factor via passcode, token will be expired at %d", user.Uid, claims.ExpiresAt) - authResp := a.getAuthResponse(c, token, false, user) + authResp := a.getAuthResponse(c, token, false, user, applicationCloudSettingSlice) return authResp, nil } @@ -303,17 +323,27 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC c.SetTextualToken(token) c.SetTokenClaims(claims) + userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid) + var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil + + if err != nil { + log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error()) + } else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 { + applicationCloudSettingSlice = &userApplicationCloudSettings.Settings + } + log.Infof(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" has authorized two-factor via recovery code \"%s\", token will be expired at %d", user.Uid, credential.RecoveryCode, claims.ExpiresAt) - authResp := a.getAuthResponse(c, token, false, user) + authResp := a.getAuthResponse(c, token, false, user, applicationCloudSettingSlice) return authResp, nil } -func (a *AuthorizationsApi) getAuthResponse(c *core.WebContext, token string, need2FA bool, user *models.User) *models.AuthResponse { +func (a *AuthorizationsApi) getAuthResponse(c *core.WebContext, token string, need2FA bool, user *models.User, applicationCloudSettings *models.ApplicationCloudSettingSlice) *models.AuthResponse { return &models.AuthResponse{ - Token: token, - Need2FA: need2FA, - User: a.GetUserBasicInfo(user), - NotificationContent: a.GetAfterLoginNotificationContent(user.Language, c.GetClientLocale()), + Token: token, + Need2FA: need2FA, + User: a.GetUserBasicInfo(user), + ApplicationCloudSettings: applicationCloudSettings, + NotificationContent: a.GetAfterLoginNotificationContent(user.Language, c.GetClientLocale()), } } diff --git a/pkg/api/tokens.go b/pkg/api/tokens.go index 1b413912..99e64cb9 100644 --- a/pkg/api/tokens.go +++ b/pkg/api/tokens.go @@ -18,8 +18,9 @@ import ( type TokensApi struct { ApiUsingConfig ApiWithUserInfo - tokens *services.TokenService - users *services.UserService + tokens *services.TokenService + users *services.UserService + userAppCloudSettings *services.UserApplicationCloudSettingsService } // Initialize a token api singleton instance @@ -36,8 +37,9 @@ var ( container: avatars.Container, }, }, - tokens: services.Tokens, - users: services.Users, + tokens: services.Tokens, + users: services.Users, + userAppCloudSettings: services.UserApplicationCloudSettings, } ) @@ -251,9 +253,19 @@ func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) { } } + userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid) + var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil + + if err != nil { + log.Warnf(c, "[tokens.TokenRefreshHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error()) + } else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 { + applicationCloudSettingSlice = &userApplicationCloudSettings.Settings + } + refreshResp := &models.TokenRefreshResponse{ - User: a.GetUserBasicInfo(user), - NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()), + User: a.GetUserBasicInfo(user), + ApplicationCloudSettings: applicationCloudSettingSlice, + NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()), } return refreshResp, nil @@ -276,13 +288,23 @@ func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) { c.SetTextualToken(token) c.SetTokenClaims(claims) + userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid) + var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil + + if err != nil { + log.Warnf(c, "[tokens.TokenRefreshHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error()) + } else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 { + applicationCloudSettingSlice = &userApplicationCloudSettings.Settings + } + log.Infof(c, "[tokens.TokenRefreshHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt) refreshResp := &models.TokenRefreshResponse{ - NewToken: token, - OldTokenId: a.tokens.GenerateTokenId(oldTokenRecord), - User: a.GetUserBasicInfo(user), - NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()), + NewToken: token, + OldTokenId: a.tokens.GenerateTokenId(oldTokenRecord), + User: a.GetUserBasicInfo(user), + ApplicationCloudSettings: applicationCloudSettingSlice, + NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()), } return refreshResp, nil diff --git a/pkg/api/user_app_cloud_settings.go b/pkg/api/user_app_cloud_settings.go new file mode 100644 index 00000000..0e575303 --- /dev/null +++ b/pkg/api/user_app_cloud_settings.go @@ -0,0 +1,191 @@ +package api + +import ( + "encoding/json" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/log" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/services" + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +// UserApplicationCloudSettingsApi represents user application cloud settings api +type UserApplicationCloudSettingsApi struct { + userAppCloudSettings *services.UserApplicationCloudSettingsService +} + +// Initialize a user application cloud settings api singleton instance +var ( + UserApplicationCloudSettings = &UserApplicationCloudSettingsApi{ + userAppCloudSettings: services.UserApplicationCloudSettings, + } +) + +// ApplicationSettingsGetHandler returns application cloud settings of current user +func (a *UserApplicationCloudSettingsApi) ApplicationSettingsGetHandler(c *core.WebContext) (any, *errs.Error) { + uid := c.GetCurrentUid() + + userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, uid) + + if err != nil { + log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsGetHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + if userApplicationCloudSettings == nil { + return false, nil + } + + applicationCloudSettingSlice := userApplicationCloudSettings.Settings + + if len(applicationCloudSettingSlice) < 1 { + return false, nil + } + + return applicationCloudSettingSlice, nil +} + +// ApplicationSettingsUpdateHandler updates user application cloud settings by request parameters for current user +func (a *UserApplicationCloudSettingsApi) ApplicationSettingsUpdateHandler(c *core.WebContext) (any, *errs.Error) { + var userAppCloudSettingUpdateReq models.UserApplicationCloudSettingsUpdateRequest + err := c.ShouldBindJSON(&userAppCloudSettingUpdateReq) + + if err != nil { + log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] parse request failed, because %s", err.Error()) + return false, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + uid := c.GetCurrentUid() + userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, uid) + + if err != nil { + log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", uid, err.Error()) + return false, errs.Or(err, errs.ErrOperationFailed) + } + + oldApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting) + + if userApplicationCloudSettings != nil { + for _, setting := range userApplicationCloudSettings.Settings { + oldApplicationCloudSettingsMap[setting.SettingKey] = setting + } + } + + // Check if the full update settings are the same as the existing settings + if userAppCloudSettingUpdateReq.FullUpdate { + if len(userAppCloudSettingUpdateReq.Settings) == len(oldApplicationCloudSettingsMap) { + needUpdate := false + + for _, setting := range userAppCloudSettingUpdateReq.Settings { + oldSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey] + + if !exists || oldSetting.SettingValue != setting.SettingValue { + needUpdate = true + break + } + } + + if !needUpdate { + return false, errs.ErrNothingWillBeUpdated + } + } + } else { // Check if the partial update settings are the same as the existing settings or the settings to update are not set to sync + needUpdate := true + + for _, setting := range userAppCloudSettingUpdateReq.Settings { + cloudSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey] + + if !exists { + needUpdate = false + log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not set to sync", setting.SettingKey) + } else if cloudSetting.SettingValue == setting.SettingValue { + needUpdate = false + log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" value \"%s\" is not changed, no need to update", setting.SettingKey, setting.SettingValue) + } + } + + if !needUpdate { + log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] no user application cloud settings need to update for user \"uid:%d\"", uid) + return false, nil + } + } + + newApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting) + var newApplicationCloudSettingSlice models.ApplicationCloudSettingSlice + + if userAppCloudSettingUpdateReq.FullUpdate { + log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings force update, will overwrite all existing settings", uid) + } else { + if len(oldApplicationCloudSettingsMap) > 0 { + log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings exists, try to merge it with request settings", uid) + newApplicationCloudSettingsMap = oldApplicationCloudSettingsMap + } + } + + for _, setting := range userAppCloudSettingUpdateReq.Settings { + newApplicationCloudSettingsMap[setting.SettingKey] = setting + } + + for settingKey, setting := range newApplicationCloudSettingsMap { + settingType, exists := models.ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES[settingKey] + + if !exists { + log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not supported to sync", settingKey) + continue + } + + if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING { + // Do Nothing + } else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER { + _, err := utils.StringToFloat64(setting.SettingValue) + + if err != nil { + log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid number value \"%s\"", settingKey, setting.SettingValue) + continue + } + } else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN { + if setting.SettingValue != "true" && setting.SettingValue != "false" { + log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid boolean value \"%s\"", settingKey, setting.SettingValue) + continue + } + } else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP { + var settingValueMap map[string]bool + err := json.Unmarshal([]byte(setting.SettingValue), &settingValueMap) + + if err != nil { + log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid map value \"%s\", because %s", settingKey, setting.SettingValue, err.Error()) + continue + } + } else { + log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has unknown type \"%s\"", settingKey, settingType) + continue + } + + newApplicationCloudSettingSlice = append(newApplicationCloudSettingSlice, setting) + } + + err = a.userAppCloudSettings.UpdateUserApplicationCloudSettings(c, uid, newApplicationCloudSettingSlice) + + if err != nil { + log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to update user application cloud settings for user \"uid:%d\", because %s", uid, err.Error()) + return false, errs.Or(err, errs.ErrOperationFailed) + } + + return true, nil +} + +// ApplicationSettingsDisableHandler disabled user application cloud settings by request parameters for current user +func (a *UserApplicationCloudSettingsApi) ApplicationSettingsDisableHandler(c *core.WebContext) (any, *errs.Error) { + uid := c.GetCurrentUid() + + err := a.userAppCloudSettings.ClearUserApplicationCloudSettings(c, uid) + + if err != nil { + log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsDisableHandler] failed to clear user application cloud settings for user \"uid:%d\", because %s", uid, err.Error()) + return false, errs.Or(err, errs.ErrOperationFailed) + } + + return true, nil +} diff --git a/pkg/models/auth_response.go b/pkg/models/auth_response.go index df901ad1..36c86e22 100644 --- a/pkg/models/auth_response.go +++ b/pkg/models/auth_response.go @@ -2,10 +2,11 @@ package models // AuthResponse returns a view-object of user authorization type AuthResponse struct { - Token string `json:"token"` - Need2FA bool `json:"need2FA"` - User *UserBasicInfo `json:"user"` - NotificationContent string `json:"notificationContent,omitempty"` + Token string `json:"token"` + Need2FA bool `json:"need2FA"` + User *UserBasicInfo `json:"user"` + ApplicationCloudSettings *ApplicationCloudSettingSlice `json:"applicationCloudSettings,omitempty"` + NotificationContent string `json:"notificationContent,omitempty"` } // RegisterResponse returns a view-object of user register response diff --git a/pkg/models/token_record.go b/pkg/models/token_record.go index 37cc7fdc..dfc02d5d 100644 --- a/pkg/models/token_record.go +++ b/pkg/models/token_record.go @@ -24,10 +24,11 @@ type TokenRevokeRequest struct { // TokenRefreshResponse represents all parameters of token refreshing request type TokenRefreshResponse struct { - NewToken string `json:"newToken,omitempty"` - OldTokenId string `json:"oldTokenId,omitempty"` - User *UserBasicInfo `json:"user"` - NotificationContent string `json:"notificationContent,omitempty"` + NewToken string `json:"newToken,omitempty"` + OldTokenId string `json:"oldTokenId,omitempty"` + User *UserBasicInfo `json:"user"` + ApplicationCloudSettings *ApplicationCloudSettingSlice `json:"applicationCloudSettings,omitempty"` + NotificationContent string `json:"notificationContent,omitempty"` } // TokenInfoResponse represents a view-object of token diff --git a/pkg/models/user_app_cloud_setting.go b/pkg/models/user_app_cloud_setting.go new file mode 100644 index 00000000..1c6a712c --- /dev/null +++ b/pkg/models/user_app_cloud_setting.go @@ -0,0 +1,89 @@ +package models + +import ( + "encoding/json" +) + +type UserApplicationCloudSettingType string + +const ( + USER_APPLICATION_CLOUD_SETTING_TYPE_STRING UserApplicationCloudSettingType = "string" + USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER UserApplicationCloudSettingType = "number" + USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN UserApplicationCloudSettingType = "boolean" + USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP UserApplicationCloudSettingType = "string_boolean_map" +) + +var ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES = map[string]UserApplicationCloudSettingType{ + // Basic Settings + "showAccountBalance": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN, + // Overview Page + "showAmountInHomePage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN, + "timezoneUsedForStatisticsInHomePage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER, + // Transaction List Page + "itemsCountInTransactionListPage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER, + "showTotalAmountInTransactionListPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN, + "showTagInTransactionListPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN, + // Transaction Edit Page + "autoSaveTransactionDraft": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING, + "autoGetCurrentGeoLocation": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN, + "alwaysShowTransactionPicturesInMobileTransactionEditPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN, + // Exchange Rates Data Page + "currencySortByInExchangeRatesPage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER, + // Statistics Settings + "statistics.defaultChartDataType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER, + "statistics.defaultTimezoneType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER, + "statistics.defaultAccountFilter": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP, + "statistics.defaultTransactionCategoryFilter": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP, + "statistics.defaultSortingType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER, + "statistics.defaultCategoricalChartType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER, + "statistics.defaultCategoricalChartDataRangeType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER, + "statistics.defaultTrendChartType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER, + "statistics.defaultTrendChartDataRangeType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER, +} + +// UserApplicationCloudSetting represents user application cloud setting stored in database +type UserApplicationCloudSetting struct { + Uid int64 `xorm:"PK"` + Settings ApplicationCloudSettingSlice `xorm:"BLOB"` + UpdatedUnixTime int64 +} + +// UserApplicationCloudSettingsUpdateRequest represents all parameters of application cloud settings update request +type UserApplicationCloudSettingsUpdateRequest struct { + Settings ApplicationCloudSettingSlice `json:"settings"` + FullUpdate bool `json:"fullUpdate"` +} + +// ApplicationCloudSettingSlice represents the slice data structure of ApplicationCloudSetting +type ApplicationCloudSettingSlice []ApplicationCloudSetting + +// ApplicationCloudSetting represents one application cloud setting +type ApplicationCloudSetting struct { + SettingKey string `json:"settingKey"` + SettingValue string `json:"settingValue"` +} + +// FromDB fills the fields from the data stored in database +func (s *ApplicationCloudSettingSlice) FromDB(data []byte) error { + return json.Unmarshal(data, s) +} + +// ToDB returns the actual stored data in database +func (s *ApplicationCloudSettingSlice) ToDB() ([]byte, error) { + return json.Marshal(s) +} + +// Len returns the count of items +func (s ApplicationCloudSettingSlice) Len() int { + return len(s) +} + +// Swap swaps two items +func (s ApplicationCloudSettingSlice) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +// Less reports whether the first item is less than the second one +func (s ApplicationCloudSettingSlice) Less(i, j int) bool { + return s[i].SettingKey < s[j].SettingKey +} diff --git a/pkg/services/user_app_cloud_settings.go b/pkg/services/user_app_cloud_settings.go new file mode 100644 index 00000000..28068c4c --- /dev/null +++ b/pkg/services/user_app_cloud_settings.go @@ -0,0 +1,92 @@ +package services + +import ( + "sort" + "time" + + "xorm.io/xorm" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/datastore" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/models" +) + +// UserApplicationCloudSettingsService represents user application cloud settings service +type UserApplicationCloudSettingsService struct { + ServiceUsingDB +} + +// Initialize a user application cloud settings service singleton instance +var ( + UserApplicationCloudSettings = &UserApplicationCloudSettingsService{ + ServiceUsingDB: ServiceUsingDB{ + container: datastore.Container, + }, + } +) + +// GetUserApplicationCloudSettingsByUid returns user application cloud settings models of user +func (s *UserApplicationCloudSettingsService) GetUserApplicationCloudSettingsByUid(c core.Context, uid int64) (*models.UserApplicationCloudSetting, error) { + if uid <= 0 { + return nil, errs.ErrUserIdInvalid + } + + applicationCloudSetting := &models.UserApplicationCloudSetting{} + has, err := s.UserDB().NewSession(c).ID(uid).Get(applicationCloudSetting) + + if err != nil { + return nil, err + } else if !has { + return nil, nil + } + + return applicationCloudSetting, nil +} + +// UpdateUserApplicationCloudSettings updates user application cloud settings +func (s *UserApplicationCloudSettingsService) UpdateUserApplicationCloudSettings(c core.Context, uid int64, settings models.ApplicationCloudSettingSlice) error { + if uid <= 0 { + return errs.ErrUserIdInvalid + } + + sort.Sort(settings) + + userApplicationCloudSetting := &models.UserApplicationCloudSetting{ + Uid: uid, + Settings: settings, + UpdatedUnixTime: time.Now().Unix(), + } + + return s.UserDB().DoTransaction(c, func(sess *xorm.Session) error { + exists, err := sess.Cols("uid").Where("uid=?", uid).Exist(&models.UserApplicationCloudSetting{}) + + if err != nil { + return err + } + + if !exists { + _, err = sess.Insert(userApplicationCloudSetting) + } else { + _, err = sess.ID(uid).Cols("settings", "updated_unix_time").Update(userApplicationCloudSetting) + } + + if err != nil { + return err + } + + return nil + }) +} + +// ClearUserApplicationCloudSettings clears user application cloud settings +func (s *UserApplicationCloudSettingsService) ClearUserApplicationCloudSettings(c core.Context, uid int64) error { + if uid <= 0 { + return errs.ErrUserIdInvalid + } + + return s.UserDB().DoTransaction(c, func(sess *xorm.Session) error { + _, err := sess.Where("uid=?", uid).Delete(&models.UserApplicationCloudSetting{}) + return err + }) +} diff --git a/src/core/setting.ts b/src/core/setting.ts index a5975f58..dfcfb312 100644 --- a/src/core/setting.ts +++ b/src/core/setting.ts @@ -20,24 +20,34 @@ export interface BaseApplicationSetting { } export interface ApplicationSettings extends BaseApplicationSetting { + // Debug Settings + debug: boolean; + // Basic Settings theme: string; fontSize: number; timeZone: string; - debug: boolean; + autoUpdateExchangeRatesData: boolean; + showAccountBalance: boolean; + animate: boolean; + // Application Lock applicationLock: boolean; applicationLockWebAuthn: boolean; - autoUpdateExchangeRatesData: boolean; - autoSaveTransactionDraft: string; - autoGetCurrentGeoLocation: boolean; - alwaysShowTransactionPicturesInMobileTransactionEditPage: boolean; + // Navigation Bar showAddTransactionButtonInDesktopNavbar: boolean; + // Overview Page showAmountInHomePage: boolean; timezoneUsedForStatisticsInHomePage: number; + // Transaction List Page itemsCountInTransactionListPage: number; showTotalAmountInTransactionListPage: boolean; showTagInTransactionListPage: boolean; - showAccountBalance: boolean; + // Transaction Edit Page + autoSaveTransactionDraft: string; + autoGetCurrentGeoLocation: boolean; + alwaysShowTransactionPicturesInMobileTransactionEditPage: boolean; + // Exchange Rates Data Page currencySortByInExchangeRatesPage: number; + // Statistics Settings statistics: { defaultChartDataType: number; defaultTimezoneType: number; @@ -49,7 +59,18 @@ export interface ApplicationSettings extends BaseApplicationSetting { defaultTrendChartType: number; defaultTrendChartDataRangeType: number; }; - animate: boolean; +} + +export enum UserApplicationCloudSettingType { + String = 'string', + Number = 'number', + Boolean = 'boolean', + StringBooleanMap = 'string_boolean_map', +} + +export interface ApplicationCloudSetting { + readonly settingKey: string; + readonly settingValue: string; } export interface LocaleDefaultSettings { @@ -66,25 +87,63 @@ export interface WebAuthnConfig { readonly credentialId: string; } +export const ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES: Record = { + // Basic Settings + 'showAccountBalance': UserApplicationCloudSettingType.Boolean, + // Overview Page + 'showAmountInHomePage': UserApplicationCloudSettingType.Boolean, + 'timezoneUsedForStatisticsInHomePage': UserApplicationCloudSettingType.Number, + // Transaction List Page + 'itemsCountInTransactionListPage': UserApplicationCloudSettingType.Number, + 'showTotalAmountInTransactionListPage': UserApplicationCloudSettingType.Boolean, + 'showTagInTransactionListPage': UserApplicationCloudSettingType.Boolean, + // Transaction Edit Page + 'autoSaveTransactionDraft': UserApplicationCloudSettingType.String, + 'autoGetCurrentGeoLocation': UserApplicationCloudSettingType.Boolean, + 'alwaysShowTransactionPicturesInMobileTransactionEditPage': UserApplicationCloudSettingType.Boolean, + // Exchange Rates Data Page + 'currencySortByInExchangeRatesPage': UserApplicationCloudSettingType.Number, + // Statistics Settings + 'statistics.defaultChartDataType': UserApplicationCloudSettingType.Number, + 'statistics.defaultTimezoneType': UserApplicationCloudSettingType.Number, + 'statistics.defaultAccountFilter': UserApplicationCloudSettingType.StringBooleanMap, + 'statistics.defaultTransactionCategoryFilter': UserApplicationCloudSettingType.StringBooleanMap, + 'statistics.defaultSortingType': UserApplicationCloudSettingType.Number, + 'statistics.defaultCategoricalChartType': UserApplicationCloudSettingType.Number, + 'statistics.defaultCategoricalChartDataRangeType': UserApplicationCloudSettingType.Number, + 'statistics.defaultTrendChartType': UserApplicationCloudSettingType.Number, + 'statistics.defaultTrendChartDataRangeType': UserApplicationCloudSettingType.Number, +}; + export const DEFAULT_APPLICATION_SETTINGS: ApplicationSettings = { + // Debug Settings + debug: false, + // Basic Settings theme: 'auto', fontSize: 1, timeZone: '', - debug: false, + autoUpdateExchangeRatesData: true, + showAccountBalance: true, + animate: true, + // Application Lock applicationLock: false, applicationLockWebAuthn: false, - autoUpdateExchangeRatesData: true, - autoSaveTransactionDraft: 'disabled', - autoGetCurrentGeoLocation: false, - alwaysShowTransactionPicturesInMobileTransactionEditPage: false, + // Navigation Bar showAddTransactionButtonInDesktopNavbar: true, + // Overview Page showAmountInHomePage: true, timezoneUsedForStatisticsInHomePage: TimezoneTypeForStatistics.Default.type, + // Transaction List Page itemsCountInTransactionListPage: 15, showTotalAmountInTransactionListPage: true, showTagInTransactionListPage: true, - showAccountBalance: true, + // Transaction Edit Page + autoSaveTransactionDraft: 'disabled', + autoGetCurrentGeoLocation: false, + alwaysShowTransactionPicturesInMobileTransactionEditPage: false, + // Exchange Rates Data Page currencySortByInExchangeRatesPage: CurrencySortingType.Default.type, + // Statistics Settings statistics: { defaultChartDataType: ChartDataType.Default.type, defaultTimezoneType: TimezoneTypeForStatistics.Default.type, @@ -95,8 +154,7 @@ export const DEFAULT_APPLICATION_SETTINGS: ApplicationSettings = { defaultCategoricalChartDataRangeType: DEFAULT_CATEGORICAL_CHART_DATA_RANGE.type, defaultTrendChartType: TrendChartType.Default.type, defaultTrendChartDataRangeType: DEFAULT_TREND_CHART_DATA_RANGE.type, - }, - animate: true + } }; export const DEFAULT_LOCALE_SETTINGS: LocaleDefaultSettings = { diff --git a/src/lib/services.ts b/src/lib/services.ts index 12bbba83..e8cd937a 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -2,6 +2,9 @@ import axios, { type AxiosRequestConfig, type AxiosRequestHeaders, type AxiosRes import type { ApiResponse } from '@/core/api.ts'; +import type { + ApplicationCloudSetting +} from '@/core/setting.ts'; import { TransactionType } from '@/core/transaction.ts'; @@ -119,6 +122,9 @@ import type { UserProfileUpdateRequest, UserProfileUpdateResponse } from '@/models/user.ts'; +import type { + UserApplicationCloudSettingsUpdateRequest +} from '@/models/user_app_cloud_setting.ts'; import { getCurrentToken, @@ -308,6 +314,15 @@ export default { resendVerifyEmailByLoginedUser: (): ApiResponsePromise => { return axios.post>('v1/users/verify_email/resend.json'); }, + getUserApplicationCloudSettings: (): ApiResponsePromise => { + return axios.get>('v1/users/settings/cloud/get.json'); + }, + updateUserApplicationCloudSettings: (req: UserApplicationCloudSettingsUpdateRequest): ApiResponsePromise => { + return axios.post>('v1/users/settings/cloud/update.json', req); + }, + disableUserApplicationCloudSettings: (): ApiResponsePromise => { + return axios.post>('v1/users/settings/cloud/disable.json'); + }, get2FAStatus: (): ApiResponsePromise => { return axios.get>('v1/users/2fa/status.json'); }, diff --git a/src/locales/de.json b/src/locales/de.json index 512e9c37..7c6174c3 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -2083,6 +2083,18 @@ "User has canceled or this device does not support WebAuthn": "Benutzer hat abgebrochen oder dieses Gerät unterstützt WebAuthn nicht", "Failed to authenticate with WebAuthn": "Authentifizierung mit WebAuthn fehlgeschlagen", "WebAuthn is not enabled": "WebAuthn ist nicht aktiviert", + "Settings Sync": "Settings Sync", + "Synchronized Settings": "Synchronized Settings", + "Enable Settings Sync": "Enable Settings Sync", + "Disable Settings Sync": "Disable Settings Sync", + "Update Synchronized Settings": "Update Synchronized Settings", + "Settings sync is not enabled": "Settings sync is not enabled", + "Settings sync has been enabled": "Settings sync has been enabled", + "Settings sync has been disabled": "Settings sync has been disabled", + "Synchronized settings have been updated": "Synchronized settings have been updated", + "Unable to retrieve user synchronized application settings": "Unable to retrieve user synchronized application settings", + "Unable to update user synchronized application settings": "Unable to update user synchronized application settings", + "Unable to disable user synchronized application settings": "Unable to disable user synchronized application settings", "Are you sure you want to re-login?": "Sind Sie sicher, dass Sie sich erneut anmelden möchten?", "Exchange Rates Data": "Wechselkursdaten", "User Custom": "User Custom", diff --git a/src/locales/en.json b/src/locales/en.json index 600986c4..8beb2897 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2083,6 +2083,18 @@ "User has canceled or this device does not support WebAuthn": "User has canceled or this device does not support WebAuthn", "Failed to authenticate with WebAuthn": "Failed to authenticate with WebAuthn", "WebAuthn is not enabled": "WebAuthn is not enabled", + "Settings Sync": "Settings Sync", + "Synchronized Settings": "Synchronized Settings", + "Enable Settings Sync": "Enable Settings Sync", + "Disable Settings Sync": "Disable Settings Sync", + "Update Synchronized Settings": "Update Synchronized Settings", + "Settings sync is not enabled": "Settings sync is not enabled", + "Settings sync has been enabled": "Settings sync has been enabled", + "Settings sync has been disabled": "Settings sync has been disabled", + "Synchronized settings have been updated": "Synchronized settings have been updated", + "Unable to retrieve user synchronized application settings": "Unable to retrieve user synchronized application settings", + "Unable to update user synchronized application settings": "Unable to update user synchronized application settings", + "Unable to disable user synchronized application settings": "Unable to disable user synchronized application settings", "Are you sure you want to re-login?": "Are you sure you want to re-login?", "Exchange Rates Data": "Exchange Rates Data", "User Custom": "User Custom", diff --git a/src/locales/es.json b/src/locales/es.json index df8af863..f355d8ba 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2083,6 +2083,18 @@ "User has canceled or this device does not support WebAuthn": "El usuario ha cancelado o este dispositivo no es compatible con WebAuthn", "Failed to authenticate with WebAuthn": "No se pudo autenticar con WebAuthn", "WebAuthn is not enabled": "WebAuthn no está habilitado", + "Settings Sync": "Settings Sync", + "Synchronized Settings": "Synchronized Settings", + "Enable Settings Sync": "Enable Settings Sync", + "Disable Settings Sync": "Disable Settings Sync", + "Update Synchronized Settings": "Update Synchronized Settings", + "Settings sync is not enabled": "Settings sync is not enabled", + "Settings sync has been enabled": "Settings sync has been enabled", + "Settings sync has been disabled": "Settings sync has been disabled", + "Synchronized settings have been updated": "Synchronized settings have been updated", + "Unable to retrieve user synchronized application settings": "Unable to retrieve user synchronized application settings", + "Unable to update user synchronized application settings": "Unable to update user synchronized application settings", + "Unable to disable user synchronized application settings": "Unable to disable user synchronized application settings", "Are you sure you want to re-login?": "¿Está seguro de que desea volver a iniciar sesión?", "Exchange Rates Data": "Datos de tipos de cambio", "User Custom": "User Custom", diff --git a/src/locales/it.json b/src/locales/it.json index ab398442..4b4b6606 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -2083,6 +2083,18 @@ "User has canceled or this device does not support WebAuthn": "L'utente ha annullato o questo dispositivo non supporta WebAuthn", "Failed to authenticate with WebAuthn": "Impossibile autenticare con WebAuthn", "WebAuthn is not enabled": "WebAuthn non è abilitato", + "Settings Sync": "Settings Sync", + "Synchronized Settings": "Synchronized Settings", + "Enable Settings Sync": "Enable Settings Sync", + "Disable Settings Sync": "Disable Settings Sync", + "Update Synchronized Settings": "Update Synchronized Settings", + "Settings sync is not enabled": "Settings sync is not enabled", + "Settings sync has been enabled": "Settings sync has been enabled", + "Settings sync has been disabled": "Settings sync has been disabled", + "Synchronized settings have been updated": "Synchronized settings have been updated", + "Unable to retrieve user synchronized application settings": "Unable to retrieve user synchronized application settings", + "Unable to update user synchronized application settings": "Unable to update user synchronized application settings", + "Unable to disable user synchronized application settings": "Unable to disable user synchronized application settings", "Are you sure you want to re-login?": "Sei sicuro di voler accedere di nuovo?", "Exchange Rates Data": "Dati tassi di cambio", "User Custom": "User Custom", diff --git a/src/locales/ja.json b/src/locales/ja.json index c9f95dc9..dea42017 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -2083,6 +2083,18 @@ "User has canceled or this device does not support WebAuthn": "ユーザーがキャンセルしたか、このデバイスがWebAuthnをサポートしていません", "Failed to authenticate with WebAuthn": "WebAuthnによる認証に失敗しました", "WebAuthn is not enabled": "WebAuthnが有効になっていません", + "Settings Sync": "Settings Sync", + "Synchronized Settings": "Synchronized Settings", + "Enable Settings Sync": "Enable Settings Sync", + "Disable Settings Sync": "Disable Settings Sync", + "Update Synchronized Settings": "Update Synchronized Settings", + "Settings sync is not enabled": "Settings sync is not enabled", + "Settings sync has been enabled": "Settings sync has been enabled", + "Settings sync has been disabled": "Settings sync has been disabled", + "Synchronized settings have been updated": "Synchronized settings have been updated", + "Unable to retrieve user synchronized application settings": "Unable to retrieve user synchronized application settings", + "Unable to update user synchronized application settings": "Unable to update user synchronized application settings", + "Unable to disable user synchronized application settings": "Unable to disable user synchronized application settings", "Are you sure you want to re-login?": "再ログインしますか?", "Exchange Rates Data": "為替レートデータ", "User Custom": "User Custom", diff --git a/src/locales/pt_BR.json b/src/locales/pt_BR.json index 401c4842..a7261303 100644 --- a/src/locales/pt_BR.json +++ b/src/locales/pt_BR.json @@ -2083,6 +2083,18 @@ "User has canceled or this device does not support WebAuthn": "O usuário cancelou ou este dispositivo não suporta WebAuthn", "Failed to authenticate with WebAuthn": "Falha na autenticação com WebAuthn", "WebAuthn is not enabled": "WebAuthn não está ativado", + "Settings Sync": "Settings Sync", + "Synchronized Settings": "Synchronized Settings", + "Enable Settings Sync": "Enable Settings Sync", + "Disable Settings Sync": "Disable Settings Sync", + "Update Synchronized Settings": "Update Synchronized Settings", + "Settings sync is not enabled": "Settings sync is not enabled", + "Settings sync has been enabled": "Settings sync has been enabled", + "Settings sync has been disabled": "Settings sync has been disabled", + "Synchronized settings have been updated": "Synchronized settings have been updated", + "Unable to retrieve user synchronized application settings": "Unable to retrieve user synchronized application settings", + "Unable to update user synchronized application settings": "Unable to update user synchronized application settings", + "Unable to disable user synchronized application settings": "Unable to disable user synchronized application settings", "Are you sure you want to re-login?": "Tem certeza de que deseja fazer login novamente?", "Exchange Rates Data": "Dados de Taxas de Câmbio", "User Custom": "Personalização do Usuário", diff --git a/src/locales/ru.json b/src/locales/ru.json index 39f96c93..4f1096a7 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2083,6 +2083,18 @@ "User has canceled or this device does not support WebAuthn": "Пользователь отменил или это устройство не поддерживает WebAuthn", "Failed to authenticate with WebAuthn": "Не удалось аутентифицироваться с помощью WebAuthn", "WebAuthn is not enabled": "WebAuthn не включен", + "Settings Sync": "Settings Sync", + "Synchronized Settings": "Synchronized Settings", + "Enable Settings Sync": "Enable Settings Sync", + "Disable Settings Sync": "Disable Settings Sync", + "Update Synchronized Settings": "Update Synchronized Settings", + "Settings sync is not enabled": "Settings sync is not enabled", + "Settings sync has been enabled": "Settings sync has been enabled", + "Settings sync has been disabled": "Settings sync has been disabled", + "Synchronized settings have been updated": "Synchronized settings have been updated", + "Unable to retrieve user synchronized application settings": "Unable to retrieve user synchronized application settings", + "Unable to update user synchronized application settings": "Unable to update user synchronized application settings", + "Unable to disable user synchronized application settings": "Unable to disable user synchronized application settings", "Are you sure you want to re-login?": "Вы уверены, что хотите войти снова?", "Exchange Rates Data": "Данные о курсах валют", "User Custom": "User Custom", diff --git a/src/locales/uk.json b/src/locales/uk.json index dcac5744..1d16b410 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -2083,6 +2083,18 @@ "User has canceled or this device does not support WebAuthn": "Користувач скасував дію або пристрій не підтримує WebAuthn", "Failed to authenticate with WebAuthn": "Не вдалося пройти автентифікацію за допомогою WebAuthn", "WebAuthn is not enabled": "WebAuthn не увімкнено", + "Settings Sync": "Settings Sync", + "Synchronized Settings": "Synchronized Settings", + "Enable Settings Sync": "Enable Settings Sync", + "Disable Settings Sync": "Disable Settings Sync", + "Update Synchronized Settings": "Update Synchronized Settings", + "Settings sync is not enabled": "Settings sync is not enabled", + "Settings sync has been enabled": "Settings sync has been enabled", + "Settings sync has been disabled": "Settings sync has been disabled", + "Synchronized settings have been updated": "Synchronized settings have been updated", + "Unable to retrieve user synchronized application settings": "Unable to retrieve user synchronized application settings", + "Unable to update user synchronized application settings": "Unable to update user synchronized application settings", + "Unable to disable user synchronized application settings": "Unable to disable user synchronized application settings", "Are you sure you want to re-login?": "Ви впевнені, що хочете увійти знову?", "Exchange Rates Data": "Дані про курси валют", "User Custom": "User Custom", diff --git a/src/locales/vi.json b/src/locales/vi.json index cb841baa..27e3fba0 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -2083,6 +2083,18 @@ "User has canceled or this device does not support WebAuthn": "Người dùng đã hủy hoặc thiết bị này không hỗ trợ WebAuthn", "Failed to authenticate with WebAuthn": "Xác thực bằng WebAuthn không thành công", "WebAuthn is not enabled": "WebAuthn chưa được bật", + "Settings Sync": "Settings Sync", + "Synchronized Settings": "Synchronized Settings", + "Enable Settings Sync": "Enable Settings Sync", + "Disable Settings Sync": "Disable Settings Sync", + "Update Synchronized Settings": "Update Synchronized Settings", + "Settings sync is not enabled": "Settings sync is not enabled", + "Settings sync has been enabled": "Settings sync has been enabled", + "Settings sync has been disabled": "Settings sync has been disabled", + "Synchronized settings have been updated": "Synchronized settings have been updated", + "Unable to retrieve user synchronized application settings": "Unable to retrieve user synchronized application settings", + "Unable to update user synchronized application settings": "Unable to update user synchronized application settings", + "Unable to disable user synchronized application settings": "Unable to disable user synchronized application settings", "Are you sure you want to re-login?": "Bạn có chắc chắn muốn đăng nhập lại không?", "Exchange Rates Data": "Dữ liệu tỷ giá hối đoái", "User Custom": "User Custom", diff --git a/src/locales/zh_Hans.json b/src/locales/zh_Hans.json index 54777ae0..e6796ae0 100644 --- a/src/locales/zh_Hans.json +++ b/src/locales/zh_Hans.json @@ -2083,6 +2083,18 @@ "User has canceled or this device does not support WebAuthn": "用户已取消或当前设备不支持 WebAuthn", "Failed to authenticate with WebAuthn": "使用 WebAuthn 认证失败", "WebAuthn is not enabled": "WebAuthn 没有启用", + "Settings Sync": "设置同步", + "Synchronized Settings": "同步的设置", + "Enable Settings Sync": "启用设置同步", + "Disable Settings Sync": "禁用设置同步", + "Update Synchronized Settings": "更新同步的设置", + "Settings sync is not enabled": "设置同步没有启用", + "Settings sync has been enabled": "设置同步已经启用", + "Settings sync has been disabled": "设置同步已经禁用", + "Synchronized settings have been updated": "同步的设置已经更新", + "Unable to retrieve user synchronized application settings": "无法获取用户同步的应用设置", + "Unable to update user synchronized application settings": "无法更新用户同步的应用设置", + "Unable to disable user synchronized application settings": "无法禁用用户同步的应用设置", "Are you sure you want to re-login?": "您确定要重新登录?", "Exchange Rates Data": "汇率数据", "User Custom": "用户自定义", diff --git a/src/locales/zh_Hant.json b/src/locales/zh_Hant.json index 23299eda..f8c3a77b 100644 --- a/src/locales/zh_Hant.json +++ b/src/locales/zh_Hant.json @@ -2083,6 +2083,18 @@ "User has canceled or this device does not support WebAuthn": "使用者已取消或此裝置不支援 WebAuthn", "Failed to authenticate with WebAuthn": "使用 WebAuthn 驗證失敗", "WebAuthn is not enabled": "WebAuthn 沒有啟用", + "Settings Sync": "設定同步", + "Synchronized Settings": "同步的設定", + "Enable Settings Sync": "啟用設定同步", + "Disable Settings Sync": "停用設定同步", + "Update Synchronized Settings": "更新同步的設定", + "Settings sync is not enabled": "設定同步沒有啟用", + "Settings sync has been enabled": "設定同步已啟用", + "Settings sync has been disabled": "設定同步已停用", + "Synchronized settings have been updated": "同步的設定已更新", + "Unable to retrieve user synchronized application settings": "無法取得使用者同步的應用程式設定", + "Unable to update user synchronized application settings": "無法更新使用者同步的應用程式設定", + "Unable to disable user synchronized application settings": "無法停用使用者同步的應用程式設定", "Are you sure you want to re-login?": "您確定要重新登入?", "Exchange Rates Data": "匯率資料", "User Custom": "使用者自訂", diff --git a/src/models/auth_response.ts b/src/models/auth_response.ts index 0d333269..629d5d93 100644 --- a/src/models/auth_response.ts +++ b/src/models/auth_response.ts @@ -1,9 +1,12 @@ +import type { ApplicationCloudSetting } from '@/core/setting.ts'; + import type { UserBasicInfo } from './user.ts'; export interface AuthResponse { readonly token: string; readonly need2FA: boolean; readonly user?: UserBasicInfo; + readonly applicationCloudSettings?: ApplicationCloudSetting[]; readonly notificationContent?: string; } diff --git a/src/models/token.ts b/src/models/token.ts index c716cabd..3fdb8046 100644 --- a/src/models/token.ts +++ b/src/models/token.ts @@ -1,3 +1,5 @@ +import type { ApplicationCloudSetting } from '@/core/setting.ts'; + import type { UserBasicInfo } from './user.ts'; export const TOKEN_CLI_USER_AGENT: string = 'ezbookkeeping Cli'; @@ -6,6 +8,7 @@ export interface TokenRefreshResponse { readonly newToken?: string; readonly oldTokenId?: string; readonly user: UserBasicInfo; + readonly applicationCloudSettings?: ApplicationCloudSetting[]; readonly notificationContent?: string; } diff --git a/src/models/user_app_cloud_setting.ts b/src/models/user_app_cloud_setting.ts new file mode 100644 index 00000000..67aba03c --- /dev/null +++ b/src/models/user_app_cloud_setting.ts @@ -0,0 +1,6 @@ +import type { ApplicationCloudSetting } from '@/core/setting.ts'; + +export interface UserApplicationCloudSettingsUpdateRequest { + readonly settings: ApplicationCloudSetting[]; + readonly fullUpdate: boolean; +} diff --git a/src/router/mobile.ts b/src/router/mobile.ts index 04b2bd42..537fd43c 100644 --- a/src/router/mobile.ts +++ b/src/router/mobile.ts @@ -19,6 +19,7 @@ import StatisticsSettingsPage from '@/views/mobile/statistics/SettingsPage.vue'; import TextSizeSettingsPage from '@/views/mobile/settings/TextSizeSettingsPage.vue'; import PageSettingsPage from '@/views/mobile/settings/PageSettingsPage.vue'; +import ApplicationCloudSyncSettingsPage from '@/views/mobile/settings/ApplicationCloudSyncSettingsPage.vue'; import AccountFilterSettingsPage from '@/views/mobile/settings/AccountFilterSettingsPage.vue'; import CategoryFilterSettingsPage from '@/views/mobile/settings/CategoryFilterSettingsPage.vue'; import TransactionTagFilterSettingsPage from '@/views/mobile/settings/TransactionTagFilterSettingsPage.vue'; @@ -225,6 +226,11 @@ const routes: Router.RouteParameters[] = [ async: asyncResolve(PageSettingsPage), beforeEnter: [checkLogin] }, + { + path: '/settings/sync', + async: asyncResolve(ApplicationCloudSyncSettingsPage), + beforeEnter: [checkLogin] + }, { path: '/settings', async: asyncResolve(SettingsPage), diff --git a/src/stores/index.ts b/src/stores/index.ts index 463718fb..2d471f3d 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -103,6 +103,8 @@ export const useRootStore = defineStore('root', () => { } } + settingsStore.setApplicationSettingsFromCloudSettings(data.result.applicationCloudSettings); + updateCurrentToken(data.result.token); if (data.result.user && isObject(data.result.user)) { @@ -162,6 +164,8 @@ export const useRootStore = defineStore('root', () => { } } + settingsStore.setApplicationSettingsFromCloudSettings(data.result.applicationCloudSettings); + updateCurrentToken(data.result.token); if (data.result.user && isObject(data.result.user)) { diff --git a/src/stores/setting.ts b/src/stores/setting.ts index c14f4605..ffa27f20 100644 --- a/src/stores/setting.ts +++ b/src/stores/setting.ts @@ -1,7 +1,24 @@ -import { ref } from 'vue'; +import { ref, computed } from 'vue'; import { defineStore } from 'pinia'; -import type { ApplicationSettings, LocaleDefaultSettings } from '@/core/setting.ts'; +import { + type ApplicationSettingValue, + type ApplicationSettingSubValue, + type ApplicationSettings, + type ApplicationCloudSetting, + type LocaleDefaultSettings, + UserApplicationCloudSettingType, + ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES +} from '@/core/setting.ts'; + +import { + isObject, + isString, + isBoolean, + getObjectOwnFieldCount, + arrayItemToObjectField +} from '@/lib/common.ts'; + import { getApplicationSettings, getLocaleDefaultSettings, @@ -10,10 +27,100 @@ import { clearSettings } from '@/lib/settings.ts'; +import logger from '@/lib/logger.ts'; +import services from '@/lib/services.ts'; + export const useSettingsStore = defineStore('settings', () => { const appSettings = ref(getApplicationSettings()); + const syncedAppSettings = ref>({}); const localeDefaultSettings = ref(getLocaleDefaultSettings()); + const enableApplicationCloudSync = computed(() => getObjectOwnFieldCount(syncedAppSettings.value) > 0); + + function updateApplicationSettingsValueAndAppSettingsFromCloudSetting(key: string, value: string | number | boolean | Record): void { + const keyItems = key.split('.'); + + if (keyItems.length === 1) { + updateApplicationSettingsValue(keyItems[0], value); + appSettings.value[keyItems[0]] = value; + } else if (keyItems.length === 2) { + updateApplicationSettingsSubValue(keyItems[0], keyItems[1], value); + (appSettings.value[keyItems[0]] as Record)[keyItems[1]] = value; + } else { + logger.warn(`cannot load application cloud setting "${key}", because it has invalid key format`); + } + } + + function createUserApplicationCloudSetting(key: string): ApplicationCloudSetting | null { + const settingType = ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES[key]; + + if (!settingType) { + logger.warn(`cannot get application cloud setting "${key}", because it is not supported to sync`); + return null; + } + + const keyItems = key.split('.'); + let value: ApplicationSettingValue | ApplicationSettingSubValue = appSettings.value[key]; + + if (keyItems.length === 2) { + value = (appSettings.value[keyItems[0]] as Record)[keyItems[1]]; + } else if (keyItems.length > 2) { + logger.warn(`cannot get application cloud setting "${key}", because it has invalid key format`); + return null; + } + + let settingValue = ''; + + if (settingType === UserApplicationCloudSettingType.String) { + if (!value) { + settingValue = ''; + } else { + settingValue = value.toString(); + } + } else { + settingValue = JSON.stringify(value); + } + + return { + settingKey: key, + settingValue: settingValue + }; + } + + function updateUserApplicationCloudSettingValue(key: string, value: string | number | boolean | Record): void { + if (!syncedAppSettings.value || !syncedAppSettings.value[key]) { + return; + } + + const settingType = ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES[key]; + + if (!settingType) { + return; + } + + const settingValue = isString(value) ? value : JSON.stringify(value); + + services.updateUserApplicationCloudSettings({ + settings: [{ + settingKey: key, + settingValue: settingValue + }], + fullUpdate: false + }).then(response => { + const data = response.data; + + if (!data || !data.success || !data.result) { + logger.debug(`failed to update user application cloud setting "${key}" with value "${settingValue}"`); + return; + } + + logger.debug(`update user application cloud setting "${key}" with value "${settingValue}" successfully`); + }).catch(error => { + logger.debug(`failed to update user application cloud setting "${key}" with value "${settingValue}"`, error); + }); + } + + // Basic Settings function setTheme(value: string): void { updateApplicationSettingsValue('theme', value); appSettings.value.theme = value; @@ -29,6 +136,23 @@ export const useSettingsStore = defineStore('settings', () => { appSettings.value.timeZone = value; } + function setAutoUpdateExchangeRatesData(value: boolean): void { + updateApplicationSettingsValue('autoUpdateExchangeRatesData', value); + appSettings.value.autoUpdateExchangeRatesData = value; + } + + function setShowAccountBalance(value: boolean): void { + updateApplicationSettingsValue('showAccountBalance', value); + appSettings.value.showAccountBalance = value; + updateUserApplicationCloudSettingValue('showAccountBalance', value); + } + + function setEnableAnimate(value: boolean): void { + updateApplicationSettingsValue('animate', value); + appSettings.value.animate = value; + } + + // Application Lock function setEnableApplicationLock(value: boolean): void { updateApplicationSettingsValue('applicationLock', value); appSettings.value.applicationLock = value; @@ -39,114 +163,123 @@ export const useSettingsStore = defineStore('settings', () => { appSettings.value.applicationLockWebAuthn = value; } - function setAutoUpdateExchangeRatesData(value: boolean): void { - updateApplicationSettingsValue('autoUpdateExchangeRatesData', value); - appSettings.value.autoUpdateExchangeRatesData = value; - } - - function setAutoSaveTransactionDraft(value: string): void { - updateApplicationSettingsValue('autoSaveTransactionDraft', value); - appSettings.value.autoSaveTransactionDraft = value; - } - - function setAutoGetCurrentGeoLocation(value: boolean): void { - updateApplicationSettingsValue('autoGetCurrentGeoLocation', value); - appSettings.value.autoGetCurrentGeoLocation = value; - } - - function setAlwaysShowTransactionPicturesInMobileTransactionEditPage(value: boolean): void { - updateApplicationSettingsValue('alwaysShowTransactionPicturesInMobileTransactionEditPage', value); - appSettings.value.alwaysShowTransactionPicturesInMobileTransactionEditPage = value; - } - + // Navigation Bar function setShowAddTransactionButtonInDesktopNavbar(value: boolean): void { updateApplicationSettingsValue('showAddTransactionButtonInDesktopNavbar', value); appSettings.value.showAddTransactionButtonInDesktopNavbar = value; } + // Overview Page function setShowAmountInHomePage(value: boolean): void { updateApplicationSettingsValue('showAmountInHomePage', value); appSettings.value.showAmountInHomePage = value; + updateUserApplicationCloudSettingValue('showAmountInHomePage', value); } function setTimezoneUsedForStatisticsInHomePage(value: number): void { updateApplicationSettingsValue('timezoneUsedForStatisticsInHomePage', value); appSettings.value.timezoneUsedForStatisticsInHomePage = value; + updateUserApplicationCloudSettingValue('timezoneUsedForStatisticsInHomePage', value); } + // Transaction List Page function setItemsCountInTransactionListPage(value: number): void { updateApplicationSettingsValue('itemsCountInTransactionListPage', value); appSettings.value.itemsCountInTransactionListPage = value; + updateUserApplicationCloudSettingValue('itemsCountInTransactionListPage', value); } function setShowTotalAmountInTransactionListPage(value: boolean): void { updateApplicationSettingsValue('showTotalAmountInTransactionListPage', value); appSettings.value.showTotalAmountInTransactionListPage = value; + updateUserApplicationCloudSettingValue('showTotalAmountInTransactionListPage', value); } function setShowTagInTransactionListPage(value: boolean): void { updateApplicationSettingsValue('showTagInTransactionListPage', value); appSettings.value.showTagInTransactionListPage = value; + updateUserApplicationCloudSettingValue('showTagInTransactionListPage', value); } - function setShowAccountBalance(value: boolean): void { - updateApplicationSettingsValue('showAccountBalance', value); - appSettings.value.showAccountBalance = value; + // Transaction Edit Page + function setAutoSaveTransactionDraft(value: string): void { + updateApplicationSettingsValue('autoSaveTransactionDraft', value); + appSettings.value.autoSaveTransactionDraft = value; + updateUserApplicationCloudSettingValue('autoSaveTransactionDraft', value); } + function setAutoGetCurrentGeoLocation(value: boolean): void { + updateApplicationSettingsValue('autoGetCurrentGeoLocation', value); + appSettings.value.autoGetCurrentGeoLocation = value; + updateUserApplicationCloudSettingValue('autoGetCurrentGeoLocation', value); + } + + function setAlwaysShowTransactionPicturesInMobileTransactionEditPage(value: boolean): void { + updateApplicationSettingsValue('alwaysShowTransactionPicturesInMobileTransactionEditPage', value); + appSettings.value.alwaysShowTransactionPicturesInMobileTransactionEditPage = value; + updateUserApplicationCloudSettingValue('alwaysShowTransactionPicturesInMobileTransactionEditPage', value); + } + + // Exchange Rates Data Page function setCurrencySortByInExchangeRatesPage(value: number): void { updateApplicationSettingsValue('currencySortByInExchangeRatesPage', value); appSettings.value.currencySortByInExchangeRatesPage = value; + updateUserApplicationCloudSettingValue('currencySortByInExchangeRatesPage', value); } + // Statistics Settings function setStatisticsDefaultChartDataType(value: number): void { updateApplicationSettingsSubValue('statistics', 'defaultChartDataType', value); appSettings.value.statistics.defaultChartDataType = value; + updateUserApplicationCloudSettingValue('statistics.defaultChartDataType', value); } function setStatisticsDefaultTimezoneType(value: number): void { updateApplicationSettingsSubValue('statistics', 'defaultTimezoneType', value); appSettings.value.statistics.defaultTimezoneType = value; + updateUserApplicationCloudSettingValue('statistics.defaultTimezoneType', value); } function setStatisticsDefaultAccountFilter(value: Record): void { updateApplicationSettingsSubValue('statistics', 'defaultAccountFilter', value); appSettings.value.statistics.defaultAccountFilter = value; + updateUserApplicationCloudSettingValue('statistics.defaultAccountFilter', value); } function setStatisticsDefaultTransactionCategoryFilter(value: Record): void { updateApplicationSettingsSubValue('statistics', 'defaultTransactionCategoryFilter', value); appSettings.value.statistics.defaultTransactionCategoryFilter = value; + updateUserApplicationCloudSettingValue('statistics.defaultTransactionCategoryFilter', value); } function setStatisticsSortingType(value: number): void { updateApplicationSettingsSubValue('statistics', 'defaultSortingType', value); appSettings.value.statistics.defaultSortingType = value; + updateUserApplicationCloudSettingValue('statistics.defaultSortingType', value); } function setStatisticsDefaultCategoricalChartType(value: number): void { updateApplicationSettingsSubValue('statistics', 'defaultCategoricalChartType', value); appSettings.value.statistics.defaultCategoricalChartType = value; + updateUserApplicationCloudSettingValue('statistics.defaultCategoricalChartType', value); } function setStatisticsDefaultCategoricalChartDateRange(value: number): void { updateApplicationSettingsSubValue('statistics', 'defaultCategoricalChartDataRangeType', value); appSettings.value.statistics.defaultCategoricalChartDataRangeType = value; + updateUserApplicationCloudSettingValue('statistics.defaultCategoricalChartDataRangeType', value); } function setStatisticsDefaultTrendChartType(value: number): void { updateApplicationSettingsSubValue('statistics', 'defaultTrendChartType', value); appSettings.value.statistics.defaultTrendChartType = value; + updateUserApplicationCloudSettingValue('statistics.defaultTrendChartType', value); } function setStatisticsDefaultTrendChartDateRange(value: number): void { updateApplicationSettingsSubValue('statistics', 'defaultTrendChartDataRangeType', value); appSettings.value.statistics.defaultTrendChartDataRangeType = value; - } - - function setEnableAnimate(value: boolean): void { - updateApplicationSettingsValue('animate', value); - appSettings.value.animate = value; + updateUserApplicationCloudSettingValue('statistics.defaultTrendChartDataRangeType', value); } function clearAppSettings(): void { @@ -154,6 +287,108 @@ export const useSettingsStore = defineStore('settings', () => { appSettings.value = getApplicationSettings(); } + function createApplicationCloudSettings(applicationSettingKeys: string[]): ApplicationCloudSetting[] { + if (!applicationSettingKeys || applicationSettingKeys.length < 1) { + return []; + } + + const settings: ApplicationCloudSetting[] = []; + + for (let i = 0; i < applicationSettingKeys.length; i++) { + const settingKey = applicationSettingKeys[i]; + const cloudSetting = createUserApplicationCloudSetting(settingKey); + + if (cloudSetting) { + settings.push(cloudSetting); + } + } + + return settings; + } + + function setApplicationSettingsFromCloudSettings(cloudSettings?: ApplicationCloudSetting[]): void { + if (!cloudSettings || cloudSettings.length < 1) { + syncedAppSettings.value = {}; + return; + } + + syncedAppSettings.value = arrayItemToObjectField(cloudSettings.map(item => item.settingKey), true); + + for (let i = 0; i < cloudSettings.length; i++) { + const setting = cloudSettings[i]; + + if (!setting || !setting.settingKey) { + continue; + } + + const settingType = ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES[setting.settingKey]; + + if (!settingType) { + logger.warn(`cannot load application cloud setting "${setting.settingKey}", because it is not supported to sync`); + continue; + } + + if (settingType === UserApplicationCloudSettingType.String) { + updateApplicationSettingsValueAndAppSettingsFromCloudSetting(setting.settingKey, setting.settingValue); + } else if (settingType === UserApplicationCloudSettingType.Number) { + const value = parseFloat(setting.settingValue); + + if (isNaN(value)) { + logger.warn(`cannot load application cloud setting "${setting.settingKey}", because it has invalid number value`); + continue; + } + + updateApplicationSettingsValueAndAppSettingsFromCloudSetting(setting.settingKey, value); + } else if (settingType === UserApplicationCloudSettingType.Boolean) { + if (setting.settingValue !== 'true' && setting.settingValue !== 'false') { + logger.warn(`cannot load application cloud setting "${setting.settingKey}", because it has invalid boolean value`); + continue; + } + + updateApplicationSettingsValueAndAppSettingsFromCloudSetting(setting.settingKey, setting.settingValue === 'true'); + } else if (settingType === UserApplicationCloudSettingType.StringBooleanMap) { + try { + const map = JSON.parse(setting.settingValue); + let isValid = isObject(map); + + if (isValid) { + for (const key in map) { + if (!Object.prototype.hasOwnProperty.call(map, key)) { + continue; + } + + const value = map[key]; + + if (!isBoolean(value)) { + isValid = false; + break; + } + } + } + + if (!isValid) { + logger.warn(`cannot load application cloud setting "${setting.settingKey}", because it has invalid map value`); + continue; + } + + updateApplicationSettingsValueAndAppSettingsFromCloudSetting(setting.settingKey, map as Record); + } catch (error) { + logger.warn(`cannot load application cloud setting "${setting.settingKey}", because cannot parse JSON (${error})`); + } + } else { + logger.warn(`cannot load application cloud setting "${setting.settingKey}", because it has unknown type "${settingType}"`); + } + } + } + + function updateApplicationSyncSettingKeys(settingKeys?: string[]): void { + if (!settingKeys || settingKeys.length < 1) { + syncedAppSettings.value = {}; + } else { + syncedAppSettings.value = arrayItemToObjectField(settingKeys, true); + } + } + function updateLocalizedDefaultSettings(newLocaleDefaultSettings: LocaleDefaultSettings | null) { if (!newLocaleDefaultSettings) { return; @@ -166,25 +401,37 @@ export const useSettingsStore = defineStore('settings', () => { return { // states appSettings, + syncedAppSettings, localeDefaultSettings, + // computed states + enableApplicationCloudSync, // functions + // -- Basic Settings setTheme, setFontSize, setTimeZone, + setAutoUpdateExchangeRatesData, + setShowAccountBalance, + setEnableAnimate, + // -- Application Lock setEnableApplicationLock, setEnableApplicationLockWebAuthn, - setAutoUpdateExchangeRatesData, - setAutoSaveTransactionDraft, - setAutoGetCurrentGeoLocation, - setAlwaysShowTransactionPicturesInMobileTransactionEditPage, + // -- Navigation Bar setShowAddTransactionButtonInDesktopNavbar, + // -- Overview Page setShowAmountInHomePage, setTimezoneUsedForStatisticsInHomePage, + // -- Transaction List Page setItemsCountInTransactionListPage, setShowTotalAmountInTransactionListPage, setShowTagInTransactionListPage, - setShowAccountBalance, + // -- Transaction Edit Page + setAutoSaveTransactionDraft, + setAutoGetCurrentGeoLocation, + setAlwaysShowTransactionPicturesInMobileTransactionEditPage, + // -- Exchange Rates Data Page setCurrencySortByInExchangeRatesPage, + // -- Statistics Settings setStatisticsDefaultChartDataType, setStatisticsDefaultTimezoneType, setStatisticsDefaultAccountFilter, @@ -194,8 +441,10 @@ export const useSettingsStore = defineStore('settings', () => { setStatisticsDefaultCategoricalChartDateRange, setStatisticsDefaultTrendChartType, setStatisticsDefaultTrendChartDateRange, - setEnableAnimate, clearAppSettings, + createApplicationCloudSettings, + setApplicationSettingsFromCloudSettings, + updateApplicationSyncSettingKeys, updateLocalizedDefaultSettings }; }); diff --git a/src/stores/token.ts b/src/stores/token.ts index 4b8dd698..8133300d 100644 --- a/src/stores/token.ts +++ b/src/stores/token.ts @@ -1,5 +1,6 @@ import { defineStore } from 'pinia'; +import { useSettingsStore } from './setting.ts'; import { useUserStore } from './user.ts'; import type { TokenRefreshResponse, TokenInfoResponse } from '@/models/token.ts'; @@ -11,6 +12,9 @@ import logger from '@/lib/logger.ts'; import services from '@/lib/services.ts'; export const useTokensStore = defineStore('tokens', () => { + const settingsStore = useSettingsStore(); + const userStore = useUserStore(); + function getAllTokens(): Promise { return new Promise((resolve, reject) => { services.getTokens().then(response => { @@ -41,8 +45,11 @@ export const useTokensStore = defineStore('tokens', () => { services.refreshToken().then(response => { const data = response.data; + if (data && data.success && data.result) { + settingsStore.setApplicationSettingsFromCloudSettings(data.result.applicationCloudSettings); + } + if (data && data.success && data.result && data.result.user && isObject(data.result.user)) { - const userStore = useUserStore(); userStore.storeUserBasicInfo(data.result.user); } diff --git a/src/stores/user.ts b/src/stores/user.ts index 221407ca..4f7a560c 100644 --- a/src/stores/user.ts +++ b/src/stores/user.ts @@ -5,6 +5,7 @@ import { useSettingsStore } from './setting.ts'; import { type WeekDayValue, WeekDay } from '@/core/datetime.ts'; import { FiscalYearStart } from '@/core/fiscalyear.ts'; +import type { ApplicationCloudSetting } from '@/core/setting.ts'; import { type UserBasicInfo, @@ -252,6 +253,88 @@ export const useUserStore = defineStore('user', () => { }); } + function getUserApplicationCloudSettings(): Promise { + return new Promise((resolve, reject) => { + services.getUserApplicationCloudSettings().then(response => { + const data = response.data; + + if (!data || !data.success || !data.result) { + resolve(data.result); + return; + } + + resolve(data.result); + }).catch(error => { + logger.error('failed to load user synchronized application settings', error); + + if (error.response && error.response.data && error.response.data.errorMessage) { + reject({ error: error.response.data }); + } else if (!error.processed) { + reject({ message: 'Unable to retrieve user synchronized application settings' }); + } else { + reject(error); + } + }); + }); + } + + function fullUpdateUserApplicationCloudSettings(enabledSettingKeys: string[]): Promise { + const settings = settingsStore.createApplicationCloudSettings(enabledSettingKeys); + + return new Promise((resolve, reject) => { + services.updateUserApplicationCloudSettings({ + settings: settings, + fullUpdate: true + }).then(response => { + const data = response.data; + + if (!data || !data.success || !data.result) { + reject({ message: 'Unable to update user synchronized application settings' }); + return; + } + + settingsStore.updateApplicationSyncSettingKeys(enabledSettingKeys); + resolve(data.result); + }).catch(error => { + logger.error('failed to update user synchronized application settings', error); + + if (error.response && error.response.data && error.response.data.errorMessage) { + reject({ error: error.response.data }); + } else if (!error.processed) { + reject({ message: 'Unable to update user synchronized application settings' }); + } else { + reject(error); + } + }); + }); + } + + function disableUserApplicationCloudSettings(): Promise { + return new Promise((resolve, reject) => { + services.disableUserApplicationCloudSettings().then(response => { + const data = response.data; + + if (!data || !data.success || !data.result) { + reject({ message: 'Unable to disable user synchronized application settings' }); + return; + } + + settingsStore.updateApplicationSyncSettingKeys(undefined); + resolve(data.result); + }).catch(error => { + logger.error('failed to disable user synchronized application settings', error); + + if (error.response && error.response.data && error.response.data.errorMessage) { + reject({ error: error.response.data }); + } else if (!error.processed) { + reject({ message: 'Unable to disable user synchronized application settings' }); + } else { + reject(error); + } + }); + }); + } + function getUserDataStatistics(): Promise { return new Promise((resolve, reject) => { services.getUserDataStatistics().then(response => { @@ -353,6 +436,9 @@ export const useUserStore = defineStore('user', () => { updateUserTransactionEditScope, updateUserAvatar, removeUserAvatar, + getUserApplicationCloudSettings, + fullUpdateUserApplicationCloudSettings, + disableUserApplicationCloudSettings, getUserDataStatistics, getExportedUserData, getUserAvatarUrl diff --git a/src/styles/mobile/font-size-default.css b/src/styles/mobile/font-size-default.css index f6af8fd2..591a221f 100644 --- a/src/styles/mobile/font-size-default.css +++ b/src/styles/mobile/font-size-default.css @@ -73,6 +73,8 @@ --ebk-trends-bar-chart-legend-icon-font-size: 18px; --ebk-trends-bar-chart-legend-text-font-size: 14px; --ebk-account-list-group-title-height: 36px; + --ebk-synchronized-settings-list-group-title-height: 36px; + --ebk-synchronized-settings-list-device-icon-font-size: 19px; --ebk-category-separate-icon-font-size: 18px; --ebk-transaction-date-width: 25px; --ebk-transaction-day-font-size: 16px; diff --git a/src/styles/mobile/font-size-large.css b/src/styles/mobile/font-size-large.css index d351c901..368fe109 100644 --- a/src/styles/mobile/font-size-large.css +++ b/src/styles/mobile/font-size-large.css @@ -73,6 +73,8 @@ --ebk-trends-bar-chart-legend-icon-font-size: 19px; --ebk-trends-bar-chart-legend-text-font-size: 15px; --ebk-account-list-group-title-height: 37px; + --ebk-synchronized-settings-list-group-title-height: 37px; + --ebk-synchronized-settings-list-device-icon-font-size: 20px; --ebk-category-separate-icon-font-size: 18px; --ebk-transaction-date-width: 28px; --ebk-transaction-day-font-size: 17px; diff --git a/src/styles/mobile/font-size-small.css b/src/styles/mobile/font-size-small.css index c5eec05d..35bf38d2 100644 --- a/src/styles/mobile/font-size-small.css +++ b/src/styles/mobile/font-size-small.css @@ -73,6 +73,8 @@ --ebk-trends-bar-chart-legend-icon-font-size: 16px; --ebk-trends-bar-chart-legend-text-font-size: 12px; --ebk-account-list-group-title-height: 36px; + --ebk-synchronized-settings-list-group-title-height: 36px; + --ebk-synchronized-settings-list-device-icon-font-size: 18px; --ebk-category-separate-icon-font-size: 18px; --ebk-transaction-date-width: 25px; --ebk-transaction-day-font-size: 16px; diff --git a/src/styles/mobile/font-size-x-large.css b/src/styles/mobile/font-size-x-large.css index 811a23f4..52640e83 100644 --- a/src/styles/mobile/font-size-x-large.css +++ b/src/styles/mobile/font-size-x-large.css @@ -73,6 +73,8 @@ --ebk-trends-bar-chart-legend-icon-font-size: 20px; --ebk-trends-bar-chart-legend-text-font-size: 16px; --ebk-account-list-group-title-height: 38px; + --ebk-synchronized-settings-list-group-title-height: 38px; + --ebk-synchronized-settings-list-device-icon-font-size: 21px; --ebk-category-separate-icon-font-size: 18px; --ebk-transaction-date-width: 30px; --ebk-transaction-day-font-size: 18px; diff --git a/src/styles/mobile/font-size-xx-large.css b/src/styles/mobile/font-size-xx-large.css index 6c21849d..c524cfad 100644 --- a/src/styles/mobile/font-size-xx-large.css +++ b/src/styles/mobile/font-size-xx-large.css @@ -73,6 +73,8 @@ --ebk-trends-bar-chart-legend-icon-font-size: 22px; --ebk-trends-bar-chart-legend-text-font-size: 18px; --ebk-account-list-group-title-height: 40px; + --ebk-synchronized-settings-list-group-title-height: 40px; + --ebk-synchronized-settings-list-device-icon-font-size: 22px; --ebk-category-separate-icon-font-size: 20px; --ebk-transaction-date-width: 32px; --ebk-transaction-day-font-size: 20px; diff --git a/src/styles/mobile/font-size-xxx-large.css b/src/styles/mobile/font-size-xxx-large.css index b39399bd..2299b336 100644 --- a/src/styles/mobile/font-size-xxx-large.css +++ b/src/styles/mobile/font-size-xxx-large.css @@ -73,6 +73,8 @@ --ebk-trends-bar-chart-legend-icon-font-size: 24px; --ebk-trends-bar-chart-legend-text-font-size: 20px; --ebk-account-list-group-title-height: 42px; + --ebk-synchronized-settings-list-group-title-height: 42px; + --ebk-synchronized-settings-list-device-icon-font-size: 24px; --ebk-category-separate-icon-font-size: 20px; --ebk-transaction-date-width: 36px; --ebk-transaction-day-font-size: 22px; diff --git a/src/styles/mobile/font-size-xxxx-large.css b/src/styles/mobile/font-size-xxxx-large.css index 1ee8ebc3..a935c83f 100644 --- a/src/styles/mobile/font-size-xxxx-large.css +++ b/src/styles/mobile/font-size-xxxx-large.css @@ -73,6 +73,8 @@ --ebk-trends-bar-chart-legend-icon-font-size: 26px; --ebk-trends-bar-chart-legend-text-font-size: 22px; --ebk-account-list-group-title-height: 44px; + --ebk-synchronized-settings-list-group-title-height: 44px; + --ebk-synchronized-settings-list-device-icon-font-size: 26px; --ebk-category-separate-icon-font-size: 22px; --ebk-transaction-date-width: 40px; --ebk-transaction-day-font-size: 24px; diff --git a/src/views/base/settings/AppCloudSyncPageBase.ts b/src/views/base/settings/AppCloudSyncPageBase.ts new file mode 100644 index 00000000..28e2c986 --- /dev/null +++ b/src/views/base/settings/AppCloudSyncPageBase.ts @@ -0,0 +1,226 @@ +import { ref, computed } from 'vue'; + +import { useSettingsStore } from '@/stores/setting.ts'; + +import type { ApplicationCloudSetting } from '@/core/setting.ts'; + +export interface CategorizedApplicationCloudSettingItems { + readonly categoryName: string; + readonly categorySubName?: string; + readonly items: ApplicationCloudSettingItem[]; +} + +export interface ApplicationCloudSettingItem { + readonly settingKey: string; + readonly settingName: string; + readonly mobile: boolean; + readonly desktop: boolean; +} + +export const ALL_APPLICATION_CLOUD_SETTINGS: CategorizedApplicationCloudSettingItems[] = [ + { + categoryName: 'Basic Settings', + items: [ + { settingKey: 'showAccountBalance', settingName: 'Show Account Balance', mobile: true, desktop: true } + ] + }, + { + categoryName: 'Overview Page', + items: [ + { settingKey: 'showAmountInHomePage', settingName: 'Show Amount', mobile: true, desktop: true }, + { settingKey: 'timezoneUsedForStatisticsInHomePage', settingName: 'Timezone Used for Statistics', mobile: true, desktop: true } + ] + }, + { + categoryName: 'Transaction List Page', + items: [ + { settingKey: 'itemsCountInTransactionListPage', settingName: 'Transactions Per Page', mobile: false, desktop: true }, + { settingKey: 'showTotalAmountInTransactionListPage', settingName: 'Show Monthly Total Amount', mobile: true, desktop: true }, + { settingKey: 'showTagInTransactionListPage', settingName: 'Show Transaction Tag', mobile: true, desktop: true } + ] + }, + { + categoryName: 'Transaction Edit Page', + items: [ + { settingKey: 'autoSaveTransactionDraft', settingName: 'Automatically Save Draft', mobile: true, desktop: true }, + { settingKey: 'autoGetCurrentGeoLocation', settingName: 'Automatically Add Geolocation', mobile: true, desktop: true }, + { settingKey: 'alwaysShowTransactionPicturesInMobileTransactionEditPage', settingName: 'Always Show Transaction Pictures', mobile: true, desktop: false } + ] + }, + { + categoryName: 'Exchange Rates Data Page', + items: [ + { settingKey: 'currencySortByInExchangeRatesPage', settingName: 'Sort by', mobile: true, desktop: true } + ] + }, + { + categoryName: 'Statistics Settings', + categorySubName: 'Common Settings', + items: [ + { settingKey: 'statistics.defaultChartDataType', settingName: 'Default Chart Data Type', mobile: true, desktop: true }, + { settingKey: 'statistics.defaultTimezoneType', settingName: 'Timezone Used for Date Range', mobile: true, desktop: true }, + { settingKey: 'statistics.defaultAccountFilter', settingName: 'Default Account Filter', mobile: true, desktop: true }, + { settingKey: 'statistics.defaultTransactionCategoryFilter', settingName: 'Default Transaction Category Filter', mobile: true, desktop: true }, + { settingKey: 'statistics.defaultSortingType', settingName: 'Default Sort Order', mobile: true, desktop: true } + ] + }, + { + categoryName: 'Statistics Settings', + categorySubName: 'Categorical Analysis Settings', + items: [ + { settingKey: 'statistics.defaultCategoricalChartType', settingName: 'Default Chart Type', mobile: true, desktop: true }, + { settingKey: 'statistics.defaultCategoricalChartDataRangeType', settingName: 'Default Date Range', mobile: true, desktop: true } + ] + }, + { + categoryName: 'Statistics Settings', + categorySubName: 'Trend Analysis Settings', + items: [ + { settingKey: 'statistics.defaultTrendChartType', settingName: 'Default Chart Type', mobile: false, desktop: true }, + { settingKey: 'statistics.defaultTrendChartDataRangeType', settingName: 'Default Date Range', mobile: true, desktop: true } + ] + } +]; + +export function useAppCloudSyncBase() { + const settingsStore = useSettingsStore(); + + const loading = ref(false); + const enabling = ref(false); + const disabling = ref(false); + const enabledApplicationCloudSettings = ref>(Object.assign({}, settingsStore.syncedAppSettings)); + + const isEnableCloudSync = computed(() => settingsStore.enableApplicationCloudSync); + + const hasEnabledApplicationCloudSettings = computed(() => { + for (const key in enabledApplicationCloudSettings.value) { + if (!Object.prototype.hasOwnProperty.call(enabledApplicationCloudSettings.value, key)) { + continue; + } + + if (enabledApplicationCloudSettings.value[key]) { + return true; + } + } + + return false; + }); + + const enabledApplicationCloudSettingKeys = computed(() => { + const keys: string[] = []; + + for (const key in enabledApplicationCloudSettings.value) { + if (!Object.prototype.hasOwnProperty.call(enabledApplicationCloudSettings.value, key)) { + continue; + } + + if (enabledApplicationCloudSettings.value[key]) { + keys.push(key); + } + } + + return keys; + }); + + function isAllSettingsSelected(categorizedItems: CategorizedApplicationCloudSettingItems): boolean { + for (let i = 0; i < categorizedItems.items.length; i++) { + const item = categorizedItems.items[i]; + if (!enabledApplicationCloudSettings.value[item.settingKey]) { + return false; + } + } + + return true; + } + + function hasSettingSelectedButNotAllChecked(categorizedItems: CategorizedApplicationCloudSettingItems): boolean { + let checkedCount = 0; + + for (let i = 0; i < categorizedItems.items.length; i++) { + const item = categorizedItems.items[i]; + if (!enabledApplicationCloudSettings.value[item.settingKey]) { + checkedCount++; + } + } + + return checkedCount > 0 && checkedCount < categorizedItems.items.length; + } + + function updateSettingsSelected(categorizedItems: CategorizedApplicationCloudSettingItems, value: boolean): void { + for (let i = 0; i < categorizedItems.items.length; i++) { + const item = categorizedItems.items[i]; + enabledApplicationCloudSettings.value[item.settingKey] = value; + } + } + + function selectAllSettings(): void { + for (let i = 0; i < ALL_APPLICATION_CLOUD_SETTINGS.length; i++) { + const categorizedItems = ALL_APPLICATION_CLOUD_SETTINGS[i]; + + for (let j = 0; j < categorizedItems.items.length; j++) { + const item = categorizedItems.items[j]; + enabledApplicationCloudSettings.value[item.settingKey] = true; + } + } + } + + function selectNoneSettings(): void { + for (let i = 0; i < ALL_APPLICATION_CLOUD_SETTINGS.length; i++) { + const categorizedItems = ALL_APPLICATION_CLOUD_SETTINGS[i]; + + for (let j = 0; j < categorizedItems.items.length; j++) { + const item = categorizedItems.items[j]; + enabledApplicationCloudSettings.value[item.settingKey] = false; + } + } + } + + function selectInvertSettings(): void { + for (let i = 0; i < ALL_APPLICATION_CLOUD_SETTINGS.length; i++) { + const categorizedItems = ALL_APPLICATION_CLOUD_SETTINGS[i]; + + for (let j = 0; j < categorizedItems.items.length; j++) { + const item = categorizedItems.items[j]; + enabledApplicationCloudSettings.value[item.settingKey] = !enabledApplicationCloudSettings.value[item.settingKey]; + } + } + } + + function setUserApplicationCloudSettings(settings: ApplicationCloudSetting[] | false) { + if (settings && settings.length > 0) { + settingsStore.setApplicationSettingsFromCloudSettings(settings); + + for (let i = 0; i < settings.length; i++) { + const setting = settings[i]; + if (setting && setting.settingKey) { + enabledApplicationCloudSettings.value[setting.settingKey] = true; + } + } + } else { + settingsStore.setApplicationSettingsFromCloudSettings(undefined); + enabledApplicationCloudSettings.value = {}; + } + } + + return { + // constants + ALL_APPLICATION_CLOUD_SETTINGS, + // states + loading, + enabling, + disabling, + enabledApplicationCloudSettings, + // computed states + isEnableCloudSync, + hasEnabledApplicationCloudSettings, + enabledApplicationCloudSettingKeys, + // functions + isAllSettingsSelected, + hasSettingSelectedButNotAllChecked, + updateSettingsSelected, + selectAllSettings, + selectNoneSettings, + selectInvertSettings, + setUserApplicationCloudSettings + }; +} diff --git a/src/views/desktop/app/AppSettingsPage.vue b/src/views/desktop/app/AppSettingsPage.vue index 7fce8db7..ad4f4c18 100644 --- a/src/views/desktop/app/AppSettingsPage.vue +++ b/src/views/desktop/app/AppSettingsPage.vue @@ -13,6 +13,10 @@ {{ tt('Statistics') }} + + + {{ tt('Settings Sync') }} + @@ -27,6 +31,10 @@ + + + + @@ -35,6 +43,7 @@ import AppBasicSettingTab from './settings/tabs/AppBasicSettingTab.vue'; import AppLockSettingTab from './settings/tabs/AppLockSettingTab.vue'; import AppStatisticsSettingTab from './settings/tabs/AppStatisticsSettingTab.vue'; +import AppCloudSyncSettingTab from './settings/tabs/AppCloudSyncSettingTab.vue'; import { ref } from 'vue'; import { useRouter, onBeforeRouteUpdate } from 'vue-router'; @@ -44,7 +53,8 @@ import { useI18n } from '@/locales/helpers.ts'; import { mdiCogOutline, mdiLockOpenOutline, - mdiChartPieOutline + mdiChartPieOutline, + mdiCloudOutline } from '@mdi/js'; const props = defineProps<{ @@ -58,7 +68,8 @@ const { tt } = useI18n(); const ALL_TABS: string[] = [ 'basicSetting', 'applicationLockSetting', - 'statisticsSetting' + 'statisticsSetting', + 'cloudSyncSetting' ]; const activeTab = ref((() => { diff --git a/src/views/desktop/app/settings/tabs/AppCloudSyncSettingTab.vue b/src/views/desktop/app/settings/tabs/AppCloudSyncSettingTab.vue new file mode 100644 index 00000000..e0b05241 --- /dev/null +++ b/src/views/desktop/app/settings/tabs/AppCloudSyncSettingTab.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/src/views/mobile/SettingsPage.vue b/src/views/mobile/SettingsPage.vue index 55c30b0b..26968b1a 100644 --- a/src/views/mobile/SettingsPage.vue +++ b/src/views/mobile/SettingsPage.vue @@ -72,8 +72,8 @@ - + {{ tt('Enable Animation') }} diff --git a/src/views/mobile/settings/ApplicationCloudSyncSettingsPage.vue b/src/views/mobile/settings/ApplicationCloudSyncSettingsPage.vue new file mode 100644 index 00000000..cc21b6ad --- /dev/null +++ b/src/views/mobile/settings/ApplicationCloudSyncSettingsPage.vue @@ -0,0 +1,197 @@ + + + + +