Compare commits

...

10 Commits

Author SHA1 Message Date
460503ba4a Add some sort of rendering 2024-10-28 19:54:55 +01:00
5b19b72ebf Enable SSR 2024-10-28 19:54:51 +01:00
08de52f5f6 Add typescript types 2024-10-28 19:48:34 +01:00
6b38c706b4 Add missing json tags to go types 2024-10-28 19:48:28 +01:00
0662ed0c56 Add tailwind to frontend 2024-10-28 00:41:13 +01:00
87edd6f478 Add astro frontend 2024-10-28 00:36:23 +01:00
c8d5540b0d Implements full player get 2024-10-28 00:05:30 +01:00
7903bb7830 Add fiber http backend 2024-10-27 23:15:38 +01:00
a74f722c58 Move everything to backend 2024-10-27 23:00:51 +01:00
4d21c77f99 Implement parsing and persisting notes 2024-10-27 21:44:01 +01:00
38 changed files with 5486 additions and 277 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
data data
main.log main.log
input input
frontend/.vscode

View File

@@ -24,4 +24,3 @@ create table note (
timestamp string, timestamp string,
player integer references player(id) player integer references player(id)
); );
create unique index idx_note_content on note(timestamp, player);

21
backend/go.mod Normal file
View File

@@ -0,0 +1,21 @@
module stinkinator
go 1.23.2
require (
github.com/gofiber/fiber/v3 v3.0.0-beta.3
github.com/mattn/go-sqlite3 v1.14.24
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/gofiber/utils/v2 v2.0.0-beta.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.55.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.21.0 // indirect
)

35
backend/go.sum Normal file
View File

@@ -0,0 +1,35 @@
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gofiber/fiber/v3 v3.0.0-beta.3 h1:7Q2I+HsIqnIEEDB+9oe7Gadpakh6ZLhXpTYz/L20vrg=
github.com/gofiber/fiber/v3 v3.0.0-beta.3/go.mod h1:kcMur0Dxqk91R7p4vxEpJfDWZ9u5IfvrtQc8Bvv/JmY=
github.com/gofiber/utils/v2 v2.0.0-beta.4 h1:1gjbVFFwVwUb9arPcqiB6iEjHBwo7cHsyS41NeIW3co=
github.com/gofiber/utils/v2 v2.0.0-beta.4/go.mod h1:sdRsPU1FXX6YiDGGxd+q2aPJRMzpsxdzCXo9dz+xtOY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"log"
) )
type GuildService struct { type GuildService struct {
@@ -39,6 +40,7 @@ func (gs *GuildService) Query(query GuildServiceQuery) ([]Guild, error) {
} }
func (gs *GuildService) Create(name string) (Guild, error) { func (gs *GuildService) Create(name string) (Guild, error) {
log.Printf("Creating guild %s", name)
guild := Guild{} guild := Guild{}
res, err := gs.db.writeConn.Exec("insert into guild (name) values (?)", name) res, err := gs.db.writeConn.Exec("insert into guild (name) values (?)", name)
if err != nil { if err != nil {
@@ -49,6 +51,7 @@ func (gs *GuildService) Create(name string) (Guild, error) {
if err != nil { if err != nil {
return guild, fmt.Errorf("failed to get last insert id: %v", err) return guild, fmt.Errorf("failed to get last insert id: %v", err)
} }
log.Printf("Created guild %s with id %d", name, id)
qres, err := gs.Query(GuildServiceQuery{ID: &id}) qres, err := gs.Query(GuildServiceQuery{ID: &id})
if err != nil { if err != nil {
@@ -64,14 +67,17 @@ func (gs *GuildService) Create(name string) (Guild, error) {
func (gs *GuildService) GetOrCreate(name string) (Guild, error) { func (gs *GuildService) GetOrCreate(name string) (Guild, error) {
guild := Guild{} guild := Guild{}
guilds, err := gs.Query(GuildServiceQuery{Name: &name}) guilds, err := gs.Query(GuildServiceQuery{Name: &name})
if err != nil {
if len(guilds) > 1 { if len(guilds) > 1 {
return guild, fmt.Errorf("more than one guild found for name %s", name) return guild, fmt.Errorf("more than one guild found for name %s", name)
} }
if err != nil || len(guilds) == 0 {
guild, err = gs.Create(name) guild, err = gs.Create(name)
if err != nil { if err != nil {
return guild, fmt.Errorf("failed creating guild: %v", err) return guild, fmt.Errorf("failed creating guild: %v", err)
} }
} }
if len(guilds) == 1 {
guild = guilds[0]
}
return guild, nil return guild, nil
} }

138
backend/main.go Normal file
View File

@@ -0,0 +1,138 @@
package main
import (
"fmt"
"github.com/gofiber/fiber/v3"
"io"
"log"
"os"
_ "embed"
)
var Error *log.Logger
var Warning *log.Logger
func init() {
log.SetFlags(log.Lmicroseconds | log.Lshortfile)
logFile, err := os.Create("main.log")
if err != nil {
log.Printf("Error creating log file: %v", err)
os.Exit(1)
}
logger := io.MultiWriter(os.Stdout, logFile)
log.SetOutput(logger)
Error = log.New(io.MultiWriter(logFile, os.Stderr, os.Stdout),
fmt.Sprintf("%sERROR:%s ", "\033[0;101m", "\033[0m"),
log.Lmicroseconds|log.Lshortfile)
Warning = log.New(io.MultiWriter(logFile, os.Stdout),
fmt.Sprintf("%sWarning:%s ", "\033[0;93m", "\033[0m"),
log.Lmicroseconds|log.Lshortfile)
}
var db DB
var gs GuildService
var ps PlayerService
var ns NoteService
var as AssociationService
//go:embed selectPlayer.sql
var selectPlayer string
//go:embed selectAssociation.sql
var selectAssociation string
//go:embed selectNotes.sql
var selectNotes string
func main() {
db = DB{
path: "data/db.db",
}
err := db.Open()
if err != nil {
Error.Printf("Failed opening database: %v", err)
return
}
defer db.Close()
gs = GuildService{db: &db}
ps = PlayerService{db: &db}
ns = NoteService{db: &db}
as = AssociationService{db: &db}
app := fiber.New()
app.Post("/note/new", CreateNote)
app.Get("/player/:name", GetPlayer)
app.Get("/player", GetPlayers)
log.Fatal(app.Listen(":3000"))
}
func CreateNote(c fiber.Ctx) error {
data := c.Body()
res := Response{}
note, err := ParseNote(string(data))
if err != nil {
Error.Printf("Failed parsing note: %v", err)
res.Success = false
res.Message = err.Error()
return c.Status(400).JSON(res)
}
if note.Player == "" {
Error.Printf("No player specified in note: %v", note)
res.Success = false
res.Message = "No player specified"
return c.Status(400).JSON(res)
}
dbnote, _, err := PersistNoteData(note)
if err != nil {
Error.Printf("Failed persisting note: %v", err)
res.Success = false
res.Message = err.Error()
return c.Status(500).JSON(res)
}
res.Data = dbnote
res.Message = "OK"
return c.Status(200).JSON(res)
}
func GetPlayer(c fiber.Ctx) error {
name := c.Params("name")
log.Printf("Getting player %s", name)
player, err := ps.GetAllPlayerInfo(name, 10)
if err != nil {
Error.Printf("Failed getting player: %v", err)
return c.Status(500).JSON(Response{
Success: false,
Message: err.Error(),
})
}
return c.Status(200).JSON(Response{
Success: true,
Data: player,
})
}
func GetPlayers(c fiber.Ctx) error {
players, err := ps.Query(PlayerServiceQuery{})
if err != nil {
Error.Printf("Failed getting players: %v", err)
return c.Status(500).JSON(Response{
Success: false,
Message: err.Error(),
})
}
return c.Status(200).JSON(Response{
Success: true,
Data: players,
})
}

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"log"
"time" "time"
) )
@@ -30,10 +31,15 @@ func (ns *NoteService) Query(query NoteServiceQuery) ([]Note, error) {
for rows.Next() { for rows.Next() {
note := Note{} note := Note{}
err := rows.Scan(&note.ID, &note.Content, &note.Timestamp, &note.Player.ID) var ts string
err := rows.Scan(&note.ID, &note.Content, &ts, &note.Player.ID)
if err != nil { if err != nil {
return res, fmt.Errorf("failed scanning note: %v", err) return res, fmt.Errorf("failed scanning note: %v", err)
} }
note.Timestamp, err = time.Parse(time.RFC3339, ts)
if err != nil {
return res, fmt.Errorf("failed parsing timestamp: %v", err)
}
res = append(res, note) res = append(res, note)
} }
@@ -41,8 +47,9 @@ func (ns *NoteService) Query(query NoteServiceQuery) ([]Note, error) {
} }
func (ns *NoteService) Create(content string, timestamp time.Time, player Player) (Note, error) { func (ns *NoteService) Create(content string, timestamp time.Time, player Player) (Note, error) {
log.Printf("Creating note %q with timestamp %s and player %d", content, timestamp, player.ID)
note := Note{} note := Note{}
res, err := ns.db.writeConn.Exec("insert into note (content, timestamp, player) values (?, ?, ?)", content, timestamp, player.ID) res, err := ns.db.writeConn.Exec("insert into note (content, timestamp, player) values (?, ?, ?)", content, timestamp.Format(time.RFC3339), player.ID)
if err != nil { if err != nil {
return note, fmt.Errorf("failed to insert note: %v", err) return note, fmt.Errorf("failed to insert note: %v", err)
} }
@@ -51,6 +58,7 @@ func (ns *NoteService) Create(content string, timestamp time.Time, player Player
if err != nil { if err != nil {
return note, fmt.Errorf("failed to get last insert id: %v", err) return note, fmt.Errorf("failed to get last insert id: %v", err)
} }
log.Printf("Created note %q with timestamp %s and player %d with id %d", content, timestamp, player.ID, id)
qres, err := ns.Query(NoteServiceQuery{ID: &id}) qres, err := ns.Query(NoteServiceQuery{ID: &id})
if err != nil { if err != nil {

149
backend/playerService.go Normal file
View File

@@ -0,0 +1,149 @@
package main
import (
"fmt"
"log"
)
type PlayerService struct {
db *DB
}
type PlayerServiceQuery struct {
Name *string `db:"name"`
ID *int64 `db:"id"`
}
func (ps *PlayerService) Query(query PlayerServiceQuery) ([]Player, error) {
res := []Player{}
sqlQuery := "SELECT id, name, guild FROM player"
whereQuery := BuildWhereQuery(query)
if whereQuery != "" {
sqlQuery += " " + whereQuery
}
rows, err := ps.db.readConn.Query(sqlQuery)
if err != nil {
return res, fmt.Errorf("failed getting players for query %q: %v", sqlQuery, err)
}
defer rows.Close()
for rows.Next() {
player := Player{}
err := rows.Scan(&player.ID, &player.Name, &player.Guild.ID)
if err != nil {
return res, fmt.Errorf("failed scanning player: %v", err)
}
res = append(res, player)
}
return res, nil
}
func (ps *PlayerService) Create(name string, guild Guild) (Player, error) {
log.Printf("Creating player %s in guild %s", name, guild.Name)
player := Player{}
res, err := ps.db.writeConn.Exec("insert into player (name, guild) values (?, ?)", name, guild.ID)
if err != nil {
return player, fmt.Errorf("failed to insert player: %v", err)
}
id, err := res.LastInsertId()
if err != nil {
return player, fmt.Errorf("failed to get last insert id: %v", err)
}
log.Printf("Created player %s in guild %s with id %d", name, guild.Name, id)
qres, err := ps.Query(PlayerServiceQuery{ID: &id})
if err != nil {
return player, fmt.Errorf("failed getting player for id %d: %v", id, err)
}
if len(qres) > 1 {
return player, fmt.Errorf("more than one player found for id %d", id)
}
return qres[0], nil
}
func (ps *PlayerService) GetOrCreate(name string, guild Guild) (Player, error) {
player := Player{}
players, err := ps.Query(PlayerServiceQuery{Name: &name})
if len(players) > 1 {
return player, fmt.Errorf("more than one player found for name %s", name)
}
if err != nil || len(players) == 0 {
player, err = ps.Create(name, guild)
if err != nil {
return player, fmt.Errorf("failed creating player: %v", err)
}
}
if len(players) == 1 {
player = players[0]
}
return player, nil
}
type (
FullPlayer struct {
ID int64 `json:"id"`
Name string `json:"name"`
Guild struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"guild"`
Associations []FullPlayerAssociation `json:"associations"`
Notes []FullPlayerNote `json:"notes"`
}
FullPlayerAssociation struct {
ID int64 `json:"id"`
Name string `json:"name"`
Note string `json:"note"`
}
FullPlayerNote struct {
ID int64 `json:"id"`
Content string `json:"content"`
Timestamp string `json:"timestamp"`
}
)
func (ps *PlayerService) GetAllPlayerInfo(name string, nnotes int) (FullPlayer, error) {
res := FullPlayer{}
err := ps.db.readConn.QueryRow(selectPlayer, name).Scan(&res.ID, &res.Name, &res.Guild.ID, &res.Guild.Name)
if err != nil {
return res, fmt.Errorf("failed getting player info: %v", err)
}
rows, err := ps.db.readConn.Query(selectAssociation, res.ID)
if err != nil {
return res, fmt.Errorf("failed getting player associations: %v", err)
}
for rows.Next() {
assoc := FullPlayerAssociation{}
err := rows.Scan(&assoc.ID, &assoc.Name, &assoc.Note)
if err != nil {
return res, fmt.Errorf("failed scanning association: %v", err)
}
res.Associations = append(res.Associations, assoc)
}
rows.Close()
rows, err = ps.db.readConn.Query(selectNotes, res.ID, nnotes)
if err != nil {
return res, fmt.Errorf("failed getting player notes: %v", err)
}
for rows.Next() {
note := FullPlayerNote{}
err := rows.Scan(&note.ID, &note.Content, &note.Timestamp)
if err != nil {
return res, fmt.Errorf("failed scanning note: %v", err)
}
res.Notes = append(res.Notes, note)
}
rows.Close()
return res, nil
}

View File

@@ -0,0 +1,28 @@
-- 3. Get all associations (N rows)
with
AllAssociations as (
select
lhs as PlayerID,
rhs as AssociateID,
note
from
association
where
lhs = $1
union
select
rhs as PlayerID,
lhs as AssociateID,
note
from
association
where
rhs = $1
)
select
p.id as AssociateID,
p.name as AssociateName,
coalesce(a.note, '') as AssociationNote
from
AllAssociations a
join player p on a.AssociateID = p.id;

22
backend/selectNotes.sql Normal file
View File

@@ -0,0 +1,22 @@
-- 2. Get N most recent notes (10 rows)
select
id as NoteID,
content as NoteContent,
timestamp as NoteTimestamp
from
(
select
n.*,
ROW_NUMBER() OVER (
order by
timestamp desc
) as rn
from
note n
where
n.player = $1
) ranked
where
rn <= $2
order by
timestamp desc;

11
backend/selectPlayer.sql Normal file
View File

@@ -0,0 +1,11 @@
-- 1. Get player and guild info (1 row)
select
p.id as PlayerID,
p.name as PlayerName,
g.id as GuildID,
g.name as GuildName
from
player p
join guild g on p.guild = g.id
where
p.name = $1;

45
backend/types.go Normal file
View File

@@ -0,0 +1,45 @@
package main
import "time"
type (
Guild struct {
ID int64
Name string `json:"name"`
}
Player struct {
ID int64 `json:"id"`
Name string `json:"name"`
Guild Guild `json:"guild,omitempty"`
}
Association struct {
ID int64 `json:"id"`
LHS Player `json:"lhs"`
RHS Player `json:"rhs"`
Note string `json:"note"`
}
Note struct {
ID int64 `json:"id"`
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
Player Player `json:"player"`
}
)
type NoteData struct {
Player string
Date time.Time
Guild string
Note string
Associations []NoteAssociationData
}
type NoteAssociationData struct {
Player string
Note string
}
type Response struct {
Success bool `json:"success"`
Message string `json:"message"`
Data interface{} `json:"data"`
}

118
backend/utils.go Normal file
View File

@@ -0,0 +1,118 @@
package main
import (
"fmt"
"log"
"reflect"
"regexp"
"strings"
"time"
)
func BuildWhereQuery(params interface{}) string {
conditions := []string{}
v := reflect.ValueOf(params)
t := v.Type()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
if field.Kind() == reflect.Ptr && !field.IsNil() {
dbTag := t.Field(i).Tag.Get("db")
if dbTag != "" {
switch field.Elem().Kind() {
case reflect.String:
conditions = append(conditions, fmt.Sprintf("%s = '%s'", dbTag, field.Elem().String()))
case reflect.Bool:
conditions = append(conditions, fmt.Sprintf("%s = %t", dbTag, field.Elem().Bool()))
case reflect.Int:
conditions = append(conditions, fmt.Sprintf("%s = %d", dbTag, field.Elem().Int()))
case reflect.Int64:
conditions = append(conditions, fmt.Sprintf("%s = %d", dbTag, field.Elem().Int()))
}
}
}
}
if len(conditions) > 0 {
return "WHERE " + strings.Join(conditions, " AND ")
}
return ""
}
var associationRe = regexp.MustCompile(`r:(\w+)(?:\(([^)]+)\))?`)
func ParseNote(data string) (NoteData, error) {
log.Printf("Parsing note: %q", data)
res := NoteData{}
lines := strings.Split(data, "\n")
note := []string{}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, ":")
switch parts[0] {
case "p":
res.Player = parts[1]
case "d":
var err error
res.Date, err = time.Parse(time.DateOnly, parts[1])
if err != nil {
return res, fmt.Errorf("failed to parse date: %v", err)
}
case "g":
res.Guild = parts[1]
default:
note = append(note, line)
}
}
res.Note = strings.Join(note, "\n")
matches := associationRe.FindAllStringSubmatch(res.Note, -1)
for _, match := range matches {
res.Associations = append(res.Associations, NoteAssociationData{
Player: match[1],
Note: match[2],
})
}
return res, nil
}
func PersistNoteData(note NoteData) (Note, []Association, error) {
res := Note{}
ass := []Association{}
res.Content = note.Note
res.Timestamp = note.Date
guild, err := gs.GetOrCreate(note.Guild)
if err != nil {
return res, ass, fmt.Errorf("failed getting guild for %s: %v", note.Guild, err)
}
player, err := ps.GetOrCreate(note.Player, guild)
if err != nil {
return res, ass, fmt.Errorf("failed getting player for %s: %v", note.Player, err)
}
res.Player = player
player.Guild = guild
for _, assoc := range note.Associations {
assocPlayer, err := ps.GetOrCreate(assoc.Player, guild)
if err != nil {
return res, ass, fmt.Errorf("failed getting player for %s: %v", assoc.Player, err)
}
association, err := as.Create(player, assocPlayer, assoc.Note)
if err != nil {
return res, ass, fmt.Errorf("failed creating association: %v", err)
}
ass = append(ass, association)
}
res, err = ns.Create(res.Content, res.Timestamp, res.Player)
if err != nil {
return res, ass, fmt.Errorf("failed creating note: %v", err)
}
return res, ass, nil
}

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

54
frontend/README.md Normal file
View File

@@ -0,0 +1,54 @@
# Astro Starter Kit: Basics
```sh
npm create astro@latest -- --template basics
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json)
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
![just-the-basics](https://github.com/withastro/astro/assets/2244813/a0a5533c-a856-4198-8470-2d67b1d7c554)
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
│ └── favicon.svg
├── src/
│ ├── components/
│ │ └── Card.astro
│ ├── layouts/
│ │ └── Layout.astro
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

View File

@@ -0,0 +1,7 @@
// @ts-check
import { defineConfig } from 'astro/config';
// https://astro.build/config
export default defineConfig({
output: "server",
});

22
frontend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "frontend",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"astro": "^4.16.7",
"typescript": "^5.6.3"
},
"devDependencies": {
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14"
}
}

4463
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View File

@@ -0,0 +1,61 @@
---
interface Props {
title: string;
body: string;
href: string;
}
const { href, title, body } = Astro.props;
---
<li class="link-card">
<a href={href}>
<h2>
{title}
<span>&rarr;</span>
</h2>
<p>
{body}
</p>
</a>
</li>
<style>
.link-card {
list-style: none;
display: flex;
padding: 1px;
background-color: #23262d;
background-image: none;
background-size: 400%;
border-radius: 7px;
background-position: 100%;
transition: background-position 0.6s cubic-bezier(0.22, 1, 0.36, 1);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.link-card > a {
width: 100%;
text-decoration: none;
line-height: 1.4;
padding: calc(1.5rem - 1px);
border-radius: 8px;
color: white;
background-color: #23262d;
opacity: 0.8;
}
h2 {
margin: 0;
font-size: 1.25rem;
transition: color 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
p {
margin-top: 0.5rem;
margin-bottom: 0;
}
.link-card:is(:hover, :focus-within) {
background-position: 0;
background-image: var(--accent-gradient);
}
.link-card:is(:hover, :focus-within) h2 {
color: rgb(var(--accent-light));
}
</style>

1
frontend/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

View File

@@ -0,0 +1,52 @@
---
import '../styles/global.css';
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Astro description" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body>
<slot />
</body>
</html>
<style is:global>
:root {
--accent: 136, 58, 234;
--accent-light: 224, 204, 250;
--accent-dark: 49, 10, 101;
--accent-gradient: linear-gradient(
45deg,
rgb(var(--accent)),
rgb(var(--accent-light)) 30%,
white 60%
);
}
html {
font-family: system-ui, sans-serif;
background: #13151a;
}
code {
font-family:
Menlo,
Monaco,
Lucida Console,
Liberation Mono,
DejaVu Sans Mono,
Bitstream Vera Sans Mono,
Courier New,
monospace;
}
</style>

View File

@@ -0,0 +1,129 @@
---
import Layout from '../layouts/Layout.astro';
import Card from '../components/Card.astro';
---
<Layout title="Welcome to Astro.">
<main>
<svg
class="astro-a"
width="495"
height="623"
viewBox="0 0 495 623"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M167.19 364.254C83.4786 364.254 0 404.819 0 404.819C0 404.819 141.781 19.4876 142.087 18.7291C146.434 7.33701 153.027 0 162.289 0H332.441C341.703 0 348.574 7.33701 352.643 18.7291C352.92 19.5022 494.716 404.819 494.716 404.819C494.716 404.819 426.67 364.254 327.525 364.254L264.41 169.408C262.047 159.985 255.147 153.581 247.358 153.581C239.569 153.581 232.669 159.985 230.306 169.408L167.19 364.254ZM160.869 530.172C160.877 530.18 160.885 530.187 160.894 530.195L160.867 530.181C160.868 530.178 160.868 530.175 160.869 530.172ZM136.218 411.348C124.476 450.467 132.698 504.458 160.869 530.172C160.997 529.696 161.125 529.242 161.248 528.804C161.502 527.907 161.737 527.073 161.917 526.233C165.446 509.895 178.754 499.52 195.577 500.01C211.969 500.487 220.67 508.765 223.202 527.254C224.141 534.12 224.23 541.131 224.319 548.105C224.328 548.834 224.337 549.563 224.347 550.291C224.563 566.098 228.657 580.707 237.264 593.914C245.413 606.426 256.108 615.943 270.749 622.478C270.593 621.952 270.463 621.508 270.35 621.126C270.045 620.086 269.872 619.499 269.685 618.911C258.909 585.935 266.668 563.266 295.344 543.933C298.254 541.971 301.187 540.041 304.12 538.112C310.591 533.854 317.059 529.599 323.279 525.007C345.88 508.329 360.09 486.327 363.431 457.844C364.805 446.148 363.781 434.657 359.848 423.275C358.176 424.287 356.587 425.295 355.042 426.275C351.744 428.366 348.647 430.33 345.382 431.934C303.466 452.507 259.152 455.053 214.03 448.245C184.802 443.834 156.584 436.019 136.218 411.348Z"
fill="url(#paint0_linear_1805_24383)"></path>
<defs>
<linearGradient
id="paint0_linear_1805_24383"
x1="247.358"
y1="0"
x2="247.358"
y2="622.479"
gradientUnits="userSpaceOnUse"
>
<stop stop-opacity="0.9"></stop>
<stop offset="1" stop-opacity="0.2"></stop>
</linearGradient>
</defs>
</svg>
<h1>Welcome to <span class="text-gradient">Astro</span></h1>
<p class="instructions">
To get started, open the directory <code>src/pages</code> in your project.<br />
<strong>Code Challenge:</strong> Tweak the "Welcome to Astro" message above.
</p>
<ul role="list" class="link-card-grid">
<Card
href="https://docs.astro.build/"
title="Documentation"
body="Learn how Astro works and explore the official API docs."
/>
<Card
href="https://astro.build/integrations/"
title="Integrations"
body="Supercharge your project with new frameworks and libraries."
/>
<Card
href="https://astro.build/themes/"
title="Themes"
body="Explore a galaxy of community-built starter themes."
/>
<Card
href="https://astro.build/chat/"
title="Community"
body="Come say hi to our amazing Discord community. ❤️"
/>
</ul>
</main>
<div class="min-h-screen bg-gray-100 flex items-center justify-center">
<h1 class="text-4xl font-bold text-blue-600 hover:text-blue-800">
Hello Tailwind + Astro!
</h1>
</div>
</Layout>
<style>
main {
margin: auto;
padding: 1rem;
width: 800px;
max-width: calc(100% - 2rem);
color: white;
font-size: 20px;
line-height: 1.6;
}
.astro-a {
position: absolute;
top: -32px;
left: 50%;
transform: translatex(-50%);
width: 220px;
height: auto;
z-index: -1;
}
h1 {
font-size: 4rem;
font-weight: 700;
line-height: 1;
text-align: center;
margin-bottom: 1em;
}
.text-gradient {
background-image: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-size: 400%;
background-position: 0%;
}
.instructions {
margin-bottom: 2rem;
border: 1px solid rgba(var(--accent-light), 25%);
background: linear-gradient(rgba(var(--accent-dark), 66%), rgba(var(--accent-dark), 33%));
padding: 1.5rem;
border-radius: 8px;
}
.instructions code {
font-size: 0.8em;
font-weight: bold;
background: rgba(var(--accent-light), 12%);
color: rgb(var(--accent-light));
border-radius: 4px;
padding: 0.3em 0.4em;
}
.instructions strong {
color: rgb(var(--accent-light));
}
.link-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(24ch, 1fr));
gap: 2rem;
padding: 0;
}
</style>

View File

@@ -0,0 +1,19 @@
---
import type { APIResponse, Player } from "../types";
const res = await fetch("http://localhost:3000/player");
const data: APIResponse<Player[]> = await res.json();
console.log(data);
---
<div>
{
data.success &&
data.data.map((player) => (
<div>
<a href={`/player/${player.name}`}>{player.name}</a>
</div>
))
}
{!data.success && <div>{data.message}</div>}
</div>

View File

@@ -0,0 +1,7 @@
---
const { name } = Astro.params;
---
<div>
<h1>Hello {name}!</h1>
</div>

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

29
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,29 @@
export type Player = {
id: number;
name: string;
guild: {
id: number;
name: string;
};
associations: Association[];
notes: Note[];
};
export type Association = {
id: number;
name: string;
note: string;
};
export type Note = {
id: number;
content: string;
timestamp: string;
player: Player;
};
export type APIResponse<T> = {
success: boolean;
message: string;
data: T;
}

View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {},
},
plugins: [],
}

3
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/strict"
}

5
go.mod
View File

@@ -1,5 +0,0 @@
module stinkinator
go 1.23.2
require github.com/mattn/go-sqlite3 v1.14.24

2
go.sum
View File

@@ -1,2 +0,0 @@
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=

111
main.go
View File

@@ -1,111 +0,0 @@
package main
import (
"flag"
"fmt"
"io"
"log"
"os"
"regexp"
"strings"
"time"
)
var Error *log.Logger
var Warning *log.Logger
func init() {
log.SetFlags(log.Lmicroseconds | log.Lshortfile)
logFile, err := os.Create("main.log")
if err != nil {
log.Printf("Error creating log file: %v", err)
os.Exit(1)
}
logger := io.MultiWriter(os.Stdout, logFile)
log.SetOutput(logger)
Error = log.New(io.MultiWriter(logFile, os.Stderr, os.Stdout),
fmt.Sprintf("%sERROR:%s ", "\033[0;101m", "\033[0m"),
log.Lmicroseconds|log.Lshortfile)
Warning = log.New(io.MultiWriter(logFile, os.Stdout),
fmt.Sprintf("%sWarning:%s ", "\033[0;93m", "\033[0m"),
log.Lmicroseconds|log.Lshortfile)
}
var db DB
var gs GuildService
var ps PlayerService
var ns NoteService
func main() {
inputFile := flag.String("if", "input", "Input file")
flag.Parse()
log.Printf("Input file: %s", *inputFile)
db = DB{
path: "data/db.db",
}
err := db.Open()
if err != nil {
Error.Printf("Failed opening database: %v", err)
return
}
defer db.Close()
gs = GuildService{db: &db}
ps = PlayerService{db: &db}
ns = NoteService{db: &db}
inputData, err := os.ReadFile(*inputFile)
if err != nil {
Error.Printf("Failed reading input file: %v", err)
return
}
log.Printf("%#v", string(inputData))
note, err := ParseNote(string(inputData))
if err != nil {
Error.Printf("Failed parsing note: %v", err)
return
}
log.Printf("%#v", note)
}
var associationRe = regexp.MustCompile(`r:(\w+)(?:\(([^)]+)\))?`)
func ParseNote(data string) (NoteData, error) {
res := NoteData{}
lines := strings.Split(data, "\n")
note := []string{}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, ":")
switch parts[0] {
case "p":
res.Player = parts[1]
case "d":
var err error
res.Date, err = time.Parse(time.DateOnly, parts[1])
if err != nil {
return res, fmt.Errorf("failed to parse date: %v", err)
}
case "g":
res.Guild = parts[1]
default:
note = append(note, line)
}
}
res.Note = strings.Join(note, "\n")
matches := associationRe.FindAllStringSubmatch(res.Note, -1)
for _, match := range matches {
res.Associations = append(res.Associations, NoteAssociationData{
Player: match[1],
Note: match[2],
})
}
return res, nil
}

View File

@@ -1,75 +0,0 @@
package main
import "fmt"
type PlayerService struct {
db *DB
}
type PlayerServiceQuery struct {
Name *string `db:"name"`
ID *int64 `db:"id"`
}
func (ps *PlayerService) Query(query PlayerServiceQuery) ([]Player, error) {
res := []Player{}
sqlQuery := "SELECT id, name, guild FROM player"
whereQuery := BuildWhereQuery(query)
if whereQuery != "" {
sqlQuery += " " + whereQuery
}
rows, err := ps.db.readConn.Query(sqlQuery)
if err != nil {
return res, fmt.Errorf("failed getting players for query %q: %v", sqlQuery, err)
}
defer rows.Close()
for rows.Next() {
player := Player{}
err := rows.Scan(&player.ID, &player.Name, &player.Guild.ID)
if err != nil {
return res, fmt.Errorf("failed scanning player: %v", err)
}
res = append(res, player)
}
return res, nil
}
func (ps *PlayerService) Create(name string, guild Guild) (Player, error) {
player := Player{}
res, err := ps.db.writeConn.Exec("insert into player (name, guild) values (?, ?)", name, guild.ID)
if err != nil {
return player, fmt.Errorf("failed to insert player: %v", err)
}
id, err := res.LastInsertId()
if err != nil {
return player, fmt.Errorf("failed to get last insert id: %v", err)
}
qres, err := ps.Query(PlayerServiceQuery{ID: &id})
if err != nil {
return player, fmt.Errorf("failed getting player for id %d: %v", id, err)
}
if len(qres) > 1 {
return player, fmt.Errorf("more than one player found for id %d", id)
}
return qres[0], nil
}
func (ps *PlayerService) GetOrCreate(name string, guild Guild) (Player, error) {
player := Player{}
players, err := ps.Query(PlayerServiceQuery{Name: &name})
if err != nil {
if len(players) > 1 {
return player, fmt.Errorf("more than one player found for name %s", name)
}
player, err = ps.Create(name, guild)
if err != nil {
return player, fmt.Errorf("failed creating player: %v", err)
}
}
return player, nil
}

View File

@@ -1,39 +0,0 @@
package main
import "time"
type (
Guild struct {
ID int64
Name string `json:"name"`
}
Player struct {
ID int64
Name string `json:"name"`
Guild Guild `json:"guild"`
}
Association struct {
ID int64
LHS Player
RHS Player
Note string
}
Note struct {
ID int64
Content string
Timestamp time.Time
Player Player
}
)
type NoteData struct {
Player string
Date time.Time
Guild string
Note string
Associations []NoteAssociationData
}
type NoteAssociationData struct {
Player string
Note string
}

View File

@@ -1,37 +0,0 @@
package main
import (
"fmt"
"reflect"
"strings"
)
func BuildWhereQuery(params interface{}) string {
conditions := []string{}
v := reflect.ValueOf(params)
t := v.Type()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
if field.Kind() == reflect.Ptr && !field.IsNil() {
dbTag := t.Field(i).Tag.Get("db")
if dbTag != "" {
switch field.Elem().Kind() {
case reflect.String:
conditions = append(conditions, fmt.Sprintf("%s = '%s'", dbTag, field.Elem().String()))
case reflect.Bool:
conditions = append(conditions, fmt.Sprintf("%s = %t", dbTag, field.Elem().Bool()))
case reflect.Int:
conditions = append(conditions, fmt.Sprintf("%s = %d", dbTag, field.Elem().Int()))
case reflect.Int64:
conditions = append(conditions, fmt.Sprintf("%s = %d", dbTag, field.Elem().Int()))
}
}
}
}
if len(conditions) > 0 {
return "WHERE " + strings.Join(conditions, " AND ")
}
return ""
}