feat: sync gitter room to node (#519)

* fix: update count field not atomic

* test: add sync topic count field test

* chore: remove redundant select queries

* feat: sync gitter to node

* feat: add go test & debug gitter.go

* debug

* feat: configure gitter room in fe

* debug

* chore: gitter sync limit time and frequence

* format

* debug

* debug & fix test
This commit is contained in:
Ryao 2022-05-22 20:16:12 +08:00 committed by GitHub
parent ab5466e0b2
commit 6f97edf5b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 558 additions and 33 deletions

3
go.mod
View File

@ -19,8 +19,11 @@ require (
github.com/mileusna/crontab v1.0.1
github.com/mozillazg/go-slugify v0.2.0
github.com/mozillazg/go-unidecode v0.1.1 // indirect
github.com/mreiferson/go-httpclient v0.0.0-20201222173833-5e475fde3a4d // indirect
github.com/mrexodia/wray v0.0.0-20160318003008-78a2c1f284ff // indirect
github.com/prometheus/client_golang v1.11.0 // indirect
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 // indirect
github.com/sromku/go-gitter v0.0.0-20170828210750-70f7030a94a6 // indirect
github.com/stretchr/testify v1.7.0 // indirect
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4

6
go.sum
View File

@ -244,6 +244,10 @@ github.com/mozillazg/go-slugify v0.2.0 h1:SIhqDlnJWZH8OdiTmQgeXR28AOnypmAXPeOTcG
github.com/mozillazg/go-slugify v0.2.0/go.mod h1:z7dPH74PZf2ZPFkyxx+zjPD8CNzRJNa1CGacv0gg8Ns=
github.com/mozillazg/go-unidecode v0.1.1 h1:uiRy1s4TUqLbcROUrnCN/V85Jlli2AmDF6EeAXOeMHE=
github.com/mozillazg/go-unidecode v0.1.1/go.mod h1:fYMdhyjni9ZeEmS6OE/GJHDLsF8TQvIVDwYR/drR26Q=
github.com/mreiferson/go-httpclient v0.0.0-20201222173833-5e475fde3a4d h1:tLWCMSjfL8XyZwpu1RzI2UpJSPbZCOZ6DVHQFnlpL7A=
github.com/mreiferson/go-httpclient v0.0.0-20201222173833-5e475fde3a4d/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8=
github.com/mrexodia/wray v0.0.0-20160318003008-78a2c1f284ff h1:HLGD5/9UxxfEuO9DtP8gnTmNtMxbPyhYltfxsITel8g=
github.com/mrexodia/wray v0.0.0-20160318003008-78a2c1f284ff/go.mod h1:B8jLfIIPn2sKyWr0D7cL2v7tnrDD5z291s2Zypdu89E=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
@ -298,6 +302,8 @@ github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d/go.mod h1:AMEsy7v5z
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sromku/go-gitter v0.0.0-20170828210750-70f7030a94a6 h1:7AV47xvYbuwoNxR9LDhkRwqzZsySCX5H8WVM4zrDmME=
github.com/sromku/go-gitter v0.0.0-20170828210750-70f7030a94a6/go.mod h1:P2BoF5QlNE1UcKtYKP8xa8B9I5eALYU5JpRdCqLddL4=
github.com/ssdb/gossdb v0.0.0-20180723034631-88f6b59b84ec/go.mod h1:QBvMkMya+gXctz3kmljlUCu/yB3GZ6oee+dUozsezQE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

@ -40,8 +40,11 @@ func InitForumBasicInfo() {
if AutoSyncPeriodSecond >= 30 {
fmt.Println("Auto sync from google group enabled.")
go AutoSyncGoogleGroup()
fmt.Println("Auto sync from gitter room enabled.")
go AutoSyncGitter()
} else {
fmt.Println("Auto sync from google group disabled.")
fmt.Println("Auto sync from gitter room disabled.")
}
}

418
object/gitter.go Normal file
View File

@ -0,0 +1,418 @@
// Copyright 2020 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"encoding/json"
"errors"
"fmt"
"runtime"
"strconv"
"sync"
"time"
"github.com/astaxie/beego/logs"
"github.com/casbin/casnode/util"
"github.com/casdoor/casdoor-go-sdk/auth"
"github.com/sromku/go-gitter"
)
const (
topicDuration = "4" // Hours
apiLIMIT = 10 // request frequency
)
type topicGitter struct {
Topic Topic
Massages []gitter.Message
MemberMsgMap map[string]int
}
var (
roomSyncMsgHeadMap = map[string]string{}
roomSyncMsgTailMap = map[string]string{}
lastMsgMap = map[string]gitter.Message{}
lastTopicMap = map[string]topicGitter{}
currentTopicMap = map[string]topicGitter{}
)
func AutoSyncGitter() {
if AutoSyncPeriodSecond < 30 {
return
}
for {
time.Sleep(time.Duration(AutoSyncPeriodSecond) * time.Second)
SyncAllGitterRooms()
}
//SyncAllGitterRooms()
}
func SyncAllGitterRooms() {
fmt.Println("Sync from gitter room started...")
var nodes []Node
err := adapter.Engine.Find(&nodes)
if err != nil {
panic(err)
}
for _, node := range nodes {
node.SyncGitter()
}
}
func (n Node) SyncGitter() {
if n.GitterRoomURL == "" || n.GitterApiToken == "" {
return
}
defer func() {
if err := recover(); err != nil {
handleErr(err.(error))
}
}()
// Get your own token At https://developer.gitter.im/
api := gitter.New(n.GitterApiToken)
// get room id by room url
rooms, err := api.GetRooms()
if err != nil {
panic(err)
}
fmt.Println("gitter room urls:", n.GitterRoomURL)
room := gitter.Room{}
for _, v := range rooms { // find RoomId by url
if "https://gitter.im/"+v.URI == n.GitterRoomURL {
room = v
break
}
}
if room.Name == "" {
panic(errors.New("room is not exist"))
}
topics := n.GetAllTopicsByNode()
topicNum := len(topics)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
messages := []gitter.Message{}
// get sync index, it is the last sync message id
headIdx, ok := roomSyncMsgHeadMap[room.ID]
if !ok { // get all msg if idx is not exist
for _, topic := range topics {
if topic.GitterMessageId != "" {
// get reply
replies := GetRepliesOfTopic(topic.Id)
// get sync msg idx
num := len(replies)
if num == 0 {
headIdx = topic.GitterMessageId
break
}
flag := false
for i := num - 1; i >= 0; i-- {
if replies[i].GitterMessageId != "" {
headIdx = replies[i].GitterMessageId
flag = true
break
}
}
if flag {
break
}
}
}
}
// the api limits the number of messages to 50
messages, err = api.GetMessages(room.ID, &gitter.Pagination{
AfterID: headIdx,
})
if err != nil {
panic(err)
}
if len(messages) == 0 {
roomSyncMsgHeadMap[room.ID] = headIdx
return
}
for i := 0; i < apiLIMIT; i++ { // restrict request frequency
msgs, err := api.GetMessages(room.ID, &gitter.Pagination{
AfterID: messages[len(messages)-1].ID,
})
if err != nil {
panic(err)
}
if len(msgs) == 0 {
break
}
messages = append(messages, msgs...)
}
fmt.Printf("sync msg for room(msgNum:%d): %s\n", len(messages), room.Name)
createTopicWithMessages(messages, room, n, topics, true)
}()
// tail
go func() {
defer wg.Done()
messages := []gitter.Message{}
tailIdx, ok := roomSyncMsgTailMap[room.ID]
if !ok {
for i := topicNum - 1; i >= 0; i-- {
topic := topics[i]
if topic.GitterMessageId != "" {
tailIdx = topic.GitterMessageId
break
}
}
}
t := time.Time{}
tExist := true // if t is not exist, sync all msg
if n.GitterSyncFromTime == "" {
tExist = false
} else {
t, err = time.Parse(time.RFC3339, n.GitterSyncFromTime)
if err != nil {
panic(err)
}
}
if tailIdx != "" {
tailMsg, err := api.GetMessage(room.ID, tailIdx)
if err != nil {
panic(err)
}
if tExist {
if tailMsg.Sent.Before(t) { // if msg is before the start time, end sync tail
return
}
}
} else {
messages, err = api.GetMessages(room.ID, nil)
if len(messages) != 0 {
tailIdx = messages[0].ID
}
}
messages, err = api.GetMessages(room.ID, &gitter.Pagination{
BeforeID: tailIdx,
})
if err != nil {
panic(err)
}
if len(messages) == 0 {
roomSyncMsgTailMap[room.ID] = tailIdx
return
}
for i := 0; i < apiLIMIT; i++ { // restrict request frequency
msgs, err := api.GetMessages(room.ID, &gitter.Pagination{
BeforeID: messages[0].ID,
})
if err != nil {
panic(err)
}
num := len(msgs)
if num == 0 {
break
}
// if msg is before the start time, end sync tail
if tExist {
if msgs[0].Sent.Before(t) {
for i := num - 1; i > 0; i-- {
if msgs[i].Sent.Before(t) {
if i == num-1 {
msgs = []gitter.Message{}
} else {
msgs = msgs[i+1:]
}
break
}
}
messages = append(msgs, messages...)
break
}
}
messages = append(msgs, messages...)
}
fmt.Printf("sync msg for room(msgNum:%d): %s\n", len(messages), room.Name)
createTopicWithMessages(messages, room, n, topics, false)
}()
wg.Wait()
}
// main create topic func
func createTopicWithMessages(messages []gitter.Message, room gitter.Room, node Node, topics []Topic, asc bool) {
GetTopicExist := func(topicTitle string) Topic {
for _, topic := range topics {
if topic.Title == topicTitle {
return topic
}
}
return Topic{}
}
// initialize value
lastMsg, ok := lastMsgMap[room.ID]
if !ok {
lastMsg = gitter.Message{}
}
lastTopic := lastTopicMap[room.ID]
if !ok {
lastTopic = topicGitter{MemberMsgMap: map[string]int{}}
}
currentTopic, ok := currentTopicMap[room.ID]
if !ok {
currentTopic = topicGitter{MemberMsgMap: map[string]int{}}
}
for _, msg := range messages {
func() {
defer func() {
if err := recover(); err != nil {
handleErr(err.(error))
}
}()
// create if user is not exist
user, err := auth.GetUser(msg.From.Username)
//fmt.Println("user:", user)
if err != nil {
panic(err)
}
if user.Id == "" { // add user
newUser := auth.User{
Name: msg.From.Username,
CreatedTime: util.GetCurrentTime(),
UpdatedTime: util.GetCurrentTime(),
DisplayName: msg.From.DisplayName,
Avatar: msg.From.AvatarURLMedium,
SignupApplication: CasdoorApplication,
}
fmt.Println("add user: ", newUser.Name)
_, err := auth.AddUser(&newUser)
if err != nil {
panic(err)
}
}
var mentioned = false // if @user
for _, mention := range msg.Mentions {
if mention.ScreenName == lastMsg.From.Username {
mentioned = true
break
}
}
// if @user and lastMsg is not @user, then create topic
// if duration is more than 4 hour, then create topic
d := msg.Sent.Sub(lastMsg.Sent)
dur, err := strconv.Atoi(topicDuration)
if err != nil {
panic(err)
}
if d > time.Hour*time.Duration(dur) && !mentioned { // if dur > `TopicDuration` and not @user last replied
tmpStr := []rune(msg.Text)
if len(tmpStr) > 200 { // limit length
tmpStr = tmpStr[:200]
}
title := string(tmpStr)
topic := GetTopicExist(title)
if topic.Id == 0 { // not exist
// add topic
topic = Topic{
Author: msg.From.Username,
NodeId: node.Id,
NodeName: node.Name,
TabId: node.TabId,
Title: title,
CreatedTime: msg.Sent.String(),
Content: msg.Text,
IsHidden: true,
GitterMessageId: msg.ID,
}
_, topicID := AddTopic(&topic)
topic.Id = topicID
}
// deep copy
data, _ := json.Marshal(currentTopic)
_ = json.Unmarshal(data, &lastTopic)
lastTopicMap[room.ID] = lastTopic
// new currentTopic
currentTopic = topicGitter{Topic: topic, MemberMsgMap: map[string]int{}}
currentTopic.Massages = append(currentTopic.Massages, msg)
currentTopic.MemberMsgMap[msg.From.Username]++
currentTopicMap[room.ID] = currentTopic
} else {
// add reply to lastTopic
reply := Reply{
Author: msg.From.Username,
TopicId: currentTopic.Topic.Id,
CreatedTime: msg.Sent.String(),
Content: msg.Text,
GitterMessageId: msg.ID,
}
_, _ = AddReply(&reply)
ChangeTopicReplyCount(reply.TopicId, 1)
ChangeTopicLastReplyUser(currentTopic.Topic.Id, msg.From.Username, msg.Sent.String())
currentTopic.Massages = append(currentTopic.Massages, msg)
currentTopic.MemberMsgMap[msg.From.Username]++
currentTopicMap[room.ID] = currentTopic
}
// deep copy
data, _ := json.Marshal(msg)
_ = json.Unmarshal(data, &lastMsg)
// add index to sync message
if asc {
roomSyncMsgHeadMap[room.ID] = msg.ID
} else {
roomSyncMsgTailMap[room.ID] = messages[0].ID
}
}()
}
}
func handleErr(err error) {
var stack string
logs.Critical("Handler crashed with error:", err)
for i := 1; ; i++ {
_, file, line, ok := runtime.Caller(i)
if !ok {
break
}
logs.Critical(fmt.Sprintf("%s:%d", file, line))
stack = stack + fmt.Sprintln(fmt.Sprintf("%s:%d", file, line))
}
}

63
object/gitter_test.go Normal file
View File

@ -0,0 +1,63 @@
// Copyright 2022 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"testing"
"github.com/issue9/assert"
"github.com/sromku/go-gitter"
)
func TestRemoveSyncGitterData(t *testing.T) {
InitConfig()
InitAdapter()
// delete all sync gitter data
var nodes []Node
err := adapter.Engine.Find(&nodes)
if err != nil {
panic(err)
}
for _, node := range nodes {
if node.GitterRoomURL == "" || node.GitterApiToken == "" {
continue
}
api := gitter.New(node.GitterApiToken)
rooms, err := api.GetRooms()
if err != nil {
panic(err)
}
url := node.GitterRoomURL
room := gitter.Room{}
for _, v := range rooms { // find RoomId by url
if "https://gitter.im/"+v.URI == url {
room = v
break
}
}
assert.NotEqual(t, room.Name, "")
adapter.Engine.ShowSQL(true)
_, err = adapter.Engine.
Query("DELETE t.*,r.* FROM topic as t LEFT JOIN reply as r ON t.id = r.topic_id WHERE t.gitter_message_id is not null AND t.node_id = ?", node.Id)
if err != nil {
panic(err)
}
fmt.Printf("INFO: delete sync gitter data of room: %s\n", room.Name)
}
}

View File

@ -21,26 +21,29 @@ import (
)
type Node struct {
Id string `xorm:"varchar(100) notnull pk" json:"id"`
Name string `xorm:"varchar(100)" json:"name"`
CreatedTime string `xorm:"varchar(40)" json:"createdTime"`
Desc string `xorm:"mediumtext" json:"desc"`
Extra string `xorm:"mediumtext" json:"extra"`
Image string `xorm:"varchar(200)" json:"image"`
BackgroundImage string `xorm:"varchar(200)" json:"backgroundImage"`
HeaderImage string `xorm:"varchar(200)" json:"headerImage"`
BackgroundColor string `xorm:"varchar(20)" json:"backgroundColor"`
BackgroundRepeat string `xorm:"varchar(20)" json:"backgroundRepeat"`
TabId string `xorm:"varchar(100)" json:"tab"`
ParentNode string `xorm:"varchar(200)" json:"parentNode"`
PlaneId string `xorm:"varchar(50)" json:"planeId"`
Sorter int `json:"sorter"`
Ranking int `json:"ranking"`
Hot int `json:"hot"`
Moderators []string `xorm:"varchar(200)" json:"moderators"`
MailingList string `xorm:"varchar(100)" json:"mailingList"`
GoogleGroupCookie string `xorm:"varchar(1500)" json:"googleGroupCookie"`
IsHidden bool `xorm:"bool" json:"isHidden"`
Id string `xorm:"varchar(100) notnull pk" json:"id"`
Name string `xorm:"varchar(100)" json:"name"`
CreatedTime string `xorm:"varchar(40)" json:"createdTime"`
Desc string `xorm:"mediumtext" json:"desc"`
Extra string `xorm:"mediumtext" json:"extra"`
Image string `xorm:"varchar(200)" json:"image"`
BackgroundImage string `xorm:"varchar(200)" json:"backgroundImage"`
HeaderImage string `xorm:"varchar(200)" json:"headerImage"`
BackgroundColor string `xorm:"varchar(20)" json:"backgroundColor"`
BackgroundRepeat string `xorm:"varchar(20)" json:"backgroundRepeat"`
TabId string `xorm:"varchar(100)" json:"tab"`
ParentNode string `xorm:"varchar(200)" json:"parentNode"`
PlaneId string `xorm:"varchar(50)" json:"planeId"`
Sorter int `json:"sorter"`
Ranking int `json:"ranking"`
Hot int `json:"hot"`
Moderators []string `xorm:"varchar(200)" json:"moderators"`
MailingList string `xorm:"varchar(100)" json:"mailingList"`
GoogleGroupCookie string `xorm:"varchar(1500)" json:"googleGroupCookie"`
GitterApiToken string `xorm:"varchar(200)" json:"gitterApiToken"`
GitterRoomURL string `xorm:"varchar(200)" json:"gitterRoomUrl"`
GitterSyncFromTime string `xorm:"varchar(40)" json:"gitterSyncFromTime"`
IsHidden bool `xorm:"bool" json:"isHidden"`
}
func GetNodes() []*Node {
@ -343,3 +346,12 @@ func (n Node) GetAllTopicTitlesOfNode() []string {
}
return ret
}
func (n Node) GetAllTopicsByNode() []Topic {
var topics []Topic
err := adapter.Engine.Where("node_id = ? and deleted = 0", n.Id).Desc("created_time").Find(&topics)
if err != nil {
panic(err)
}
return topics
}

View File

@ -23,19 +23,20 @@ import (
)
type Reply struct {
Id int `xorm:"int notnull pk autoincr" json:"id"`
Author string `xorm:"varchar(100) index" json:"author"`
TopicId int `xorm:"int index" json:"topicId"`
ParentId int `xorm:"int" json:"parentId"`
Tags []string `xorm:"varchar(200)" json:"tags"`
CreatedTime string `xorm:"varchar(40)" json:"createdTime"`
Deleted bool `xorm:"bool" json:"deleted"`
IsHidden bool `xorm:"bool" json:"isHidden"`
ThanksNum int `xorm:"int" json:"thanksNum"`
EditorType string `xorm:"varchar(40)" json:"editorType"`
Content string `xorm:"mediumtext" json:"content"`
Ip string `xorm:"varchar(100)" json:"ip"`
State string `xorm:"varchar(100)" json:"state"`
Id int `xorm:"int notnull pk autoincr" json:"id"`
Author string `xorm:"varchar(100) index" json:"author"`
TopicId int `xorm:"int index" json:"topicId"`
ParentId int `xorm:"int" json:"parentId"`
Tags []string `xorm:"varchar(200)" json:"tags"`
CreatedTime string `xorm:"varchar(40)" json:"createdTime"`
Deleted bool `xorm:"bool" json:"deleted"`
IsHidden bool `xorm:"bool" json:"isHidden"`
ThanksNum int `xorm:"int" json:"thanksNum"`
EditorType string `xorm:"varchar(40)" json:"editorType"`
Content string `xorm:"mediumtext" json:"content"`
Ip string `xorm:"varchar(100)" json:"ip"`
State string `xorm:"varchar(100)" json:"state"`
GitterMessageId string `xorm:"varchar(100)" json:"gitterMessageId"`
}
var enableNestedReply, _ = beego.AppConfig.Bool("enableNestedReply")

View File

@ -53,6 +53,7 @@ type Topic struct {
IsHidden bool `xorm:"bool index" json:"isHidden"`
Ip string `xorm:"varchar(100)" json:"ip"`
State string `xorm:"varchar(100)" json:"state"`
GitterMessageId string `xorm:"varchar(100)" json:"gitterMessageId"`
}
func GetTopicCount() int {

View File

@ -187,6 +187,8 @@ class AdminNode extends React.Component {
form["headerImage"] = this.state.nodeInfo?.headerImage;
form["mailingList"] = this.state.nodeInfo?.mailingList;
form["googleGroupCookie"] = this.state.nodeInfo?.googleGroupCookie;
form["gitterApiToken"] = this.state.nodeInfo?.gitterApiToken;
form["gitterRoomUrl"] = this.state.nodeInfo?.gitterRoomUrl;
form["isHidden"] = this.state.nodeInfo?.isHidden;
}
@ -718,6 +720,22 @@ class AdminNode extends React.Component {
<input type="text" className="sl" name="googleGroupCookie" defaultValue={this.state.form?.googleGroupCookie} onChange={(event) => this.updateFormField("googleGroupCookie", event.target.value)} autoComplete="off" />
</td>
</tr>
<tr>
<td width="120" align="right">
{i18next.t("node:Gitter API Token")}
</td>
<td width="auto" align="left">
<input type="text" className="sl" name="gitterApiToken" defaultValue={this.state.form?.gitterApiToken} onChange={(event) => this.updateFormField("googleGroupCookie", event.target.value)} autoComplete="off" />
</td>
</tr>
<tr>
<td width="120" align="right">
{i18next.t("node:Gitter Room URL")}
</td>
<td width="auto" align="left">
<input type="text" className="sl" name="gitterRoomUrl" defaultValue={this.state.form?.gitterRoomUrl} onChange={(event) => this.updateFormField("googleGroupCookie", event.target.value)} autoComplete="off" />
</td>
</tr>
<tr>
<td width="120" align="right">
{i18next.t("node:Is hidden")}