summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLeonardo Bishop <me@leonardobishop.net>2025-09-09 22:44:09 +0100
committerLeonardo Bishop <me@leonardobishop.net>2025-09-09 22:44:09 +0100
commitf1741a7faa9538e9b12ac60e0fbf6c7721a36059 (patch)
tree2b70b14fc20a8520718910a0cd2e0134790c6f53
Initial commit
-rw-r--r--.gitignore2
-rw-r--r--Makefile4
-rw-r--r--api/dto/entry.go9
-rw-r--r--api/dto/response.go38
-rw-r--r--api/dto/util.go54
-rw-r--r--api/handlers/html.go27
-rw-r--r--api/handlers/record.go35
-rw-r--r--api/middleware/cors.go12
-rw-r--r--api/router.go27
-rw-r--r--go.mod22
-rw-r--r--go.sum33
-rw-r--r--internal/config/config.go27
-rw-r--r--internal/constants/constants.go3
-rw-r--r--main.go54
-rw-r--r--pkg/database/database.go31
-rw-r--r--pkg/database/migrations/0001_initial.sql19
-rw-r--r--pkg/database/migrations/0002_seed.sql6
-rw-r--r--pkg/database/query/entries.sql11
-rw-r--r--pkg/database/sqlc/db.go31
-rw-r--r--pkg/database/sqlc/entries.sql.go89
-rw-r--r--pkg/database/sqlc/models.go20
-rw-r--r--pkg/entries/entries.go52
-rw-r--r--pkg/html/service.go50
-rw-r--r--sqlc.yaml10
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
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..c336356
--- /dev/null
+++ b/go.mod
@@ -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
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..b017ac7
--- /dev/null
+++ b/go.sum
@@ -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
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..e337b0e
--- /dev/null
+++ b/main.go
@@ -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