diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | Makefile | 4 | ||||
| -rw-r--r-- | api/dto/entry.go | 9 | ||||
| -rw-r--r-- | api/dto/response.go | 38 | ||||
| -rw-r--r-- | api/dto/util.go | 54 | ||||
| -rw-r--r-- | api/handlers/html.go | 27 | ||||
| -rw-r--r-- | api/handlers/record.go | 35 | ||||
| -rw-r--r-- | api/middleware/cors.go | 12 | ||||
| -rw-r--r-- | api/router.go | 27 | ||||
| -rw-r--r-- | go.mod | 22 | ||||
| -rw-r--r-- | go.sum | 33 | ||||
| -rw-r--r-- | internal/config/config.go | 27 | ||||
| -rw-r--r-- | internal/constants/constants.go | 3 | ||||
| -rw-r--r-- | main.go | 54 | ||||
| -rw-r--r-- | pkg/database/database.go | 31 | ||||
| -rw-r--r-- | pkg/database/migrations/0001_initial.sql | 19 | ||||
| -rw-r--r-- | pkg/database/migrations/0002_seed.sql | 6 | ||||
| -rw-r--r-- | pkg/database/query/entries.sql | 11 | ||||
| -rw-r--r-- | pkg/database/sqlc/db.go | 31 | ||||
| -rw-r--r-- | pkg/database/sqlc/entries.sql.go | 89 | ||||
| -rw-r--r-- | pkg/database/sqlc/models.go | 20 | ||||
| -rw-r--r-- | pkg/entries/entries.go | 52 | ||||
| -rw-r--r-- | pkg/html/service.go | 50 | ||||
| -rw-r--r-- | sqlc.yaml | 10 |
24 files changed, 666 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4bd7de --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +history +database.db diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..acd1427 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +all: build + +build: + CGO_ENABLED=1 go build -ldflags "-X 'git.leonardobishop.net/history/internal/constants.SysConfPrefix=/etc/history/'" -o history main.go diff --git a/api/dto/entry.go b/api/dto/entry.go new file mode 100644 index 0000000..85e39f3 --- /dev/null +++ b/api/dto/entry.go @@ -0,0 +1,9 @@ +package dto + +type CreateEntryRequest struct { + Title string `json:"title"` + Kind string `json:"kind"` + Url string `json:"url"` + Description string `json:"description"` + Token string `json:"token"` +} diff --git a/api/dto/response.go b/api/dto/response.go new file mode 100644 index 0000000..2c13218 --- /dev/null +++ b/api/dto/response.go @@ -0,0 +1,38 @@ +package dto + +import "fmt" + +type Response interface { + Error() string + Status() int +} + +type OkResponse struct { + Code int `json:"code"` + Data interface{} `json:"data,omitempty"` +} + +var _ Response = (*OkResponse)(nil) + +type ErrorResponse struct { + Code int `json:"code"` + Message string `json:"message"` +} + +var _ Response = (*ErrorResponse)(nil) + +func (r *OkResponse) Status() int { + return r.Code +} + +func (r *OkResponse) Error() string { + return fmt.Sprintf("HTTP status %d", r.Code) +} + +func (r *ErrorResponse) Status() int { + return r.Code +} + +func (r *ErrorResponse) Error() string { + return fmt.Sprintf("HTTP status %d: %s", r.Code, r.Message) +} diff --git a/api/dto/util.go b/api/dto/util.go new file mode 100644 index 0000000..651f026 --- /dev/null +++ b/api/dto/util.go @@ -0,0 +1,54 @@ +package dto + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + + "github.com/go-playground/validator/v10" +) + +var validate = validator.New(validator.WithRequiredStructEnabled()) + +func ReadDto(r *http.Request, o interface{}) Response { + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(o); err != nil { + return &ErrorResponse{ + Code: http.StatusBadRequest, + Message: fmt.Errorf("Invalid request (%w)", err).Error(), + } + } + + if err := validate.Struct(o); err != nil { + return &ErrorResponse{ + Code: http.StatusBadRequest, + Message: err.Error(), + } + } + + return nil +} + +func WrapResponseFunc(dtoFunc func(http.ResponseWriter, *http.Request) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + WriteDto(w, r, dtoFunc(w, r)) + } +} + +func WriteDto(w http.ResponseWriter, r *http.Request, err error) { + if o, ok := err.(Response); ok { + data, err := json.Marshal(o) + if err != nil { + w.WriteHeader(500) + slog.Error("could not serialise JSON", "error", err) + return + } + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(o.Status()) + w.Write(data) + } else { + w.WriteHeader(500) + slog.Error("internal server error handling request", "error", err) + } +} diff --git a/api/handlers/html.go b/api/handlers/html.go new file mode 100644 index 0000000..dd5d4e8 --- /dev/null +++ b/api/handlers/html.go @@ -0,0 +1,27 @@ +package handlers + +import ( + "net/http" + + "git.leonardobishop.net/history/pkg/entries" + "git.leonardobishop.net/history/pkg/html" +) + +func GetEntriesHtml(entriesService entries.Service, htmlService html.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + entries, err := entriesService.GetEntries() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + html, err := htmlService.GenerateHtml(entries) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html;charset=UTF-8") + w.Write([]byte(html)) + } +} diff --git a/api/handlers/record.go b/api/handlers/record.go new file mode 100644 index 0000000..0f98f11 --- /dev/null +++ b/api/handlers/record.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "crypto/subtle" + "net/http" + + "git.leonardobishop.net/history/api/dto" + "git.leonardobishop.net/history/pkg/entries" +) + +func RecordEntry(service entries.Service, token string) http.HandlerFunc { + return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + var request dto.CreateEntryRequest + if err := dto.ReadDto(r, &request); err != nil { + return err + } + + if subtle.ConstantTimeCompare([]byte(token), []byte(request.Token)) != 1 { + return &dto.ErrorResponse{ + Code: http.StatusForbidden, + Message: "Forbidden", + } + } + + entry, err := service.CreateEntry(request.Title, request.Kind, request.Url, request.Description) + if err != nil { + return err + } + + return &dto.OkResponse{ + Code: http.StatusCreated, + Data: entry, + } + }) +} diff --git a/api/middleware/cors.go b/api/middleware/cors.go new file mode 100644 index 0000000..926c1ed --- /dev/null +++ b/api/middleware/cors.go @@ -0,0 +1,12 @@ +package middleware + +import "net/http" + +func Cors(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + + next(w, r) + } +} diff --git a/api/router.go b/api/router.go new file mode 100644 index 0000000..9ea7ac0 --- /dev/null +++ b/api/router.go @@ -0,0 +1,27 @@ +package api + +import ( + "net/http" + + "git.leonardobishop.net/history/api/handlers" + "git.leonardobishop.net/history/api/middleware" + "git.leonardobishop.net/history/internal/config" + "git.leonardobishop.net/history/pkg/entries" + "git.leonardobishop.net/history/pkg/html" +) + +type ApiServices struct { + EntiresService entries.Service + HtmlService html.Service + + Config config.Config +} + +func NewServer(api ApiServices) *http.ServeMux { + mux := http.NewServeMux() + + mux.HandleFunc("POST /record", middleware.Cors(handlers.RecordEntry(api.EntiresService, api.Config.Token))) + mux.HandleFunc("GET /html", handlers.GetEntriesHtml(api.EntiresService, api.HtmlService)) + + return mux +} @@ -0,0 +1,22 @@ +module git.leonardobishop.net/history + +go 1.24.6 + +require ( + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/pressly/goose/v3 v3.25.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.27.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) @@ -0,0 +1,33 @@ +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng= +github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..98ae805 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,27 @@ +package config + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Server struct { + Host string `yaml:"host"` + Port string `yaml:"port"` + } `yaml:"server"` + Token string `yaml:"token"` +} + +func ReadConfig(configPath string, dst *Config) error { + config, err := os.ReadFile(configPath) + if err != nil { + return err + } + + if err := yaml.Unmarshal(config, dst); err != nil { + return err + } + return nil +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go new file mode 100644 index 0000000..2a98dfd --- /dev/null +++ b/internal/constants/constants.go @@ -0,0 +1,3 @@ +package constants + +var SysConfPrefix string @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "log/slog" + "net/http" + "os" + + "git.leonardobishop.net/history/api" + "git.leonardobishop.net/history/internal/config" + "git.leonardobishop.net/history/pkg/database" + "git.leonardobishop.net/history/pkg/entries" + "git.leonardobishop.net/history/pkg/html" +) + +func main() { + if err := run(); err != nil { + slog.Error("Unhandled error", "error", err) + os.Exit(1) + } +} + +func run() error { + c := &config.Config{} + err := config.ReadConfig("config.yaml", c) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + db, err := database.Connect("database.db") + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + + if err := database.Migrate(db); err != nil { + return fmt.Errorf("database migration failed: %w", err) + } + + entriesService := entries.NewService(db) + htmlService := html.NewService() + + api := api.NewServer(api.ApiServices{ + EntiresService: entriesService, + HtmlService: htmlService, + }) + + slog.Info("starting HTTP server", "host", c.Server.Host, "port", c.Server.Port) + + if err := http.ListenAndServe(fmt.Sprintf("%s:%s", c.Server.Host, c.Server.Port), api); err != nil { + return fmt.Errorf("failed to start server: %w", err) + } + + return nil +} diff --git a/pkg/database/database.go b/pkg/database/database.go new file mode 100644 index 0000000..b236d2d --- /dev/null +++ b/pkg/database/database.go @@ -0,0 +1,31 @@ +package database + +import ( + "database/sql" + "embed" + "fmt" + + _ "github.com/mattn/go-sqlite3" + "github.com/pressly/goose/v3" +) + +//go:embed migrations/*.sql +var embedMigrations embed.FS + +func Connect(path string) (*sql.DB, error) { + return sql.Open("sqlite3", path+"?_foreign_keys=on") +} + +func Migrate(db *sql.DB) error { + goose.SetBaseFS(embedMigrations) + + if err := goose.SetDialect("sqlite3"); err != nil { + return fmt.Errorf("could not set dialect: %w", err) + } + + if err := goose.Up(db, "migrations"); err != nil { + return fmt.Errorf("could not apply migrations: %w", err) + } + + return nil +} diff --git a/pkg/database/migrations/0001_initial.sql b/pkg/database/migrations/0001_initial.sql new file mode 100644 index 0000000..5e69229 --- /dev/null +++ b/pkg/database/migrations/0001_initial.sql @@ -0,0 +1,19 @@ +-- +goose Up + +CREATE TABLE entries ( + id integer PRIMARY KEY, + title text NOT NULL, + kind integer NOT NULL, + url text NOT NULL, + description text NOT NULL, + timestamp text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(kind) REFERENCES kinds(id) +) STRICT; + +CREATE TABLE kinds ( + id integer PRIMARY KEY, + name text NOT NULL, + emoji text NOT NULL +) STRICT; + +CREATE INDEX idx_kinds_name ON kinds(name); diff --git a/pkg/database/migrations/0002_seed.sql b/pkg/database/migrations/0002_seed.sql new file mode 100644 index 0000000..8be0bba --- /dev/null +++ b/pkg/database/migrations/0002_seed.sql @@ -0,0 +1,6 @@ +-- +goose Up + +INSERT INTO kinds (name, emoji) +VALUES + ("read", "👀"), + ("starred", "⭐"); diff --git a/pkg/database/query/entries.sql b/pkg/database/query/entries.sql new file mode 100644 index 0000000..1927468 --- /dev/null +++ b/pkg/database/query/entries.sql @@ -0,0 +1,11 @@ +-- name: CreateEntryWithKindName :one +INSERT INTO entries (title, kind, url, description) +SELECT ?, kinds.id, ?, ? +FROM kinds +WHERE kinds.name = ? +RETURNING *; + +-- name: GetEntries :many +SELECT title, url, description, timestamp, kinds.name as kind_name, kinds.emoji as kind_emoji FROM entries +JOIN kinds ON entries.id == kinds.id +ORDER BY timestamp DESC; diff --git a/pkg/database/sqlc/db.go b/pkg/database/sqlc/db.go new file mode 100644 index 0000000..e4d7828 --- /dev/null +++ b/pkg/database/sqlc/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 + +package sqlc + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/pkg/database/sqlc/entries.sql.go b/pkg/database/sqlc/entries.sql.go new file mode 100644 index 0000000..f412811 --- /dev/null +++ b/pkg/database/sqlc/entries.sql.go @@ -0,0 +1,89 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: entries.sql + +package sqlc + +import ( + "context" +) + +const createEntryWithKindName = `-- name: CreateEntryWithKindName :one +INSERT INTO entries (title, kind, url, description) +SELECT ?, kinds.id, ?, ? +FROM kinds +WHERE kinds.name = ? +RETURNING id, title, kind, url, description, timestamp +` + +type CreateEntryWithKindNameParams struct { + Title string `json:"title"` + Url string `json:"url"` + Description string `json:"description"` + Name string `json:"name"` +} + +func (q *Queries) CreateEntryWithKindName(ctx context.Context, arg CreateEntryWithKindNameParams) (Entry, error) { + row := q.db.QueryRowContext(ctx, createEntryWithKindName, + arg.Title, + arg.Url, + arg.Description, + arg.Name, + ) + var i Entry + err := row.Scan( + &i.ID, + &i.Title, + &i.Kind, + &i.Url, + &i.Description, + &i.Timestamp, + ) + return i, err +} + +const getEntries = `-- name: GetEntries :many +SELECT title, url, description, timestamp, kinds.name as kind_name, kinds.emoji as kind_emoji FROM entries +JOIN kinds ON entries.id == kinds.id +ORDER BY timestamp DESC +` + +type GetEntriesRow struct { + Title string `json:"title"` + Url string `json:"url"` + Description string `json:"description"` + Timestamp string `json:"timestamp"` + KindName string `json:"kind_name"` + KindEmoji string `json:"kind_emoji"` +} + +func (q *Queries) GetEntries(ctx context.Context) ([]GetEntriesRow, error) { + rows, err := q.db.QueryContext(ctx, getEntries) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetEntriesRow + for rows.Next() { + var i GetEntriesRow + if err := rows.Scan( + &i.Title, + &i.Url, + &i.Description, + &i.Timestamp, + &i.KindName, + &i.KindEmoji, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/pkg/database/sqlc/models.go b/pkg/database/sqlc/models.go new file mode 100644 index 0000000..2e74c4b --- /dev/null +++ b/pkg/database/sqlc/models.go @@ -0,0 +1,20 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 + +package sqlc + +type Entry struct { + ID int64 `json:"id"` + Title string `json:"title"` + Kind int64 `json:"kind"` + Url string `json:"url"` + Description string `json:"description"` + Timestamp string `json:"timestamp"` +} + +type Kind struct { + ID int64 `json:"id"` + Name string `json:"name"` + Emoji string `json:"emoji"` +} diff --git a/pkg/entries/entries.go b/pkg/entries/entries.go new file mode 100644 index 0000000..2b0967b --- /dev/null +++ b/pkg/entries/entries.go @@ -0,0 +1,52 @@ +package entries + +import ( + "context" + "database/sql" + "fmt" + + "git.leonardobishop.net/history/pkg/database/sqlc" +) + +type Service interface { + CreateEntry(title, kind, url, description string) (*sqlc.Entry, error) + GetEntries() ([]sqlc.GetEntriesRow, error) +} + +type service struct { + db *sql.DB +} + +func NewService(db *sql.DB) Service { + return &service{ + db: db, + } +} + +func (s *service) CreateEntry(title, kind, url, description string) (*sqlc.Entry, error) { + queries := sqlc.New(s.db) + + entry, err := queries.CreateEntryWithKindName(context.Background(), sqlc.CreateEntryWithKindNameParams{ + Title: title, + Url: url, + Description: description, + Name: kind, + }) + + if err != nil { + return nil, fmt.Errorf("could not create entry: %w", err) + } + + return &entry, nil +} + +func (s *service) GetEntries() ([]sqlc.GetEntriesRow, error) { + queries := sqlc.New(s.db) + + entries, err := queries.GetEntries(context.Background()) + if err != nil { + return make([]sqlc.GetEntriesRow, 0), fmt.Errorf("could not get entries: %w", err) + } + + return entries, nil +} diff --git a/pkg/html/service.go b/pkg/html/service.go new file mode 100644 index 0000000..cba3a6c --- /dev/null +++ b/pkg/html/service.go @@ -0,0 +1,50 @@ +package html + +import ( + "time" + + "git.leonardobishop.net/history/pkg/database/sqlc" +) + +type Service interface { + GenerateHtml([]sqlc.GetEntriesRow) (string, error) +} + +type service struct{} + +func NewService() Service { + return &service{} +} + +func (s *service) GenerateHtml(entries []sqlc.GetEntriesRow) (string, error) { + var str string + + var currentDate time.Time + + for _, entry := range entries { + date, err := time.Parse(time.DateTime, entry.Timestamp) + if err != nil { + return "", err + } + + if currentDate.Year() != date.Year() || currentDate.Month() != date.Month() { + str = str + "<h2>" + date.Format("January 2006") + "</h2>" + } + + currentDate = date + + str += "<span class=\"entry\">" + if entry.KindName == "starred" { + str += "<b>" + } + str += "<a class=\"entry-title\" href=" + entry.Url + " target=\"_blank\">" + entry.KindEmoji + " " + entry.Title + "</a>" + if entry.KindName == "starred" { + str += "</b>" + } + str += " - <span class=\"entry-description\">" + entry.Description + "</span>" + str += " - <span class=\"entry-date\">" + date.Format(time.DateOnly) + "</span>" + str += "</span><br>" + } + + return str, nil +} diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..785c780 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,10 @@ +version: "2" +sql: + - engine: "sqlite" + queries: "pkg/database/query" + schema: "pkg/database/migrations" + gen: + go: + package: "sqlc" + out: "pkg/database/sqlc" + emit_json_tags: true
\ No newline at end of file |
