mirror of
https://github.com/documize/community.git
synced 2025-07-19 05:09:42 +02:00
i18n server-side strings
This commit is contained in:
parent
f4a1350a41
commit
df534f72fa
11 changed files with 59 additions and 131 deletions
|
@ -1,110 +0,0 @@
|
||||||
// Copyright 2016 Documize Inc. <legal@documize.com>. All rights reserved.
|
|
||||||
//
|
|
||||||
// This software (Documize Community Edition) is licensed under
|
|
||||||
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
|
|
||||||
//
|
|
||||||
// You can operate outside the AGPL restrictions by purchasing
|
|
||||||
// Documize Enterprise Edition and obtaining a commercial license
|
|
||||||
// by contacting <sales@documize.com>.
|
|
||||||
//
|
|
||||||
// https://documize.com
|
|
||||||
|
|
||||||
package database
|
|
||||||
|
|
||||||
// import (
|
|
||||||
// "crypto/rand"
|
|
||||||
// "time"
|
|
||||||
|
|
||||||
// "github.com/documize/community/core/env"
|
|
||||||
// "github.com/jmoiron/sqlx"
|
|
||||||
// )
|
|
||||||
|
|
||||||
// // Lock will try to lock the database instance to the running process.
|
|
||||||
// // Uses a "random" delay as a por man's database cluster-aware process.
|
|
||||||
// // We skip delay if there are no scripts to process.
|
|
||||||
// func Lock(runtime *env.Runtime, scriptsToProcess int) (bool, error) {
|
|
||||||
// // Wait for random period of time.
|
|
||||||
// b := make([]byte, 2)
|
|
||||||
// _, err := rand.Read(b)
|
|
||||||
// if err != nil {
|
|
||||||
// return false, err
|
|
||||||
// }
|
|
||||||
// wait := ((time.Duration(b[0]) << 8) | time.Duration(b[1])) * time.Millisecond / 10 // up to 6.5 secs wait
|
|
||||||
|
|
||||||
// // Why delay if nothing to process?
|
|
||||||
// if scriptsToProcess > 0 {
|
|
||||||
// time.Sleep(wait)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Start transaction fotr lock process.
|
|
||||||
// tx, err := runtime.Db.Beginx()
|
|
||||||
// if err != nil {
|
|
||||||
// runtime.Log.Error("Database: unable to start transaction", err)
|
|
||||||
// return false, err
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Lock the database.
|
|
||||||
// _, err = tx.Exec(runtime.StoreProvider.QueryStartLock())
|
|
||||||
// if err != nil {
|
|
||||||
// runtime.Log.Error("Database: unable to lock tables", err)
|
|
||||||
// return false, err
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Unlock the database at the end of this function.
|
|
||||||
// defer func() {
|
|
||||||
// _, err = tx.Exec(runtime.StoreProvider.QueryFinishLock())
|
|
||||||
// if err != nil {
|
|
||||||
// runtime.Log.Error("Database: unable to unlock tables", err)
|
|
||||||
// }
|
|
||||||
// tx.Commit()
|
|
||||||
// }()
|
|
||||||
|
|
||||||
// // Try to record this process as leader of database migration process.
|
|
||||||
// _, err = tx.Exec(runtime.StoreProvider.QueryInsertProcessID())
|
|
||||||
// if err != nil {
|
|
||||||
// runtime.Log.Info("Database: marked as slave process awaiting upgrade")
|
|
||||||
// return false, nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // We are the leader!
|
|
||||||
// runtime.Log.Info("Database: marked as database upgrade process leader")
|
|
||||||
// return true, err
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Unlock completes process that was started with Lock().
|
|
||||||
// func Unlock(runtime *env.Runtime, tx *sqlx.Tx, err error, amLeader bool) error {
|
|
||||||
// if amLeader {
|
|
||||||
// defer func() {
|
|
||||||
// doUnlock(runtime)
|
|
||||||
// }()
|
|
||||||
|
|
||||||
// if tx != nil {
|
|
||||||
// if err == nil {
|
|
||||||
// tx.Commit()
|
|
||||||
// runtime.Log.Info("Database: is ready")
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
// tx.Rollback()
|
|
||||||
// }
|
|
||||||
|
|
||||||
// runtime.Log.Error("Database: install/upgrade failed", err)
|
|
||||||
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return nil // not the leader, so ignore errors
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Helper method for defer function called from Unlock().
|
|
||||||
// func doUnlock(runtime *env.Runtime) error {
|
|
||||||
// tx, err := runtime.Db.Beginx()
|
|
||||||
// if err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
// _, err = tx.Exec(runtime.StoreProvider.QueryDeleteProcessID())
|
|
||||||
// if err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return tx.Commit()
|
|
||||||
// }
|
|
|
@ -110,7 +110,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<h1>Database Error</h1>
|
<h1>Database Error</h1>
|
||||||
<p>There seems to be a problem with the Documize database: <strong>{{.DBname}}</strong></p>
|
<p>There seems to be a problem with the Documize Community database: <strong>{{.DBname}}</strong></p>
|
||||||
<p><em>{{.Issue}}</em></p>
|
<p><em>{{.Issue}}</em></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,12 +4,17 @@ import (
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/documize/community/core/asset"
|
"github.com/documize/community/core/asset"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultLocale = "en-US"
|
||||||
|
)
|
||||||
|
|
||||||
var localeMap map[string]map[string]string
|
var localeMap map[string]map[string]string
|
||||||
|
|
||||||
// type translation struct {
|
// type translation struct {
|
||||||
|
@ -57,11 +62,15 @@ func Initialize(e embed.FS) (err error) {
|
||||||
|
|
||||||
// Localize will returns string value for given key using specified locale).
|
// Localize will returns string value for given key using specified locale).
|
||||||
// e.g. locale = "en-US", key = "admin_billing"
|
// e.g. locale = "en-US", key = "admin_billing"
|
||||||
func Localize(locale, key string) (s string) {
|
//
|
||||||
|
// Replacements are for replacing string placeholders ({1} {2} {3}) with
|
||||||
|
// replacement text.
|
||||||
|
// e.g. "This is {1} example" where replacements[0] will replace {1}
|
||||||
|
func Localize(locale string, key string, replacements ...string) (s string) {
|
||||||
l, ok := localeMap[locale]
|
l, ok := localeMap[locale]
|
||||||
if !ok {
|
if !ok {
|
||||||
// fallback
|
// fallback
|
||||||
l = localeMap["en-US"]
|
l = localeMap[DefaultLocale]
|
||||||
}
|
}
|
||||||
|
|
||||||
s, ok = l[key]
|
s, ok = l[key]
|
||||||
|
@ -70,5 +79,13 @@ func Localize(locale, key string) (s string) {
|
||||||
s = fmt.Sprintf("!! %s !!", key)
|
s = fmt.Sprintf("!! %s !!", key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// placeholders are one-based: {1} {2} {3}
|
||||||
|
// replacements array is zero-based hence the +1 below
|
||||||
|
if len(replacements) > 0 {
|
||||||
|
for i := range replacements {
|
||||||
|
s = strings.Replace(s, fmt.Sprintf("{%d}", i+1), replacements[i], 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,7 +38,7 @@ func CommandWithTimeout(command *exec.Cmd, timeout time.Duration) ([]byte, error
|
||||||
select {
|
select {
|
||||||
case <-time.After(timeout):
|
case <-time.After(timeout):
|
||||||
if err := command.Process.Kill(); err != nil {
|
if err := command.Process.Kill(); err != nil {
|
||||||
fmt.Errorf("failed to kill: ", err)
|
fmt.Printf("failed to kill: %s", err.Error())
|
||||||
}
|
}
|
||||||
<-done // prevent memory leak
|
<-done // prevent memory leak
|
||||||
//fmt.Println("DEBUG timeout")
|
//fmt.Println("DEBUG timeout")
|
||||||
|
|
|
@ -21,6 +21,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/documize/community/core/env"
|
"github.com/documize/community/core/env"
|
||||||
|
"github.com/documize/community/core/i18n"
|
||||||
"github.com/documize/community/core/response"
|
"github.com/documize/community/core/response"
|
||||||
"github.com/documize/community/core/secrets"
|
"github.com/documize/community/core/secrets"
|
||||||
"github.com/documize/community/core/streamutil"
|
"github.com/documize/community/core/streamutil"
|
||||||
|
@ -57,7 +58,7 @@ func (h *Handler) Sync(w http.ResponseWriter, r *http.Request) {
|
||||||
// Org contains raw auth provider config
|
// Org contains raw auth provider config
|
||||||
org, err := h.Store.Organization.GetOrganization(ctx, ctx.OrgID)
|
org, err := h.Store.Organization.GetOrganization(ctx, ctx.OrgID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Message = "Error: unable to get organization record"
|
result.Message = i18n.Localize(ctx.Locale, "server_err_org")
|
||||||
result.IsError = true
|
result.IsError = true
|
||||||
response.WriteJSON(w, result)
|
response.WriteJSON(w, result)
|
||||||
h.Runtime.Log.Error(result.Message, err)
|
h.Runtime.Log.Error(result.Message, err)
|
||||||
|
@ -66,7 +67,7 @@ func (h *Handler) Sync(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Exit if not using Keycloak
|
// Exit if not using Keycloak
|
||||||
if org.AuthProvider != ath.AuthProviderKeycloak {
|
if org.AuthProvider != ath.AuthProviderKeycloak {
|
||||||
result.Message = "Error: skipping user sync with Keycloak as it is not the configured option"
|
result.Message = i18n.Localize(ctx.Locale, "server_keycloak_error1")
|
||||||
result.IsError = true
|
result.IsError = true
|
||||||
response.WriteJSON(w, result)
|
response.WriteJSON(w, result)
|
||||||
h.Runtime.Log.Info(result.Message)
|
h.Runtime.Log.Info(result.Message)
|
||||||
|
@ -77,7 +78,7 @@ func (h *Handler) Sync(w http.ResponseWriter, r *http.Request) {
|
||||||
c := ath.KeycloakConfig{}
|
c := ath.KeycloakConfig{}
|
||||||
err = json.Unmarshal([]byte(org.AuthConfig), &c)
|
err = json.Unmarshal([]byte(org.AuthConfig), &c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Message = "Error: unable read Keycloak configuration data"
|
result.Message = i18n.Localize(ctx.Locale, "server_keycloak_error2")
|
||||||
result.IsError = true
|
result.IsError = true
|
||||||
response.WriteJSON(w, result)
|
response.WriteJSON(w, result)
|
||||||
h.Runtime.Log.Error(result.Message, err)
|
h.Runtime.Log.Error(result.Message, err)
|
||||||
|
@ -87,7 +88,7 @@ func (h *Handler) Sync(w http.ResponseWriter, r *http.Request) {
|
||||||
// User list from Keycloak
|
// User list from Keycloak
|
||||||
kcUsers, err := Fetch(c)
|
kcUsers, err := Fetch(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Message = "Error: unable to fetch Keycloak users: " + err.Error()
|
result.Message = i18n.Localize(ctx.Locale, "server_keycloak_error3", err.Error())
|
||||||
result.IsError = true
|
result.IsError = true
|
||||||
response.WriteJSON(w, result)
|
response.WriteJSON(w, result)
|
||||||
h.Runtime.Log.Error(result.Message, err)
|
h.Runtime.Log.Error(result.Message, err)
|
||||||
|
@ -97,7 +98,7 @@ func (h *Handler) Sync(w http.ResponseWriter, r *http.Request) {
|
||||||
// User list from Documize
|
// User list from Documize
|
||||||
dmzUsers, err := h.Store.User.GetUsersForOrganization(ctx, "", 99999)
|
dmzUsers, err := h.Store.User.GetUsersForOrganization(ctx, "", 99999)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Message = "Error: unable to fetch Documize users"
|
result.Message = i18n.Localize(ctx.Locale, "server_error_user")
|
||||||
result.IsError = true
|
result.IsError = true
|
||||||
response.WriteJSON(w, result)
|
response.WriteJSON(w, result)
|
||||||
h.Runtime.Log.Error(result.Message, err)
|
h.Runtime.Log.Error(result.Message, err)
|
||||||
|
@ -135,8 +136,8 @@ func (h *Handler) Sync(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Message = fmt.Sprintf("Keycloak sync found %d users, %d new users added, %d users with missing data ignored",
|
result.Message = i18n.Localize(ctx.Locale, "server_keycloak_summary",
|
||||||
len(kcUsers), len(insert), missing)
|
fmt.Sprintf("%d", len(kcUsers)), fmt.Sprintf("%d", len(insert)), fmt.Sprintf("%d", missing))
|
||||||
|
|
||||||
response.WriteJSON(w, result)
|
response.WriteJSON(w, result)
|
||||||
h.Runtime.Log.Info(result.Message)
|
h.Runtime.Log.Info(result.Message)
|
||||||
|
|
|
@ -21,6 +21,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/documize/community/core/env"
|
"github.com/documize/community/core/env"
|
||||||
|
"github.com/documize/community/core/i18n"
|
||||||
"github.com/documize/community/core/response"
|
"github.com/documize/community/core/response"
|
||||||
"github.com/documize/community/core/secrets"
|
"github.com/documize/community/core/secrets"
|
||||||
"github.com/documize/community/core/streamutil"
|
"github.com/documize/community/core/streamutil"
|
||||||
|
@ -146,7 +147,7 @@ func (h *Handler) Sync(w http.ResponseWriter, r *http.Request) {
|
||||||
// Org contains raw auth provider config
|
// Org contains raw auth provider config
|
||||||
org, err := h.Store.Organization.GetOrganization(ctx, ctx.OrgID)
|
org, err := h.Store.Organization.GetOrganization(ctx, ctx.OrgID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Message = "Error: unable to get organization record"
|
result.Message = i18n.Localize(ctx.Locale, "server_error_org")
|
||||||
result.IsError = true
|
result.IsError = true
|
||||||
response.WriteJSON(w, result)
|
response.WriteJSON(w, result)
|
||||||
h.Runtime.Log.Error(result.Message, err)
|
h.Runtime.Log.Error(result.Message, err)
|
||||||
|
@ -155,7 +156,7 @@ func (h *Handler) Sync(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Exit if not using LDAP
|
// Exit if not using LDAP
|
||||||
if org.AuthProvider != ath.AuthProviderLDAP {
|
if org.AuthProvider != ath.AuthProviderLDAP {
|
||||||
result.Message = "Error: skipping user sync with LDAP as it is not the configured option"
|
result.Message = i18n.Localize(ctx.Locale, "server_ldap_error1")
|
||||||
result.IsError = true
|
result.IsError = true
|
||||||
response.WriteJSON(w, result)
|
response.WriteJSON(w, result)
|
||||||
h.Runtime.Log.Info(result.Message)
|
h.Runtime.Log.Info(result.Message)
|
||||||
|
@ -166,7 +167,7 @@ func (h *Handler) Sync(w http.ResponseWriter, r *http.Request) {
|
||||||
c := lm.LDAPConfig{}
|
c := lm.LDAPConfig{}
|
||||||
err = json.Unmarshal([]byte(org.AuthConfig), &c)
|
err = json.Unmarshal([]byte(org.AuthConfig), &c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Message = "Error: unable read LDAP configuration data"
|
result.Message = i18n.Localize(ctx.Locale, "server_ldap_error2")
|
||||||
result.IsError = true
|
result.IsError = true
|
||||||
response.WriteJSON(w, result)
|
response.WriteJSON(w, result)
|
||||||
h.Runtime.Log.Error(result.Message, err)
|
h.Runtime.Log.Error(result.Message, err)
|
||||||
|
@ -176,7 +177,7 @@ func (h *Handler) Sync(w http.ResponseWriter, r *http.Request) {
|
||||||
// Get user list from LDAP.
|
// Get user list from LDAP.
|
||||||
ldapUsers, err := fetchUsers(c)
|
ldapUsers, err := fetchUsers(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Message = "Error: unable to fetch LDAP users: " + err.Error()
|
result.Message = i18n.Localize(ctx.Locale, "server_ldap_error3", err.Error())
|
||||||
result.IsError = true
|
result.IsError = true
|
||||||
response.WriteJSON(w, result)
|
response.WriteJSON(w, result)
|
||||||
h.Runtime.Log.Error(result.Message, err)
|
h.Runtime.Log.Error(result.Message, err)
|
||||||
|
@ -186,7 +187,7 @@ func (h *Handler) Sync(w http.ResponseWriter, r *http.Request) {
|
||||||
// Get user list from Documize
|
// Get user list from Documize
|
||||||
dmzUsers, err := h.Store.User.GetUsersForOrganization(ctx, "", 99999)
|
dmzUsers, err := h.Store.User.GetUsersForOrganization(ctx, "", 99999)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Message = "Error: unable to fetch Documize users"
|
result.Message = i18n.Localize(ctx.Locale, "server_error_user")
|
||||||
result.IsError = true
|
result.IsError = true
|
||||||
response.WriteJSON(w, result)
|
response.WriteJSON(w, result)
|
||||||
h.Runtime.Log.Error(result.Message, err)
|
h.Runtime.Log.Error(result.Message, err)
|
||||||
|
@ -223,10 +224,8 @@ func (h *Handler) Sync(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
result.IsError = false
|
result.IsError = false
|
||||||
result.Message = "Sync complete with LDAP server"
|
result.Message = i18n.Localize(ctx.Locale, "server_ldap_complete")
|
||||||
result.Message = fmt.Sprintf(
|
result.Message = i18n.Localize(ctx.Locale, "server_ldap_summary", fmt.Sprintf("%d", len(ldapUsers)), fmt.Sprintf("%d", len(insert)), fmt.Sprintf("%d", missing))
|
||||||
"LDAP sync found %d users, %d new users added, %d users with missing data ignored",
|
|
||||||
len(ldapUsers), len(insert), missing)
|
|
||||||
|
|
||||||
h.Runtime.Log.Info(result.Message)
|
h.Runtime.Log.Info(result.Message)
|
||||||
|
|
||||||
|
|
|
@ -44,6 +44,7 @@ type RequestContext struct {
|
||||||
GlobalAdmin bool
|
GlobalAdmin bool
|
||||||
ViewUsers bool
|
ViewUsers bool
|
||||||
Subscription Subscription
|
Subscription Subscription
|
||||||
|
Locale string
|
||||||
}
|
}
|
||||||
|
|
||||||
//GetAppURL returns full HTTP url for the app
|
//GetAppURL returns full HTTP url for the app
|
||||||
|
|
|
@ -689,5 +689,19 @@
|
||||||
"404": "Oops! That page couldn't be found.",
|
"404": "Oops! That page couldn't be found.",
|
||||||
"404_explain": "Maybe the content you're looking for is no longer available?",
|
"404_explain": "Maybe the content you're looking for is no longer available?",
|
||||||
"close_account": "Please close my Documize account.",
|
"close_account": "Please close my Documize account.",
|
||||||
"third_party": "Documize Community utilizes open source libraries and components from third parties"
|
"third_party": "Documize Community utilizes open source libraries and components from third parties",
|
||||||
|
|
||||||
|
"server_ldap_error1": "Error: skipping user sync with LDAP as it is not the configured option",
|
||||||
|
"server_ldap_error2": "Error: unable read LDAP configuration data",
|
||||||
|
"server_ldap_error3": "Error: unable to fetch LDAP users: {1}",
|
||||||
|
"server_ldap_complete": "Sync complete with LDAP server",
|
||||||
|
"server_ldap_summary": "LDAP sync found {1} users, {2} new users added, {3} users with missing data ignored",
|
||||||
|
|
||||||
|
"server_keycloak_error1": "Error: skipping user sync with Keycloak as it is not the configured option",
|
||||||
|
"server_keycloak_error2": "Error: unable read Keycloak configuration data",
|
||||||
|
"server_keycloak_error3": "Error: unable to fetch Keycloak users: {1}",
|
||||||
|
"server_keycloak_summary": "Keycloak sync found {1} users, {2} new users added, {3} users with missing data ignored",
|
||||||
|
|
||||||
|
"server_error_user": "Error: unable to fetch users",
|
||||||
|
"server_error_org": "Error: unable to get organization record"
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,6 +37,7 @@ type User struct {
|
||||||
Reset string `json:"-"`
|
Reset string `json:"-"`
|
||||||
LastVersion string `json:"lastVersion"`
|
LastVersion string `json:"lastVersion"`
|
||||||
Theme string `json:"theme"`
|
Theme string `json:"theme"`
|
||||||
|
Locale string `json:"locale"`
|
||||||
Accounts []account.Account `json:"accounts"`
|
Accounts []account.Account `json:"accounts"`
|
||||||
Groups []group.Record `json:"groups"`
|
Groups []group.Record `json:"groups"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/documize/community/core/env"
|
"github.com/documize/community/core/env"
|
||||||
|
"github.com/documize/community/core/i18n"
|
||||||
"github.com/documize/community/core/request"
|
"github.com/documize/community/core/request"
|
||||||
"github.com/documize/community/core/response"
|
"github.com/documize/community/core/response"
|
||||||
"github.com/documize/community/domain"
|
"github.com/documize/community/domain"
|
||||||
|
@ -191,6 +192,10 @@ func (m *middleware) Authorize(w http.ResponseWriter, r *http.Request, next http
|
||||||
rc.GlobalAdmin = u.GlobalAdmin
|
rc.GlobalAdmin = u.GlobalAdmin
|
||||||
rc.ViewUsers = u.ViewUsers
|
rc.ViewUsers = u.ViewUsers
|
||||||
rc.Fullname = u.Fullname()
|
rc.Fullname = u.Fullname()
|
||||||
|
rc.Locale = u.Locale
|
||||||
|
if len(rc.Locale) == 0 {
|
||||||
|
u.Locale = i18n.DefaultLocale
|
||||||
|
}
|
||||||
|
|
||||||
// We send back with every HTTP request/response cycle the latest
|
// We send back with every HTTP request/response cycle the latest
|
||||||
// user state. This helps client-side applications to detect changes in
|
// user state. This helps client-side applications to detect changes in
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue