package services import ( "context" "errors" "git.aiterp.net/rpdata/api/internal/auth" "git.aiterp.net/rpdata/api/models" "git.aiterp.net/rpdata/api/models/changekeys" "git.aiterp.net/rpdata/api/models/channels" "git.aiterp.net/rpdata/api/repositories" "git.aiterp.net/rpdata/api/services/parsers" "golang.org/x/sync/errgroup" "log" "strings" "time" ) type LogService struct { logs repositories.LogRepository posts repositories.PostRepository changeService *ChangeService channelService *ChannelService characterService *CharacterService } func (s *LogService) Find(ctx context.Context, id string) (*models.Log, error) { return s.logs.Find(ctx, id) } func (s *LogService) FindPosts(ctx context.Context, id string) (*models.Post, error) { return s.posts.Find(ctx, id) } func (s *LogService) List(ctx context.Context, filter *models.LogFilter) ([]*models.Log, error) { if filter == nil { filter = &models.LogFilter{} } return s.logs.List(ctx, *filter) } func (s *LogService) ListPosts(ctx context.Context, filter *models.PostFilter) ([]*models.Post, error) { // Some sanity checks to avoid querying an insame amount of posts. if filter == nil { filter = &models.PostFilter{Limit: 100} } else { if (filter.Limit <= 0 || filter.Limit > 512) && (filter.LogID == nil && len(filter.IDs) == 0) { return nil, errors.New("a limit of 0 (no limit) or >512 without a logId or a set of IDs is not allowed") } if len(filter.IDs) > 100 { return nil, errors.New("you may not query for more than 100 ids, split your query") } } return s.posts.List(ctx, *filter) } func (s *LogService) Create(ctx context.Context, title, description, channelName, eventName string, open bool) (*models.Log, error) { if channelName == "" { return nil, errors.New("channel name cannot be empty") } log := &models.Log{ Title: title, Description: description, ChannelName: channelName, EventName: eventName, Date: time.Now(), Open: open, } if err := auth.CheckPermission(ctx, "add", log); err != nil { return nil, err } _, err := s.channelService.Ensure(ctx, channelName) if err != nil { return nil, err } log, err = s.logs.Insert(ctx, *log) if err != nil { return nil, err } s.changeService.Submit(ctx, models.ChangeModelLog, "add", true, changekeys.Listed(log), log) return log, nil } // Import creates new logs from common formats. func (s *LogService) Import(ctx context.Context, importer models.LogImporter, date time.Time, tz *time.Location, channelName string, data string) ([]*models.Log, error) { if err := auth.CheckPermission(ctx, "add", &models.Log{}); err != nil { return nil, err } results := make([]*models.Log, 0, 8) _, err := s.channelService.Ensure(ctx, channelName) if err != nil { return nil, err } eventName := "" if channel, err := channels.FindName(channelName); err == nil { eventName = channel.EventName } date = date.In(tz) switch importer { case models.LogImporterMircLike: { if date.IsZero() { return nil, errors.New("date is not optional for mirc-like logs") } parsed, err := parsers.MircLog(data, date, true) if err != nil { return nil, err } parsed.Log.EventName = eventName parsed.Log.ChannelName = channelName log, err := s.logs.Insert(ctx, parsed.Log) if err != nil { return nil, err } for _, post := range parsed.Posts { post.LogID = log.ShortID } posts, err := s.posts.InsertMany(ctx, parsed.Posts...) if err != nil { _ = s.logs.Delete(ctx, *log) return nil, err } s.changeService.Submit(ctx, models.ChangeModelLog, "add", true, changekeys.Listed(log), log) s.changeService.Submit(ctx, models.ChangeModelPost, "add", true, changekeys.Listed(log, posts), log, posts) results = append(results, log) } case models.LogImporterForumLog: { parseResults, err := parsers.ForumLog(data, tz) if err != nil { return nil, err } for _, parsed := range parseResults { log, err := s.logs.Insert(ctx, parsed.Log) if err != nil { return nil, err } parsed.Log.EventName = eventName parsed.Log.ChannelName = channelName for _, post := range parsed.Posts { post.LogID = log.ShortID } posts, err := s.posts.InsertMany(ctx, parsed.Posts...) if err != nil { _ = s.logs.Delete(ctx, *log) return nil, err } s.changeService.Submit(ctx, models.ChangeModelLog, "add", true, changekeys.Listed(log), log) s.changeService.Submit(ctx, models.ChangeModelPost, "add", true, changekeys.Listed(log, posts), log, posts) } } default: { return nil, errors.New("Invalid importer: " + importer.String()) } } return results, nil } func (s *LogService) Update(ctx context.Context, id string, update models.LogUpdate) (*models.Log, error) { log, err := s.logs.Find(ctx, id) if err != nil { return nil, err } if err := auth.CheckPermission(ctx, "edit", log); err != nil { return nil, err } log, err = s.logs.Update(ctx, *log, update) if err != nil { return nil, err } s.changeService.Submit(ctx, models.ChangeModelLog, "edit", true, changekeys.Listed(log), log) return log, nil } func (s *LogService) AddPost(ctx context.Context, logId string, time time.Time, kind, nick, text string) (*models.Post, error) { if kind == "" || nick == "" || time.IsZero() { return nil, errors.New("kind, nick and time must be non-empty") } l, err := s.logs.Find(ctx, logId) if err != nil { return nil, err } post := &models.Post{ LogID: l.ShortID, Kind: kind, Nick: nick, Text: text, Time: time, } if err := auth.CheckPermission(ctx, "add", post); err != nil { return nil, err } post, err = s.posts.Insert(ctx, *post) if err != nil { return nil, err } err = s.refreshLogCharacters(ctx, *l, nil) if err != nil { log.Printf("Failed to update characters in log %s: %s", l.ID, err) } s.changeService.Submit(ctx, models.ChangeModelPost, "add", true, changekeys.Many(l, post), post) return post, nil } func (s *LogService) EditPost(ctx context.Context, id string, update models.PostUpdate) (*models.Post, error) { if (update.Kind != nil && *update.Kind == "") || (update.Nick != nil && *update.Nick == "") || (update.Text != nil && *update.Text == "") { return nil, errors.New("kind, nick and time must be non-empty") } post, err := s.posts.Find(ctx, id) if err != nil { return nil, err } if err := auth.CheckPermission(ctx, "edit", post); err != nil { return nil, err } post, err = s.posts.Update(ctx, *post, update) if err != nil { return nil, err } go func() { l, err := s.logs.Find(context.Background(), post.LogID) if err != nil { return } err = s.refreshLogCharacters(ctx, *l, nil) if err != nil { log.Printf("Failed to update characters in log %s: %s", l.ID, err) } s.changeService.Submit(ctx, models.ChangeModelPost, "edit", true, changekeys.Many(l, post), post) }() return post, nil } func (s *LogService) MovePost(ctx context.Context, id string, position int) ([]*models.Post, error) { if position < 1 { return nil, repositories.ErrInvalidPosition } post, err := s.posts.Find(ctx, id) if err != nil { return nil, err } if err := auth.CheckPermission(ctx, "move", post); err != nil { return nil, err } posts, err := s.posts.Move(ctx, *post, position) if err != nil { return nil, err } go func() { if len(posts) == 0 { return } log, err := s.logs.Find(context.Background(), posts[0].LogID) if err != nil { return } s.changeService.Submit(ctx, models.ChangeModelPost, "move", true, changekeys.Many(log, posts), posts) }() return posts, nil } func (s *LogService) DeletePost(ctx context.Context, id string) (*models.Post, error) { post, err := s.posts.Find(ctx, id) if err != nil { return nil, err } if err := auth.CheckPermission(ctx, "remove", post); err != nil { return nil, err } err = s.posts.Delete(ctx, *post) if err != nil { return nil, err } go func() { l, err := s.logs.Find(context.Background(), post.LogID) if err != nil { return } err = s.refreshLogCharacters(ctx, *l, nil) if err != nil { log.Printf("Failed to update characters in log %s: %s", l.ID, err) } s.changeService.Submit(ctx, models.ChangeModelPost, "remove", true, changekeys.Many(l, post), post) }() return post, nil } func (s *LogService) Delete(ctx context.Context, id string) (*models.Log, error) { log, err := s.logs.Find(ctx, id) if err != nil { return nil, err } if err := auth.CheckPermission(ctx, "remove", log); err != nil { return nil, err } err = s.logs.Delete(ctx, *log) if err != nil { return nil, err } s.changeService.Submit(ctx, models.ChangeModelLog, "remove", true, changekeys.Listed(log), log) return log, nil } func (s *LogService) FixImportDateBug(ctx context.Context) error { start := time.Now() logs, err := s.logs.List(ctx, models.LogFilter{}) if err != nil { return err } eg := errgroup.Group{} for i := range logs { l := logs[i] eg.Go(func() error { return s.fixImportDateBug(ctx, *l) }) } err = eg.Wait() if err != nil { return err } log.Printf("Date import bug check finished: logs: %d, duration: %s", len(logs), time.Since(start)) return nil } func (s *LogService) fixImportDateBug(ctx context.Context, l models.Log) error { // Find the log's posts. posts, err := s.ListPosts(ctx, &models.PostFilter{LogID: &l.ShortID}) if err != nil { return err } if len(posts) < 8 { return nil } // Find first action post first := posts[0] fi := 0 for first.Kind != "action" && first.Kind != "text" { fi++ if fi >= len(posts) { return nil } first = posts[fi] } last := posts[len(posts)-1] if first == last { return nil } // Stop here if this log probably isn't affected if last.Time.Sub(first.Time) < time.Hour*72 { return nil } // Find the first post past midnight. midnight := first mi := fi for i, post := range posts[fi+1:] { if post.Time.Hour() < first.Time.Hour() { midnight = post mi = fi + 1 + i break } } if midnight == last { return nil } if len(posts[mi+1:]) == 1 { return nil } hits := 0 prev := midnight for _, offender := range posts[mi+1:] { if offender.Time.Day() != prev.Time.Day() { hits += 1 } prev = offender } if hits < ((len(posts[mi+1:]) * 3) / 4) { return nil } for _, offender := range posts[mi+1:] { ot := offender.Time.UTC() mt := midnight.Time.UTC() y, m, d := mt.Date() hr, mi, se, ns := ot.Hour(), ot.Minute(), ot.Second(), ot.Nanosecond() newTime := time.Date(y, m, d, hr, mi, se, ns, time.UTC) _, err := s.posts.Update(ctx, *offender, models.PostUpdate{Time: &newTime}) if err != nil { return err } } log.Printf("Fixed import date bug in %d posts in log %s", len(posts[mi+1:]), l.ID) return nil } func (s *LogService) RefreshAllLogCharacters(ctx context.Context) error { start := time.Now() // Get all logs logs, err := s.logs.List(ctx, models.LogFilter{}) if err != nil { return err } // Check all characters now instead of later. characters, err := s.characterService.List(ctx, models.CharacterFilter{}) if err != nil { return err } characterMap := s.makeCharacterMap(characters) eg := errgroup.Group{} for i := range logs { l := logs[i] eg.Go(func() error { return s.refreshLogCharacters(ctx, *l, characterMap) }) } err = eg.Wait() if err != nil { return err } log.Printf("Full log character refresh complete; nicks: %d, logs: %d, duration: %s", len(characterMap), len(logs), time.Since(start)) return nil } func (s *LogService) RefreshLogCharacters(ctx context.Context, log models.Log) error { return s.refreshLogCharacters(ctx, log, nil) } func (s *LogService) refreshLogCharacters(ctx context.Context, log models.Log, characterMap map[string]*models.Character) error { posts, err := s.ListPosts(ctx, &models.PostFilter{LogID: &log.ShortID}) if err != nil { return nil } counts := make(map[string]int) 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]") || strings.HasSuffix(post.Nick, "|") { 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 counts[post.Nick]++ } 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) } } if characterMap == nil { characters, err := s.characterService.List(ctx, models.CharacterFilter{Nicks: nicks}) if err != nil { return err } characterMap = s.makeCharacterMap(characters) } log.CharacterIDs = log.CharacterIDs[:0] for key := range added { delete(added, key) } for _, nick := range nicks { character := characterMap[nick] if character == nil || added[character.ID] { continue } added[character.ID] = true log.CharacterIDs = append(log.CharacterIDs, character.ID) } _, err = s.logs.Update(ctx, log, models.LogUpdate{CharacterIDs: log.CharacterIDs}) return err } func (s *LogService) makeCharacterMap(characters []*models.Character) map[string]*models.Character { characterMap := make(map[string]*models.Character, len(characters)*3) for _, character := range characters { for _, nick := range character.Nicks { characterMap[nick] = character } } return characterMap }