package log import ( "errors" "fmt" "log" "sort" "strconv" "strings" "sync" "time" "git.aiterp.net/rpdata/api/internal/store" "git.aiterp.net/rpdata/api/model/channel" "git.aiterp.net/rpdata/api/model/character" "git.aiterp.net/rpdata/api/model/counter" "github.com/globalsign/mgo/bson" "github.com/globalsign/mgo" ) var postMutex sync.RWMutex var characterUpdateMutex sync.Mutex var logsCollection *mgo.Collection // Log is the header/session for a log file. type Log struct { ID string `bson:"_id"` ShortID string `bson:"shortId"` Date time.Time `bson:"date"` Channel string `bson:"channel"` Title string `bson:"title,omitempty"` Event string `bson:"event,omitempty"` Description string `bson:"description,omitempty"` Open bool `bson:"open"` CharacterIDs []string `bson:"characterIds"` } // New creates a new Log func New(date time.Time, channelName, title, event, description string, open bool) (Log, error) { nextID, err := counter.Next("auto_increment", "Log") if err != nil { return Log{}, err } _, err = channel.Ensure(channelName, open) if err != nil { return Log{}, err } log := Log{ ID: MakeLogID(date, channelName), ShortID: "L" + strconv.Itoa(nextID), Date: date, Channel: channelName, Title: title, Event: event, Description: description, Open: open, CharacterIDs: nil, } err = logsCollection.Insert(log) if err != nil { return Log{}, err } return log, nil } // FindID finds a log either by it's ID or short ID. func FindID(id string) (Log, error) { return findLog(bson.M{ "$or": []bson.M{ bson.M{"_id": id}, bson.M{"shortId": id}, }, }) } // List lists all logs func List(filter *Filter) ([]Log, error) { query := bson.M{} // Run a text search if filter.Search != nil { searchResults := make([]string, 0, 32) postMutex.RLock() err := postCollection.Find(bson.M{"$text": bson.M{"$search": *filter.Search}}).Distinct("logId", &searchResults) if err != nil { return nil, err } postMutex.RUnlock() // Posts always use shortId to refer to the log query["shortId"] = bson.M{"$in": searchResults} } // Find logs including any of the specified events and channels if filter.Channels != nil { query["channel"] = bson.M{"$in": *filter.Channels} } if filter.Events != nil { query["event"] = bson.M{"$in": *filter.Events} } // Find logs including all of the specified character IDs. if filter.Characters != nil { query["characterIds"] = bson.M{"$all": *filter.Characters} } // Limit to only open logs if filter.Open != nil { query["open"] = *filter.Open } limit := 0 if filter.Limit != nil { limit = int(*filter.Limit) } return listLog(query, limit) } // Edit sets the metadata func (log *Log) Edit(title *string, event *string, description *string, open *bool) error { changes := bson.M{} if title != nil && *title != log.Title { changes["title"] = *title } if event != nil && *event != log.Event { changes["event"] = *event } if description != nil && *description != log.Description { changes["description"] = *description } if open != nil && *open != log.Open { changes["open"] = *open } if len(changes) == 0 { return nil } err := logsCollection.UpdateId(log.ID, bson.M{"$set": changes}) if err != nil { return err } if title != nil { log.Title = *title } if event != nil { log.Event = *event } if description != nil { log.Description = *description } if open != nil { log.Open = *open } return nil } // Posts gets all the posts under the log. If no kinds are specified, it // will get all posts func (log *Log) Posts(kinds ...string) ([]Post, error) { postMutex.RLock() defer postMutex.RUnlock() query := bson.M{ "$or": []bson.M{ bson.M{"logId": log.ID}, bson.M{"logId": log.ShortID}, }, } if len(kinds) > 0 { for i := range kinds { kinds[i] = strings.ToLower(kinds[i]) } query["kind"] = bson.M{"$in": kinds} } posts, err := listPosts(query) if err != nil { return nil, err } sort.SliceStable(posts, func(i, j int) bool { return posts[i].Position < posts[j].Position }) return posts, nil } // NewPost creates a new post. func (log *Log) NewPost(time time.Time, kind, nick, text string) (Post, error) { if kind == "" || nick == "" || text == "" { return Post{}, errors.New("Missing/empty parameters") } postMutex.RLock() defer postMutex.RUnlock() position, err := counter.Next("next_post_id", log.ShortID) if err != nil { return Post{}, err } post := Post{ ID: MakePostID(time), Position: position, LogID: log.ShortID, Time: time, Kind: kind, Nick: nick, Text: text, } err = postCollection.Insert(post) if err != nil { return Post{}, err } return post, nil } // UpdateCharacters updates the character list func (log *Log) UpdateCharacters() error { characterUpdateMutex.Lock() defer characterUpdateMutex.Unlock() posts, err := log.Posts() if err != nil { return err } added := make(map[string]bool) removed := make(map[string]bool) for _, post := range posts { if post.Kind == "text" || post.Kind == "action" { if strings.HasPrefix(post.Text, "(") || strings.Contains(post.Nick, "(") || strings.Contains(post.Nick, "[E]") { continue } // Clean up the nick (remove possessive suffix, comma, formatting stuff) if strings.HasSuffix(post.Nick, "'s") || strings.HasSuffix(post.Nick, "`s") { post.Nick = post.Nick[:len(post.Nick)-2] } else if strings.HasSuffix(post.Nick, "'") || strings.HasSuffix(post.Nick, "`") || strings.HasSuffix(post.Nick, ",") || strings.HasSuffix(post.Nick, "\x0f") { post.Nick = post.Nick[:len(post.Nick)-1] } added[post.Nick] = true } if post.Kind == "chars" { tokens := strings.Fields(post.Text) for _, token := range tokens { if strings.HasPrefix(token, "-") { removed[token[1:]] = true } else { added[strings.Replace(token, "+", "", 1)] = true } } } } nicks := make([]string, 0, len(added)) for nick := range added { if added[nick] && !removed[nick] { nicks = append(nicks, nick) } } characters, err := character.ListNicks(nicks...) if err != nil { return err } characterIDs := make([]string, len(characters)) for i, char := range characters { characterIDs[i] = char.ID } err = logsCollection.UpdateId(log.ID, bson.M{"$set": bson.M{"characterIds": characterIDs}}) if err != nil { return err } for _, nick := range nicks { found := false for _, character := range characters { if character.HasNick(nick) { found = true break } } if !found { addUnknownNick(nick) } } log.CharacterIDs = characterIDs return nil } // Remove removes the log and all associated posts from the database func (log *Log) Remove() error { err := logsCollection.Remove(bson.M{"_id": log.ID}) if err != nil { return err } _, err = postCollection.RemoveAll(bson.M{"$or": []bson.M{ bson.M{"logId": log.ID}, bson.M{"logId": log.ShortID}, }}) if err != nil { return err } return nil } func findLog(query interface{}) (Log, error) { log := Log{} err := logsCollection.Find(query).One(&log) if err != nil { return Log{}, err } return log, nil } func listLog(query interface{}, limit int) ([]Log, error) { logs := make([]Log, 0, 64) err := logsCollection.Find(query).Limit(limit).Sort("-date").All(&logs) if err != nil { return nil, err } return logs, nil } func iterLogs(query interface{}, limit int) *mgo.Iter { return logsCollection.Find(query).Sort("-date").Limit(limit).Batch(8).Iter() } // MakeLogID generates log IDs that are of the format from logbot2, though it will break compatibility. func MakeLogID(date time.Time, channel string) string { return fmt.Sprintf("%s%03d_%s", date.UTC().Format("2006-01-02_150405"), (date.Nanosecond() / int(time.Millisecond/time.Nanosecond)), channel[1:]) } func init() { store.HandleInit(func(db *mgo.Database) { logsCollection = db.C("logbot3.logs") logsCollection.EnsureIndexKey("date") logsCollection.EnsureIndexKey("channel") logsCollection.EnsureIndexKey("characterIds") logsCollection.EnsureIndexKey("event") logsCollection.EnsureIndex(mgo.Index{ Key: []string{"channel", "open"}, }) err := logsCollection.EnsureIndex(mgo.Index{ Key: []string{"shortId"}, Unique: true, DropDups: true, }) if err != nil { log.Fatalln("init logbot3.logs:", err) } }) }