This commit is contained in:
@@ -150,6 +150,9 @@ func (s *CharacterService) AddNick(ctx context.Context, id string, nick string)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.loader.Clear(character.ID)
|
||||
s.loader.Prime(character.ID, character)
|
||||
|
||||
s.changeService.Submit(ctx, "Character", "edit", true, changekeys.Listed(character), character)
|
||||
|
||||
return character, nil
|
||||
@@ -171,6 +174,9 @@ func (s *CharacterService) RemoveNick(ctx context.Context, id string, nick strin
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.loader.Clear(character.ID)
|
||||
s.loader.Prime(character.ID, character)
|
||||
|
||||
s.changeService.Submit(ctx, "Character", "edit", true, changekeys.Listed(character), character)
|
||||
|
||||
return character, nil
|
||||
@@ -192,6 +198,9 @@ func (s *CharacterService) Delete(ctx context.Context, id string) (*models.Chara
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.loader.Clear(character.ID)
|
||||
s.loader.Prime(character.ID, character)
|
||||
|
||||
s.changeService.Submit(ctx, "Character", "remove", true, changekeys.Listed(character), character)
|
||||
|
||||
return character, nil
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
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"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LogService struct {
|
||||
logs repositories.LogRepository
|
||||
posts repositories.PostRepository
|
||||
changeService *ChangeService
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// TODO: s.channelService.Ensure()
|
||||
|
||||
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)
|
||||
|
||||
eventName := ""
|
||||
if channel, err := channels.FindName(channelName); err != nil {
|
||||
eventName = channel.EventName
|
||||
}
|
||||
|
||||
// TODO: Ensure channel
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
|
||||
log, err := s.logs.Find(ctx, logId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
post := &models.Post{
|
||||
LogID: log.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
|
||||
}
|
||||
|
||||
s.changeService.Submit(ctx, models.ChangeModelPost, "add", true, changekeys.Many(log, post), log, 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() {
|
||||
log, err := s.logs.Find(context.Background(), post.LogID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.changeService.Submit(ctx, models.ChangeModelPost, "edit", true, changekeys.Many(log, 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() {
|
||||
log, err := s.logs.Find(context.Background(), post.LogID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.changeService.Submit(ctx, models.ChangeModelPost, "remove", true, changekeys.Many(log, 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
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package parsers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A ParsedLog contains the parsed log header and its posts.
|
||||
type ParsedLog struct {
|
||||
Log models.Log
|
||||
Posts []*models.Post
|
||||
}
|
||||
|
||||
// ForumLog parses the logs from the data.
|
||||
func ForumLog(data string, tz *time.Location) ([]ParsedLog, error) {
|
||||
metadata := ForumLogMetadata(data)
|
||||
results := make([]ParsedLog, 0, len(metadata["Date"]))
|
||||
scanner := bufio.NewScanner(strings.NewReader(data))
|
||||
|
||||
for i, dateStr := range metadata["Date"] {
|
||||
// Parse date
|
||||
date, err := time.ParseInLocation("January 2, 2006", dateStr, tz)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse date #%d: %#+v is not the in the correct format of \"January 2, 2006\"", i+1, dateStr)
|
||||
}
|
||||
|
||||
// Parse posts
|
||||
posts := make([]*models.Post, 0, 128)
|
||||
parsing := false
|
||||
prev := ""
|
||||
prevPost := models.Post{}
|
||||
for scanner.Scan() {
|
||||
line := strings.Trim(scanner.Text(), "\r\t ")
|
||||
|
||||
// Skip lines with links to other logs using the --> and <-- notation.
|
||||
if strings.HasPrefix(line, "-") || strings.HasPrefix(line, "<") {
|
||||
prev = line
|
||||
continue
|
||||
}
|
||||
|
||||
// If parsing and reaching a double empty-line, the session is done.
|
||||
if parsing && len(line) < 2 && len(prev) < 2 {
|
||||
break
|
||||
}
|
||||
|
||||
// Skip empty lines, but record them as thep revious for the above check.
|
||||
if len(line) < 2 {
|
||||
prev = line
|
||||
continue
|
||||
}
|
||||
|
||||
// If not parsing, skip until the first mirclike post.
|
||||
if !parsing {
|
||||
if strings.HasPrefix(line, "[") {
|
||||
parsing = true
|
||||
} else {
|
||||
prev = line
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the post.
|
||||
post, err := MircPost(line, date, prevPost)
|
||||
if err != nil {
|
||||
summary := ""
|
||||
for _, ru := range line {
|
||||
summary += string(ru)
|
||||
if len(summary) > 60 {
|
||||
summary += "..."
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to parse post: %s", summary)
|
||||
}
|
||||
|
||||
posts = append(posts, &post)
|
||||
prevPost = post
|
||||
prev = line
|
||||
}
|
||||
|
||||
// No posts means there's a problem.
|
||||
if len(posts) == 0 {
|
||||
return nil, fmt.Errorf("session %d (%s) has no posts (too many dates?)", i+1, dateStr)
|
||||
}
|
||||
|
||||
// Add it.
|
||||
results = append(results, ParsedLog{
|
||||
Log: models.Log{Date: posts[0].Time},
|
||||
Posts: posts,
|
||||
})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ForumLogMetadata parses metadata, discards the broken parts, and returns the
|
||||
// parsed data as a map (`m`) and the position of the first IRC post (`n`)
|
||||
func ForumLogMetadata(data string) map[string][]string {
|
||||
result := make(map[string][]string)
|
||||
key := ""
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(data))
|
||||
for scanner.Scan() {
|
||||
line := strings.Trim(scanner.Text(), "\r\t ")
|
||||
|
||||
if strings.HasPrefix(line, "[") {
|
||||
break
|
||||
}
|
||||
if len(line) < 1 {
|
||||
key = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
split := strings.Split(line, ":")
|
||||
key = split[0]
|
||||
} else {
|
||||
result[key] = append(result[key], line)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package parsers_test
|
||||
|
||||
import (
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/services/parsers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestForumLogMetadata(t *testing.T) {
|
||||
data := parsers.ForumLogMetadata(testData1)
|
||||
|
||||
assert.Equal(t, []string{
|
||||
"August 11, 2014",
|
||||
"August 12, 2014",
|
||||
"August 13, 2014",
|
||||
}, data["Date"])
|
||||
assert.Equal(t, []string{
|
||||
"Aroste, an island off the Derraian coastline.",
|
||||
}, data["Location"])
|
||||
assert.Equal(t, []string{
|
||||
"Gisle",
|
||||
}, data["GM"])
|
||||
assert.Equal(t, []string{
|
||||
"847 posts",
|
||||
}, data["Length"])
|
||||
assert.Equal(t, len(data["Stuff"]), 0)
|
||||
assert.Equal(t, []string{
|
||||
"Calyx Vadris - Osiris",
|
||||
"Damien Monroe - Bowe",
|
||||
"Jason Wolfe - Dante",
|
||||
"Renala T'Iavay - Gisle",
|
||||
"Victoria Steels - MCB280",
|
||||
}, data["Characters"])
|
||||
assert.Equal(t, []string{
|
||||
"Recurring:",
|
||||
"Halisi - Tyranniac",
|
||||
"Jattic - Dante",
|
||||
"Jelvan Darennon - Gisle",
|
||||
"Leah - Dante",
|
||||
"Marissa T'Evin - Gisle",
|
||||
"Philip Lacour - Tyranniac",
|
||||
}, data["NPCs"], "Labels immediately following another should be considered part of the former's content.")
|
||||
|
||||
for key := range data {
|
||||
if strings.HasPrefix(key, "[") {
|
||||
t.Error("It should have stopped at the IRC post, yet this key exists:", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForumLog(t *testing.T) {
|
||||
results, err := parsers.ForumLog(testLog2, time.UTC)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %s", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, 2, len(results), "Amount of results.")
|
||||
assert.Equal(t, testLog2Posts[0], results[0].Posts, "First log's posts.")
|
||||
assert.Equal(t, testLog2Posts[1], results[1].Posts, "Second log's posts.")
|
||||
}
|
||||
|
||||
func TestForumLogErrors(t *testing.T) {
|
||||
_, err1 := parsers.ForumLog(brokenLogNoPosts, time.UTC)
|
||||
_, err2 := parsers.ForumLog(brokenLogBrokenPost, time.UTC)
|
||||
_, err3 := parsers.ForumLog(brokenLogBrokenDate, time.UTC)
|
||||
|
||||
t.Log("Should be about no posts:", err1)
|
||||
t.Log("Should be about a broken post:", err2)
|
||||
t.Log("Should be about a broken date:", err3)
|
||||
|
||||
assert.NotEqual(t, err1, nil, "Should be no posts")
|
||||
assert.NotEqual(t, err2, nil, "Should be about a bad post")
|
||||
assert.NotEqual(t, err3, nil, "Should be about a broken date")
|
||||
}
|
||||
|
||||
var testLog2Posts = [][]*models.Post{
|
||||
{
|
||||
{
|
||||
ID: "UNASSIGNED",
|
||||
LogID: "UNASSIGNED",
|
||||
Time: parseDate(nil, "2013-07-25 21:28:00"),
|
||||
Kind: "action",
|
||||
Nick: "Tyranniac",
|
||||
Text: "| The Hnipinn Minerals local administrative center is a rather small building located next to the refinery. The area is mostly asphalt and industrial surroundings. The gate in the fence surrounding the refinery is nearby, with a transport truck just being admitted by the guard - rather heavily armed for corporate security. Despite not being that late, it's rather dark due to the rain-bearing clouds that have just started emptying their content. The windows of the office glow with invitingly warm light. A receptionist can be seen working through the glass door.",
|
||||
Position: 1,
|
||||
},
|
||||
{
|
||||
ID: "UNASSIGNED",
|
||||
LogID: "UNASSIGNED",
|
||||
Time: parseDate(nil, "2013-07-25 21:46:00"),
|
||||
Kind: "action",
|
||||
Nick: "Steve_Briggs",
|
||||
Text: `approaches the building at a brisk jog, pulling up the collar of his jacket to block as much of the rain as possible. Stopping just short of the door, he holds back and awaits Renala. "Well, hopefully this is the universe's way of getting our bad luck out of the way early." he says, glancing up at the sky. "Good things to come!" he adds with a smile.`,
|
||||
Position: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
{
|
||||
ID: "UNASSIGNED",
|
||||
LogID: "UNASSIGNED",
|
||||
Time: parseDate(nil, "2013-07-26 21:46:00"),
|
||||
Kind: "action",
|
||||
Nick: "Steve_Briggs",
|
||||
Text: `approaches the building at a brisk jog, pulling up the collar of his jacket to block as much of the rain as possible. Stopping just short of the door, he holds back and awaits Renala. "Well, hopefully this is the universe's way of getting our bad luck out of the way early." he says, glancing up at the sky. "Good things to come!" he adds with a smile.`,
|
||||
Position: 1,
|
||||
},
|
||||
{
|
||||
ID: "UNASSIGNED",
|
||||
LogID: "UNASSIGNED",
|
||||
Time: parseDate(nil, "2013-07-26 21:46:00"),
|
||||
Kind: "action",
|
||||
Nick: "Steve_Briggs",
|
||||
Text: `approaches the building at a brisk jog, pulling up the collar of his jacket to block as much of the rain as possible. Stopping just short of the door, he holds back and awaits Renala. "Well, hopefully this is the universe's way of getting our bad luck out of the way early." he says, glancing up at the sky. "Good things to come!" he adds with a smile.`,
|
||||
Position: 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var testData1 = `
|
||||
Date: August 2185
|
||||
August 11, 2014
|
||||
August 12, 2014
|
||||
August 13, 2014
|
||||
|
||||
Location:
|
||||
Aroste, an island off the Derraian coastline.
|
||||
|
||||
GM:
|
||||
Gisle
|
||||
|
||||
Length:
|
||||
847 posts
|
||||
|
||||
Characters:
|
||||
Calyx Vadris - Osiris
|
||||
Damien Monroe - Bowe
|
||||
Jason Wolfe - Dante
|
||||
Renala T'Iavay - Gisle
|
||||
Victoria Steels - MCB280
|
||||
|
||||
NPCs:
|
||||
Recurring:
|
||||
Halisi - Tyranniac
|
||||
Jattic - Dante
|
||||
Jelvan Darennon - Gisle
|
||||
Leah - Dante
|
||||
Marissa T'Evin - Gisle
|
||||
Philip Lacour - Tyranniac
|
||||
|
||||
One-off:
|
||||
Reidas Falten (Turian - Guard meeting them on their trip to the building)
|
||||
Sarian T'Dera (Asari - Asari opening fire on Calyx, Leah and Renala)
|
||||
Mariam Adams (Human - The receptionist.)
|
||||
Prerix Falten (Turian2 - Victoria's chair)
|
||||
Jonathan Lyng (OtherHuman - The one opening fire on Leah entering the garage)
|
||||
Alerena Teris (Teris - Asari met in the maintenance corridor)
|
||||
|
||||
<-- Redorck Agency - August 11, 2014
|
||||
|
||||
[13:19] * Leah looks over at Damien. "Haven't seen you around the agency." she says curiously, "New hire?"
|
||||
|
||||
`
|
||||
|
||||
var brokenLogNoPosts = `
|
||||
Date:
|
||||
September 27, 2014
|
||||
`
|
||||
|
||||
var brokenLogBrokenPost = `
|
||||
Date:
|
||||
September 10, 2038
|
||||
|
||||
[12:38] * Some_Character is lasered by a reaper, and turns to ash before even knowing what happened.
|
||||
|
||||
[12:4 * Some_Other_Character also meets the same fate, because the Reapers are no joke.
|
||||
`
|
||||
|
||||
var brokenLogBrokenDate = `
|
||||
Date:
|
||||
10 September, 2038
|
||||
|
||||
[12:34] * =Scene= Stuff happens.
|
||||
`
|
||||
|
||||
var testLog2 = `
|
||||
Date:
|
||||
July 25, 2013
|
||||
July 26, 2013
|
||||
|
||||
GM:
|
||||
Tyranniac
|
||||
|
||||
Length:
|
||||
92 posts
|
||||
|
||||
Characters:
|
||||
Fera'Sel nar Veltar - Baphomet
|
||||
Renala T’Iavay - Gisle
|
||||
Steven Briggs - Dante
|
||||
|
||||
NPCs:
|
||||
Receptionist
|
||||
|
||||
Relevant RPs:
|
||||
<-- Hub - Miner's Respite (July 16)
|
||||
--> Event - Hinpinn's Salvage Mission (August 5)
|
||||
|
||||
[21:28] * @Tyranniac | The Hnipinn Minerals local administrative center is a rather small building located next to the refinery. The area is mostly asphalt and industrial surroundings. The gate in the fence surrounding the refinery is nearby, with a transport truck just being admitted by the guard - rather heavily armed for corporate security. Despite not being that late, it's rather dark due to the rain-bearing clouds that have just started emptying their content. The windows of the office glow with invitingly warm light. A receptionist can be seen working through the glass door.
|
||||
|
||||
[21:46] * Steve_Briggs approaches the building at a brisk jog, pulling up the collar of his jacket to block as much of the rain as possible. Stopping just short of the door, he holds back and awaits Renala. "Well, hopefully this is the universe's way of getting our bad luck out of the way early." he says, glancing up at the sky. "Good things to come!" he adds with a smile.
|
||||
|
||||
|
||||
[21:46] * Steve_Briggs approaches the building at a brisk jog, pulling up the collar of his jacket to block as much of the rain as possible. Stopping just short of the door, he holds back and awaits Renala. "Well, hopefully this is the universe's way of getting our bad luck out of the way early." he says, glancing up at the sky. "Good things to come!" he adds with a smile.
|
||||
|
||||
--> Stuff and things
|
||||
|
||||
<-- Stuff and things
|
||||
|
||||
[21:46] * Steve_Briggs approaches the building at a brisk jog, pulling up the collar of his jacket to block as much of the rain as possible. Stopping just short of the door, he holds back and awaits Renala. "Well, hopefully this is the universe's way of getting our bad luck out of the way early." he says, glancing up at the sky. "Good things to come!" he adds with a smile.
|
||||
`
|
||||
@@ -0,0 +1,136 @@
|
||||
package parsers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotPost is returned by parsePost if the line is empty or not a post.
|
||||
var ErrNotPost = errors.New("not a post")
|
||||
|
||||
// ErrEmptyLog is returned by ParseLog if there are no (valid) posts in the log.
|
||||
var ErrEmptyLog = errors.New("no valid posts found in log")
|
||||
|
||||
// MircLog parses the log and returns the things that can be gleamed from them.
|
||||
func MircLog(data string, date time.Time, strict bool) (*ParsedLog, error) {
|
||||
lines := strings.Split(data, "\n")
|
||||
posts := make([]*models.Post, 0, len(lines))
|
||||
prev := models.Post{}
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.Trim(line, "\r\t ")
|
||||
if len(line) < 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
post, err := MircPost(line, date, prev)
|
||||
if err != nil {
|
||||
if strict {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
posts = append(posts, &post)
|
||||
prev = post
|
||||
}
|
||||
|
||||
if len(posts) == 0 {
|
||||
return nil, ErrEmptyLog
|
||||
}
|
||||
|
||||
log := models.Log{
|
||||
Date: posts[0].Time,
|
||||
}
|
||||
|
||||
return &ParsedLog{log, posts}, nil
|
||||
}
|
||||
|
||||
// MircPost parses a post from a mirc-like line. If the previous post is included (it can be empty), it will be used
|
||||
// to determine whether midnight has passed.
|
||||
func MircPost(line string, date time.Time, prev models.Post) (models.Post, error) {
|
||||
// Do basic validation
|
||||
line = strings.Trim(line, " \t\n\r")
|
||||
if len(line) == 0 || !strings.HasPrefix(line, "[") {
|
||||
return models.Post{}, ErrNotPost
|
||||
}
|
||||
|
||||
// Parse timestamp
|
||||
tsEndIndex := strings.IndexByte(line, ']')
|
||||
if tsEndIndex == -1 || len(line) < tsEndIndex+5 {
|
||||
return models.Post{}, ErrNotPost
|
||||
}
|
||||
tsStr := line[1:tsEndIndex]
|
||||
tsSplit := strings.Split(tsStr, ":")
|
||||
tsUnits := make([]int, len(tsSplit))
|
||||
if len(tsSplit) < 2 {
|
||||
return models.Post{}, ErrNotPost
|
||||
}
|
||||
for i := range tsSplit {
|
||||
n, err := strconv.Atoi(tsSplit[i])
|
||||
if err != nil {
|
||||
return models.Post{}, ErrNotPost
|
||||
}
|
||||
|
||||
tsUnits[i] = n
|
||||
}
|
||||
if len(tsUnits) == 2 {
|
||||
tsUnits = append(tsUnits, 0)
|
||||
}
|
||||
|
||||
// Determine timestamp from parsed data and previous post.
|
||||
ts := time.Date(date.Year(), date.Month(), date.Day(), tsUnits[0], tsUnits[1], tsUnits[2], 0, date.Location())
|
||||
if !prev.Time.IsZero() && prev.Time.Sub(ts) > 30*time.Minute {
|
||||
ts = time.Date(prev.Time.Year(), prev.Time.Month(), prev.Time.Day()+1, tsUnits[0], tsUnits[1], tsUnits[2], 0, date.Location())
|
||||
}
|
||||
|
||||
if line[tsEndIndex+2] == '*' {
|
||||
split := strings.SplitN(line[tsEndIndex+4:], " ", 2)
|
||||
if len(split) == 1 {
|
||||
return models.Post{}, ErrNotPost
|
||||
}
|
||||
|
||||
post := models.Post{
|
||||
ID: "UNASSIGNED",
|
||||
LogID: "UNASSIGNED",
|
||||
Time: ts,
|
||||
Kind: "action",
|
||||
Nick: strings.TrimLeft(split[0], "+@!~"),
|
||||
Text: split[1],
|
||||
Position: prev.Position + 1,
|
||||
}
|
||||
|
||||
if post.Nick[0] == '=' {
|
||||
post.Kind = "scene"
|
||||
}
|
||||
|
||||
return post, nil
|
||||
} else if line[tsEndIndex+2] == '<' {
|
||||
split := strings.SplitN(line[tsEndIndex+2:], " ", 2)
|
||||
if len(split) == 1 {
|
||||
return models.Post{}, ErrNotPost
|
||||
}
|
||||
|
||||
post := models.Post{
|
||||
ID: "UNASSIGNED",
|
||||
LogID: "UNASSIGNED",
|
||||
Time: ts,
|
||||
Kind: "text",
|
||||
Nick: strings.TrimLeft(split[0][1:len(split[0])-1], "+@!~"),
|
||||
Text: split[1],
|
||||
Position: prev.Position + 1,
|
||||
}
|
||||
|
||||
if post.Nick[0] == '=' {
|
||||
post.Kind = "scene"
|
||||
}
|
||||
|
||||
return post, nil
|
||||
} else {
|
||||
return models.Post{}, ErrNotPost
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package parsers_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.aiterp.net/rpdata/api/models"
|
||||
"git.aiterp.net/rpdata/api/services/parsers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMircPost(t *testing.T) {
|
||||
table := []struct {
|
||||
Input string
|
||||
TS string
|
||||
Kind string
|
||||
Nick string
|
||||
Text string
|
||||
}{
|
||||
{
|
||||
"[12:34] * Stuff does things.",
|
||||
"12:34:00", "action", "Stuff", "does things.",
|
||||
},
|
||||
{
|
||||
"[12:34] <Stuff> Things said.",
|
||||
"12:34:00", "text", "Stuff", "Things said.",
|
||||
},
|
||||
{
|
||||
"[13:36:59] <Stuff> Things said.",
|
||||
"13:36:59", "text", "Stuff", "Things said.",
|
||||
},
|
||||
{
|
||||
"[23:59] <=Scene=> Scenery and such.",
|
||||
"23:59:00", "scene", "=Scene=", "Scenery and such.",
|
||||
},
|
||||
{
|
||||
"[01:10:11] * =Scene= Scenery and such from the forum or mIRC using my old script.",
|
||||
"01:10:11", "scene", "=Scene=", "Scenery and such from the forum or mIRC using my old script.",
|
||||
},
|
||||
}
|
||||
|
||||
for i, row := range table {
|
||||
t.Run(fmt.Sprintf("Row_%d", i), func(t *testing.T) {
|
||||
post, err := parsers.MircPost(row.Input, time.Now(), models.Post{})
|
||||
if err != nil {
|
||||
t.Fatal("Could not parse post:", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, row.TS, post.Time.Format("15:04:05"), "Timestamps should match.")
|
||||
assert.Equal(t, row.Kind, post.Kind, "Kinds should match.")
|
||||
assert.Equal(t, row.Nick, post.Nick, "Kinds should match.")
|
||||
assert.Equal(t, row.Text, post.Text, "Kinds should match.")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMircPostErrors(t *testing.T) {
|
||||
table := []struct {
|
||||
Input string
|
||||
Err error
|
||||
}{
|
||||
{"[12:34] <Stuff> Things said.", nil},
|
||||
{"[12:34] >Stuff> Things said.", parsers.ErrNotPost},
|
||||
{"12:34] <Stuff> Things said.", parsers.ErrNotPost},
|
||||
{"* Stuff Things said.", parsers.ErrNotPost},
|
||||
{"", parsers.ErrNotPost},
|
||||
{"[12:34 <Stuff> Things said.", parsers.ErrNotPost},
|
||||
{"[TE:XT] <Stuff> Things said.", parsers.ErrNotPost},
|
||||
{"[10] <Stuff> Things said.", parsers.ErrNotPost},
|
||||
{"[12:34:56:789] <Stuff> Things said.", nil},
|
||||
{"[12:34:56.789] <Stuff> Things said.", parsers.ErrNotPost},
|
||||
{"[12:34] <Stuff>", parsers.ErrNotPost},
|
||||
{"[12:34] * Stuff", parsers.ErrNotPost},
|
||||
{"[12:34] =Scene=", parsers.ErrNotPost},
|
||||
{"[12:34] <=Scene=>", parsers.ErrNotPost},
|
||||
}
|
||||
|
||||
for i, row := range table {
|
||||
t.Run(fmt.Sprintf("Row_%d", i), func(t *testing.T) {
|
||||
_, err := parsers.MircPost(row.Input, time.Now(), models.Post{})
|
||||
|
||||
assert.Equal(t, row.Err, err, "Error should match")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMircPostNextDay(t *testing.T) {
|
||||
table := []struct {
|
||||
Prev time.Time
|
||||
TS string
|
||||
Time time.Time
|
||||
}{
|
||||
{Prev: parseDate(t, "2019-01-12 12:00:00"), TS: "12:01", Time: parseDate(t, "2019-01-12 12:01:00")},
|
||||
{Prev: parseDate(t, "2019-01-12 12:00:00"), TS: "11:53:13", Time: parseDate(t, "2019-01-12 11:53:13")},
|
||||
{Prev: parseDate(t, "2019-04-08 23:51:59"), TS: "00:09", Time: parseDate(t, "2019-04-09 00:09:00")},
|
||||
{Prev: parseDate(t, "2019-01-12 12:00:00"), TS: "11:29:59", Time: parseDate(t, "2019-01-13 11:29:59")},
|
||||
}
|
||||
|
||||
for i, row := range table {
|
||||
t.Run(fmt.Sprintf("Row_%d", i), func(t *testing.T) {
|
||||
input := fmt.Sprintf("[%s] * Stuff does things.", row.TS)
|
||||
|
||||
post, err := parsers.MircPost(input, row.Prev, models.Post{Time: row.Prev})
|
||||
if err != nil {
|
||||
t.Fatal("Could not parse post:", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, row.Time, post.Time)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMircLog(t *testing.T) {
|
||||
parsed, err := parsers.MircLog(testLog, parseDate(t, "2018-05-11 00:00:00"), false)
|
||||
if err != nil {
|
||||
t.Fatal("ParseLog", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, testLogPosts, parsed.Posts)
|
||||
assert.Equal(t, parsed.Posts[0].Time, parsed.Log.Date, "Log's date should be the first post's.")
|
||||
}
|
||||
|
||||
func TestMircLogErrors(t *testing.T) {
|
||||
_, err1 := parsers.MircLog("\n\n\n\n\n \n\t\r\n", time.Time{}, false)
|
||||
_, err2 := parsers.MircLog("\n\n\n\n\n[14:57]* Stuff \n\t\r\n", time.Time{}, true)
|
||||
|
||||
assert.Equal(t, parsers.ErrEmptyLog, err1)
|
||||
assert.Equal(t, parsers.ErrNotPost, err2)
|
||||
}
|
||||
|
||||
func parseDate(t *testing.T, date string) time.Time {
|
||||
result, err := time.Parse("2006-01-02 15:04:05", date)
|
||||
if err != nil {
|
||||
if t != nil {
|
||||
t.Fatal("Could not parse date", date, err)
|
||||
} else {
|
||||
panic("Could not parse date: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
var testLog = strings.Join([]string{
|
||||
"[11:21] * Va`ynna_Atana returns to the apartment at the end of another long day. She hangs up her jacket and a green scarf next to the door before going straight to the bathroom; she leaves the door open, though. She walked home with Uvena this time, but the two parted ways downstairs.",
|
||||
"",
|
||||
"[11:21] <=Scene=> The weather outside is not pleasant in the slightest. The storm is still going on, visibility is reduced and the worst gusts of wind can be felt inside the apartment as a faint shudder. ",
|
||||
"",
|
||||
"[11:",
|
||||
" [11:27] <Test> Stuff and things.",
|
||||
}, "\r\n")
|
||||
|
||||
var testLogPosts = []*models.Post{
|
||||
{
|
||||
ID: "UNASSIGNED",
|
||||
LogID: "UNASSIGNED",
|
||||
Time: parseDate(nil, "2018-05-11 11:21:00"),
|
||||
Kind: "action",
|
||||
Nick: "Va`ynna_Atana",
|
||||
Position: 1,
|
||||
Text: "returns to the apartment at the end of another long day. She hangs up her jacket and a green scarf next to the door before going straight to the bathroom; she leaves the door open, though. She walked home with Uvena this time, but the two parted ways downstairs.",
|
||||
},
|
||||
{
|
||||
ID: "UNASSIGNED",
|
||||
LogID: "UNASSIGNED",
|
||||
Time: parseDate(nil, "2018-05-11 11:21:00"),
|
||||
Kind: "scene",
|
||||
Nick: "=Scene=",
|
||||
Position: 2,
|
||||
Text: "The weather outside is not pleasant in the slightest. The storm is still going on, visibility is reduced and the worst gusts of wind can be felt inside the apartment as a faint shudder.",
|
||||
},
|
||||
{
|
||||
ID: "UNASSIGNED",
|
||||
LogID: "UNASSIGNED",
|
||||
Time: parseDate(nil, "2018-05-11 11:27:00"),
|
||||
Kind: "text",
|
||||
Nick: "Test",
|
||||
Position: 3,
|
||||
Text: "Stuff and things.",
|
||||
},
|
||||
}
|
||||
@@ -10,12 +10,12 @@ type Bundle struct {
|
||||
Tags *TagService
|
||||
Characters *CharacterService
|
||||
Changes *ChangeService
|
||||
Logs *LogService
|
||||
}
|
||||
|
||||
// NewBundle creates a new bundle.
|
||||
func NewBundle(db database.Database) *Bundle {
|
||||
bundle := &Bundle{}
|
||||
|
||||
bundle.Changes = &ChangeService{
|
||||
changes: db.Changes(),
|
||||
}
|
||||
@@ -25,6 +25,11 @@ func NewBundle(db database.Database) *Bundle {
|
||||
loader: loaders.CharacterLoaderFromRepository(db.Characters()),
|
||||
changeService: bundle.Changes,
|
||||
}
|
||||
bundle.Logs = &LogService{
|
||||
logs: db.Logs(),
|
||||
posts: db.Posts(),
|
||||
changeService: bundle.Changes,
|
||||
}
|
||||
|
||||
return bundle
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user