Browse Source

add IrcCloud log importer.

master
Gisle Aune 4 years ago
parent
commit
df982fac17
  1. 7
      graph2/resolvers/log.go
  2. 8
      graph2/schema/types/Log.gql
  3. 4
      models/log-importer.go
  4. 40
      services/logs.go
  5. 161
      services/parsers/irccloud.go
  6. 154
      services/parsers/irccloud_test.go
  7. 4
      services/parsers/mirclike.go
  8. 4
      services/parsers/mirclike_test.go

7
graph2/resolvers/log.go

@ -55,7 +55,12 @@ func (r *mutationResolver) ImportLog(ctx context.Context, input graphcore.LogImp
tz = parsedTZ tz = parsedTZ
} }
return r.s.Logs.Import(ctx, input.Importer, date, tz, input.ChannelName, input.Data)
sessionThreshold := time.Hour * 12
if input.SessionThresholdMs != nil {
sessionThreshold = time.Duration(*input.SessionThresholdMs) * time.Millisecond
}
return r.s.Logs.Import(ctx, input.Importer, date, tz, input.ChannelName, sessionThreshold, input.Data)
} }
func (r *mutationResolver) SplitLog(ctx context.Context, input graphcore.LogSplitInput) (*models.Log, error) { func (r *mutationResolver) SplitLog(ctx context.Context, input graphcore.LogSplitInput) (*models.Log, error) {

8
graph2/schema/types/Log.gql

@ -132,6 +132,9 @@ input LogImportInput {
"The date of the log, if not provided in the log body." "The date of the log, if not provided in the log body."
date: Time date: Time
"Session threwhold"
sessionThresholdMs: Int
"The log body itself." "The log body itself."
data: String! data: String!
} }
@ -171,4 +174,9 @@ enum LogImporter {
Forum log: This importer parses the format on the forum. The displayed log, not the post source. Forum log: This importer parses the format on the forum. The displayed log, not the post source.
""" """
ForumLog ForumLog
"""
IRCCloud: This importer parses irccloud exports.
"""
IrcCloud
} }

4
models/log-importer.go

@ -14,12 +14,14 @@ const (
LogImporterMircLike LogImporter = "MircLike" LogImporterMircLike LogImporter = "MircLike"
// LogImporterForumLog is a value of LogImporter // LogImporterForumLog is a value of LogImporter
LogImporterForumLog LogImporter = "ForumLog" LogImporterForumLog LogImporter = "ForumLog"
// LogImporterForumLog is a value of LogImporter
LogImporterIrcCloud LogImporter = "IrcCloud"
) )
// IsValid returns true if the underlying string is one of the correct values. // IsValid returns true if the underlying string is one of the correct values.
func (e LogImporter) IsValid() bool { func (e LogImporter) IsValid() bool {
switch e { switch e {
case LogImporterForumLog, LogImporterMircLike:
case LogImporterForumLog, LogImporterMircLike, LogImporterIrcCloud:
return true return true
} }
return false return false

40
services/logs.go

@ -112,7 +112,7 @@ func (s *LogService) Create(ctx context.Context, title, description, channelName
} }
// Import creates new logs from common formats. // 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) {
func (s *LogService) Import(ctx context.Context, importer models.LogImporter, date time.Time, tz *time.Location, channelName string, sessionThreshold time.Duration, data string) ([]*models.Log, error) {
if err := s.authService.CheckPermission(ctx, "add", &models.Log{}); err != nil { if err := s.authService.CheckPermission(ctx, "add", &models.Log{}); err != nil {
return nil, err return nil, err
} }
@ -174,13 +174,48 @@ func (s *LogService) Import(ctx context.Context, importer models.LogImporter, da
} }
for _, parsed := range parseResults { for _, parsed := range parseResults {
parsed.Log.EventName = eventName
parsed.Log.ChannelName = channelName
log, err := s.logs.Insert(ctx, parsed.Log) log, err := s.logs.Insert(ctx, parsed.Log)
if err != nil { if err != nil {
return nil, err 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.LogImporterIrcCloud:
{
parseResults, err := parsers.IRCCloudLogs(data, tz, sessionThreshold)
if err != nil {
return nil, err
}
for _, parsed := range parseResults {
parsed.Log.EventName = eventName parsed.Log.EventName = eventName
parsed.Log.ChannelName = channelName parsed.Log.ChannelName = channelName
log, err := s.logs.Insert(ctx, parsed.Log)
if err != nil {
return nil, err
}
for _, post := range parsed.Posts { for _, post := range parsed.Posts {
post.LogID = log.ShortID post.LogID = log.ShortID
} }
@ -194,8 +229,11 @@ func (s *LogService) Import(ctx context.Context, importer models.LogImporter, da
s.changeService.Submit(ctx, models.ChangeModelLog, "add", true, changekeys.Listed(log), log) 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) s.changeService.Submit(ctx, models.ChangeModelPost, "add", true, changekeys.Listed(log, posts), log, posts)
results = append(results, log)
} }
} }
default: default:
{ {
return nil, errors.New("Invalid importer: " + importer.String()) return nil, errors.New("Invalid importer: " + importer.String())

161
services/parsers/irccloud.go

@ -0,0 +1,161 @@
package parsers
import (
"errors"
"fmt"
"git.aiterp.net/rpdata/api/models"
"strings"
"time"
)
var ErrSkip = errors.New("parsers: skip this post")
func IRCCloudLogs(data string, location *time.Location, threshold time.Duration) ([]ParsedLog, error) {
lines := strings.Split(data, "\n")
pos := 0
results := make([]ParsedLog, 0, 8)
for pos < len(lines) {
log, n, err := IRCCloudLog(lines[pos:], location, threshold)
if err != nil {
if err == ErrEmptyLog {
pos += n
continue
}
return nil, err
}
pos += n
results = append(results, *log)
}
return results, nil
}
// IRCCloudLog parses the log and returns the things that can be gleamed from them.
func IRCCloudLog(lines []string, location *time.Location, threshold time.Duration) (*ParsedLog, int, error) {
posts := make([]*models.Post, 0, len(lines))
prev := (*models.Post)(nil)
amount := 0
for _, line := range lines {
line = strings.Trim(line, "\r\t  ")
if len(line) < 1 {
amount += 1
continue
}
post, err := IRCCloudPost(line, location)
if err == ErrSkip {
amount += 1
continue
} else if err != nil {
return nil, -1, err
}
if prev != nil {
if post.Time.Sub(prev.Time) >= threshold {
break
}
post.Position = prev.Position + 1
} else {
post.Position = 1
}
posts = append(posts, &post)
prev = &post
amount += 1
}
if len(posts) == 0 {
return nil, amount, ErrEmptyLog
}
log := models.Log{
Date: posts[0].Time,
}
return &ParsedLog{log, posts}, amount, nil
}
// IRCCloudPost 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 IRCCloudPost(line string, tz *time.Location) (models.Post, error) {
// Do basic validation
line = strings.Trim(line, "  \t\n\r")
if len(line) == 0 || !strings.HasPrefix(line, "[") {
return models.Post{}, &ParseError{
Line: line,
Problem: "no timestamp",
}
}
// Parse timestamp
tsEndIndex := strings.IndexByte(line, ']')
if tsEndIndex == -1 || len(line) < tsEndIndex+5 {
return models.Post{}, &ParseError{
Line: line,
Problem: "incomplete timestamp",
}
}
tsStr := line[1:tsEndIndex]
ts, err := time.ParseInLocation("2006-01-02 15:04:05", tsStr, tz)
if err != nil {
return models.Post{}, &ParseError{
Line: line,
Problem: fmt.Sprintf("Could not parse date: %s", err.Error()),
}
}
if strings.HasPrefix(line[tsEndIndex+2:], "—") {
split := strings.SplitN(line[tsEndIndex+6:], " ", 2)
if len(split) == 1 {
return models.Post{}, &ParseError{
Line: line,
Problem: "post is empty",
}
}
post := models.Post{
ID: "UNASSIGNED",
LogID: "UNASSIGNED",
Time: ts,
Kind: "action",
Nick: strings.Trim(strings.TrimLeft(split[0], "+@!~"), "\u001F"),
Text: split[1],
}
if strings.HasPrefix(post.Nick, "=") {
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{}, &ParseError{
Line: line,
Problem: "post is empty",
}
}
post := models.Post{
ID: "UNASSIGNED",
LogID: "UNASSIGNED",
Time: ts,
Kind: "text",
Nick: strings.Trim(strings.TrimLeft(split[0][1:len(split[0])-1], "+@!~"), "\u001F"),
Text: split[1],
}
if strings.HasPrefix(post.Nick, "=") {
post.Kind = "scene"
}
return post, nil
} else {
return models.Post{}, ErrSkip
}
}

154
services/parsers/irccloud_test.go

@ -0,0 +1,154 @@
package parsers
import (
"fmt"
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestIRCCloudPost(t *testing.T) {
table := []struct {
Input string
TS string
Kind string
Nick string
Text string
Skipped bool
}{
{
"[2016-07-18 12:57:17] — \u001FCharacter_Name\u001F does things.",
"2016-07-18 12:57:17", "action", "Character_Name", "does things.",
false,
},
{
"[2016-07-18 13:04:06] — Character keeps their mouth shut again for the time beings.",
"2016-07-18 13:04:06", "action", "Character", "keeps their mouth shut again for the time beings.",
false,
},
{
"[2016-07-18 12:34:42] <Victoria_Steels> \"I can keep it professional.\"",
"2016-07-18 12:34:42", "text", "Victoria_Steels", "\"I can keep it professional.\"",
false,
},
{
"[2016-07-18 10:41:10] * UserZzz → User",
"", "", "", "",
true,
},
{
"[2016-07-18 12:53:55] → Someone joined",
"", "", "", "",
true,
},
{
"[2016-07-18 22:14:15] ⇐ SomeoneElse quit (s@example.com): Ping timeout: 547 seconds",
"", "", "", "",
true,
},
}
for i, row := range table {
t.Run(fmt.Sprintf("Row_%d", i), func(t *testing.T) {
post, err := IRCCloudPost(row.Input, time.UTC)
if err != nil && err != ErrSkip {
t.Fatal("Could not parse post:", err)
}
if row.Skipped {
if err == ErrSkip {
return
}
t.Fatal("Row should be skipped")
}
if !row.Skipped && err == ErrSkip {
t.Fatal("Row should not be skipped, but is")
}
assert.Equal(t, row.TS, post.Time.Format("2006-01-02 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 TestIRCCloudLog(t *testing.T) {
type logLine struct {
TS string
Kind string
Nick string
Text string
}
logs := [][]logLine{
{
{"2011-09-23 15:37:16", "action", "Jason_Wolfe", "sheds a bit of the tension he was harboring as Markus extends his hand."},
{"2011-09-23 15:41:15", "action", "Markus_Vasquez", "gives another nod, but there's no smile from him."},
{"2011-09-23 15:47:22", "action", "Jason_Wolfe", "fights the temptation to shove his hand back in his pocket as he notices Markus."},
{"2011-09-23 15:47:22", "action", "Jason_Wolfe", "warmer to wear."},
{"2011-09-23 15:47:32", "text", "Dante", "((you're*))"},
{"2011-09-23 15:48:45", "action", "Markus_Vasquez", "grunts as they walk toward the door, shaking his head slightly."},
},
{
{"2011-09-25 21:03:55", "scene", "=Scene=", "It's early evening on the 11th of November, and the weather is clear."},
{"2011-09-25 21:03:55", "scene", "=Scene=", "café on the eastern edge of town."},
{"2011-09-25 21:08:33", "action", "Sofia_Tennhausen", "is sat on a small table outside the cafe, a cup of lukewarm coffee in her hand."},
},
}
results, err := IRCCloudLogs(testLog, time.UTC, time.Hour*6)
if err != nil {
t.Fatal("Parse", err)
}
if len(results) != len(logs) {
t.Fatal("Two logs expected, got", len(logs))
}
for i, log := range logs {
result := results[i]
if len(result.Posts) != len(log) {
t.Error(len(log), "posts expected in log", i, "but got", len(result.Posts))
for _, post := range result.Posts {
t.Log(*post)
}
return
}
for j, post := range result.Posts {
t.Run(fmt.Sprintf("Log_%d_Post_%d", i, j), func(t *testing.T) {
assert.Equal(t, log[j].TS, post.Time.Format("2006-01-02 15:04:05"), "Timestamps should match.")
assert.Equal(t, log[j].Kind, post.Kind, "Kinds should match.")
assert.Equal(t, log[j].Nick, post.Nick, "Kinds should match.")
assert.Equal(t, log[j].Text, post.Text, "Kinds should match.")
})
}
}
}
var testLog = `
[2011-09-23 15:37:16] Jason_Wolfe sheds a bit of the tension he was harboring as Markus extends his hand.
[2011-09-23 15:41:15] Markus_Vasquez gives another nod, but there's no smile from him.
[2011-09-23 15:47:22] Jason_Wolfe fights the temptation to shove his hand back in his pocket as he notices Markus.
[2011-09-23 15:47:22] Jason_Wolfe warmer to wear.
[2011-09-23 15:47:32] <Dante> ((you're*))
[2011-09-23 15:48:45] Markus_Vasquez grunts as they walk toward the door, shaking his head slightly.
[2011-09-23 18:00:28] Tyranniac quit (Tyranniac@example.com): Ping timeout: 260 seconds
[2011-09-23 18:58:39] Tyranniac joined (Tyranniac@example.com)
[2011-09-23 18:58:39] * ChanServ set +v Tyranniac
[2011-09-24 01:45:21] Hobo joined (Hobo@example.com)
[2011-09-24 11:37:07] Hobo quit (Hobo@example.com):
[2011-09-24 21:01:34] Tyranniac quit (Tyranniac@example.com): Ping timeout: 264 seconds
[2011-09-24 23:00:32] Tyranniac joined (Tyranniac@example.com)
[2011-09-25 20:54:43] * Bowe Sofia_Tennhausen
[2011-09-25 21:03:55] <=Scene=> It's early evening on the 11th of November, and the weather is clear.
[2011-09-25 21:03:55] <=Scene=> café on the eastern edge of town.
[2011-09-25 21:08:33] Sofia_Tennhausen is sat on a small table outside the cafe, a cup of lukewarm coffee in her hand.
`

4
services/parsers/mirclike.go

@ -129,7 +129,7 @@ func MircPost(line string, date time.Time, prev models.Post) (models.Post, error
LogID: "UNASSIGNED", LogID: "UNASSIGNED",
Time: ts, Time: ts,
Kind: "action", Kind: "action",
Nick: strings.TrimLeft(split[0], "+@!~"),
Nick: strings.Trim(strings.TrimLeft(split[0], "+@!~\u001F"), "\u001F"),
Text: split[1], Text: split[1],
Position: prev.Position + 1, Position: prev.Position + 1,
} }
@ -153,7 +153,7 @@ func MircPost(line string, date time.Time, prev models.Post) (models.Post, error
LogID: "UNASSIGNED", LogID: "UNASSIGNED",
Time: ts, Time: ts,
Kind: "text", Kind: "text",
Nick: strings.TrimLeft(split[0][1:len(split[0])-1], "+@!~"),
Nick: strings.Trim(strings.TrimLeft(split[0][1:len(split[0])-1], "+@!~"), "\u001F"),
Text: split[1], Text: split[1],
Position: prev.Position + 1, Position: prev.Position + 1,
} }

4
services/parsers/mirclike_test.go

@ -30,6 +30,10 @@ func TestMircPost(t *testing.T) {
"[13:36:59] <Stuff> Things said.", "[13:36:59] <Stuff> Things said.",
"13:36:59", "text", "Stuff", "Things said.", "13:36:59", "text", "Stuff", "Things said.",
}, },
{
"[13:36:59] <\u001FStuff\u001F> Things said.",
"13:36:59", "text", "Stuff", "Things said.",
},
{ {
"[23:59] <=Scene=> Scenery and such.", "[23:59] <=Scene=> Scenery and such.",
"23:59:00", "scene", "=Scene=", "Scenery and such.", "23:59:00", "scene", "=Scene=", "Scenery and such.",

Loading…
Cancel
Save